fix(frontend): finish the token migration — raw grey classes -> theme tokens
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used raw Tailwind greys, kept readable in light mode only by the !important override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both. Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2, 500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised, border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder. Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700, text-white and marketing pages left alone. text-[9px] -> text-[10px]. Frontend unverified: no Node on the authoring box (#207). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3968b49b80
commit
bf206334e3
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
|
||||
className={`border-b border-line font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-raised/50" : "hover:bg-raised/30"}`}
|
||||
onClick={() => hasDetails && setExpanded((v) => !v)}
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
||||
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
||||
<td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
|
||||
<span className="text-ink-muted">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
||||
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-300">
|
||||
<td className="px-4 py-2 text-ink-2">
|
||||
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||
{call.tags && call.tags.length > 0 && (
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}>
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-raised text-ink-2"}`}>
|
||||
{call.tags[0]}
|
||||
</span>
|
||||
)}
|
||||
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
||||
<td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{call.node_id}</td>
|
||||
<td className="px-4 py-2">
|
||||
{isActive ? (
|
||||
<span className="text-orange-400 animate-pulse">● live</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
||||
<span className="text-ink-muted">{duration(call.started_at, call.ended_at)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{hasAudio ? (
|
||||
<span className="text-blue-400">▶</span>
|
||||
) : (
|
||||
<span className="text-gray-700">—</span>
|
||||
<span className="text-ink-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 text-xs">
|
||||
<td className="px-4 py-2 text-ink-muted text-xs">
|
||||
{hasDetails && (expanded ? "▲" : "▼")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{expanded && hasDetails && (
|
||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||
<tr className="bg-surface/60 border-b border-line">
|
||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||
{/* Audio player */}
|
||||
{hasAudio && (
|
||||
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
||||
<p className="text-xs text-ink-muted font-mono">Loading audio…</p>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{call.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-raised text-ink-2"}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{/* Incident links — one per scene detected in the recording */}
|
||||
{incidentIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
||||
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||
<span className="text-ink-muted">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||
{incidentIds.map((id) => (
|
||||
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
||||
{id.slice(0, 8)}…
|
||||
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
|
||||
{/* Correlation debug — admin only */}
|
||||
{isAdmin && call.corr_path && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-ink-muted">
|
||||
<span>corr:</span>
|
||||
<span className="text-gray-400">{call.corr_path}</span>
|
||||
<span className="text-ink-2">{call.corr_path}</span>
|
||||
{call.corr_incident_idle_min != null && (
|
||||
<span>idle {call.corr_incident_idle_min}min</span>
|
||||
)}
|
||||
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
||||
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-xs text-ink font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors"
|
||||
className="text-xs text-ink-muted hover:text-ink px-3 py-1 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
) : hasSegments ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
<span className="text-xs text-ink-muted font-mono">
|
||||
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
|
||||
)}
|
||||
</div>
|
||||
{hasBoth && !showOriginal ? (
|
||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||
{call.transcript_corrected}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
<div className="bg-raised rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
{call.segments!.map((seg, i) => (
|
||||
<div key={i} className="flex gap-3 text-xs font-mono">
|
||||
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span>
|
||||
<span className="text-gray-300">{seg.text}</span>
|
||||
<span className="text-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
|
||||
<span className="text-ink-2">{seg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{hasBoth && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
||||
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
||||
className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
|
||||
>
|
||||
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
||||
</button>
|
||||
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
||||
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>}
|
||||
{hasBoth && <span className="text-xs text-ink-muted font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
||||
{!hasBoth && call.transcript_corrected && <span className="text-xs text-ink-muted font-mono">corrected</span>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
||||
<pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
||||
{displayTranscript}
|
||||
</pre>
|
||||
{hasBoth && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
||||
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
||||
className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
|
||||
>
|
||||
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
|
||||
<p className="text-xs text-ink-muted font-mono italic">No transcript available.</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
|
||||
{camera.image_url ? "…" : "No still for this camera"}
|
||||
</div>
|
||||
)}
|
||||
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
||||
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[10px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
||||
{badge.text}
|
||||
</span>
|
||||
{credit?.attribution && (
|
||||
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
|
||||
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[10px] leading-[13px] truncate">
|
||||
{credit.attribution_url ? (
|
||||
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
||||
) : credit.attribution}
|
||||
|
||||
@@ -74,7 +74,7 @@ const RECENT_CALLS = 500;
|
||||
const KIOSK_TYPE = [
|
||||
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
|
||||
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
|
||||
"[&[data-kiosk]_[class*='text-[9px]']]:text-[18px]",
|
||||
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
|
||||
"[&[data-kiosk]_.w-56]:w-80",
|
||||
|
||||
@@ -440,7 +440,7 @@ function RadarBadge({ meta, loop, loopClock, onLoop }: {
|
||||
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}>
|
||||
{stale && <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide"
|
||||
{stale && <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold tracking-wide"
|
||||
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
|
||||
<span className="tabular-nums whitespace-nowrap">{text}</span>
|
||||
{meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
|
||||
@@ -1233,8 +1233,8 @@ function FanNodeLayer({
|
||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||
>
|
||||
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
||||
<p className="text-xs capitalize text-gray-700">{node.status}</p>
|
||||
<p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
|
||||
<p className="text-xs capitalize text-ink-muted">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-0.5">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
@@ -1346,8 +1346,8 @@ function FanIncidentLayer({
|
||||
<p className="text-xs capitalize font-medium" style={{ color }}>
|
||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
|
||||
</p>
|
||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||
<p className="text-[11px] text-gray-500">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
|
||||
{inc.location && <p className="text-xs text-ink-muted">{inc.location}</p>}
|
||||
<p className="text-[11px] text-ink-muted">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
|
||||
<a
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||
@@ -1490,19 +1490,19 @@ function IncidentPathLayer({
|
||||
>
|
||||
<Popup minWidth={160}>
|
||||
<div className="text-gray-900">
|
||||
<p className="text-xs text-gray-500">{heading}</p>
|
||||
<p className="text-xs text-ink-muted">{heading}</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>}
|
||||
{c.location && <p className="text-xs text-ink-muted mt-0.5">{c.location}</p>}
|
||||
{c.location_coords?.location_type === "GEOMETRIC_CENTER" && (
|
||||
<p className="text-[11px] text-gray-500">Approximate: centre of a road, junction or place</p>
|
||||
<p className="text-[11px] text-ink-muted">Approximate: centre of a road, junction or place</p>
|
||||
)}
|
||||
{s.dashedIn && !s.ghost && path.movement && (
|
||||
<p className="text-[11px] text-gray-500">Dashed in: long gap or talkgroup change — route unknown</p>
|
||||
<p className="text-[11px] text-ink-muted">Dashed in: long gap or talkgroup change — route unknown</p>
|
||||
)}
|
||||
{hasAudio(c) && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} />
|
||||
<span className="text-xs text-gray-600">Play this call</span>
|
||||
<span className="text-xs text-ink-muted">Play this call</span>
|
||||
</div>
|
||||
)}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||
@@ -1997,7 +1997,7 @@ function MapView({
|
||||
<span className="text-ink-2">Call location (not a pursuit)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
|
||||
<span className="text-[10px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
|
||||
<span className="text-ink-2">Path > {MAY_BE_MERGED_KM} km — may be 2+ events</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2114,7 +2114,7 @@ function MapView({
|
||||
<span className="uppercase tracking-wide font-semibold text-[11px]" style={{ color }}>
|
||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||
</span>
|
||||
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>}
|
||||
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[10px] font-semibold tracking-wide">ON AIR</span>}
|
||||
</div>
|
||||
<p className="text-ink font-semibold leading-snug truncate">
|
||||
{inc.title ?? "Incident"}
|
||||
|
||||
@@ -19,22 +19,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||
: "never";
|
||||
|
||||
const body = (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
||||
<div className="bg-surface border border-line rounded-lg p-4 hover:border-line-strong transition-colors cursor-pointer">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-mono font-semibold text-white">{node.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
||||
<p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
|
||||
</div>
|
||||
<StatusBadge status={node.status} />
|
||||
</div>
|
||||
<div className="space-y-1 text-xs font-mono text-gray-400">
|
||||
<div className="space-y-1 text-xs font-mono text-ink-2">
|
||||
<div className="flex justify-between">
|
||||
<span>System</span>
|
||||
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span>
|
||||
<span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Location</span>
|
||||
<span className="text-gray-300">
|
||||
<span className="text-ink-2">
|
||||
{node.lat != null && node.lon != null
|
||||
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
|
||||
: "Unknown"}
|
||||
@@ -42,23 +42,23 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Last seen</span>
|
||||
<span className="text-gray-300">{lastSeen}</span>
|
||||
<span className="text-ink-2">{lastSeen}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!node.configured && (
|
||||
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-gray-800 pt-2">
|
||||
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
|
||||
⚠ Needs configuration
|
||||
</div>
|
||||
)}
|
||||
{node.is_overridden && (
|
||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-line pt-2 flex justify-between">
|
||||
<span>⚠ Local Override</span>
|
||||
{node.override_timeout_at ? (
|
||||
<span className="text-gray-500">
|
||||
<span className="text-ink-muted">
|
||||
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Permanent</span>
|
||||
<span className="text-ink-muted">Permanent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,20 +51,20 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||
<div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||
<p className="text-gray-400 text-sm mb-5">
|
||||
<p className="text-ink-2 text-sm mb-5">
|
||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||
Assign it a radio system to begin monitoring.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Radio System</label>
|
||||
<label className="block text-xs text-ink-2 mb-1">Radio System</label>
|
||||
<select
|
||||
value={systemId}
|
||||
onChange={(e) => setSystemId(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select a system…</option>
|
||||
@@ -77,41 +77,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">SDR Hardware</label>
|
||||
<label className="block text-xs text-ink-2 mb-1">SDR Hardware</label>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPreset && (
|
||||
<p className="text-xs text-gray-600 mt-1">{selectedPreset.hint}</p>
|
||||
<p className="text-xs text-ink-muted mt-1">{selectedPreset.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">
|
||||
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||
<label className="block text-xs text-ink-2 mb-1">
|
||||
PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ppm}
|
||||
onChange={(e) => setPpm(e.target.value)}
|
||||
step="0.1"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
|
||||
<label className="block text-xs text-ink-2 mb-1">Node Type *</label>
|
||||
<select
|
||||
value={nodeType}
|
||||
onChange={(e) => setNodeType(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select node type...</option>
|
||||
@@ -127,9 +127,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
id="enforceTimeout"
|
||||
checked={enforceTimeout}
|
||||
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
/>
|
||||
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
|
||||
<label htmlFor="enforceTimeout" className="text-xs text-ink-2 cursor-pointer select-none">
|
||||
Enforce Local Override Timeout (24 hours)
|
||||
</label>
|
||||
</div>
|
||||
@@ -148,7 +148,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
||||
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2>
|
||||
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Conventional Channels</h2>
|
||||
{encrypted.length > 0 && (
|
||||
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
|
||||
{subject} encrypted — those calls can't be decoded, so nothing from{" "}
|
||||
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
||||
)}
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
{channels.map((c, i) => (
|
||||
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300">
|
||||
<li key={i} className="flex flex-wrap gap-x-3 text-ink-2">
|
||||
<span className="text-white">{c.name || mhz(c.freq)}</span>
|
||||
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>}
|
||||
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>}
|
||||
{c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
|
||||
{c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
|
||||
{c.encrypted ? (
|
||||
<span className="text-red-400">
|
||||
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
|
||||
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">
|
||||
<span className="text-ink-2">
|
||||
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
||||
"bg-raised border border-line-strong rounded px-2 py-1 text-ink text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
||||
|
||||
function DeviceSelect({
|
||||
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
|
||||
<p className="text-xs text-ink-muted font-mono mb-3">
|
||||
{devicesKnown
|
||||
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||
: "This node hasn't reported its SDRs yet."}
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||
<div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
|
||||
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-gray-200">OP25 SDR</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-ink">OP25 SDR</div>
|
||||
<div className="text-xs text-ink-muted">
|
||||
{op25Moving
|
||||
? "Saving restarts OP25 on the selected SDR."
|
||||
: node.op25_sdr_serial
|
||||
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
: "Waiting for SDR";
|
||||
return (
|
||||
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
|
||||
<span className="w-4 text-right text-ink-muted text-xs">{row.enabled ? ++rank : ""}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.enabled}
|
||||
disabled={!canEdit}
|
||||
aria-label={`Enable ${meta.name}`}
|
||||
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
/>
|
||||
<div className="flex-1 min-w-[7rem]">
|
||||
<div className="text-gray-200">{meta.name}</div>
|
||||
<div className="text-xs text-gray-500">{meta.hint}</div>
|
||||
<div className="text-ink">{meta.name}</div>
|
||||
<div className="text-xs text-ink-muted">{meta.hint}</div>
|
||||
</div>
|
||||
<DeviceSelect
|
||||
value={row.pin}
|
||||
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={`Move ${meta.name} up`}
|
||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||
className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === rows.length - 1}
|
||||
aria-label={`Move ${meta.name} down`}
|
||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
||||
className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
||||
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-ink-muted"}`}>
|
||||
{state}
|
||||
</span>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 font-mono mt-2">
|
||||
<p className="text-xs text-ink-muted font-mono mt-2">
|
||||
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
||||
antenna, or leave it on "Any spare SDR".
|
||||
</p>
|
||||
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
||||
{message && <span className="text-xs text-ink-muted font-mono">{message}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
|
||||
const styles: Record<NodeStatus, string> = {
|
||||
online: "bg-green-950 text-green-400 border border-green-800",
|
||||
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
|
||||
offline: "bg-gray-900 text-gray-500 border border-gray-700",
|
||||
offline: "bg-surface text-ink-muted border border-line-strong",
|
||||
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
|
||||
};
|
||||
|
||||
|
||||
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<p className="text-gray-400 text-xs leading-snug">
|
||||
<p className="text-ink-2 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>
|
||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||
{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;
|
||||
@@ -77,8 +77,8 @@ export function CameraProvidersTab() {
|
||||
<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>
|
||||
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||
<p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right space-y-1">
|
||||
<button
|
||||
@@ -93,25 +93,25 @@ export function CameraProvidersTab() {
|
||||
>
|
||||
<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 className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
|
||||
{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">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-ink-2">
|
||||
<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.licence.attribution && <p className="text-[11px] text-ink-2">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">
|
||||
<details className="text-[11px] text-ink-muted">
|
||||
<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>
|
||||
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
|
||||
|
||||
{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">
|
||||
<div className="bg-surface 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">
|
||||
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||
<label className="flex items-start gap-2 text-sm text-ink">
|
||||
<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" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">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>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function OutageProvidersTab() {
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<p className="text-gray-400 text-xs leading-snug">
|
||||
<p className="text-ink-2 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
|
||||
@@ -68,9 +68,9 @@ export function OutageProvidersTab() {
|
||||
</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>
|
||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||
{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;
|
||||
@@ -80,8 +80,8 @@ export function OutageProvidersTab() {
|
||||
<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>
|
||||
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||
<p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right space-y-1">
|
||||
<button
|
||||
@@ -96,27 +96,27 @@ export function OutageProvidersTab() {
|
||||
>
|
||||
<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 className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
|
||||
{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">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-ink-2">
|
||||
<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.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
|
||||
{p.licence.attribution && <p className="text-[11px] text-ink-2">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">
|
||||
<details className="text-[11px] text-ink-muted">
|
||||
<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>
|
||||
@@ -134,17 +134,17 @@ export function OutageProvidersTab() {
|
||||
|
||||
{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">
|
||||
<div className="bg-surface 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>
|
||||
<p className="text-ink-2 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">
|
||||
<label className="flex items-start gap-2 text-sm text-ink">
|
||||
<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" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">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>
|
||||
|
||||
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
|
||||
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
||||
|
||||
const input =
|
||||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||||
"bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||||
const btn =
|
||||
"bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
||||
"bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
||||
|
||||
function toLocalInput(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -88,18 +88,18 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
||||
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">From</label>
|
||||
<label className="text-xs text-ink-2 block mb-1">From</label>
|
||||
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">To</label>
|
||||
<label className="text-xs text-ink-2 block mb-1">To</label>
|
||||
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||
<label className="text-xs text-ink-2 block mb-1">Label</label>
|
||||
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,14 +109,14 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
||||
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
|
||||
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
||||
<span className="text-white">{m.label}</span>
|
||||
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span>
|
||||
<span className="text-ink-muted text-xs mt-0.5">{m.help}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "reuse" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label>
|
||||
<label className="text-xs text-ink-2 block mb-1">Reuse extraction from</label>
|
||||
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
||||
<option value="">— pick a finished run —</option>
|
||||
{sources.map((r) => (
|
||||
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
||||
<p className="text-xs text-ink-muted mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{systems.length > 1 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="text-xs text-gray-400">Systems (none = all):</span>
|
||||
<span className="text-xs text-ink-2">Systems (none = all):</span>
|
||||
{systems.map((s) => (
|
||||
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer">
|
||||
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-ink-2 cursor-pointer">
|
||||
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
||||
{s.name}
|
||||
</label>
|
||||
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
||||
</div>
|
||||
|
||||
{est && (
|
||||
<p className="text-sm font-mono text-gray-300">
|
||||
<p className="text-sm font-mono text-ink-2">
|
||||
{est.truncated ? (
|
||||
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
||||
) : (
|
||||
@@ -193,15 +193,15 @@ function RunsTable({
|
||||
onChanged();
|
||||
}
|
||||
|
||||
if (!runs.length) return <p className="text-sm text-gray-500 font-mono">No runs yet.</p>;
|
||||
if (!runs.length) return <p className="text-sm text-ink-muted font-mono">No runs yet.</p>;
|
||||
|
||||
const th = "text-left text-xs text-gray-500 font-normal px-2 py-1.5 whitespace-nowrap";
|
||||
const th = "text-left text-xs text-ink-muted font-normal px-2 py-1.5 whitespace-nowrap";
|
||||
const td = "px-2 py-1.5 whitespace-nowrap";
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<tr className="border-b border-line">
|
||||
<th className={th}>Run</th>
|
||||
<th className={th}>Range</th>
|
||||
<th className={th}>Status</th>
|
||||
@@ -222,13 +222,13 @@ function RunsTable({
|
||||
<tr
|
||||
key={r.run_id}
|
||||
onClick={() => onSelect(r.run_id)}
|
||||
className={`border-b border-gray-800/60 cursor-pointer ${selected === r.run_id ? "bg-gray-800/60" : "hover:bg-gray-900"}`}
|
||||
className={`border-b border-line/60 cursor-pointer ${selected === r.run_id ? "bg-raised/60" : "hover:bg-raised"}`}
|
||||
>
|
||||
<td className={td}>
|
||||
<div className="text-white">{r.label || r.run_id}</div>
|
||||
<div className="text-xs text-gray-500">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
||||
<div className="text-xs text-ink-muted">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
||||
</td>
|
||||
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
||||
<td className={`${td} text-xs text-ink-2`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
||||
<td className={td}>
|
||||
{running ? (
|
||||
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
||||
@@ -245,7 +245,7 @@ function RunsTable({
|
||||
<>
|
||||
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
||||
{" / "}
|
||||
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||
<span className="text-ink-2">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||
</>
|
||||
) : "—"}
|
||||
</td>
|
||||
@@ -255,7 +255,7 @@ function RunsTable({
|
||||
{running ? (
|
||||
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
|
||||
) : (
|
||||
<button onClick={() => remove(r.run_id)} className="text-xs text-gray-500 hover:text-red-400">delete</button>
|
||||
<button onClick={() => remove(r.run_id)} className="text-xs text-ink-muted hover:text-red-400">delete</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
|
||||
} catch { /* audio is a convenience */ }
|
||||
}
|
||||
return (
|
||||
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono">
|
||||
<div className="flex flex-wrap gap-2 text-gray-500">
|
||||
<div className="py-1.5 border-t border-line/60 text-xs font-mono">
|
||||
<div className="flex flex-wrap gap-2 text-ink-muted">
|
||||
<span>{fmtTime(call.started_at)}</span>
|
||||
<span>{call.talkgroup_name}</span>
|
||||
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
|
||||
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
|
||||
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
|
||||
{call.skip_reason && <span className="text-gray-600">{call.skip_reason}</span>}
|
||||
{call.skip_reason && <span className="text-ink-muted">{call.skip_reason}</span>}
|
||||
{audio ? (
|
||||
<audio src={audio} controls autoPlay className="h-6" />
|
||||
) : (
|
||||
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button>
|
||||
<button onClick={play} className="text-ink-2 hover:text-white">▶ audio</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div>
|
||||
<div className="text-ink mt-0.5">{call.transcript || <span className="text-ink-muted">(no transcript)</span>}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
||||
<div className="bg-surface border border-line rounded-lg px-3 py-2">
|
||||
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
|
||||
<span className="text-gray-500">{open ? "▾" : "▸"}</span>
|
||||
<span className="text-ink-muted">{open ? "▾" : "▸"}</span>
|
||||
<span className="text-white">{inc.title || inc.incident_id}</span>
|
||||
<span className="text-gray-500 text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
||||
<span className="text-gray-500 text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
||||
<span className={`text-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span>
|
||||
<span className="text-ink-muted text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
||||
<span className="text-ink-muted text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
||||
<span className={`text-xs ${signal ? "text-green-400" : "text-ink-muted"}`}>{inc.resolved_via ?? inc.status}</span>
|
||||
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
||||
</button>
|
||||
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
||||
@@ -347,22 +347,22 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
||||
["With units cleared", m.incidents_with_units_cleared],
|
||||
["With map pin", m.incidents_with_coords],
|
||||
].map(([k, v]) => (
|
||||
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2">
|
||||
<div className="text-gray-500">{k}</div>
|
||||
<div key={k as string} className="bg-surface border border-line rounded-lg p-2">
|
||||
<div className="text-ink-muted">{k}</div>
|
||||
<div className="text-white text-base">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{m && (
|
||||
<p className="text-xs font-mono text-gray-500">
|
||||
<p className="text-xs font-mono text-ink-muted">
|
||||
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||
<br />
|
||||
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
|
||||
<p className="text-xs font-mono text-gray-500">
|
||||
<p className="text-xs font-mono text-ink-muted">
|
||||
Gemini tokens:{" "}
|
||||
{Object.entries(m.gemini_usage)
|
||||
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
|
||||
@@ -380,7 +380,7 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
||||
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||
</details>
|
||||
)}
|
||||
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>}
|
||||
{run.status === "running" && <p className="text-sm text-ink-muted font-mono">Incidents appear when the run finishes.</p>}
|
||||
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||||
{data && (
|
||||
<>
|
||||
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 rounded-md ${filter === f ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
||||
className={`px-3 py-1 rounded-md ${filter === f ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
|
||||
>
|
||||
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowOrphans(!showOrphans)}
|
||||
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
||||
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
|
||||
>
|
||||
Orphans ({data.orphans.length})
|
||||
</button>
|
||||
</div>
|
||||
{showOrphans ? (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
||||
<div className="bg-surface border border-line rounded-lg px-3 py-2">
|
||||
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -443,7 +443,7 @@ export function ReplayTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
Re-run the pipeline over a past time range, in the calls' original order, into a sandbox — live incidents are never
|
||||
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
|
||||
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
||||
|
||||
@@ -51,7 +51,7 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came
|
||||
return (
|
||||
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
||||
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
||||
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
||||
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[10px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
||||
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -277,7 +277,7 @@ function WindContext({ head }: { head: LatLon | null }) {
|
||||
{calm ? "" : ` ${dir}`}
|
||||
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
|
||||
{obsLabel ? ` · observed ${obsLabel}` : ""}
|
||||
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[9px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
|
||||
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
|
||||
<SourceCredit credit={METAR_CREDIT}
|
||||
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
|
||||
</li>
|
||||
@@ -350,7 +350,7 @@ export function RegionPanel({
|
||||
{wallCameras.map(({ cam, km }) => (
|
||||
<div key={cam.id} className="relative">
|
||||
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
||||
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>}
|
||||
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[10px] px-1 rounded-sm">PINNED</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -465,7 +465,7 @@ export function IncidentPanel({
|
||||
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
||||
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
||||
{resolved ? <span className="text-ink-muted">resolved</span>
|
||||
: onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span>
|
||||
: onAir ? <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold">ON AIR</span>
|
||||
: <span className="text-ink-muted">active</span>}
|
||||
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface EmptyStateProps {
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
|
||||
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-ink-muted">` scattered across pages. */
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
|
||||
|
||||
@@ -78,7 +78,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
||||
className={["text-[10px] leading-snug text-ink-muted mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user