Files
server-26/drb-frontend/components/LiveView.tsx
T
Logan CusanoandClaude Opus 5.5 f252e3eb61 Live wall: truth strip keyed to the pipeline, not node heartbeats (#232)
The '● Live · Ns ago' pill was reset by any node/activeCalls/incident change,
so heartbeats kept it green while STT/correlation were dead (#196).

- lib/usePipelineHealth.ts: polls /health/ai (auth-free) every 60 s and
  derives stage staleness from data too, since /health/ai resets on restart.
  Radio = newest call; STT = calls heard after the newest transcript, dry when
  >=3 are waiting and the oldest is > N min; AI = newest incident write /
  linked call + any degraded /health/ai tier. N = NEXT_PUBLIC_WALL_STALE_MIN
  (default 10); radio silence and AI absence get 3N (weaker evidence).
- components/live/TruthStrip.tsx: per-stage chips; full-width amber
  DEGRADED bar naming the dry stage and any degraded tier.
- LiveView: heartbeat lastUpdated removed and no longer passed to MapView
  (hides the old pill without touching MapView); 'Listening' only when every
  stage is healthy; map + rail greyed when degraded; nodes KPI is now
  'nodes hearing' (a call in the last N min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:32:00 -04:00

374 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Live — the default landing for every authenticated user (UI_REDESIGN.md
* §3, §5.1), now the live ops wall (design-canvas/live-ops-wall-mockup.html):
* Region → Incident → Camera drill-down, worst-first incident rail (MapView),
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes";
import { useActiveCalls, useCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents";
import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels";
import { useCamerasInBbox } from "@/lib/useCameras";
import { c2api } from "@/lib/c2api";
import { severityRank } from "@/lib/severity";
import type { Camera, Ny511Response } from "@/lib/types";
import {
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
} from "@/lib/liveWall";
import { TimeScrubber } from "@/components/TimeScrubber";
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
import { CameraDrill } from "@/components/live/CameraDrill";
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
import { TruthStrip } from "@/components/live/TruthStrip";
import { usePipelineHealth } from "@/lib/usePipelineHealth";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TOUR_MS = 20_000;
const PIN_KEY = "drb.wall.pinnedCameras";
const MAX_PINNED = 6;
const EVENTS_POLL_MS = 2 * 60 * 1000;
function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
return `${Math.floor(s / 3600)}h ago`;
}
/** Per-viewer convenience only; the wall works without it. */
function loadPinned(): Camera[] {
try {
const raw = window.localStorage.getItem(PIN_KEY);
const v = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v.filter((c) => c && typeof c.id === "string").slice(0, MAX_PINNED) : [];
} catch {
return [];
}
}
function savePinned(cams: Camera[]) {
try { window.localStorage.setItem(PIN_KEY, JSON.stringify(cams)); } catch { /* storage blocked */ }
}
/** 511NY road events around the region (situational context + KPI). */
function useRegionEvents(box: ReturnType<typeof bboxAround>) {
const [data, setData] = useState<Ny511Response>({});
const key = box ? [box.south, box.west, box.north, box.east].map((n) => n.toFixed(2)).join(",") : "";
useEffect(() => {
if (!box) return;
const b = box;
let cancelled = false;
const load = () => {
if (document.hidden) return;
c2api.get511(b, "events").then((r) => { if (!cancelled) setData(r); }).catch((e) => console.error("region events:", e));
};
load();
const id = setInterval(load, EVENTS_POLL_MS);
return () => { cancelled = true; clearInterval(id); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return data;
}
function Kpi({ value, label }: { value: string | number; label: string }) {
return (
<span className="inline-flex items-baseline gap-1 text-xs text-ink-muted whitespace-nowrap">
<b className="text-ink font-semibold tabular-nums">{value}</b>{label}
</span>
);
}
export function LiveView() {
const { nodes, loading: nodesLoading } = useNodes();
const activeCalls = useActiveCalls();
const activeIncidents = useActiveIncidents();
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
// and the scrubber's density bars. Real recent calls, not a fixture.
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
const { aircraft } = useAircraft();
const { vessels } = useVessels();
const [clock, setClock] = useState<string>("");
// No heartbeat-driven "last updated" any more (server-26#232): a node
// check-in kept '● Live' fresh while STT/correlation were dead (#196). The
// wall's liveness now comes from usePipelineHealth, keyed to pipeline output.
useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
useEffect(() => {
const read = () => setLinkState(parseWallHash(window.location.hash));
read();
window.addEventListener("hashchange", read);
return () => window.removeEventListener("hashchange", read);
}, []);
const setLink = useCallback((next: WallLink) => {
setLinkState(next);
const h = wallHash(next);
window.history.replaceState(null, "", h || window.location.pathname + window.location.search);
}, []);
const [pinned, setPinned] = useState<Camera[]>([]);
useEffect(() => { setPinned(loadPinned()); }, []);
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
const togglePin = useCallback((cam: Camera) => {
setPinned((prev) => {
const next = prev.some((c) => c.id === cam.id) ? prev.filter((c) => c.id !== cam.id) : [...prev, cam].slice(-MAX_PINNED);
savePinned(next);
return next;
});
}, []);
// ── Geography ─────────────────────────────────────────────────────────────
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
const pointsById = useMemo(() => {
const m = new Map<string, LatLon[]>();
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
return m;
}, [activeIncidents, callsByIncident]);
const regionBox = useMemo(() => {
const pts = Array.from(pointsById.values()).flat();
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
}, [pointsById, nodes]);
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
// Pins live in this browser and carry their image URLs, so a pin must not
// outlive its provider: once an admin switches a provider off (licence
// gating, server-26#198) its pinned cameras stop rendering until it is back.
const servingPinned = useMemo(() => pinned.filter((c) => feeds.has(c.provider)), [pinned, feeds]);
const nearById = useMemo(() => {
const m = new Map<string, NearCamera[]>();
pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras)));
return m;
}, [pointsById, cams.cameras]);
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
const events = useRegionEvents(regionBox);
const health = usePipelineHealth({
calls: recentCalls,
incidents: activeIncidents,
nodes,
eventsStatus: events.events_status,
cameraProviders: cams.providers,
});
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
const drillCam: Camera | null = link.cam
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
: null;
// Region wall: pinned first, then the nearest camera to each major/moderate incident.
const wallCameras = useMemo(() => {
const out: { cam: Camera; km?: number }[] = servingPinned.map((cam) => ({ cam }));
for (const inc of sorted) {
if (severityRank(inc.severity) < 2) continue;
const first = nearById.get(inc.incident_id)?.[0];
if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km });
}
return out.slice(0, 6);
}, [servingPinned, sorted, nearById]);
// ── Navigation ────────────────────────────────────────────────────────────
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
const stepUp = useCallback(() => {
if (link.cam) setLink({ ...link, cam: null, tour: false });
else if (link.inc) setLink({ inc: null, cam: null, tour: false });
else if (link.tour) setLink({ ...link, tour: false });
}, [link, setLink]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [stepUp]);
// ── Auto-tour: worst-first through major/moderate (else all plotted) ──────
const tourList = useMemo(() => {
const plotted = sorted.filter((i) => (pointsById.get(i.incident_id) ?? []).length);
const serious = plotted.filter((i) => severityRank(i.severity) >= 2);
return (serious.length ? serious : plotted).map((i) => i.incident_id);
}, [sorted, pointsById]);
const [tourStart, setTourStart] = useState(0);
const [tourNow, setTourNow] = useState(0);
useEffect(() => {
if (!link.tour || !tourList.length) return;
const next = () => {
setLinkState((cur) => {
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
window.history.replaceState(null, "", wallHash(nxt));
return nxt;
});
setTourStart(Date.now());
};
if (!link.inc || !tourList.includes(link.inc)) next();
else setTourStart(Date.now());
const id = setInterval(next, TOUR_MS);
const prog = setInterval(() => setTourNow(Date.now()), 500);
return () => { clearInterval(id); clearInterval(prog); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [link.tour, tourList.join(",")]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0;
// ── KPIs ──────────────────────────────────────────────────────────────────
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
// that checks in but hears nothing is not contributing to the picture.
const nodesHearing = health.nodesHearing;
const eventsOff = !!events.events_status?.disabled;
// "Listening" only when every stage is healthy; otherwise the amber bar says why.
const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0
&& activeIncidents.length === 0 && !health.degraded;
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
className={`px-1.5 py-0.5 rounded ${active ? "text-ink font-semibold" : enabled ? "text-ink-2 hover:text-ink" : "text-ink-muted"}`}>
{label}
</button>
);
return (
<div className="flex flex-col h-full">
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<nav aria-label="Drill level" className="flex items-center gap-0.5">
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
() => setLink({ ...link, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
</nav>
<div className="hidden md:flex items-center gap-3 flex-wrap">
<Kpi value={count("major")} label="major" />
<Kpi value={count("moderate")} label="moderate" />
<Kpi value={activeIncidents.length} label="active" />
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
<Kpi value={cams.cameras.length} label="cams" />
<Kpi value={aircraft.length} label="aircraft" />
<Kpi value={vessels.length} label="vessels" />
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
</div>
<div className="ml-auto flex items-center gap-3">
<TruthStrip health={health} placement="chips" />
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
title="Wall mode: cycle through active incidents, worst first"
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Auto-tour
</button>
{link.tour && (
<span className="w-20 h-1 bg-raised rounded overflow-hidden" aria-hidden="true">
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
</span>
)}
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div>
</div>
<TruthStrip health={health} placement="bar" />
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
{/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : (
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={activeIncidents}
calls={recentCalls}
selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident}
nearCameras={near}
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
/>
)}
</div>
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */}
{configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
</span>
</div>
)}
</div>
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
{selected ? (
<IncidentPanel
inc={selected}
calls={callsByIncident.get(selected.incident_id) ?? []}
points={selectedPoints}
near={near}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
onAir={onAir.has(selected.incident_id)}
aircraft={aircraft}
vessels={vessels}
events={events.events ?? []}
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
onOpenCamera={openCamera}
/>
) : (
<RegionPanel
incidents={activeIncidents}
calls={recentCalls}
onAirCount={onAir.size}
nodes={nodes}
wallCameras={wallCameras}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
aircraftCount={aircraft.length}
vesselCount={vessels.length}
events={events.events ?? []}
eventsOff={eventsOff}
pinnedIds={pinnedIds}
onOpenCamera={openCamera}
/>
)}
</aside>
</div>
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
{drillCam && (
<CameraDrill
camera={drillCam}
feed={feeds.get(drillCam.provider)}
distanceKm={selectedPoints.length ? Math.min(...selectedPoints.map((p) => km(p, [drillCam.lat, drillCam.lon]))) : null}
incidentTitle={selected?.title}
pinned={pinnedIds.has(drillCam.id)}
onTogglePin={() => togglePin(drillCam)}
onClose={() => setLink({ ...link, cam: null })}
/>
)}
</div>
);
}