Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
catalog cache; one adapter per source in design-canvas/camera-sources.md.
Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
(agreement required / unverified / non-commercial / paid) need an explicit
acknowledgement stored with who/when and written to audit_log; the audit
write is a precondition. Stubs and prohibited sources cannot be enabled.
Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
+ NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
("may not alter the substance of a report").
Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
(ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
8.9 KiB
TypeScript
151 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Admin → Camera providers. Every source in design-canvas/camera-sources.md,
|
|
* each with its licence status shown first. All default OFF. A licence-gated
|
|
* source (agreement required / unverified / non-commercial / paid) shows a
|
|
* blocking warning and needs an explicit confirmation, which c2-core records
|
|
* with who/when in config/camera_providers and audit_log. Stubs and sources
|
|
* whose terms forbid the use cannot be switched on at all.
|
|
*/
|
|
import { useEffect, useState } from "react";
|
|
import { c2api } from "@/lib/c2api";
|
|
import type { CameraProviderAdmin, LicenceStatus } 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 CameraProvidersTab() {
|
|
const [providers, setProviders] = useState<CameraProviderAdmin[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [confirming, setConfirming] = useState<CameraProviderAdmin | null>(null);
|
|
const [ackChecked, setAckChecked] = useState(false);
|
|
|
|
const load = () =>
|
|
c2api.listCameraProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function toggle(p: CameraProviderAdmin, enabled: boolean, acknowledge = false) {
|
|
if (enabled && p.licence.gated && !acknowledge) {
|
|
setAckChecked(false);
|
|
setConfirming(p);
|
|
return;
|
|
}
|
|
setBusy(p.id);
|
|
setError(null);
|
|
try {
|
|
const updated = await c2api.setCameraProvider(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">
|
|
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
|
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
|
|
Required attributions render on every camera tile.
|
|
</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.formats}</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>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400">
|
|
<span>cameras: {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>
|
|
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
|
</div>
|
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
|
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{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="cam-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="cam-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>
|
|
<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's imagery. 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>
|
|
);
|
|
}
|