From f16f0a9f937e2d1343e47fe3e3f3fc271bcac8a4 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 25 Oct 2026 21:38:13 -0400 Subject: [PATCH] fix(frontend): readable API errors and no more silent action failures (#284, #286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- drb-frontend/app/alerts/page.tsx | 32 +++++++++--- drb-frontend/app/incidents/[id]/page.tsx | 13 ++++- drb-frontend/app/incidents/page.tsx | 9 +++- drb-frontend/app/nodes/[id]/page.tsx | 9 +++- drb-frontend/app/systems/page.tsx | 3 +- drb-frontend/app/trips/[id]/page.tsx | 5 +- drb-frontend/app/trips/page.tsx | 4 +- drb-frontend/lib/c2api.ts | 3 +- drb-frontend/lib/c2error.ts | 64 ++++++++++++++++++++++++ drb-frontend/lib/discord.ts | 4 +- drb-frontend/lib/discordOAuth.ts | 5 +- drb-frontend/lib/discordPicker.ts | 4 +- 12 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 drb-frontend/lib/c2error.ts diff --git a/drb-frontend/app/alerts/page.tsx b/drb-frontend/app/alerts/page.tsx index 2a4e597..1df97b8 100644 --- a/drb-frontend/app/alerts/page.tsx +++ b/drb-frontend/app/alerts/page.tsx @@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider"; import { useAlerts } from "@/lib/useAlerts"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { c2api } from "@/lib/c2api"; +import { errorMessage } from "@/lib/c2error"; +import { ErrorBanner } from "@/components/ui/EmptyState"; import type { AlertRule } from "@/lib/types"; function fmtTime(iso: string) { @@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) { const [webhook, setWebhook] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // Load and toggle/delete failures (server-26#284) — separate from the create + // form's own `error`, and it keeps the list from claiming "no rules" when + // the fetch actually failed. + const [listError, setListError] = useState(null); async function load() { if (loaded) return; @@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) { const data = await c2api.getAlertRules() as AlertRule[]; setRules(data); setLoaded(true); + setListError(null); } catch (e) { - console.error(e); + setListError(`Couldn't load alert rules: ${errorMessage(e)}`); } } @@ -50,8 +57,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) { const created = await c2api.createAlertRule(body) as AlertRule; setRules((prev) => [created, ...prev]); setName(""); setKeywords(""); setTgIds(""); setWebhook(""); - } catch { - setError("Failed to create rule."); + } catch (e) { + setError(`Failed to create rule: ${errorMessage(e)}`); } finally { setSaving(false); } @@ -61,14 +68,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) { try { await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled }); setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r)); - } catch (e) { console.error(e); } + setListError(null); + } catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); } } async function handleDelete(id: string) { + if (!window.confirm("Delete this alert rule?")) return; try { await c2api.deleteAlertRule(id); setRules((prev) => prev.filter((r) => r.rule_id !== id)); - } catch (e) { console.error(e); } + setListError(null); + } catch (e) { setListError(`Couldn't delete the rule: ${errorMessage(e)}`); } } return ( @@ -120,8 +130,12 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) { )} + {listError && } +
- {rules.length === 0 ? ( + {!loaded ? ( +

{listError ? "Rules unavailable." : "Loading…"}

+ ) : rules.length === 0 ? (

No alert rules configured.

) : ( @@ -188,11 +202,13 @@ export default function AlertsPage() { const { isAdmin } = useAuth(); const { alerts, loading, error } = useAlerts(); const [tab, setTab] = useState<"events" | "rules">("events"); + const [ackError, setAckError] = useState(null); async function handleAcknowledge(id: string) { + setAckError(null); try { await c2api.acknowledgeAlert(id); - } catch (e) { console.error(e); } + } catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); } } const unacked = alerts.filter((a) => !a.acknowledged); @@ -223,6 +239,8 @@ export default function AlertsPage() { ))} + {tab === "events" && ackError && } + {tab === "events" && ( loading ? (

Loading…

diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx index 2221983..db627d4 100644 --- a/drb-frontend/app/incidents/[id]/page.tsx +++ b/drb-frontend/app/incidents/[id]/page.tsx @@ -13,6 +13,8 @@ import { SeverityMark } from "@/components/marks/SeverityMark"; import { isKnownSeverity } from "@/lib/severity"; import { Button } from "@/components/ui/Button"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; +import { ErrorBanner } from "@/components/ui/EmptyState"; +import { errorMessage } from "@/lib/c2error"; import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); @@ -39,6 +41,7 @@ export default function IncidentDetailPage() { const [summarizing, setSummarizing] = useState(false); const [resolving, setResolving] = useState(false); + const [actionError, setActionError] = useState(null); const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE); // Stop numbers and path length come from the same liveWall.pathStops() the @@ -54,16 +57,20 @@ export default function IncidentDetailPage() { ); async function handleResolve() { + // No reopen path exists (server-26#284), so resolving is final from the UI. + if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return; setResolving(true); + setActionError(null); try { await c2api.updateIncident(id, { status: "resolved" }); } - catch (e) { console.error(e); } + catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); } finally { setResolving(false); } } async function handleSummarize() { setSummarizing(true); + setActionError(null); try { await c2api.summarizeIncident(id); } - catch (e) { console.error(e); } + catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); } finally { setSummarizing(false); } } @@ -126,6 +133,8 @@ export default function IncidentDetailPage() { )} + {actionError && } + {displayTags.length > 0 && (
{displayTags.map((t) => ( diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index ba49063..1e8388f 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider"; import { useIncidents } from "@/lib/useIncidents"; import { useActiveCalls } from "@/lib/useCalls"; import { c2api } from "@/lib/c2api"; +import { errorMessage } from "@/lib/c2error"; import type { IncidentRecord } from "@/lib/types"; import { PageHeader } from "@/components/ui/PageHeader"; import { Button } from "@/components/ui/Button"; @@ -204,6 +205,7 @@ export default function IncidentsPage() { const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo); const activeCalls = useActiveCalls(); const [showCreate, setShowCreate] = useState(false); + const [actionError, setActionError] = useState(null); const [severityFilter, setSeverityFilter] = useState("all"); const [sortMode, setSortMode] = useState("recent"); const [statusFilter, setStatusFilter] = useState("any"); @@ -257,8 +259,11 @@ export default function IncidentsPage() { }, [filtered]); async function handleResolve(id: string) { + // No reopen path exists (server-26#284), so resolving is final from the UI. + if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return; + setActionError(null); try { await c2api.updateIncident(id, { status: "resolved" }); } - catch (e) { console.error(e); } + catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); } } return ( @@ -273,6 +278,8 @@ export default function IncidentsPage() { chips are pipeline output, so the notice rides with the list. */} + {actionError && } +
{SEVERITY_FILTERS.map(({ key, label }) => ( diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index e7e6c26..0174722 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -15,6 +15,7 @@ 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 }) { @@ -54,10 +55,14 @@ export default function NodeDetailPage() { return unsub; }, [id]); - async function sendCommand(action: string) { + 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); } @@ -249,7 +254,7 @@ export default function NodeDetailPage() {