From f252e3eb611eb0baab334343148fc388ba7ba540 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 02:32:00 -0400 Subject: [PATCH 1/2] Live wall: truth strip keyed to the pipeline, not node heartbeats (#232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- drb-frontend/components/LiveView.tsx | 38 ++- drb-frontend/components/live/TruthStrip.tsx | 53 ++++ drb-frontend/lib/usePipelineHealth.ts | 263 ++++++++++++++++++++ 3 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 drb-frontend/components/live/TruthStrip.tsx create mode 100644 drb-frontend/lib/usePipelineHealth.ts diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index d0cc2c2..641d804 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -27,6 +27,8 @@ 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 }); @@ -93,12 +95,11 @@ export function LiveView() { const { calls: recentCalls, loading: callsLoading } = useCalls(500); const { aircraft } = useAircraft(); const { vessels } = useVessels(); - const [lastUpdated, setLastUpdated] = useState(null); const [clock, setClock] = useState(""); - useEffect(() => { - if (!nodesLoading) setLastUpdated(new Date()); - }, [nodes, activeCalls, activeIncidents, nodesLoading]); + // 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(); @@ -156,6 +157,13 @@ export function LiveView() { }, [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; @@ -220,13 +228,14 @@ export function LiveView() { // ── KPIs ────────────────────────────────────────────────────────────────── const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length; - const nodesUp = nodes.filter((n) => n.status !== "offline").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; - const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0; - const mostRecentSeen = configuredButQuiet - ? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1) - : null; + // "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) => (