Merge pull request 'Outage providers: licence-gated registry, ODIN adapter, /outages API, admin tab (Refs #184)' (#255) from feat/184-outage-providers into main
Build & Deploy / Build & push images (push) Successful in 4m13s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m39s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #255.
This commit is contained in:
2026-10-05 02:47:15 -04:00
12 changed files with 4715 additions and 4 deletions
+6 -3
View File
@@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { ReplayTab } from "@/components/admin/ReplayTab";
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab";
import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
@@ -1268,11 +1269,12 @@ function SttEvalTab() {
// Main admin page
// ---------------------------------------------------------------------------
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
type AdminTab = "features" | "cameras" | "outages" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "cameras", label: "Camera providers" },
{ key: "outages", label: "Outage providers" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "replay", label: "Replay" },
{ key: "calls", label: "Calls" },
@@ -1285,7 +1287,7 @@ export default function AdminPage() {
const { user, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features");
// Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state.
// Deep link to a tab (/admin#cameras, /admin#outages), e.g. from a map layer's empty state.
useEffect(() => {
const h = window.location.hash.slice(1);
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
@@ -1304,7 +1306,7 @@ export default function AdminPage() {
if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras";
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
@@ -1326,6 +1328,7 @@ export default function AdminPage() {
{tab === "features" && <FeaturesTab />}
{tab === "cameras" && <CameraProvidersTab />}
{tab === "outages" && <OutageProvidersTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "replay" && <ReplayTab />}
{tab === "calls" && <StaleCallsTab />}
@@ -0,0 +1,156 @@
"use client";
/**
* Admin → Outage providers (server-26#184). Same licence gate as Camera
* providers: every source is listed with its licence status shown first, all
* default OFF. A licence-gated source (unverified / agreement required / paid)
* shows a blocking warning and needs an explicit confirmation, which c2-core
* records with who/when in config/outage_providers and audit_log. Stubs —
* utility storm-center maps, PowerOutage.us, EAGLE-I live — cannot be switched
* on at all: DRB holds no right to that data.
*/
import { useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
};
function fmtEpoch(s: number | null | undefined): string {
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
}
export function OutageProvidersTab() {
const [providers, setProviders] = useState<OutageProviderAdmin[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [confirming, setConfirming] = useState<OutageProviderAdmin | null>(null);
const [ackChecked, setAckChecked] = useState(false);
const load = () =>
c2api.listOutageProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
useEffect(() => { load(); }, []);
async function toggle(p: OutageProviderAdmin, enabled: boolean, acknowledge = false) {
if (enabled && p.licence.gated && !acknowledge) {
setError(null);
setAckChecked(false);
setConfirming(p);
return;
}
setBusy(p.id);
setError(null);
try {
const updated = await c2api.setOutageProvider(p.id, enabled, acknowledge);
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
setConfirming(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
return (
<section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug">
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
agreement exists.
</p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
return (
<div key={p.id} className="px-5 py-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p>
</div>
<div className="shrink-0 text-right space-y-1">
<button
type="button"
role="switch"
aria-checked={p.enabled}
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
disabled={busy === p.id || (!!lockReason && !p.enabled)}
title={lockReason ?? undefined}
onClick={() => toggle(p, !p.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p>
</div>
</div>
{p.implemented && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400">
<span>counties out: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
</div>
)}
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.coverage_note && <p className="text-[11px] text-gray-400">{p.coverage_note}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>}
{p.acknowledgement && (
<p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p>
)}
<details className="text-[11px] text-gray-500">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p>
<p className="mt-1 space-x-2">
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
</p>
</details>
</div>
);
})}
</div>
)}
{confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
<label className="flex items-start gap-2 text-sm text-gray-200">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div>
</div>
</div>
)}
</section>
);
}
+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) }),
+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;