#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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6d667e81c9
commit
f16f0a9f93
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useAlerts } from "@/lib/useAlerts";
|
import { useAlerts } from "@/lib/useAlerts";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
|
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import type { AlertRule } from "@/lib/types";
|
import type { AlertRule } from "@/lib/types";
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [webhook, setWebhook] = useState("");
|
const [webhook, setWebhook] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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() {
|
async function load() {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const data = await c2api.getAlertRules() as AlertRule[];
|
const data = await c2api.getAlertRules() as AlertRule[];
|
||||||
setRules(data);
|
setRules(data);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
setListError(null);
|
||||||
} catch (e) {
|
} 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;
|
const created = await c2api.createAlertRule(body) as AlertRule;
|
||||||
setRules((prev) => [created, ...prev]);
|
setRules((prev) => [created, ...prev]);
|
||||||
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
||||||
} catch {
|
} catch (e) {
|
||||||
setError("Failed to create rule.");
|
setError(`Failed to create rule: ${errorMessage(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -61,14 +68,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
try {
|
try {
|
||||||
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
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));
|
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) {
|
async function handleDelete(id: string) {
|
||||||
|
if (!window.confirm("Delete this alert rule?")) return;
|
||||||
try {
|
try {
|
||||||
await c2api.deleteAlertRule(id);
|
await c2api.deleteAlertRule(id);
|
||||||
setRules((prev) => prev.filter((r) => r.rule_id !== 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 (
|
return (
|
||||||
@@ -120,8 +130,12 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{listError && <ErrorBanner message={listError} />}
|
||||||
|
|
||||||
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
<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>
|
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
@@ -188,11 +202,13 @@ export default function AlertsPage() {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { alerts, loading, error } = useAlerts();
|
const { alerts, loading, error } = useAlerts();
|
||||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||||
|
const [ackError, setAckError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleAcknowledge(id: string) {
|
async function handleAcknowledge(id: string) {
|
||||||
|
setAckError(null);
|
||||||
try {
|
try {
|
||||||
await c2api.acknowledgeAlert(id);
|
await c2api.acknowledgeAlert(id);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||||
@@ -223,6 +239,8 @@ export default function AlertsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
|
||||||
|
|
||||||
{tab === "events" && (
|
{tab === "events" && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
<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 { isKnownSeverity } from "@/lib/severity";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
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";
|
import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
@@ -39,6 +41,7 @@ export default function IncidentDetailPage() {
|
|||||||
|
|
||||||
const [summarizing, setSummarizing] = useState(false);
|
const [summarizing, setSummarizing] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||||
|
|
||||||
// Stop numbers and path length come from the same liveWall.pathStops() the
|
// Stop numbers and path length come from the same liveWall.pathStops() the
|
||||||
@@ -54,16 +57,20 @@ export default function IncidentDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handleResolve() {
|
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);
|
setResolving(true);
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||||
finally { setResolving(false); }
|
finally { setResolving(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSummarize() {
|
async function handleSummarize() {
|
||||||
setSummarizing(true);
|
setSummarizing(true);
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.summarizeIncident(id); }
|
try { await c2api.summarizeIncident(id); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
|
||||||
finally { setSummarizing(false); }
|
finally { setSummarizing(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +133,8 @@ export default function IncidentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actionError && <ErrorBanner message={actionError} />}
|
||||||
|
|
||||||
{displayTags.length > 0 && (
|
{displayTags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{displayTags.map((t) => (
|
{displayTags.map((t) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useIncidents } from "@/lib/useIncidents";
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
|
|||||||
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
||||||
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
|
|||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
|
|
||||||
async function handleResolve(id: string) {
|
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" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
|
|||||||
chips are pipeline output, so the notice rides with the list. */}
|
chips are pipeline output, so the notice rides with the list. */}
|
||||||
<MachineOutputNotice variant="inline" />
|
<MachineOutputNotice variant="inline" />
|
||||||
|
|
||||||
|
{actionError && <ErrorBanner message={actionError} />}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CallRow } from "@/components/CallRow";
|
|||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
function ApprovalBadge({ status }: { status: string | null }) {
|
function ApprovalBadge({ status }: { status: string | null }) {
|
||||||
@@ -54,10 +55,14 @@ export default function NodeDetailPage() {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
async function sendCommand(action: string) {
|
async function sendCommand(action: string, label: string) {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await c2api.sendCommand(id, { action });
|
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 {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -249,7 +254,7 @@ export default function NodeDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={sending}
|
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"
|
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
|
Restart OP25
|
||||||
|
|||||||
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
|
|||||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Delete this system?")) return;
|
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) {
|
function openEdit(s: SystemRecord) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -955,7 +956,7 @@ export default function TripDetailPage() {
|
|||||||
async function handleDeleteTrip() {
|
async function handleDeleteTrip() {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
|
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) {
|
async function handleDeleteEvent(eventId: string) {
|
||||||
@@ -963,7 +964,7 @@ export default function TripDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
||||||
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
|
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) {
|
async function handleUpdateEvent(body: object) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useTrips } from "@/lib/useTrips";
|
import { useTrips } from "@/lib/useTrips";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { TripRecord } from "@/lib/types";
|
import type { TripRecord } from "@/lib/types";
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
function fmtDate(iso: string) {
|
||||||
@@ -179,8 +180,9 @@ export default function TripsPage() {
|
|||||||
const past = trips.filter((t) => t.end_date < today);
|
const past = trips.filter((t) => t.end_date < today);
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Delete this trip?")) return;
|
||||||
try { await c2api.deleteTrip(id); }
|
try { await c2api.deleteTrip(id); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { C2ApiError } from "@/lib/c2error";
|
||||||
import type {
|
import type {
|
||||||
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
||||||
WeatherAlertsResponse, WindResponse,
|
WeatherAlertsResponse, WindResponse,
|
||||||
@@ -19,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
...(options?.headers as Record<string, string> | undefined),
|
...(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;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
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
|
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
||||||
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
||||||
|
|
||||||
|
import { legacyErrorText } from "@/lib/c2error";
|
||||||
|
|
||||||
export interface RadioBotLease {
|
export interface RadioBotLease {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
lease_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.
|
* 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 }[] } {
|
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]*)$/);
|
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// browser: the code exchange happens in c2-core.
|
// browser: the code exchange happens in c2-core.
|
||||||
|
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { C2ApiError } from "@/lib/c2error";
|
||||||
|
|
||||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
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) } : {}),
|
...(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;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
|
|||||||
try {
|
try {
|
||||||
raw = await post("/discord/oauth/start", req);
|
raw = await post("/discord/oauth/start", req);
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
const url = raw.url ?? raw.authorize_url ?? "";
|
const url = raw.url ?? raw.authorize_url ?? "";
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
|
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
|
||||||
// the server never sends them to the browser.
|
// the server never sends them to the browser.
|
||||||
|
|
||||||
|
import { legacyErrorText } from "@/lib/c2error";
|
||||||
|
|
||||||
export type StreamState =
|
export type StreamState =
|
||||||
| "pending"
|
| "pending"
|
||||||
| "connecting"
|
| "connecting"
|
||||||
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
|
|||||||
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
||||||
* detail, code and invite links back out of a JSON body. */
|
* detail, code and invite links back out of a JSON body. */
|
||||||
export function parseC2Error(err: unknown): ParsedC2Error {
|
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 m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||||
const status = m ? Number(m[1]) : null;
|
const status = m ? Number(m[1]) : null;
|
||||||
const idx = raw.indexOf(": ");
|
const idx = raw.indexOf(": ");
|
||||||
|
|||||||
Reference in New Issue
Block a user