Merge remote-tracking branch 'origin/main' into feat/225-s7-oauth-ui

This commit is contained in:
Logan Cusano
2026-10-05 03:03:26 -04:00
38 changed files with 7775 additions and 246 deletions
+176
View File
@@ -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);
}
+19 -1
View File
@@ -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) }),
+4
View File
@@ -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[] };
+68
View File
@@ -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;
+271
View File
@@ -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,
};
}