Merge remote-tracking branch 'origin/main' into feat/225-s7-oauth-ui
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Call-audio playback store — server-26#233.
|
||||
*
|
||||
* ONE module-level <audio> element for the whole app, so exactly one call
|
||||
* plays at a time no matter how many players are on screen (spine rows, map
|
||||
* path-stop popups, rail cards, the incident page). Components read it through
|
||||
* useSyncExternalStore, which is what lets the wall panel and the map share
|
||||
* "now playing" without LiveView.tsx threading state between them.
|
||||
*
|
||||
* Nothing here runs until play()/playIncident() is called from a click, so
|
||||
* nothing autoplays on page load. The playback URL is minted per play by
|
||||
* GET /calls/{id} (a short-lived HMAC link, see c2-core storage.py) rather
|
||||
* than read off the Firestore doc, which only holds the gs:// location.
|
||||
* c2-core serves it with HTTP Range, so a long call starts after the first
|
||||
* span and seeking is a 206 for just the bytes needed.
|
||||
*/
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
export type AudioStatus = "idle" | "loading" | "playing" | "paused";
|
||||
|
||||
export interface AudioState {
|
||||
/** Calls still to play after the current one, oldest first. */
|
||||
queue: string[];
|
||||
playingCallId: string | null;
|
||||
status: AudioStatus;
|
||||
/** The last call whose audio could not be fetched or decoded. */
|
||||
errorCallId: string | null;
|
||||
current: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const IDLE: AudioState = {
|
||||
queue: [], playingCallId: null, status: "idle", errorCallId: null, current: 0, duration: 0,
|
||||
};
|
||||
|
||||
let state: AudioState = IDLE;
|
||||
const listeners = new Set<() => void>();
|
||||
let el: HTMLAudioElement | null = null;
|
||||
// Bumped on every play/stop so a getCall() that resolves after the user has
|
||||
// already moved on is dropped instead of hijacking the player.
|
||||
let token = 0;
|
||||
|
||||
function set(patch: Partial<AudioState>) {
|
||||
state = { ...state, ...patch };
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function audio(): HTMLAudioElement {
|
||||
if (el) return el;
|
||||
el = new Audio();
|
||||
el.preload = "auto";
|
||||
el.addEventListener("play", () => set({ status: "playing" }));
|
||||
el.addEventListener("pause", () => { if (state.status === "playing") set({ status: "paused" }); });
|
||||
el.addEventListener("timeupdate", () => set({ current: el!.currentTime }));
|
||||
el.addEventListener("loadedmetadata", () => set({ duration: Number.isFinite(el!.duration) ? el!.duration : 0 }));
|
||||
el.addEventListener("ended", advance);
|
||||
el.addEventListener("error", () => {
|
||||
if (!state.playingCallId || !el!.getAttribute("src")) return;
|
||||
set({ errorCallId: state.playingCallId });
|
||||
advance();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function advance() {
|
||||
const [next, ...rest] = state.queue;
|
||||
if (next) void start(next, rest);
|
||||
else stop();
|
||||
}
|
||||
|
||||
async function start(callId: string, queue: string[]) {
|
||||
const mine = ++token;
|
||||
const a = audio();
|
||||
a.pause();
|
||||
set({ queue, playingCallId: callId, status: "loading", current: 0, duration: 0,
|
||||
errorCallId: state.errorCallId === callId ? null : state.errorCallId });
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await c2api.getCall(callId)).audio_url ?? null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
if (mine !== token) return;
|
||||
if (!url) {
|
||||
set({ errorCallId: callId });
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
a.src = url;
|
||||
// The click that got us here is the play intent; a rejection (autoplay
|
||||
// policy after a slow fetch) leaves it paused for one more click.
|
||||
a.play().catch(() => { if (mine === token) set({ status: "paused" }); });
|
||||
}
|
||||
|
||||
/** Play one call now, replacing whatever is playing or queued. */
|
||||
export function play(callId: string) {
|
||||
void start(callId, []);
|
||||
}
|
||||
|
||||
/** Play an incident's calls back to back. Pass them oldest first. */
|
||||
export function playIncident(callIdsOldestFirst: string[]) {
|
||||
const [first, ...rest] = callIdsOldestFirst;
|
||||
if (first) void start(first, rest);
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
token++;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
set({ ...IDLE, errorCallId: state.errorCallId });
|
||||
}
|
||||
|
||||
export function pause() {
|
||||
el?.pause();
|
||||
}
|
||||
|
||||
export function resume() {
|
||||
el?.play().catch(() => {});
|
||||
}
|
||||
|
||||
export function seek(t: number) {
|
||||
if (el && Number.isFinite(t)) {
|
||||
el.currentTime = t;
|
||||
set({ current: t });
|
||||
}
|
||||
}
|
||||
|
||||
/** Play/pause toggle for one call's button. */
|
||||
export function toggle(callId: string) {
|
||||
if (state.playingCallId !== callId) play(callId);
|
||||
else if (state.status === "playing") pause();
|
||||
else if (state.status === "paused") resume();
|
||||
}
|
||||
|
||||
function subscribe(l: () => void): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
// Last player unmounted (navigated away from every page that shows one):
|
||||
// stop rather than keep playing audio nothing on screen can pause.
|
||||
// Deferred so a same-tick remount (StrictMode, route swap) doesn't stop it.
|
||||
if (!listeners.size) setTimeout(() => { if (!listeners.size && state.playingCallId) stop(); }, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a slice of the store. Return a primitive from `select` (an id,
|
||||
* a status, a number) so a component re-renders only when that slice changes
|
||||
* — the map must not re-render on every timeupdate.
|
||||
*/
|
||||
export function useAudio<T>(select: (s: AudioState) => T): T {
|
||||
return useSyncExternalStore(subscribe, () => select(state), () => select(IDLE));
|
||||
}
|
||||
|
||||
/** True when a call has a stored recording c2-core can serve. */
|
||||
export function hasAudio(c: Pick<CallRecord, "audio_gcs_uri" | "audio_url">): boolean {
|
||||
return !!(c.audio_gcs_uri || c.audio_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE gate for call-audio playback in the UI. BUSINESS_MODEL.md §3 puts
|
||||
* audio playback in Scanner and up, with the Public tier getting none; no tier
|
||||
* concept exists in the frontend yet (and Gate A keeps tier names off public
|
||||
* surfaces), so today this is true for any signed-in org member. When tiers
|
||||
* land, switch the Public tier off HERE rather than at each player.
|
||||
*/
|
||||
export function canPlayAudio(auth: { user: unknown; orgId: string | null; isAdmin?: boolean }): boolean {
|
||||
return !!auth.user && (!!auth.orgId || !!auth.isAdmin);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
|
||||
import type {
|
||||
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
||||
} from "@/lib/types";
|
||||
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
@@ -514,6 +516,22 @@ export const c2api = {
|
||||
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||
}),
|
||||
|
||||
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
|
||||
// `status` explains an empty list: none_enabled / no_source / error / ok.
|
||||
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
|
||||
request<OutagesResponse>(`/outages?${new URLSearchParams({
|
||||
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
||||
})}`),
|
||||
|
||||
// Admin → Outage providers. Same licence gate as cameras: a gated provider
|
||||
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
|
||||
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
|
||||
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
|
||||
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||
}),
|
||||
|
||||
// Public waitlist — no auth, see routers/waitlist.py
|
||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface DiscordGuildOption {
|
||||
linked: boolean;
|
||||
synced: boolean;
|
||||
synced_at: string | null;
|
||||
/** Who listed the channels (server-26#225 S5): the DRB server bot, or a radio bot read over REST. */
|
||||
directory_source?: "main_bot" | "radio_bot" | null;
|
||||
/** The DRB server bot is in this server. Not required: a radio bot alone is enough. */
|
||||
main_bot_present?: boolean;
|
||||
voice_channels: DiscordVoiceChannel[];
|
||||
streams_on: number;
|
||||
bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] };
|
||||
|
||||
@@ -217,6 +217,74 @@ export interface CameraProviderAdmin {
|
||||
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
|
||||
}
|
||||
|
||||
// Power outages (server-26#184) — GET /outages, cached server-side, not Firestore.
|
||||
/**
|
||||
* One county with active outages from an enabled provider. `id` is
|
||||
* provider-prefixed ("odin:36067"). Counts are utility-reported meters, used
|
||||
* as customers. `lat`/`lon` is the centre of the county's bounding box — a
|
||||
* label point, not an outage location. Times are ISO-8601 UTC.
|
||||
*/
|
||||
export interface OutageArea {
|
||||
id: string;
|
||||
provider: string;
|
||||
fips: string;
|
||||
county: string | null;
|
||||
state: string | null;
|
||||
customers_out: number; // live events only
|
||||
/** Meters on events the source still lists whose own ETR passed >24h ago (ODIN keeps months-old rows). */
|
||||
stale_customers_out: number;
|
||||
stale: boolean; // every event in the county is stale: dim or hide, never show as live
|
||||
customers_served: number | null; // null: no county-level denominator from the source
|
||||
etr: string | null; // latest estimated restoration across the county's outages
|
||||
since: string | null; // earliest reported start
|
||||
updated_at: string | null; // newest time a reporting utility here last sent data
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
bbox: [number, number, number, number] | null; // south, west, north, east
|
||||
utilities: { name: string | null; eia_id: string | null; customers_out: number; stale_customers_out: number }[];
|
||||
}
|
||||
|
||||
export interface OutageProviderFeed extends CameraCredit {
|
||||
id: string;
|
||||
name: string;
|
||||
granularity: string;
|
||||
coverage_note: string | null;
|
||||
covers_bbox: boolean;
|
||||
fetched_at: number | null; // epoch s of the provider's last successful fetch
|
||||
error: string | null;
|
||||
total_in_bbox: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface OutagesResponse {
|
||||
areas: OutageArea[];
|
||||
providers: OutageProviderFeed[];
|
||||
serving_count: number;
|
||||
/** Why the list is what it is — an empty layer is never ambiguous. */
|
||||
status: "ok" | "none_enabled" | "no_source" | "error";
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** Admin → Outage providers (GET /admin/outage-providers). */
|
||||
export interface OutageProviderAdmin {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
granularity: string;
|
||||
implemented: boolean;
|
||||
notes: string;
|
||||
homepage: string | null;
|
||||
licence: CameraProviderAdmin["licence"];
|
||||
enabled: boolean;
|
||||
serving: boolean;
|
||||
not_serving_reason: string | null;
|
||||
acknowledgement: CameraProviderAdmin["acknowledgement"];
|
||||
updated_at: string | null;
|
||||
updated_by: { uid: string; email: string } | null;
|
||||
coverage_note: string | null;
|
||||
health: CameraProviderAdmin["health"];
|
||||
}
|
||||
|
||||
export interface Ny511Event {
|
||||
id: string;
|
||||
lat: number;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Pipeline truth for the live wall (server-26#232).
|
||||
*
|
||||
* The old '● Live · Ns ago' pill was reset by any node heartbeat, so the wall
|
||||
* read live while transcription/correlation were dead (#196). This hook keys
|
||||
* health to the PIPELINE instead: radio → STT → AI, plus the context feeds.
|
||||
*
|
||||
* Two sources, deliberately both:
|
||||
* - Data timestamps on what the wall already subscribes to (recent calls,
|
||||
* active incidents). These survive a c2-core restart.
|
||||
* - GET /health/ai (main.py, auth-free) — per-tier provider state. It is
|
||||
* in-memory per process and resets on restart, so it can only ADD a
|
||||
* degraded verdict, never clear one the data shows.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CallRecord, CameraProviderFeed, IncidentRecord, NodeRecord, Ny511FeedStatus } from "@/lib/types";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
const HEALTH_POLL_MS = 60_000;
|
||||
const TICK_MS = 30_000;
|
||||
|
||||
function envMinutes(): number {
|
||||
const n = Number(process.env.NEXT_PUBLIC_WALL_STALE_MIN);
|
||||
return Number.isFinite(n) && n > 0 ? n : 10;
|
||||
}
|
||||
/** Minutes a stage may be dry before the wall calls itself degraded. */
|
||||
export const WALL_STALE_MIN = envMinutes();
|
||||
/**
|
||||
* Radio silence and "no incident written" are weaker evidence than "radio
|
||||
* heard but not transcribed" — a quiet county at 4am is not an outage, and the
|
||||
* correlator orphans in doubt — so those two stages get a longer leash.
|
||||
*/
|
||||
const ABSENCE_FACTOR = 3;
|
||||
/**
|
||||
* Calls Whisper returned nothing for (silence, kerchunks, degenerate output)
|
||||
* get no transcript by design, so one or two blank calls after the last
|
||||
* transcript are normal. It takes this many to call STT dry.
|
||||
*/
|
||||
const MIN_UNSERVED = 3;
|
||||
|
||||
// ── /health/ai response (app/internal/ai_health.py snapshot()) ──────────────
|
||||
export interface AiTierState {
|
||||
degraded: boolean;
|
||||
permanent: boolean;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
problem: string | null;
|
||||
fix: string | null;
|
||||
first_seen: string | null;
|
||||
last_seen: string | null;
|
||||
consecutive_failures: number;
|
||||
alerted: boolean;
|
||||
}
|
||||
export interface AiHealthResponse {
|
||||
tiers: Record<string, AiTierState>;
|
||||
}
|
||||
|
||||
export type StageKey = "radio" | "stt" | "ai" | "feeds";
|
||||
export type StageState = "ok" | "degraded" | "unknown";
|
||||
|
||||
export interface PipelineStage {
|
||||
key: StageKey;
|
||||
label: string;
|
||||
state: StageState;
|
||||
/** Newest evidence this stage produced output, or null if none in the window. */
|
||||
lastAt: Date | null;
|
||||
/** One-line human explanation, shown as the chip tooltip / bar text. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface PipelineHealth {
|
||||
stages: PipelineStage[];
|
||||
/** True when a core stage (radio/STT/AI) is dry or an AI tier is degraded. */
|
||||
degraded: boolean;
|
||||
/** Bar text, e.g. "radio heard, not transcribed since 12:15 · transcription tier degraded (openai: …)". */
|
||||
message: string | null;
|
||||
degradedTiers: { tier: string; state: AiTierState }[];
|
||||
/** null until the first /health/ai answer; false if it could not be reached. */
|
||||
aiHealthReachable: boolean | null;
|
||||
/** Distinct nodes with a call started in the last WALL_STALE_MIN minutes. */
|
||||
nodesHearing: number;
|
||||
newestCallAt: Date | null;
|
||||
staleMin: number;
|
||||
}
|
||||
|
||||
function useAiHealth(): { data: AiHealthResponse | null; reachable: boolean | null } {
|
||||
const [data, setData] = useState<AiHealthResponse | null>(null);
|
||||
const [reachable, setReachable] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
if (document.hidden) return;
|
||||
fetch(`${BASE}/health/ai`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((j: AiHealthResponse) => {
|
||||
if (cancelled) return;
|
||||
setData(j && typeof j.tiers === "object" ? j : { tiers: {} });
|
||||
setReachable(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
console.error("health/ai:", e);
|
||||
setReachable(false);
|
||||
});
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, HEALTH_POLL_MS);
|
||||
const onVis = () => { if (!document.hidden) load(); };
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => { cancelled = true; clearInterval(id); document.removeEventListener("visibilitychange", onVis); };
|
||||
}, []);
|
||||
return { data, reachable };
|
||||
}
|
||||
|
||||
const ms = (iso: string | null | undefined): number => {
|
||||
if (!iso) return NaN;
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
};
|
||||
const maxDate = (vals: number[]): Date | null => {
|
||||
const ok = vals.filter((v) => Number.isFinite(v));
|
||||
return ok.length ? new Date(Math.max(...ok)) : null;
|
||||
};
|
||||
export function clockTime(d: Date): string {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
function agoText(d: Date | null, now: number): string {
|
||||
if (!d) return "none in window";
|
||||
const s = Math.max(0, Math.floor((now - d.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`;
|
||||
}
|
||||
const hasTranscript = (c: CallRecord) => !!(c.transcript || c.transcript_corrected);
|
||||
const isAiTier = (t: string) => t !== "transcription";
|
||||
|
||||
export interface PipelineInput {
|
||||
calls: CallRecord[];
|
||||
incidents: IncidentRecord[];
|
||||
nodes: NodeRecord[];
|
||||
eventsStatus?: Ny511FeedStatus;
|
||||
cameraProviders: CameraProviderFeed[];
|
||||
}
|
||||
|
||||
export function usePipelineHealth(input: PipelineInput): PipelineHealth {
|
||||
const { calls, incidents, nodes, eventsStatus, cameraProviders } = input;
|
||||
const { data: ai, reachable } = useAiHealth();
|
||||
// Staleness is a function of wall-clock time, not only of new data arriving.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return useMemo(
|
||||
() => derivePipelineHealth({ calls, incidents, nodes, eventsStatus, cameraProviders }, ai, reachable, now),
|
||||
[calls, incidents, nodes, eventsStatus, cameraProviders, ai, reachable, now],
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure derivation — separated from the hook so it can be exercised without React. */
|
||||
export function derivePipelineHealth(
|
||||
input: PipelineInput,
|
||||
ai: AiHealthResponse | null,
|
||||
reachable: boolean | null,
|
||||
now: number,
|
||||
): PipelineHealth {
|
||||
const { calls, incidents, nodes, eventsStatus, cameraProviders } = input;
|
||||
const staleMs = WALL_STALE_MIN * 60_000;
|
||||
const absenceMs = staleMs * ABSENCE_FACTOR;
|
||||
// Canonical, finished recordings only: a second node's copy of the same
|
||||
// transmission (duplicate_of) is never transcribed, and an in-flight call
|
||||
// has not been uploaded yet.
|
||||
const real = calls.filter((c) => !c.duplicate_of && c.status !== "active" && Number.isFinite(ms(c.started_at)));
|
||||
|
||||
const tiers = Object.entries(ai?.tiers ?? {});
|
||||
const degradedTiers = tiers.filter(([, s]) => s?.degraded).map(([tier, state]) => ({ tier, state }));
|
||||
const tierText = (tier: string, s: AiTierState) =>
|
||||
`${tier} tier degraded${s.provider ? ` (${s.provider}${s.model ? ` ${s.model}` : ""})` : ""}${s.problem ? `: ${s.problem}` : ""}`;
|
||||
|
||||
// ── 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 || 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 && knownNodes.has(c.node_id)).map((c) => c.node_id),
|
||||
).size;
|
||||
|
||||
// ── STT: radio heard, nothing transcribed ────────────────────────────────
|
||||
const newestTranscriptAt = maxDate(real.filter(hasTranscript).map((c) => ms(c.started_at)));
|
||||
const unserved = real
|
||||
.filter((c) => !hasTranscript(c) && (!newestTranscriptAt || ms(c.started_at) > newestTranscriptAt.getTime()))
|
||||
.map((c) => ms(c.started_at));
|
||||
const oldestUnserved = unserved.length ? Math.min(...unserved) : NaN;
|
||||
const sttDry = unserved.length >= MIN_UNSERVED && now - oldestUnserved > staleMs;
|
||||
const sttTier = degradedTiers.find((t) => t.tier === "transcription");
|
||||
const sttSince = newestTranscriptAt ?? (Number.isFinite(oldestUnserved) ? new Date(oldestUnserved) : null);
|
||||
const stt: PipelineStage = {
|
||||
key: "stt", label: "Transcripts", lastAt: newestTranscriptAt,
|
||||
state: sttDry || sttTier ? "degraded" : real.length ? "ok" : "unknown",
|
||||
detail: sttDry
|
||||
? `radio heard, not transcribed since ${sttSince ? clockTime(sttSince) : "—"} (${unserved.length} calls waiting)`
|
||||
: newestTranscriptAt ? `last transcript ${agoText(newestTranscriptAt, now)}` : "no transcripts in the recent window",
|
||||
};
|
||||
|
||||
// ── AI: transcribed, but nothing correlated ─────────────────────────────
|
||||
const newestAiAt = maxDate([
|
||||
...incidents.flatMap((i) => [ms(i.updated_at), ms(i.last_thin_at)]),
|
||||
...real.filter((c) => (c.incident_ids?.length ?? 0) > 0 || !!c.incident_id).map((c) => ms(c.started_at)),
|
||||
]);
|
||||
const transcribedSinceAi = real
|
||||
.filter((c) => hasTranscript(c) && (!newestAiAt || ms(c.started_at) > newestAiAt.getTime()))
|
||||
.map((c) => ms(c.started_at));
|
||||
const oldestUncorrelated = transcribedSinceAi.length ? Math.min(...transcribedSinceAi) : NaN;
|
||||
const aiDry = transcribedSinceAi.length >= MIN_UNSERVED && now - oldestUncorrelated > absenceMs;
|
||||
const aiTiers = degradedTiers.filter((t) => isAiTier(t.tier));
|
||||
const ai_: PipelineStage = {
|
||||
key: "ai", label: "AI", lastAt: newestAiAt,
|
||||
state: aiDry || aiTiers.length ? "degraded" : reachable === false && !newestAiAt ? "unknown" : "ok",
|
||||
detail: aiDry
|
||||
? `transcribed, no incident written since ${newestAiAt ? clockTime(newestAiAt) : clockTime(new Date(oldestUncorrelated))}`
|
||||
: `${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) ──
|
||||
const feedProblems: string[] = [];
|
||||
if (eventsStatus && !eventsStatus.disabled) {
|
||||
if (eventsStatus.error) feedProblems.push(`511: ${eventsStatus.error}`);
|
||||
else if (eventsStatus.fetched_at && now / 1000 - eventsStatus.fetched_at > absenceMs / 1000)
|
||||
feedProblems.push(`511 data ${Math.round((now / 1000 - eventsStatus.fetched_at) / 60)}m old`);
|
||||
}
|
||||
for (const p of cameraProviders) if (p.error) feedProblems.push(`${p.name}: ${p.error}`);
|
||||
const feeds: PipelineStage = {
|
||||
key: "feeds", label: "Feeds", lastAt: null,
|
||||
state: feedProblems.length ? "degraded" : "ok",
|
||||
detail: feedProblems.length ? feedProblems.join(" · ") : "511 / camera providers OK",
|
||||
};
|
||||
|
||||
// ── Verdict ─────────────────────────────────────────────────────────────
|
||||
const parts: string[] = [];
|
||||
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));
|
||||
const degraded = parts.length > 0;
|
||||
|
||||
return {
|
||||
stages: [radio, stt, ai_, feeds],
|
||||
degraded,
|
||||
message: degraded ? parts.join(" · ") : null,
|
||||
degradedTiers,
|
||||
aiHealthReachable: reachable,
|
||||
nodesHearing,
|
||||
newestCallAt,
|
||||
staleMin: WALL_STALE_MIN,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user