@@ -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