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) => (