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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a75149b2eb
commit
f252e3eb61
@@ -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<Date | null>(null);
|
||||
const [clock, setClock] = useState<string>("");
|
||||
|
||||
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) => (
|
||||
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
||||
@@ -251,13 +260,14 @@ export function LiveView() {
|
||||
<Kpi value={count("major")} label="major" />
|
||||
<Kpi value={count("moderate")} label="moderate" />
|
||||
<Kpi value={activeIncidents.length} label="active" />
|
||||
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
|
||||
<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"}`}>
|
||||
@@ -271,9 +281,13 @@ export function LiveView() {
|
||||
<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>
|
||||
) : (
|
||||
@@ -282,7 +296,6 @@ export function LiveView() {
|
||||
activeCalls={activeCalls}
|
||||
incidents={activeIncidents}
|
||||
calls={recentCalls}
|
||||
lastUpdated={lastUpdated}
|
||||
selectedIncidentId={selected?.incident_id ?? null}
|
||||
onSelectIncident={selectIncident}
|
||||
nearCameras={near}
|
||||
@@ -291,13 +304,14 @@ export function LiveView() {
|
||||
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{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
||||
● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user