Files
server-26/drb-frontend/app/incidents/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

413 lines
17 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
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";
import { Badge } from "@/components/ui/Badge";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
import { isKnownSeverity, severityRank } from "@/lib/severity";
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
import { TypeGlyph } from "@/components/marks/TypeGlyph";
type SeverityFilter = "all" | "minor" | "moderate" | "major";
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "minor", label: "Minor+" },
{ key: "moderate", label: "Moderate+" },
{ key: "major", label: "Major only" },
];
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
type SortMode = "recent" | "severity";
type StatusFilter = "any" | "active" | "resolved";
const INCIDENT_TYPES = ["fire", "police", "ems", "accident", "other"];
// Firestore holds the paging; text/type/status filtering runs over the loaded
// window, so "Load more" also widens what the search can find.
const PAGE_SIZE = 100;
function matchesSearch(inc: IncidentRecord, needle: string): boolean {
if (!needle) return true;
const hay = [
inc.title, inc.location, inc.summary, inc.type,
...(inc.units ?? []), ...(inc.vehicles ?? []), ...(inc.tags ?? []),
...(inc.location_mentions ?? []),
].filter(Boolean).join(" ").toLowerCase();
return hay.includes(needle);
}
// The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something
// to put in front of an operator. Collapse the known infra failures to a plain
// line; pass anything else straight through so a real bug still shows.
function friendlyIncidentsError(raw: string): string {
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
}
return `Couldn't load incidents: ${raw}`;
}
function fmtTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
}
function dayBucket(iso: string): string {
const d = new Date(iso);
const now = new Date();
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000);
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
}
function timeAgo(iso: string): string {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
// Same rail-card anatomy as Live (MapView's incident panel), at browse
// density: severity spine, type glyph, severity chip, ON AIR pill, title,
// location, units-on-scene chips, age + call count. UI_REDESIGN.md §5.1/§5.2
// — the point is that Live and Incidents read as the same object.
function IncidentBrowseRow({
incident,
isAdmin,
onAir,
onResolve,
}: {
incident: IncidentRecord;
isAdmin: boolean;
onAir: boolean;
onResolve: (id: string) => void;
}) {
const router = useRouter();
const sev = isKnownSeverity(incident.severity) ? incident.severity : "routine";
const units = incident.units_active ?? incident.units ?? [];
return (
<div
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-raised cursor-pointer transition-colors items-stretch"
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
>
<SeveritySpine severity={sev} />
<TypeGlyph type={incident.type} size={20} className="text-ink-2 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<SeverityMark severity={sev} showLabel />
{onAir && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-sev-major/15 text-sev-major uppercase tracking-wide">
On air
</span>
)}
<Badge tone={incident.status === "active" ? "brand" : "neutral"}>{incident.status}</Badge>
</div>
<p className="text-ink text-sm font-semibold leading-snug mt-0.5 truncate">{incident.title ?? "Incident"}</p>
{incident.location && <p className="text-ink-muted text-xs mt-0.5 truncate">{incident.location}</p>}
<div className="flex items-center gap-2 flex-wrap mt-1">
{units.slice(0, 4).map((u) => (
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-raised text-ink-2">{u}</span>
))}
<span className="text-xs text-ink-muted font-mono ml-auto">
{fmtTime(incident.started_at)} · {timeAgo(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</span>
</div>
</div>
{isAdmin && incident.status === "active" && (
<Button
size="sm" variant="secondary"
className="self-center shrink-0"
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
>
Resolve
</Button>
)}
</div>
);
}
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
const [title, setTitle] = useState("");
const [type, setType] = useState("other");
const [summary, setSummary] = useState("");
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
await onCreate({ title, type, summary: summary || null, status: "active" });
onClose();
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form onSubmit={handleSubmit} className="bg-surface border border-line rounded-xl p-6 w-full max-w-md space-y-4">
<h2 className="text-ink font-semibold">Create Incident</h2>
<div>
<label className="text-xs text-ink-muted block mb-1">Title</label>
<input
required value={title} onChange={(e) => setTitle(e.target.value)}
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Type</label>
<select
value={type} onChange={(e) => setType(e.target.value)}
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none"
>
{["fire", "police", "ems", "accident", "other"].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Summary (optional)</label>
<textarea
value={summary} onChange={(e) => setSummary(e.target.value)} rows={2}
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
</div>
</form>
</div>
);
}
export default function IncidentsPage() {
const { isAdmin } = useAuth();
const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const rangeFrom = useMemo(() => dayStart(dateFrom), [dateFrom]);
const rangeTo = useMemo(() => dayEnd(dateTo), [dateTo]);
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");
const [typeFilter, setTypeFilter] = useState("");
const [search, setSearch] = useState("");
const onAirIncidentIds = useMemo(() => {
const s = new Set<string>();
for (const c of activeCalls) {
for (const id of c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []) s.add(id);
}
return s;
}, [activeCalls]);
const filtered = useMemo(() => {
const threshold = FILTER_THRESHOLD[severityFilter];
const needle = search.trim().toLowerCase();
const list = incidents.filter((i) =>
severityRank(i.severity) >= threshold &&
(statusFilter === "any" || i.status === statusFilter) &&
(!typeFilter || i.type === typeFilter) &&
matchesSearch(i, needle)
);
if (sortMode === "severity") {
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
}
return list; // useIncidents() already orders by started_at desc
}, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "" || dateFrom !== "" || dateTo !== "";
function clearFilters() {
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
}
const hiddenCount = incidents.length - filtered.length;
const activeCount = filtered.filter((i) => i.status === "active").length;
// Timeline grouping (Today / Yesterday / date) replaces the old
// active/resolved two-table split — status is now a chip on the row, not a
// section boundary, so an active and a resolved incident from the same
// evening read as what they are: the same kind of object.
const groups = useMemo(() => {
const byDay = new Map<string, IncidentRecord[]>();
for (const inc of filtered) {
const key = dayBucket(inc.started_at);
if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key)!.push(inc);
}
return byDay;
}, [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) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
}
return (
<div className="space-y-6">
<PageHeader
title="Incidents"
badge={activeCount > 0 && <Badge tone="danger">{activeCount} active</Badge>}
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
/>
{/* Gate A / A2 (server-26#46) — every row's title, location and unit
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 }) => (
<button
key={key}
onClick={() => setSeverityFilter(key)}
className={`text-sm px-3.5 py-1.5 rounded-md transition-colors ${
severityFilter === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
}`}
>
{label}
</button>
))}
</div>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search title, location, units…"
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-full sm:w-64 focus:outline-none focus:border-accent"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
>
<option value="any">Any status</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
>
<option value="">All types</option>
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<DateRange
from={dateFrom}
to={dateTo}
onChange={(f, t) => { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }}
/>
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
Sort
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-ink-2 focus:outline-none focus:border-accent"
>
<option value="recent">Most recent</option>
<option value="severity">Highest severity</option>
</select>
</label>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<SkeletonCard /><SkeletonCard />
</div>
) : (
<>
{hiddenCount > 0 && (
<p className="text-xs text-ink-muted">
{hiddenCount} of {incidents.length} loaded incident{incidents.length !== 1 ? "s" : ""} hidden by filters
{hasMore && " — load more to search further back"}.
</p>
)}
{Array.from(groups.entries()).map(([day, incs]) => (
<section key={day}>
<h2 className="text-sm text-ink-muted font-medium mb-1">{day}</h2>
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
{incs.map((inc) => (
<IncidentBrowseRow
key={inc.incident_id}
incident={inc}
isAdmin={isAdmin}
onAir={onAirIncidentIds.has(inc.incident_id)}
onResolve={handleResolve}
/>
))}
</div>
</section>
))}
{/* An empty list is only news when the query actually succeeded.
A failed Firestore query (missing composite index, denied rules)
also leaves `incidents` empty, and rendering "no incidents
recorded yet" over the top of it told the operator the radio was
quiet when the page had simply failed to load — server-26#13. */}
{filtered.length === 0 && error && (
<ErrorBanner message={friendlyIncidentsError(error)} />
)}
{filtered.length === 0 && !error && (
<EmptyState
title={incidents.length === 0 && !filtersActive ? "No incidents recorded yet" : "No incidents match these filters"}
description={
incidents.length === 0 && !filtersActive
? "Incidents appear automatically once calls start correlating."
: "Try clearing a filter, or load older incidents."
}
action={
filtersActive ? (
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
) : undefined
}
/>
)}
{hasMore && (
<div className="flex justify-center">
<Button variant="secondary" onClick={() => setPageLimit((n) => n + PAGE_SIZE)}>
Load more
</Button>
</div>
)}
</>
)}
{showCreate && (
<CreateModal onClose={() => setShowCreate(false)} onCreate={async (b) => { await c2api.createIncident(b); }} />
)}
</div>
);
}