Files
server-26/drb-frontend/app/nodes/[id]/page.tsx
T
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00

330 lines
13 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { doc, onSnapshot } from "firebase/firestore";
import { db } from "@/lib/firebase";
import { useSystems } from "@/lib/useSystems";
import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SdrSettings } from "@/components/SdrSettings";
import { RadioChannels } from "@/components/RadioChannels";
import { NodeDiscordRadio } from "@/components/NodeDiscordRadio";
import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { NodeRecord } from "@/lib/types";
function ApprovalBadge({ status }: { status: string | null }) {
if (status === "approved") return (
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded font-mono">Approved</span>
);
if (status === "rejected") return (
<span className="text-xs text-red-400 bg-red-400/10 px-2 py-0.5 rounded font-mono">Rejected</span>
);
return (
<span className="text-xs text-yellow-400 bg-yellow-400/10 px-2 py-0.5 rounded font-mono">Pending approval</span>
);
}
export default function NodeDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [node, setNode] = useState<NodeRecord | null>(null);
const [showConfig, setShowConfig] = useState(false);
const [sending, setSending] = useState(false);
const [approving, setApproving] = useState(false);
const [deleting, setDeleting] = useState(false);
const { systems } = useSystems();
// TODO(server-26#109 item5): server-side node_id filter. A where("node_id","==",id)
// alongside the existing org_id equality + started_at orderBy needs a brand-new
// composite index, so for now pull a wider window and filter client-side.
const { calls } = useCalls(200);
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const nodeCalls = calls.filter((c) => c.node_id === id);
useEffect(() => {
const unsub = onSnapshot(doc(db, "nodes", id), (snap) => {
setNode(snap.exists() ? (snap.data() as NodeRecord) : null);
});
return unsub;
}, [id]);
async function sendCommand(action: string, label: string) {
setSending(true);
try {
await c2api.sendCommand(id, { action });
alert(`${label} sent to the node.`);
} catch (e) {
// Was try/finally only: an unhandled rejection and no feedback (server-26#284).
alert(`Couldn't send ${label.toLowerCase()}: ${errorMessage(e)}`);
} finally {
setSending(false);
}
}
async function handleRequestUpdate() {
if (
!confirm(
"Update this node's software?\n\n" +
"The node pulls the latest release it was installed to track and recreates its containers " +
"(radio capture pauses for about a minute). An online node starts within seconds; an offline " +
"or locked-out node picks it up on its next check-in (up to ~15 minutes). Nodes installed " +
"before the host updater existed need install.sh re-run once.",
)
)
return;
setSending(true);
try {
const r = await c2api.requestNodeUpdate(id);
alert(
r.mqtt_sent
? "Update requested. The node should restart within a minute."
: "Update requested. The node is not reachable over MQTT right now; it will update on its next HTTPS check-in.",
);
} catch (err) {
alert(err instanceof Error ? err.message : "Update request failed.");
} finally {
setSending(false);
}
}
async function handleApprove() {
setApproving(true);
try {
await c2api.approveNode(id);
} catch (err) {
alert(err instanceof Error ? err.message : "Approval failed.");
} finally {
setApproving(false);
}
}
async function handleDelete() {
if (!confirm(`Permanently delete node "${node?.name ?? id}"? This cannot be undone.`)) return;
setDeleting(true);
try {
await c2api.deleteNode(id);
router.push("/nodes");
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed.");
setDeleting(false);
}
}
async function handleReject() {
if (!confirm("Reject this node? Any MQTT login and API key it holds are revoked, so it cannot connect or upload. Re-approving later issues a new key.")) return;
setApproving(true);
try {
await c2api.rejectNode(id);
} catch (err) {
alert(err instanceof Error ? err.message : "Rejection failed.");
} finally {
setApproving(false);
}
}
if (!node) {
return <p className="text-ink-muted font-mono text-sm">Loading node…</p>;
}
const system = systemMap[node.assigned_system_id ?? ""];
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
<p className="text-ink-muted text-sm font-mono">{node.node_id}</p>
</div>
<StatusBadge status={node.status} />
</div>
{/* Override Warning */}
{node.is_overridden && (
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
<span className="text-base">⚠</span>
<span>Local System Override Active</span>
</div>
<p className="text-ink-2 text-xs leading-relaxed">
This node is operating on a local system override.
{node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
) : (
<> No timeout is currently enforced (permanent override).</>
)}
</p>
<div className="flex gap-2">
{node.override_timeout_at && (
<button
onClick={async () => {
try {
await c2api.ackOverride(id, 1440);
} catch (e) {
alert("Failed to extend timer.");
}
}}
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Ack (Reset 24h Timer)
</button>
)}
<button
onClick={async () => {
if (confirm("Force this node to revert back to its assigned system config?")) {
try {
await c2api.resetOverride(id);
} catch (e) {
alert("Failed to reset override.");
}
}
}}
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
>
Force Revert Config
</button>
</div>
</div>
)}
{/* Info */}
<div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
{[
["System", system?.name ?? "Unassigned"],
["Location", `${node.lat}, ${node.lon}`],
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
["Updater Check-in", node.updater_last_check_at ? new Date(node.updater_last_check_at).toLocaleString() : "never"],
...(node.update_requested_at
? [[
"Last Update",
node.update_applied_at && node.update_applied_at >= node.update_requested_at
? `${node.update_result === "failed" ? "FAILED" : "applied"} ${new Date(node.update_applied_at).toLocaleString()}`
: `requested ${new Date(node.update_requested_at).toLocaleString()} — pending`,
]]
: []),
["Configured", node.configured ? "Yes" : "No"],
["Node Type", node.node_type ?? "fixed"],
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">{label}</span>
<span className="text-ink">{value}</span>
</div>
))}
<div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Approval</span>
<ApprovalBadge status={node.approval_status ?? null} />
</div>
</div>
{/* Admin approval actions */}
{isAdmin && node.approval_status === "pending" && (
<div className="flex gap-3 items-center">
<span className="text-sm text-yellow-400 font-mono">This node is awaiting approval.</span>
<button
onClick={handleApprove}
disabled={approving}
className="px-4 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-50 text-white rounded-lg text-sm font-mono transition-colors"
>
{approving ? "…" : "Approve"}
</button>
<button
onClick={handleReject}
disabled={approving}
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-ink-2 rounded-lg text-sm font-mono transition-colors"
>
Reject
</button>
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => setShowConfig(true)}
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
>
{node.configured ? "Reassign System" : "Configure"}
</button>
<button
disabled={sending}
onClick={() => sendCommand("op25_restart", "Restart OP25")}
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Restart OP25
</button>
{isAdmin && (
<button
disabled={sending}
onClick={handleRequestUpdate}
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Update Node Software
</button>
)}
{isAdmin && (
<button
disabled={deleting}
onClick={handleDelete}
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-red-300 rounded-lg text-sm font-mono transition-colors ml-auto"
>
{deleting ? "Deleting…" : "Delete Node"}
</button>
)}
</div>
{/* server-26#213 item 3: picker over the org's linked servers, not raw IDs. */}
<NodeDiscordRadio nodeId={id} />
<RadioChannels channels={node.radio_channels} />
<SdrSettings node={node} canEdit={isAdmin} />
{/* Recent calls */}
<section>
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Recent Calls</h2>
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
{nodeCalls.length === 0 ? (
<p className="text-ink-muted text-sm font-mono">No calls recorded from this node.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
</tr>
</thead>
<tbody>
{nodeCalls.map((c) => (
<CallRow key={c.call_id} call={c} systemName={system?.name} />
))}
</tbody>
</table>
</div>
)}
</section>
{showConfig && (
<NodeConfigModal
node={node}
systems={systems}
onClose={() => setShowConfig(false)}
/>
)}
</div>
);
}