Frontend: readable API errors, no silent action failures (#284, #286) #290

Merged
logan merged 1 commits from fix/284-286-error-feedback into main 2026-10-25 21:39:16 -04:00
12 changed files with 134 additions and 21 deletions
Showing only changes of commit f16f0a9f93 - Show all commits
+25 -7
View File
@@ -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<string | null>(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<string | null>(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 }) {
</form>
)}
{listError && <ErrorBanner message={listError} />}
<div className="bg-surface border border-line rounded-xl overflow-hidden">
{rules.length === 0 ? (
{!loaded ? (
<p className="text-ink-muted text-sm font-mono p-4">{listError ? "Rules unavailable." : "Loading…"}</p>
) : rules.length === 0 ? (
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
) : (
<table className="w-full text-left">
@@ -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<string | null>(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() {
))}
</div>
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
{tab === "events" && (
loading ? (
<p className="text-ink-muted text-sm font-mono">Loading…</p>
+11 -2
View File
@@ -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<string | null>(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() {
)}
</div>
{actionError && <ErrorBanner message={actionError} />}
{displayTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{displayTags.map((t) => (
+8 -1
View File
@@ -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<string | null>(null);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("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. */}
<MachineOutputNotice variant="inline" />
{actionError && <ErrorBanner message={actionError} />}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => (
+7 -2
View File
@@ -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() {
</button>
<button
disabled={sending}
onClick={() => sendCommand("op25_restart")}
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
+2 -1
View File
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
if (authLoading || (!isAdmin && !isOperator)) return null;
async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return;
await c2api.deleteSystem(id);
try { await c2api.deleteSystem(id); }
catch (e) { alert(`Couldn't delete the system: ${e instanceof Error ? e.message : String(e)}`); }
}
function openEdit(s: SystemRecord) {
+3 -2
View File
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
// ---------------------------------------------------------------------------
@@ -955,7 +956,7 @@ export default function TripDetailPage() {
async function handleDeleteTrip() {
if (!trip) return;
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
catch (e) { console.error(e); }
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
}
async function handleDeleteEvent(eventId: string) {
@@ -963,7 +964,7 @@ export default function TripDetailPage() {
try {
await c2api.deleteTripEvent(trip.trip_id, eventId);
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
} catch (e) { console.error(e); }
} catch (e) { alert(`Couldn't delete the event: ${errorMessage(e)}`); }
}
async function handleUpdateEvent(body: object) {
+3 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) {
@@ -179,8 +180,9 @@ export default function TripsPage() {
const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) {
if (!confirm("Delete this trip?")) return;
try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); }
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
}
return (
+2 -1
View File
@@ -1,4 +1,5 @@
import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
WeatherAlertsResponse, WindResponse,
@@ -19,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...(options?.headers as Record<string, string> | undefined),
},
});
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T;
return res.json();
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Error thrown by every C2 API request (server-26#286).
*
* `message` is a sentence a user can read — FastAPI's `detail` when the
* server sent one, otherwise a plain mapping of the status. The raw status
* and body stay on the error for callers that need the structured parts
* (the Discord parsers read `invite`/`code` out of `body`).
*/
export class C2ApiError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string) {
super(humanMessage(status, body));
this.name = "C2ApiError";
this.status = status;
this.body = body;
}
// Call sites render String(e); without this it reads "C2ApiError: …".
toString(): string {
return this.message;
}
}
function detailOf(body: string): string | null {
try {
const parsed = JSON.parse(body);
const d = parsed?.detail;
if (typeof d === "string" && d.trim()) return d;
// pydantic validation errors: [{loc, msg, type}, ...]
if (Array.isArray(d)) {
const msgs = d.map((x: { msg?: unknown }) => (typeof x?.msg === "string" ? x.msg : null)).filter(Boolean);
if (msgs.length) return msgs.join("; ");
}
} catch {
/* not JSON */
}
return null;
}
function humanMessage(status: number, body: string): string {
const detail = detailOf(body);
if (status === 401) return "Your session has expired. Sign in again.";
if (status === 403) return detail ? `Not allowed: ${detail}` : "You don't have permission to do that.";
if (detail) return detail;
if (status === 404) return "Not found. It may have been deleted.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (status >= 500) return "The server hit an error. Try again in a moment.";
return `Request failed (${status}).`;
}
/** Message for any caught value — use in catch blocks instead of String(e). */
export function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
/** Rebuilds the legacy `C2 API error <status>: <body>` string for the
* Discord parsers, which predate C2ApiError and match on that shape. */
export function legacyErrorText(err: unknown): string {
if (err instanceof C2ApiError) return `C2 API error ${err.status}: ${err.body}`;
return err instanceof Error ? err.message : String(err);
}
+3 -1
View File
@@ -2,6 +2,8 @@
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
// small helpers shared by /settings/discord and /tokens (server-26#213).
import { legacyErrorText } from "@/lib/c2error";
export interface RadioBotLease {
node_id: string;
lease_id?: string;
@@ -160,7 +162,7 @@ export const SNOWFLAKE = /^\d{15,21}$/;
* so "no bot in this server" can render its invite links.
*/
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
const raw = err instanceof Error ? err.message : String(err);
const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
if (m) {
try {
+3 -2
View File
@@ -9,6 +9,7 @@
// browser: the code exchange happens in c2-core.
import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -24,7 +25,7 @@ async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: o
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T;
return res.json();
}
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
try {
raw = await post("/discord/oauth/start", req);
} catch (err) {
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message);
if (err instanceof C2ApiError && err.status === 404) throw new OAuthNotConfigured(err.message);
throw err;
}
const url = raw.url ?? raw.authorize_url ?? "";
+3 -1
View File
@@ -2,6 +2,8 @@
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
// the server never sends them to the browser.
import { legacyErrorText } from "@/lib/c2error";
export type StreamState =
| "pending"
| "connecting"
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
* detail, code and invite links back out of a JSON body. */
export function parseC2Error(err: unknown): ParsedC2Error {
const raw = err instanceof Error ? err.message : String(err);
const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
const status = m ? Number(m[1]) : null;
const idx = raw.indexOf(": ");