Files
server-26/drb-frontend/app/incidents/[id]/page.tsx
T
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#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>
2026-10-25 21:38:13 -04:00

252 lines
10 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useIncident } from "@/lib/useIncidents";
import { useCallsByIncident } from "@/lib/useCalls";
import { useAuth } from "@/components/AuthProvider";
import { CallSpineEntry } from "@/components/CallSpineEntry";
import { c2api } from "@/lib/c2api";
import { TypeGlyph } from "@/components/marks/TypeGlyph";
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 });
const EARLIER_PAGE_SIZE = 8;
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
const start = new Date(startedAt).getTime();
const end = active ? Date.now() : new Date(updatedAt).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000));
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ${mins % 60}m`;
}
export default function IncidentDetailPage() {
const params = useParams();
const id = params.id as string;
const router = useRouter();
const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id);
const { isAdmin } = useAuth();
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
// map's IncidentPathLayer draws from (server-26#236), so the spine numbers
// exactly what the map numbers: only a movement incident (police/accident
// with a pursuit tag) is numbered, and an impossible-speed stop never is.
const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map<string, number>()), [path]);
const newestFirst = useMemo(
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
[calls]
);
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) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
finally { setResolving(false); }
}
async function handleSummarize() {
setSummarizing(true);
setActionError(null);
try { await c2api.summarizeIncident(id); }
catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
finally { setSummarizing(false); }
}
if (loading) return <p className="text-ink-muted text-sm p-6">Loading…</p>;
if (!incident) return <p className="text-ink-muted text-sm p-6">Incident not found.</p>;
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
const unitsActive = incident.units_active ?? incident.units ?? [];
const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? [];
const active = incident.status === "active";
const visible = newestFirst.slice(0, earlierShown);
const remaining = newestFirst.length - visible.length;
return (
<div className="space-y-4">
<button
onClick={() => router.back()}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
>
← Incidents
</button>
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex flex-col gap-1.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<TypeGlyph type={incident.type} size={20} className="text-ink-2" />
{isKnownSeverity(incident.severity) && <SeverityMark severity={incident.severity} showLabel size="md" />}
<span className={`text-xs px-2 py-0.5 rounded-full ${active ? "bg-accent/15 text-accent" : "bg-raised text-ink-2"}`}>
{active ? "Active" : "Resolved"}
</span>
</div>
<h1 className="text-[27px] font-semibold text-ink leading-tight">
{incident.title ?? "Incident"}
</h1>
<p className="text-xs text-ink-muted font-mono">
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
{path?.mayBeMerged && (
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
{" · "}may be merged
</span>
)}
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
{isAdmin && (
<div className="flex gap-2 shrink-0 flex-wrap">
<Button variant="secondary" size="sm" onClick={handleSummarize} disabled={summarizing}>
{summarizing ? "Generating…" : "Regenerate summary"}
</Button>
{active && (
<Button variant="secondary" size="sm" onClick={handleResolve} disabled={resolving}>
{resolving ? "Resolving…" : "Mark resolved"}
</Button>
)}
</div>
)}
</div>
{actionError && <ErrorBanner message={actionError} />}
{displayTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{displayTags.map((t) => (
<span key={t} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded-full">{t}</span>
))}
</div>
)}
{/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
{/* Left */}
<div className="lg:col-span-3 space-y-4">
{incident.location_coords && (
<div style={{ height: "352px" }} className="rounded-xl overflow-hidden border border-line">
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} calls={calls} />
</div>
)}
{/* Summary — first, in prose. Not a tab. */}
<div className="space-y-2.5">
{incident.summary ? (
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
) : (
<p className="text-sm text-ink-muted italic">
No summary yet.{" "}
{isAdmin && (
<button onClick={handleSummarize} disabled={summarizing} className="text-accent not-italic hover:underline">
Generate now
</button>
)}
</p>
)}
{/* Gate A / A2 (server-26#46): the summary, the title, the location,
the units and the vehicles below are ALL pipeline output, so the
notice sits on this screen with them — not on a policy page. */}
<MachineOutputNotice />
</div>
{/* On scene / Cleared */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">On scene</p>
{unitsActive.length > 0 ? (
<div className="flex flex-wrap gap-1">
{unitsActive.map((u) => (
<span key={u} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{u}</span>
))}
</div>
) : (
<p className="text-xs text-ink-muted italic">None extracted.</p>
)}
</div>
<div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Cleared</p>
{unitsCleared.length > 0 ? (
<div className="flex flex-wrap gap-1">
{unitsCleared.map((u) => (
<span key={u} className="text-xs bg-transparent border border-line text-ink-muted px-2 py-0.5 rounded font-mono line-through">{u}</span>
))}
</div>
) : (
<p className="text-xs text-ink-muted italic">None yet.</p>
)}
</div>
</div>
{vehicles.length > 0 && (
<div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
<div className="flex flex-wrap gap-1">
{vehicles.map((v) => (
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
))}
</div>
</div>
)}
</div>
{/* Right — the call spine */}
<div className="lg:col-span-2">
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
Calls ({calls.length})
</p>
{/* Gate A / A2 — the spine renders transcripts. */}
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
{callsLoading ? (
<p className="text-ink-muted text-sm">Loading…</p>
) : calls.length === 0 ? (
<p className="text-ink-muted text-sm">No calls linked yet.</p>
) : (
<div>
{visible.map((c) => (
<CallSpineEntry
key={c.call_id}
call={c}
stopNumber={stopNumberByCallId.get(c.call_id)}
isAdmin={isAdmin}
/>
))}
{remaining > 0 && (
<button
onClick={() => setEarlierShown((n) => n + EARLIER_PAGE_SIZE)}
className="text-xs text-accent hover:underline mt-2"
>
{remaining} earlier call{remaining !== 1 ? "s" : ""}
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}