diff --git a/drb-frontend/lib/usePipelineHealth.ts b/drb-frontend/lib/usePipelineHealth.ts index 80f3036..c183bf2 100644 --- a/drb-frontend/lib/usePipelineHealth.ts +++ b/drb-frontend/lib/usePipelineHealth.ts @@ -182,14 +182,21 @@ export function derivePipelineHealth( // ── Radio ──────────────────────────────────────────────────────────────── const newestCallAt = maxDate(calls.map((c) => ms(c.started_at))); const anyNodeUp = nodes.some((n) => n.status !== "offline"); + // Every configured node offline is not "Listening" — nothing can be heard. + const allNodesOffline = nodes.length > 0 && !anyNodeUp; const radioDry = anyNodeUp && (!newestCallAt || now - newestCallAt.getTime() > absenceMs); const radio: PipelineStage = { key: "radio", label: "Radio", lastAt: newestCallAt, - state: nodes.length === 0 ? "unknown" : radioDry ? "degraded" : "ok", - detail: newestCallAt ? `last call heard ${agoText(newestCallAt, now)}` : "no calls in the recent window", + state: nodes.length === 0 ? "unknown" : radioDry || allNodesOffline ? "degraded" : "ok", + detail: allNodesOffline + ? `all ${nodes.length} node${nodes.length === 1 ? "" : "s"} offline` + : newestCallAt ? `last call heard ${agoText(newestCallAt, now)}` : "no calls in the recent window", }; + // Bounded to configured nodes so a call from a since-removed node can't push + // the KPI past N/N. + const knownNodes = new Set(nodes.map((n) => n.node_id)); const nodesHearing = new Set( - calls.filter((c) => now - ms(c.started_at) <= staleMs).map((c) => c.node_id), + calls.filter((c) => now - ms(c.started_at) <= staleMs && knownNodes.has(c.node_id)).map((c) => c.node_id), ).size; // ── STT: radio heard, nothing transcribed ──────────────────────────────── @@ -225,7 +232,7 @@ export function derivePipelineHealth( state: aiDry || aiTiers.length ? "degraded" : reachable === false && !newestAiAt ? "unknown" : "ok", detail: aiDry ? `transcribed, no incident written since ${newestAiAt ? clockTime(newestAiAt) : clockTime(new Date(oldestUncorrelated))}` - : `last incident write ${agoText(newestAiAt, now)}${reachable === false ? " · /health/ai unreachable" : ""}`, + : `${newestAiAt ? `last incident write ${agoText(newestAiAt, now)}` : "no incident writes in the recent window"}${reachable === false ? " · /health/ai unreachable" : ""}`, }; // ── Context feeds (chip only — a dead camera feed doesn't blind the pipeline) ── @@ -244,7 +251,8 @@ export function derivePipelineHealth( // ── Verdict ───────────────────────────────────────────────────────────── const parts: string[] = []; - if (radio.state === "degraded") parts.push(`nodes checking in, no radio heard since ${newestCallAt ? clockTime(newestCallAt) : "—"}`); + if (allNodesOffline) parts.push(`${radio.detail}${newestCallAt ? `, last call heard ${clockTime(newestCallAt)}` : ""}`); + else if (radioDry) parts.push(`nodes checking in, no radio heard since ${newestCallAt ? clockTime(newestCallAt) : "—"}`); if (sttDry) parts.push(stt.detail); if (aiDry) parts.push(ai_.detail); for (const t of degradedTiers) parts.push(tierText(t.tier, t.state));