Merge remote-tracking branch 'origin/main' into feat/225-s7-oauth-ui
This commit is contained in:
@@ -8,105 +8,15 @@
|
||||
* memorable thing per §2.4: "where was he when he said that" is answered
|
||||
* by looking, not cross-referencing timestamps.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||
import { hasAudio as callHasAudio } from "@/lib/audioQueue";
|
||||
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
function fmtClock(s: number): string {
|
||||
if (!Number.isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const r = Math.floor(s % 60);
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function InlinePlayer({ callId }: { callId: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
async function ensureUrl() {
|
||||
if (url || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const full = await c2api.getCall(callId);
|
||||
setUrl(full.audio_url ?? null);
|
||||
if (!full.audio_url) setError(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (!url) {
|
||||
await ensureUrl();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (playing) el.pause();
|
||||
else el.play();
|
||||
}
|
||||
|
||||
// Once the URL lands, autoplay (the click that fetched it was the play intent).
|
||||
useEffect(() => {
|
||||
if (url && audioRef.current) audioRef.current.play().catch(() => {});
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return <span className="text-xs text-sev-major">Audio unavailable</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
|
||||
title={playing ? "Pause" : "Play"}
|
||||
>
|
||||
{loading ? "…" : playing ? "❚❚" : "▶"}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={current}
|
||||
onChange={(e) => {
|
||||
const t = Number(e.target.value);
|
||||
if (audioRef.current) audioRef.current.currentTime = t;
|
||||
setCurrent(t);
|
||||
}}
|
||||
className="flex-1 h-1 accent-accent"
|
||||
disabled={!url}
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
||||
{fmtClock(current)}
|
||||
</span>
|
||||
{url && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={url}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
className="hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
||||
if (!stopNumber) {
|
||||
@@ -132,7 +42,7 @@ export function CallSpineEntry({
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
||||
const hasAudio = callHasAudio(call);
|
||||
const transcript = call.transcript_corrected || call.transcript;
|
||||
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
||||
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Click-to-listen control for one call — server-26#233. Moved out of
|
||||
* CallSpineEntry.tsx so the live wall's spine, the map's path-stop popups and
|
||||
* rail cards share it. It no longer owns an <audio> element: playback goes
|
||||
* through lib/audioQueue.ts's single element, so starting a call anywhere
|
||||
* stops whatever else was playing, and the map can ring the stop that's
|
||||
* speaking.
|
||||
*
|
||||
* Every hook runs before any early return (the server-26#109 item 2 bug was a
|
||||
* return between hooks). Nothing plays until the button is clicked.
|
||||
*/
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { canPlayAudio, seek, toggle, useAudio } from "@/lib/audioQueue";
|
||||
|
||||
function fmtClock(s: number): string {
|
||||
if (!Number.isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const r = Math.floor(s % 60);
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Just the round play/pause button — for map popups, rail cards, spine rows. */
|
||||
export function PlayButton({ callId, label, className = "" }: { callId: string; label?: string; className?: string }) {
|
||||
const auth = useAuth();
|
||||
const active = useAudio((s) => s.playingCallId === callId);
|
||||
const status = useAudio((s) => (s.playingCallId === callId ? s.status : "idle"));
|
||||
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
|
||||
if (!canPlayAudio(auth)) return null;
|
||||
const playing = active && status === "playing";
|
||||
const loading = active && status === "loading";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggle(callId); }}
|
||||
disabled={loading}
|
||||
title={failed ? "Audio unavailable — click to retry" : playing ? "Pause" : label ?? "Play this call"}
|
||||
aria-label={playing ? "Pause call audio" : label ?? "Play call audio"}
|
||||
aria-pressed={playing}
|
||||
className={`w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[10px] disabled:opacity-50 ${failed ? "bg-raised text-sev-major border border-sev-major" : "bg-accent text-white"} ${className}`}
|
||||
>
|
||||
{loading ? "…" : playing ? "❚❚" : failed ? "!" : "▶"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Button + seek bar + clock. `compact` shows only the button until this call
|
||||
* is the one playing, then expands — for dense lists like the wall's spine.
|
||||
*/
|
||||
export function InlinePlayer({ callId, compact = false }: { callId: string; compact?: boolean }) {
|
||||
const auth = useAuth();
|
||||
const active = useAudio((s) => s.playingCallId === callId);
|
||||
const current = useAudio((s) => (s.playingCallId === callId ? s.current : 0));
|
||||
const duration = useAudio((s) => (s.playingCallId === callId ? s.duration : 0));
|
||||
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
|
||||
|
||||
if (!canPlayAudio(auth)) return null;
|
||||
if (failed && !compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<PlayButton callId={callId} />
|
||||
<span className="text-xs text-sev-major">Audio unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (compact && !active) return <PlayButton callId={callId} />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
||||
<PlayButton callId={callId} />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step="any"
|
||||
value={Math.min(current, duration || 0)}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
className="flex-1 h-1 accent-accent"
|
||||
disabled={!active || !duration}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
||||
{fmtClock(current)}{duration ? ` / ${fmtClock(duration)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import { TimeScrubber } from "@/components/TimeScrubber";
|
||||
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
||||
import { CameraDrill } from "@/components/live/CameraDrill";
|
||||
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
||||
import { TruthStrip } from "@/components/live/TruthStrip";
|
||||
import { usePipelineHealth } from "@/lib/usePipelineHealth";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
@@ -93,12 +95,11 @@ export function LiveView() {
|
||||
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
||||
const { aircraft } = useAircraft();
|
||||
const { vessels } = useVessels();
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [clock, setClock] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodesLoading) setLastUpdated(new Date());
|
||||
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
|
||||
// No heartbeat-driven "last updated" any more (server-26#232): a node
|
||||
// check-in kept '● Live' fresh while STT/correlation were dead (#196). The
|
||||
// wall's liveness now comes from usePipelineHealth, keyed to pipeline output.
|
||||
useEffect(() => {
|
||||
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
|
||||
tick();
|
||||
@@ -156,6 +157,13 @@ export function LiveView() {
|
||||
}, [pointsById, cams.cameras]);
|
||||
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
|
||||
const events = useRegionEvents(regionBox);
|
||||
const health = usePipelineHealth({
|
||||
calls: recentCalls,
|
||||
incidents: activeIncidents,
|
||||
nodes,
|
||||
eventsStatus: events.events_status,
|
||||
cameraProviders: cams.providers,
|
||||
});
|
||||
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
||||
|
||||
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
||||
@@ -220,13 +228,14 @@ export function LiveView() {
|
||||
|
||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
||||
const nodesUp = nodes.filter((n) => n.status !== "offline").length;
|
||||
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
|
||||
// that checks in but hears nothing is not contributing to the picture.
|
||||
const nodesHearing = health.nodesHearing;
|
||||
const eventsOff = !!events.events_status?.disabled;
|
||||
|
||||
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
|
||||
const mostRecentSeen = configuredButQuiet
|
||||
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1)
|
||||
: null;
|
||||
// "Listening" only when every stage is healthy; otherwise the amber bar says why.
|
||||
const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0
|
||||
&& activeIncidents.length === 0 && !health.degraded;
|
||||
|
||||
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
|
||||
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
||||
@@ -251,13 +260,14 @@ export function LiveView() {
|
||||
<Kpi value={count("major")} label="major" />
|
||||
<Kpi value={count("moderate")} label="moderate" />
|
||||
<Kpi value={activeIncidents.length} label="active" />
|
||||
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
|
||||
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
||||
<Kpi value={cams.cameras.length} label="cams" />
|
||||
<Kpi value={aircraft.length} label="aircraft" />
|
||||
<Kpi value={vessels.length} label="vessels" />
|
||||
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<TruthStrip health={health} placement="chips" />
|
||||
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
|
||||
title="Wall mode: cycle through active incidents, worst first"
|
||||
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||
@@ -271,9 +281,13 @@ export function LiveView() {
|
||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TruthStrip health={health} placement="bar" />
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
||||
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
|
||||
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
||||
MapView, so it is wrapped here rather than edited). */}
|
||||
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
||||
{nodesLoading || callsLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||
) : (
|
||||
@@ -282,7 +296,6 @@ export function LiveView() {
|
||||
activeCalls={activeCalls}
|
||||
incidents={activeIncidents}
|
||||
calls={recentCalls}
|
||||
lastUpdated={lastUpdated}
|
||||
selectedIncidentId={selected?.incident_id ?? null}
|
||||
onSelectIncident={selectIncident}
|
||||
nearCameras={near}
|
||||
@@ -291,13 +304,14 @@ export function LiveView() {
|
||||
camCountByIncident={camCountByIncident}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
||||
Activation screen handles the zero-node case). */}
|
||||
{configuredButQuiet && (
|
||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
||||
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
||||
● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,8 @@ import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Ev
|
||||
import { CameraFeed } from "@/components/CameraFeed";
|
||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
|
||||
import { PlayButton } from "@/components/InlinePlayer";
|
||||
import { hasAudio, useAudio } from "@/lib/audioQueue";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
@@ -788,20 +790,26 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||
}
|
||||
|
||||
// ── Incident path stop marker — numbered, shared index with the call spine ───
|
||||
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon {
|
||||
const size = 22;
|
||||
// `playing` (server-26#233): the stop whose call is on the speaker gets a
|
||||
// thick accent ring outside the severity halo — "where was he when he said
|
||||
// that", answered while it is being said.
|
||||
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string, playing = false): L.DivIcon {
|
||||
const size = playing ? 30 : 22;
|
||||
const c = size / 2;
|
||||
const fill = isFirst ? "var(--surface)" : color;
|
||||
const textColor = isFirst ? color : "var(--page)";
|
||||
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
|
||||
const halo = isLast ? `<circle cx="${c}" cy="${c}" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
|
||||
const ring = playing ? `<circle cx="${c}" cy="${c}" r="13.5" fill="none" stroke="var(--accent)" stroke-width="3"/>` : "";
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22">
|
||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
${ring}
|
||||
${halo}
|
||||
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
|
||||
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
|
||||
<circle cx="${c}" cy="${c}" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
|
||||
<text x="${c}" y="${c + 3.5}" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
|
||||
</svg>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
iconAnchor: [c, c],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1023,6 +1031,8 @@ function IncidentPathLayer({
|
||||
incidents: IncidentRecord[];
|
||||
callsByIncident: Map<string, CallRecord[]>;
|
||||
}) {
|
||||
// Only the playing id, so a timeupdate never re-renders the map.
|
||||
const playingCallId = useAudio((s) => s.playingCallId);
|
||||
return (
|
||||
<>
|
||||
{incidents.map((inc) => {
|
||||
@@ -1043,13 +1053,20 @@ function IncidentPathLayer({
|
||||
<Marker
|
||||
key={c.call_id}
|
||||
position={[c.location_coords!.lat, c.location_coords!.lng]}
|
||||
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)}
|
||||
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color, c.call_id === playingCallId)}
|
||||
zIndexOffset={c.call_id === playingCallId ? 1000 : 0}
|
||||
>
|
||||
<Popup minWidth={160}>
|
||||
<div className="text-gray-900">
|
||||
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
|
||||
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
||||
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
|
||||
{hasAudio(c) && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<PlayButton callId={c.call_id} label={`Play stop ${i + 1}`} />
|
||||
<span className="text-xs text-gray-600">Play this call</span>
|
||||
</div>
|
||||
)}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||
View incident →
|
||||
</a>
|
||||
@@ -1388,6 +1405,11 @@ export default function MapView({
|
||||
const live = onAir?.has(inc.incident_id) ?? false;
|
||||
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
|
||||
const callCount = inc.call_ids?.length ?? 0;
|
||||
// "Play latest" (server-26#233): newest call of this incident in
|
||||
// the loaded calls that has a recording.
|
||||
const latest = (callsByIncident.get(inc.incident_id) ?? [])
|
||||
.filter(hasAudio)
|
||||
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
|
||||
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
|
||||
const cardBody = (
|
||||
<>
|
||||
@@ -1425,28 +1447,37 @@ export default function MapView({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
// The play control is a sibling of the card, not inside it — a
|
||||
// <button> can't nest in the card's <button>/<a>.
|
||||
const playLatest = latest && (
|
||||
<PlayButton callId={latest.call_id} label="Play latest call" className="absolute bottom-2 right-2 shadow" />
|
||||
);
|
||||
if (inc.location_coords || onSelectIncident) {
|
||||
return (
|
||||
<button
|
||||
key={inc.incident_id}
|
||||
onClick={() => handleIncidentSelect(inc)}
|
||||
aria-pressed={selected}
|
||||
className={baseClass}
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{cardBody}
|
||||
</button>
|
||||
<div key={inc.incident_id} className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => handleIncidentSelect(inc)}
|
||||
aria-pressed={selected}
|
||||
className={`${baseClass} ${latest ? "pr-10" : ""}`}
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{cardBody}
|
||||
</button>
|
||||
{playLatest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={inc.incident_id}
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
className={`block ${baseClass}`}
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{cardBody}
|
||||
</a>
|
||||
<div key={inc.incident_id} className="relative shrink-0">
|
||||
<a
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{cardBody}
|
||||
</a>
|
||||
{playLatest}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Truth strip (server-26#232) — replaces the heartbeat-driven '● Live' pill.
|
||||
*
|
||||
* Healthy: compact per-stage chips (Radio · Transcripts · AI · Feeds), each
|
||||
* with its last-output age. Any core stage dry past the threshold, or any
|
||||
* /health/ai tier degraded: a full-width amber bar that says what broke and
|
||||
* since when — "DEGRADED — radio heard, not transcribed since 12:15".
|
||||
* Theme tokens only (sev-moderate is the amber), so both themes follow.
|
||||
*/
|
||||
import type { PipelineHealth, PipelineStage } from "@/lib/usePipelineHealth";
|
||||
|
||||
function dotClass(s: PipelineStage["state"]): string {
|
||||
if (s === "degraded") return "bg-sev-moderate";
|
||||
if (s === "unknown") return "bg-ink-muted";
|
||||
return "bg-ink-2";
|
||||
}
|
||||
|
||||
export function TruthChips({ health }: { health: PipelineHealth }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap" role="status" aria-label="Pipeline health">
|
||||
{health.stages.map((s) => (
|
||||
<span key={s.key} title={s.detail}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] whitespace-nowrap ${
|
||||
s.state === "degraded" ? "border-sev-moderate text-ink bg-sev-moderate/10" : "border-line text-ink-2"}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${dotClass(s.state)}`} aria-hidden="true" />
|
||||
{s.label}
|
||||
<span className="sr-only">: {s.state === "ok" ? "healthy" : s.state}, {s.detail}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TruthBar({ health }: { health: PipelineHealth }) {
|
||||
if (!health.degraded || !health.message) return null;
|
||||
return (
|
||||
<div role="alert"
|
||||
className="shrink-0 w-full px-3 py-1.5 border-b border-sev-moderate bg-sev-moderate/15 text-xs text-ink flex flex-wrap items-baseline gap-x-2">
|
||||
<b className="font-bold tracking-wide">DEGRADED</b>
|
||||
<span>— {health.message}</span>
|
||||
<span className="ml-auto text-ink-muted whitespace-nowrap">
|
||||
stale after {health.staleMin} min · map shows last-known state
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chips for the wall bar, plus the bar itself directly below it when degraded. */
|
||||
export function TruthStrip({ health, placement }: { health: PipelineHealth; placement: "chips" | "bar" }) {
|
||||
return placement === "chips" ? <TruthChips health={health} /> : <TruthBar health={health} />;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { useAuth } from "@/components/AuthProvider";
|
||||
import { CameraFeed } from "@/components/CameraFeed";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
||||
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
||||
|
||||
function sevColor(s: string | null | undefined) {
|
||||
@@ -31,6 +33,8 @@ function hhmm(iso: string): string {
|
||||
}
|
||||
|
||||
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
|
||||
// Spine rows rendered before "show all loaded" (server-26#239).
|
||||
const SPINE_CAP = 30;
|
||||
const SEC = "px-4 py-3 border-b border-line";
|
||||
|
||||
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
|
||||
@@ -183,6 +187,20 @@ export function IncidentPanel({
|
||||
return m;
|
||||
}, [calls]);
|
||||
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
|
||||
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
|
||||
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
|
||||
const auth = useAuth();
|
||||
const audioOk = canPlayAudio(auth);
|
||||
const playingCallId = useAudio((s) => s.playingCallId);
|
||||
const queueLen = useAudio((s) => s.queue.length);
|
||||
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
|
||||
// Truncation is said out loud, not silent (server-26#239): the spine shows
|
||||
// what this page has loaded, which can be less than the incident holds.
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
|
||||
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
|
||||
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
|
||||
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
|
||||
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
|
||||
const cleared = inc.units_cleared ?? [];
|
||||
|
||||
@@ -270,23 +288,57 @@ export function IncidentPanel({
|
||||
</div>
|
||||
|
||||
<div className={SEC}>
|
||||
<h3 className={H3}>Call spine · newest first</h3>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
|
||||
{audioOk && playable.length > 0 && (
|
||||
playingHere ? (
|
||||
<button type="button" onClick={stopAudio}
|
||||
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
|
||||
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => playIncident(playable)}
|
||||
title="Play this incident's loaded calls back to back, oldest first"
|
||||
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
|
||||
▶ Play incident · {playable.length}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
|
||||
<p className="text-[10px] text-ink-muted mb-1.5">
|
||||
{showAll || spine.length <= SPINE_CAP
|
||||
? `Showing all ${spine.length} loaded of ${totalCalls}`
|
||||
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
|
||||
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
|
||||
{spine.length > SPINE_CAP && (
|
||||
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
|
||||
{showAll ? "show latest 30" : "show all loaded"}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{spine.length ? (
|
||||
<ol className="space-y-2">
|
||||
{spine.slice(0, 30).map((c) => {
|
||||
{shown.map((c) => {
|
||||
const stop = stopOf.get(c.call_id);
|
||||
const text = c.transcript_corrected || c.transcript;
|
||||
const isPlaying = c.call_id === playingCallId;
|
||||
return (
|
||||
<li key={c.call_id} className="flex gap-2 text-xs">
|
||||
<li key={c.call_id}
|
||||
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
|
||||
aria-current={isPlaying ? "true" : undefined}>
|
||||
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
||||
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
||||
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
||||
</div>
|
||||
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
||||
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
|
||||
</div>
|
||||
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user