Merge pull request 'Frontend: readable API errors, no silent action failures (#284, #286)' (#290) from fix/284-286-error-feedback into main
This commit was merged in pull request #290.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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(": ");
|
||||
|
||||
Reference in New Issue
Block a user