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>
374 lines
18 KiB
TypeScript
374 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|