Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 36fdfb2758 map: mount without waiting on the 500-call window; legend collapsible, hidden under open layers list
The Live map was held behind useCalls(500), measured at ~9.2s to first map
paint on prod; it now mounts as soon as the saved view is known (nodes are
awaited only when there is no view). The legend painted over the expanded
Leaflet layers list (z-1001 vs the map's z-0 stacking context): it is now a
<details> that hides while the list is open and starts folded on short
viewports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:40:57 -05:00
Logan CusanoandClaude Opus 5.5 7cd289874a frontend: Iris palette — graphite neutrals + indigo-violet accent
Tokens in globals.css, Tailwind gray/indigo scales remapped so the ~350
legacy raw-class uses follow the palette, light-mode override sheet moved
off slate, map accent hexes updated. Severity hues retuned for AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:38:59 -05:00
logan f4d78aba77 Merge pull request 'Nodes: store and show host disk free, warn when low (node-26#41)' (#295) from feat/41-node-disk into main
Build & Deploy / Build & push images (push) Successful in 4m43s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m43s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-26 02:28:10 -04:00
Logan CusanoandClaude Opus 5.5 268470b6c0 nodes: store and show host disk free; warn when low (node-26#41)
Checkin now carries disk_free_bytes/disk_total_bytes. Stored on the node doc
(not overwritten by older nodes that don't send them). Node page shows a Disk
row; node cards warn amber under 15% free, red under 5% or 2 GB. A full disk
stopped node-002 recording for 17 days while it showed Online.

Frontend unverified locally (no Node, #207); CI build typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:24:10 -04:00
logan 4a93390967 Merge pull request 'Frontend: fixes from the 10-25 Chrome sweep' (#294) from fix/site-sweep-1025 into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m26s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 23:13:55 -04:00
Logan CusanoandClaude Opus 5.5 94898d1af1 fix(frontend): issues from the 10-25 Chrome sweep
- Incident detail "← Incidents" used router.back(), so arriving from Live
  or a shared link it went to the map / out of the app. Now a Link.
- Resolved incidents showed "0m elapsed" (manual resolve sets no
  resolved_at, updated_at can predate the last call): end is now the
  latest of resolved_at, updated_at and the newest call.
- Admin: the max-width toggle wrapped the tab bar, so it reflowed on every
  tab switch and left the cursor over e.g. "Close calls". Body only now.
- STT Eval sat on "Loading…" forever when a scanned page had zero matches
  but a next_cursor — the refill effect didn't depend on cursor.
- Trip event blocks used dark-only bg-indigo-950/60: unreadable in light
  mode. Theme tokens now.
- Reassign modal said "connected for the first time" on configured nodes.
- Dark-mode placeholders dimmed + italic; they read as filled-in values.

Frontend unverified locally (no Node, #207); CI build typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:12:07 -04:00
logan 33056e2269 Merge pull request 'Frontend: hide admin-only node/system controls from non-admins (#222, #283) + popup contrast' (#291) from fix/222-283-admin-gating into main
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 22:17:26 -04:00
Logan CusanoandClaude Opus 5.5 e8084357c3 fix(map): keep muted popup text AA on Leaflet's white popups
Token text-ink-muted is #8C98AE in dark mode, ~2.9:1 on the always-white
popups (node, incident, path-stop, AIS, 511 camera). Scoped override to
#5B6577 (~6:1). Found in tonight's regression review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:09:57 -04:00
Logan CusanoandClaude Opus 5.5 9702b90d40 fix(frontend): hide admin-only node and system controls from non-admins (#222, #283)
UI gate only — no route auth changes. Node page: override Ack/Force
Revert, Configure/Reassign and Restart OP25 are admin-only; non-admins on
an unconfigured node get a "an admin needs to assign it" note. /nodes
"Needs Configuration" cards open the config modal only for admins, and
link to the node page otherwise. /systems: New/Edit/Duplicate/Delete and
the AI-flags, area-context, talkgroup-pending and vocabulary panels are
admin-only; operators get a read-only list. (Delete's missing catch was
fixed in #290.)

Widening any of these routes to org owners/operators is a separate,
security-reviewed backend change.

Frontend unverified: no Node on the authoring box (#207).

Closes #222
Closes #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:07:22 -04:00
logan 606b0a103c Merge pull request 'Frontend: readable API errors, no silent action failures (#284, #286)' (#290) from fix/284-286-error-feedback into main
Build & Deploy / Build & push images (push) Successful in 4m16s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m24s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:39:15 -04:00
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00
logan 6d667e81c9 Merge pull request 'Frontend: finish the token migration — raw greys to AA theme tokens' (#282) from fix/readability-tokens into main
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m36s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:34:38 -04:00
Logan CusanoandClaude Opus 5.5 bf206334e3 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>
2026-10-25 21:29:13 -04:00
logan 3968b49b80 Merge pull request 'Map: readable incident rail — dark basemap, opaque cards, AA colours (#136)' (#281) from fix/136-map-contrast into main
Build & Deploy / Build & push images (push) Successful in 4m54s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 3m17s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:03:22 -04:00
Logan CusanoandClaude Opus 5.5 8099871c68 fix(map): readable incident rail — dark basemap, opaque cards, AA colours (#136)
- "Dark" base layer was bright OSM in prod; invert the raster via
  .drb-tiles-dark so dark-theme overlays sit on a dark ground.
- Rail and mobile incident cards are opaque, not glass over the map.
- Dark --ink-muted #77839A -> #8C98AE, --sev-major #D03B3B -> #F05252
  (both failed AA as small text on surface/raised).
- Card borders used `color + "55"` on CSS var() colours, which is
  invalid CSS — no border rendered. Now color-mix.
- 10px rail labels -> 11px.

Frontend unverified: no Node on the authoring box (#207).

Closes #136

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:03:08 -04:00
logan 49166789f4 Merge pull request 'Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock (#279 #275 #268 #278)' (#280) from fix/map-followups into main
Build & Deploy / Build & push images (push) Successful in 4m16s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m46s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 04:02:32 -04:00
Logan CusanoandClaude Opus 5.5 9698d463ad Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock
- #279: cache incident/fan divIcons by their visual inputs and React.memo
  MapView, so LiveView's 1s clock no longer rebuilds icons / calls setIcon
  and the #238 pulse ring completes its cycle.
- #275: IncidentPanel shows "resolved", "lasted X" frozen at resolved_at,
  and labels live cameras/context as current, not incident-time.
- #268: rail header (desktop + mobile drawer) reads "N of M · filtered"
  via a new filteredFrom prop while the wall filter is on.
- #278: while camera tiles are docked bottom-centre, the weather status
  chip and radar badge stack under the tiles inside the dock instead of
  being covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:01:20 -04:00
logan 3c5496acb1 Merge pull request 'Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)' (#276) from feat/238-attention-cues into main
Build & Deploy / Build & push images (push) Successful in 4m42s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 2m55s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:53:41 -04:00
Logan CusanoandClaude Opus 5.5 292c4fffd9 Attention cues review fixes (#238)
- NEW lane: Gate A notice pinned below the rows (it scrolled off with >3
  entries, same rule as the rail, #46); rows at text-xs for the wall.
- Mobile chip moved off MapView's centred "Live" chip, which it covered.
- Keyword cues re-resolve to the incident once correlation links the call,
  so the lane row is clickable and the pin pulses.
- Enabling notifications sends one confirmation notification; a browser
  that grants permission but throws on construct (mobile Chrome) now shows
  "unsupported" instead of a switched-on control that never delivers.
- useActiveIncidentsState: includeMetadataChanges so `loaded` flips on a
  metadata-only server sync (quiet org / offline start) instead of waiting
  for the first incident change and swallowing it into the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:52:02 -04:00
Logan CusanoandClaude Opus 5.5 eb0faf2aea Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)
- lib/attention.ts: pure snapshot diff (Major opened, severity escalated,
  pursuit path gained a stop, priority keyword in a new transcript) with a
  silent baseline on page open; calls baseline separately on their first
  non-empty snapshot. Diffed over all active incidents so widening a filter
  is never "new"; emitted, laned and pulsed only for the FILTERED view.
- components/live/NewLane.tsx: "NEW · last 15 min" overlay above the rail
  (desktop) / compact chip (mobile), Gate A notice included.
- components/live/AttentionControls.tsx: opt-in Web Audio tone (enabling
  click unlocks audio) and Web Notifications while the tab is hidden, body
  carries the machine-output notice; prefs + keywords in localStorage.
- LiveView wires it and passes pulseIncidentIds to MapView (2 min pulse).
- useIncidents: useActiveIncidentsState exposes a loaded flag (first
  server snapshot) so the baseline never sees an empty pre-load list.
- MachineOutputNotice exports MACHINE_OUTPUT_NOTICE_TEXT (same wording).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:45:05 -04:00
56 changed files with 2046 additions and 830 deletions
+6 -1
View File
@@ -128,6 +128,8 @@ class MQTTHandler:
"sdr_devices": payload.get("sdr_devices"), "sdr_devices": payload.get("sdr_devices"),
"op25_sdr_serial": payload.get("op25_sdr_serial"), "op25_sdr_serial": payload.get("op25_sdr_serial"),
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1 "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"disk_free_bytes": payload.get("disk_free_bytes"),
"disk_total_bytes": payload.get("disk_total_bytes"),
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
"override_system_id": None, "override_system_id": None,
@@ -161,8 +163,11 @@ class MQTTHandler:
# radio_channels (node-26#24): a conventional node's channels and # radio_channels (node-26#24): a conventional node's channels and
# whether each is encrypted — the one decode failure the owner has # whether each is encrypted — the one decode failure the owner has
# to hear about from C2, since nothing on the node can fix it. # to hear about from C2, since nothing on the node can fix it.
# disk_* (node-26#41): free/total bytes of the host disk the node
# records to — a full disk silently stopped node-002 for 17 days.
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running", for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels"): "sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
"disk_free_bytes", "disk_total_bytes"):
if key in payload: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: if "sdr_count" in payload:
+16
View File
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
assert "last_seen" in updates assert "last_seen" in updates
@pytest.mark.asyncio
async def test_checkin_stores_disk_free_and_total(handler):
# node-26#41: a full disk stopped node-002 recording while it showed Online.
existing = {"node_id": "node-01", "status": "online", "configured": True}
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(return_value=existing)
mock_fstore.doc_update = AsyncMock()
await handler._handle_checkin("node-01", {"disk_free_bytes": 5, "disk_total_bytes": 100})
updates = mock_fstore.doc_update.call_args[0][2]
assert updates["disk_free_bytes"] == 5 and updates["disk_total_bytes"] == 100
# An older node that doesn't report disk must not wipe the fields.
await handler._handle_checkin("node-01", {"name": "x"})
assert "disk_free_bytes" not in mock_fstore.doc_update.call_args[0][2]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_checkin_does_not_promote_unconfigured_to_online(handler): async def test_checkin_does_not_promote_unconfigured_to_online(handler):
existing = { existing = {
+118 -111
View File
@@ -85,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
const ROLE_COLORS: Record<UserRole, string> = { const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300", admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300", operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400", viewer: "bg-raised text-ink-2",
}; };
function RoleBadge({ role }: { role: UserRole }) { function RoleBadge({ role }: { role: UserRole }) {
@@ -136,14 +136,14 @@ function FeaturesTab() {
</div> </div>
)} )}
{loading ? ( {loading ? (
<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">
{FLAG_META.map(({ key, label, description }) => ( {FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4"> <div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p> <p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p> <p className="text-ink-muted text-xs mt-0.5 leading-snug">{description}</p>
</div> </div>
<Toggle <Toggle
enabled={flags?.[key] ?? true} enabled={flags?.[key] ?? true}
@@ -238,30 +238,30 @@ function CorrelationDebugTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls. Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
Copy the JSON and paste it to Claude to diagnose correlation problems. Copy the JSON and paste it to Claude to diagnose correlation problems.
</p> </p>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label> <label className="text-xs text-ink-2 block mb-1">Incidents (limit)</label>
<input <input
type="number" type="number"
min={1} max={100} min={1} max={100}
value={limit} value={limit}
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))} onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
className="w-24 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" className="w-24 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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label> <label className="text-xs text-ink-2 block mb-1">Orphan scan window (hours)</label>
<input <input
type="number" type="number"
min={1} max={168} min={1} max={168}
value={orphanHours} value={orphanHours}
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))} onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
className="w-28 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" className="w-28 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"
/> />
</div> </div>
<button <button
@@ -281,12 +281,12 @@ function CorrelationDebugTab() {
{meta && ( {meta && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at} {meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
</p> </p>
<button <button
onClick={handleCopy} onClick={handleCopy}
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors" className="text-xs font-mono px-3 py-1.5 rounded-lg border border-line-strong bg-surface text-ink-2 hover:text-white transition-colors"
> >
{copied ? "Copied!" : "Copy JSON"} {copied ? "Copied!" : "Copy JSON"}
</button> </button>
@@ -295,22 +295,22 @@ function CorrelationDebugTab() {
{meta && Object.keys(pathCounts).length > 0 && ( {meta && Object.keys(pathCounts).length > 0 && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">corr_path distribution</p>
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => ( {Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
<div key={path} className="flex justify-between text-xs font-mono"> <div key={path} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{path}</span> <span className="text-ink-2">{path}</span>
<span className="text-indigo-400">{n}</span> <span className="text-indigo-400">{n}</span>
</div> </div>
))} ))}
</div> </div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">fit_signal distribution</p>
{Object.keys(signalCounts).length === 0 {Object.keys(signalCounts).length === 0
? <p className="text-xs text-gray-600 font-mono">No signal data yet — deploy correlator update first</p> ? <p className="text-xs text-ink-muted font-mono">No signal data yet — deploy correlator update first</p>
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => ( : Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
<div key={sig} className="flex justify-between text-xs font-mono"> <div key={sig} className="flex justify-between text-xs font-mono">
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span> <span className={sig === "time_fallback" ? "text-yellow-400" : "text-ink-2"}>{sig}</span>
<span className="text-indigo-400">{n}</span> <span className="text-indigo-400">{n}</span>
</div> </div>
)) ))
@@ -320,13 +320,13 @@ function CorrelationDebugTab() {
)} )}
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && ( {meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">orphans by talkgroup</p>
{meta.orphans_by_talkgroup.map((tg) => ( {meta.orphans_by_talkgroup.map((tg) => (
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono"> <div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span> <span className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
<span className="text-gray-400"> <span className="text-ink-2">
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span> {tg.count} total · <span className="text-ink-muted">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
</span> </span>
</div> </div>
))} ))}
@@ -336,7 +336,7 @@ function CorrelationDebugTab() {
{json && ( {json && (
<pre <pre
ref={preRef} ref={preRef}
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre" className="bg-page border border-line rounded-xl p-4 text-xs text-ink-2 font-mono overflow-auto max-h-[60vh] whitespace-pre"
> >
{json} {json}
</pre> </pre>
@@ -455,13 +455,13 @@ function UserDetailPanel({
const isSelf = user.uid === currentUid; const isSelf = user.uid === currentUid;
return ( return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-5 space-y-5 font-mono">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{detail.email}</p> <p className="text-white font-semibold">{detail.email}</p>
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p> <p className="text-xs text-ink-muted mt-0.5">{detail.uid}</p>
</div> </div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button> <button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors text-xl leading-none">×</button>
</div> </div>
{error && ( {error && (
@@ -472,21 +472,21 @@ function UserDetailPanel({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Display Name</label> <label className="text-xs text-ink-2 block mb-1">Display Name</label>
<input <input
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 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-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Full name" placeholder="Full name"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={editRole} value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)} onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 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-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -496,13 +496,13 @@ function UserDetailPanel({
{editRole === "operator" && ( {editRole === "operator" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span> Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
</label> </label>
<input <input
value={editNodes} value={editNodes}
onChange={(e) => setEditNodes(e.target.value)} onChange={(e) => setEditNodes(e.target.value)}
className="w-full 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" className="w-full 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"
placeholder="node-abc123, node-def456" placeholder="node-abc123, node-def456"
/> />
</div> </div>
@@ -517,10 +517,10 @@ function UserDetailPanel({
</button> </button>
</div> </div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs"> <div className="border-t border-line pt-4 space-y-2 text-xs">
<div className="flex justify-between items-center gap-3"> <div className="flex justify-between items-center gap-3">
<span className="text-gray-500">Organization</span> <span className="text-ink-muted">Organization</span>
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}> <span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
{!detail.org_id {!detail.org_id
? "None: sees no data" ? "None: sees no data"
: detail.org_id === myOrgId : detail.org_id === myOrgId
@@ -538,34 +538,34 @@ function UserDetailPanel({
</button> </button>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Status</span> <span className="text-ink-muted">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}> <span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"} {detail.disabled ? "Disabled" : "Active"}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Discord</span> <span className="text-ink-muted">Discord</span>
<span className="text-gray-300"> <span className="text-ink-2">
{detail.discord_linked {detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}` ? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"} : "Not linked"}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Created</span> <span className="text-ink-muted">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span> <span className="text-ink-2">{fmtDate(detail.creation_time)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span> <span className="text-ink-muted">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span> <span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
</div> </div>
</div> </div>
{(detail.sessions?.length ?? 0) > 0 && ( {(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4"> <div className="border-t border-line pt-4">
<button <button
onClick={() => setShowSessions((v) => !v)} onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink transition-colors flex items-center gap-1"
> >
<span>{showSessions ? "▲" : "▼"}</span> <span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span> <span>Login history ({detail.sessions?.length} recent)</span>
@@ -573,9 +573,9 @@ function UserDetailPanel({
{showSessions && ( {showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto"> <div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => ( {detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4"> <div key={s.session_id} className="text-xs text-ink-2 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span> <span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span> <span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
</div> </div>
))} ))}
</div> </div>
@@ -583,7 +583,7 @@ function UserDetailPanel({
</div> </div>
)} )}
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap"> <div className="border-t border-line pt-4 flex gap-4 flex-wrap">
{!isSelf ? ( {!isSelf ? (
<> <>
<button <button
@@ -602,7 +602,7 @@ function UserDetailPanel({
</button> </button>
</> </>
) : ( ) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p> <p className="text-xs text-ink-muted">Cannot disable or delete your own account.</p>
)} )}
</div> </div>
</div> </div>
@@ -667,19 +667,19 @@ function CreateUserModal({
if (inviteLink) { if (inviteLink) {
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2> <h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Share this one-time invite link with the new user so they can set their password. Share this one-time invite link with the new user so they can set their password.
It expires after use. It expires after use.
</p> </p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p> <p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={copyLink} onClick={copyLink}
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"
> >
{copied ? "Copied!" : "Copy link"} {copied ? "Copied!" : "Copy link"}
</button> </button>
@@ -697,37 +697,37 @@ function CreateUserModal({
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2> <h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
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"
placeholder="user@example.com" placeholder="user@example.com"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Display Name <span className="text-gray-600">(optional)</span> Display Name <span className="text-ink-muted">(optional)</span>
</label> </label>
<input <input
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(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"
placeholder="Jane Smith" placeholder="Jane Smith"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={role} value={role}
onChange={(e) => setRole(e.target.value as UserRole)} onChange={(e) => setRole(e.target.value as UserRole)}
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"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -736,14 +736,14 @@ function CreateUserModal({
</div> </div>
{role === "operator" && ( {role === "operator" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span> Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
</label> </label>
<input <input
value={nodeIds} value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)} onChange={(e) => setNodeIds(e.target.value)}
required required
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="node-abc123, node-def456" placeholder="node-abc123, node-def456"
/> />
</div> </div>
@@ -760,7 +760,7 @@ function CreateUserModal({
<button <button
type="button" type="button"
onClick={onClose} 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 Cancel
</button> </button>
@@ -820,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p> <p className="text-xs text-ink-muted font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
@@ -836,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p> <p className="text-ink-muted text-sm font-mono">No users found.</p>
) : ( ) : (
<div className="border border-gray-800 rounded-xl overflow-hidden"> <div className="border border-line rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider"> <tr className="bg-surface text-ink-muted uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th> <th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th> <th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th> <th className="px-4 py-2.5 text-left">Role</th>
@@ -857,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
{users.map((u) => ( {users.map((u) => (
<tr <tr
key={u.uid} key={u.uid}
className={`border-t border-gray-800 transition-colors ${ className={`border-t border-line transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60" selectedUid === u.uid ? "bg-raised/60" : "hover:bg-raised/60"
}`} }`}
> >
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td> <td className="px-4 py-2.5 text-ink">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td> <td className="px-4 py-2.5 text-ink-2 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td> <td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell"> <td className="px-4 py-2.5 text-ink-muted hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"} {u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td> </td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td> <td className="px-4 py-2.5 text-ink-muted hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
{u.disabled {u.disabled
? <span className="text-red-500">Disabled</span> ? <span className="text-red-500">Disabled</span>
@@ -952,15 +952,15 @@ function AuditLogTab() {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p> <p className="text-ink-muted text-sm font-mono">No audit entries yet.</p>
) : ( ) : (
<> <>
<div className="border border-gray-800 rounded-xl overflow-hidden"> <div className="border border-line rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider"> <tr className="bg-surface text-ink-muted uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th> <th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th> <th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th> <th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
@@ -970,12 +970,12 @@ function AuditLogTab() {
</thead> </thead>
<tbody> <tbody>
{entries.map((e) => ( {entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40"> <tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td> <td className="px-4 py-2.5 text-ink-muted whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td> <td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td> <td className="px-4 py-2.5 text-ink-2 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td> <td className="px-4 py-2.5 text-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate"> <td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
{Object.keys(e.details).length > 0 {Object.keys(e.details).length > 0
? Object.entries(e.details) ? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`) .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
@@ -1029,26 +1029,26 @@ function StaleCallsTab() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event. Finds calls stuck in <span className="text-ink-2">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close. Preview first, then close.
</p> </p>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label> <label className="text-xs text-ink-2 block mb-1">Older than (minutes)</label>
<input <input
type="number" type="number"
min={1} max={1440} min={1} max={1440}
value={minutes} value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))} onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 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" className="w-28 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"
/> />
</div> </div>
<button <button
onClick={() => run(true)} onClick={() => run(true)}
disabled={loading} disabled={loading}
className="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" className="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"
> >
{loading ? "Working…" : "Preview"} {loading ? "Working…" : "Preview"}
</button> </button>
@@ -1068,18 +1068,18 @@ function StaleCallsTab() {
)} )}
{result && ( {result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2"> <div className="bg-surface border border-line rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white"> <p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "} {result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}> <span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""} {result.count} stale call{result.count !== 1 ? "s" : ""}
</span> </span>
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>} {result.count === 0 && <span className="text-ink-muted"> — nothing to clear</span>}
</p> </p>
{result.call_ids.length > 0 && ( {result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5"> <div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => ( {result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p> <p key={id} className="text-xs font-mono text-ink-2">{id}</p>
))} ))}
</div> </div>
)} )}
@@ -1104,17 +1104,17 @@ function fmtPct(x: number | null | undefined): string {
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) { function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2"> <div className="bg-surface border border-line rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
<div> <div>
<p className="text-xs text-gray-500 font-mono">Calls verified</p> <p className="text-xs text-ink-muted font-mono">Calls verified</p>
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p> <p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p> <p className="text-xs text-ink-muted font-mono">Raw WER (whisper-1)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p> <p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p> <p className="text-xs text-ink-muted font-mono">Corrected WER (shipped)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p> <p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
</div> </div>
{stats && stats.eval_count > 0 && stats.eval_count < 20 && ( {stats && stats.eval_count > 0 && stats.eval_count < 20 && (
@@ -1169,8 +1169,10 @@ function SttEvalTab() {
// scanning", not "done", so this fires again on its own. // scanning", not "done", so this fires again on its own.
useEffect(() => { useEffect(() => {
if (queue.length === 0 && !exhausted) loadBatch(); if (queue.length === 0 && !exhausted) loadBatch();
// `cursor` is a dep so an empty page that advanced the cursor scans the
// next one; without it the tab sat on "Loading…" forever.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue.length, exhausted]); }, [queue.length, exhausted, cursor]);
useEffect(() => { useEffect(() => {
setDraft(current ? (current.transcript_corrected || current.transcript || "") : ""); setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
@@ -1197,7 +1199,7 @@ function SttEvalTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Listen to the audio, correct the transcript below until it matches what was actually said, then save. Listen to the audio, correct the transcript below until it matches what was actually said, then save.
This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off. for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
@@ -1212,8 +1214,8 @@ function SttEvalTab() {
)} )}
{current ? ( {current ? (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3"> <div className="bg-surface border border-line rounded-xl p-4 space-y-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-ink-2">
<span>{new Date(current.started_at).toLocaleString()}</span> <span>{new Date(current.started_at).toLocaleString()}</span>
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span> <span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
</div> </div>
@@ -1222,18 +1224,18 @@ function SttEvalTab() {
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={current.audio_url} className="w-full h-9" /> <audio controls src={current.audio_url} className="w-full h-9" />
) : ( ) : (
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p> <p className="text-xs text-ink-muted italic">No audio on this call — skip it.</p>
)} )}
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Machine transcript (pre-filled) — correct it into what was actually said Machine transcript (pre-filled) — correct it into what was actually said
</label> </label>
<textarea <textarea
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
rows={4} rows={4}
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"
/> />
</div> </div>
@@ -1248,15 +1250,15 @@ function SttEvalTab() {
<button <button
onClick={skip} onClick={skip}
disabled={saving} disabled={saving}
className="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" className="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"
> >
Skip Skip
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4"> <div className="bg-surface border border-line rounded-xl p-4">
<p className="text-sm font-mono text-gray-400"> <p className="text-sm font-mono text-ink-2">
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"} {loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
</p> </p>
</div> </div>
@@ -1309,16 +1311,19 @@ export default function AdminPage() {
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages"; const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
return ( return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}> // The width toggle wraps the tab body only. It used to wrap the tab bar
// too, so the bar reflowed on every switch and left the cursor over
// whatever landed under it (e.g. Calls -> "Close calls").
<div className="space-y-6">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => ( {TAB_LABELS.map(({ key, label }) => (
<button <button
key={key} key={key}
onClick={() => setTab(key)} onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === key ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{label} {label}
@@ -1326,6 +1331,7 @@ export default function AdminPage() {
))} ))}
</div> </div>
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
{tab === "features" && <FeaturesTab />} {tab === "features" && <FeaturesTab />}
{tab === "cameras" && <CameraProvidersTab />} {tab === "cameras" && <CameraProvidersTab />}
{tab === "outages" && <OutageProvidersTab />} {tab === "outages" && <OutageProvidersTab />}
@@ -1335,6 +1341,7 @@ export default function AdminPage() {
{tab === "eval" && <SttEvalTab />} {tab === "eval" && <SttEvalTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />} {tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />} {tab === "audit" && <AuditLogTab />}
</div>
</div> </div>
); );
} }
+55 -37
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
import { useAlerts } from "@/lib/useAlerts"; import { useAlerts } from "@/lib/useAlerts";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import { ErrorBanner } from "@/components/ui/EmptyState";
import type { AlertRule } from "@/lib/types"; import type { AlertRule } from "@/lib/types";
function fmtTime(iso: string) { function fmtTime(iso: string) {
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const [webhook, setWebhook] = useState(""); const [webhook, setWebhook] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Load and toggle/delete failures (server-26#284) — separate from the create
// form's own `error`, and it keeps the list from claiming "no rules" when
// the fetch actually failed.
const [listError, setListError] = useState<string | null>(null);
async function load() { async function load() {
if (loaded) return; if (loaded) return;
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const data = await c2api.getAlertRules() as AlertRule[]; const data = await c2api.getAlertRules() as AlertRule[];
setRules(data); setRules(data);
setLoaded(true); setLoaded(true);
setListError(null);
} catch (e) { } catch (e) {
console.error(e); setListError(`Couldn't load alert rules: ${errorMessage(e)}`);
} }
} }
@@ -50,8 +57,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const created = await c2api.createAlertRule(body) as AlertRule; const created = await c2api.createAlertRule(body) as AlertRule;
setRules((prev) => [created, ...prev]); setRules((prev) => [created, ...prev]);
setName(""); setKeywords(""); setTgIds(""); setWebhook(""); setName(""); setKeywords(""); setTgIds(""); setWebhook("");
} catch { } catch (e) {
setError("Failed to create rule."); setError(`Failed to create rule: ${errorMessage(e)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -61,52 +68,55 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
try { try {
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled }); await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r)); setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
} catch (e) { console.error(e); } setListError(null);
} catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); }
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!window.confirm("Delete this alert rule?")) return;
try { try {
await c2api.deleteAlertRule(id); await c2api.deleteAlertRule(id);
setRules((prev) => prev.filter((r) => r.rule_id !== id)); setRules((prev) => prev.filter((r) => r.rule_id !== id));
} catch (e) { console.error(e); } setListError(null);
} catch (e) { setListError(`Couldn't delete the rule: ${errorMessage(e)}`); }
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{isAdmin && ( {isAdmin && (
<form onSubmit={handleCreate} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4"> <form onSubmit={handleCreate} className="bg-surface border border-line rounded-xl p-5 space-y-4">
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3> <h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Structure Fire" placeholder="e.g. Structure Fire"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Keywords (comma-separated)</label> <label className="text-xs text-ink-2 block mb-1">Keywords (comma-separated)</label>
<input <input
value={keywords} onChange={(e) => setKeywords(e.target.value)} value={keywords} onChange={(e) => setKeywords(e.target.value)}
placeholder="fire, smoke, structure" placeholder="fire, smoke, structure"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Talkgroup IDs (comma-separated)</label> <label className="text-xs text-ink-2 block mb-1">Talkgroup IDs (comma-separated)</label>
<input <input
value={tgIds} onChange={(e) => setTgIds(e.target.value)} value={tgIds} onChange={(e) => setTgIds(e.target.value)}
placeholder="9048, 9600" placeholder="9048, 9600"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Discord Webhook URL (optional)</label> <label className="text-xs text-ink-2 block mb-1">Discord Webhook URL (optional)</label>
<input <input
value={webhook} onChange={(e) => setWebhook(e.target.value)} value={webhook} onChange={(e) => setWebhook(e.target.value)}
placeholder="https://discord.com/api/webhooks/…" placeholder="https://discord.com/api/webhooks/…"
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"
/> />
</div> </div>
</div> </div>
@@ -120,13 +130,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</form> </form>
)} )}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> {listError && <ErrorBanner message={listError} />}
{rules.length === 0 ? (
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p> <div className="bg-surface border border-line rounded-xl overflow-hidden">
{!loaded ? (
<p className="text-ink-muted text-sm font-mono p-4">{listError ? "Rules unavailable." : "Loading…"}</p>
) : rules.length === 0 ? (
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
) : ( ) : (
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <tr className="border-b border-line text-xs text-ink-muted uppercase">
<th className="px-4 py-3">Name</th> <th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Keywords</th> <th className="px-4 py-3">Keywords</th>
<th className="px-4 py-3">Talkgroups</th> <th className="px-4 py-3">Talkgroups</th>
@@ -137,11 +151,11 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</thead> </thead>
<tbody> <tbody>
{rules.map((rule) => ( {rules.map((rule) => (
<tr key={rule.rule_id} className="border-b border-gray-800"> <tr key={rule.rule_id} className="border-b border-line">
<td className="px-4 py-3 text-white text-sm">{rule.name}</td> <td className="px-4 py-3 text-white text-sm">{rule.name}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
<td className="px-4 py-3 text-gray-400 text-xs"> <td className="px-4 py-3 text-ink-2 text-xs">
{rule.discord_webhook ? ( {rule.discord_webhook ? (
<span className="text-green-400">configured</span> <span className="text-green-400">configured</span>
) : "—"} ) : "—"}
@@ -153,13 +167,13 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${ className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
rule.enabled rule.enabled
? "bg-green-900 text-green-300 hover:bg-green-800" ? "bg-green-900 text-green-300 hover:bg-green-800"
: "bg-gray-800 text-gray-400 hover:bg-gray-700" : "bg-raised text-ink-2 hover:bg-gray-700"
}`} }`}
> >
{rule.enabled ? "enabled" : "disabled"} {rule.enabled ? "enabled" : "disabled"}
</button> </button>
) : ( ) : (
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-gray-500"}`}> <span className={`text-xs ${rule.enabled ? "text-green-400" : "text-ink-muted"}`}>
{rule.enabled ? "enabled" : "disabled"} {rule.enabled ? "enabled" : "disabled"}
</span> </span>
)} )}
@@ -188,11 +202,13 @@ export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading, error } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
const [ackError, setAckError] = useState<string | null>(null);
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
setAckError(null);
try { try {
await c2api.acknowledgeAlert(id); await c2api.acknowledgeAlert(id);
} catch (e) { console.error(e); } } catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
} }
const unacked = alerts.filter((a) => !a.acknowledged); const unacked = alerts.filter((a) => !a.acknowledged);
@@ -209,13 +225,13 @@ export default function AlertsPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => ( {(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
<button <button
key={t} key={t}
onClick={() => setTab(t as "events" | "rules")} onClick={() => setTab(t as "events" | "rules")}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{t === "events" ? "Triggered Alerts" : "Rules"} {t === "events" ? "Triggered Alerts" : "Rules"}
@@ -223,9 +239,11 @@ export default function AlertsPage() {
))} ))}
</div> </div>
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : error ? ( ) : error ? (
<p className="text-red-400 text-sm font-mono"> <p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error) {/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
@@ -233,7 +251,7 @@ export default function AlertsPage() {
: `Couldn't load alerts: ${error}`} : `Couldn't load alerts: ${error}`}
</p> </p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-ink-muted text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text, {/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
@@ -242,10 +260,10 @@ export default function AlertsPage() {
variant="inline" variant="inline"
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words." detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
/> />
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-surface border border-line rounded-xl overflow-hidden">
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <tr className="border-b border-line text-xs text-ink-muted uppercase">
<th className="px-4 py-3">Rule</th> <th className="px-4 py-3">Rule</th>
<th className="px-4 py-3">Talkgroup</th> <th className="px-4 py-3">Talkgroup</th>
<th className="px-4 py-3">Matched</th> <th className="px-4 py-3">Matched</th>
@@ -258,10 +276,10 @@ export default function AlertsPage() {
{alerts.map((alert) => ( {alerts.map((alert) => (
<tr <tr
key={alert.alert_id} key={alert.alert_id}
className={`border-b border-gray-800 ${alert.acknowledged ? "opacity-50" : ""}`} className={`border-b border-line ${alert.acknowledged ? "opacity-50" : ""}`}
> >
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td> <td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono"> <td className="px-4 py-3 text-ink-2 text-xs font-mono">
{alert.talkgroup_name || alert.talkgroup_id || "—"} {alert.talkgroup_name || alert.talkgroup_id || "—"}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -273,17 +291,17 @@ export default function AlertsPage() {
))} ))}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-xs truncate"> <td className="px-4 py-3 text-ink-2 text-xs font-mono max-w-xs truncate">
{alert.transcript_snippet || "—"} {alert.transcript_snippet || "—"}
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(alert.triggered_at)}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{alert.acknowledged ? ( {alert.acknowledged ? (
<span className="text-xs text-gray-500">acked</span> <span className="text-xs text-ink-muted">acked</span>
) : ( ) : (
<button <button
onClick={() => handleAcknowledge(alert.alert_id)} onClick={() => handleAcknowledge(alert.alert_id)}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors" className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
> >
Acknowledge Acknowledge
</button> </button>
+4 -4
View File
@@ -68,7 +68,7 @@ function ChevronIcon({ open }: { open: boolean }) {
<svg <svg
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" strokeLinecap="round" strokeLinejoin="round"
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} className={`text-ink-muted shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
> >
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
@@ -83,10 +83,10 @@ export default function FaqPage() {
<div className="text-center"> <div className="text-center">
<Badge tone="brand">FAQ</Badge> <Badge tone="brand">FAQ</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1> <h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
<p className="text-gray-400 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p> <p className="text-ink-2 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p>
</div> </div>
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800"> <div className="mt-12 divide-y divide-line border-t border-b border-line">
{FAQS.map((item, i) => { {FAQS.map((item, i) => {
const open = openIndex === i; const open = openIndex === i;
return ( return (
@@ -100,7 +100,7 @@ export default function FaqPage() {
<ChevronIcon open={open} /> <ChevronIcon open={open} />
</button> </button>
{open && ( {open && (
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div> <div className="text-ink-2 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
)} )}
</div> </div>
); );
+4 -4
View File
@@ -76,7 +76,7 @@ export default function FeaturesPage() {
<div className="max-w-2xl"> <div className="max-w-2xl">
<Badge tone="brand">Features</Badge> <Badge tone="brand">Features</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1> <h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
<p className="text-gray-400 mt-4 leading-relaxed"> <p className="text-ink-2 mt-4 leading-relaxed">
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription, DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
correlation, mapping, and a live relay — end to end. correlation, mapping, and a live relay — end to end.
</p> </p>
@@ -88,12 +88,12 @@ export default function FeaturesPage() {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p> <p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2> <h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p> <p className="text-ink-2 mt-3 leading-relaxed">{s.body}</p>
</div> </div>
<Card padding="lg" className="lg:col-span-3"> <Card padding="lg" className="lg:col-span-3">
<ul className="space-y-3"> <ul className="space-y-3">
{s.points.map((p) => ( {s.points.map((p) => (
<li key={p} className="flex items-start gap-3 text-sm text-gray-300"> <li key={p} className="flex items-start gap-3 text-sm text-ink-2">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" /> <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
{p} {p}
</li> </li>
@@ -107,7 +107,7 @@ export default function FeaturesPage() {
))} ))}
</div> </div>
<div className="text-center mt-20 pt-16 border-t border-gray-800"> <div className="text-center mt-20 pt-16 border-t border-line">
<h2 className="text-display-sm text-white">See it running on your own traffic</h2> <h2 className="text-display-sm text-white">See it running on your own traffic</h2>
<div className="mt-6"> <div className="mt-6">
<LinkButton href="/login" size="lg">Get started</LinkButton> <LinkButton href="/login" size="lg">Get started</LinkButton>
+98 -66
View File
@@ -4,6 +4,8 @@
@import 'leaflet/dist/leaflet.css'; @import 'leaflet/dist/leaflet.css';
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
/* ── Design tokens ──────────────────────────────────────────────────────────── /* ── Design tokens ────────────────────────────────────────────────────────────
* Single source of truth for surface, ink and encoding colour. `:root` is the * Single source of truth for surface, ink and encoding colour. `:root` is the
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"]) * LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
@@ -16,43 +18,43 @@
* Do not add a hue here for a category; add a glyph. * Do not add a hue here for a category; add a glyph.
*/ */
:root { :root {
--page: #F4F6F9; --page: #F7F7F8;
--surface: #FFFFFF; --surface: #FFFFFF;
--raised: #F7F9FB; --raised: #F2F2F5;
--line: rgba(11,17,27,.11); --line: rgba(24,24,27,.10);
--line-strong: rgba(11,17,27,.22); --line-strong: rgba(24,24,27,.20);
--ink: #101620; --ink: #18181B;
--ink-2: #46536A; --ink-2: #52525B;
--ink-muted: #6B788C; --ink-muted: #6B6B76;
--accent: #2A78D6; --accent: #5E6AD2;
--sev-moderate: #B07800; --sev-moderate: #B54708;
--sev-major: #C0281F; --sev-major: #D92D20;
--map-bg: #E8ECF1; --map-bg: #ECECF0;
--map-block: #DFE4EB; --map-block: #E1E1E8;
--map-road: #FFFFFF; --map-road: #FFFFFF;
--map-water: #D3E2EE; --map-water: #DADDF2;
} }
.dark { .dark {
--page: #0B0E13; --page: #08090A;
--surface: #141922; --surface: #111214;
--raised: #1B2230; --raised: #1A1B1F;
--line: rgba(255,255,255,.09); --line: rgba(255,255,255,.08);
--line-strong: rgba(255,255,255,.17); --line-strong: rgba(255,255,255,.16);
--ink: #E8EBF0; --ink: #EDEDEF;
--ink-2: #A5B0C2; --ink-2: #A9A9B3;
--ink-muted: #77839A; --ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
--accent: #3987E5; --accent: #8B93F0;
--sev-moderate: #C98500; --sev-moderate: #FDB022;
--sev-major: #D03B3B; --sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
--map-bg: #0E131B; --map-bg: #0D0E11;
--map-block: #161C26; --map-block: #16171B;
--map-road: #232C3A; --map-road: #26272D;
--map-water: #12202E; --map-water: #141729;
} }
/* ── Base ─────────────────────────────────────────────────────────────────── */ /* ── Base ─────────────────────────────────────────────────────────────────── */
@@ -69,7 +71,7 @@ html, body {
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below /* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
* readable. Lift both to gray-500 (~4:1). */ * readable. Lift both to gray-500 (~4:1). */
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; } .dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
/* ── Light mode overrides ─────────────────────────────────────────────────── */ /* ── Light mode overrides ─────────────────────────────────────────────────── */
/* /*
@@ -80,34 +82,34 @@ html, body {
/* Structural backgrounds */ /* Structural backgrounds */
html:not(.dark), html:not(.dark),
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; } html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; } html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; } html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; } html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; } html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; } html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; } html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; } html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; } html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,0.70) !important; }
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; } html:not(.dark) .bg-gray-800\/40 { background-color: rgba(242,242,245,0.60) !important; }
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; } html:not(.dark) .bg-gray-800\/30 { background-color: rgba(242,242,245,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; } html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
/* Borders */ /* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; } html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; } html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; } html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
/* Text */ /* Text */
html:not(.dark) .text-white { color: #0f172a !important; } html:not(.dark) .text-white { color: #18181b !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !important; } html:not(.dark) .text-gray-100 { color: #27272a !important; }
html:not(.dark) .text-gray-200 { color: #1e293b !important; } html:not(.dark) .text-gray-200 { color: #27272a !important; }
html:not(.dark) .text-gray-300 { color: #334155 !important; } html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; } html:not(.dark) .text-gray-400 { color: #52525b !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; } html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; } html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
/* …except on saturated fills (primary/danger/success buttons), where the fill /* …except on saturated fills (primary/danger/success buttons), where the fill
* stays dark in both themes — remapping to navy made their labels unreadable. */ * stays dark in both themes — remapping to navy made their labels unreadable. */
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; } .bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
/* Hover states */ /* Hover states */
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; } html:not(.dark) .hover\:bg-gray-900:hover { background-color: #fafafa !important; }
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; } html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; } html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; } html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; } html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
/* Hover text */ /* Hover text */
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; } html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; } html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */ /* ── Accent badge palette (dark → light) ─────────────────────────────────── */
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
html:not(.dark) .border-green-800 { border-color: #86efac !important; } html:not(.dark) .border-green-800 { border-color: #86efac !important; }
/* Unconfigured / Info */ /* Unconfigured / Info */
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; } html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; } html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
html:not(.dark) .text-indigo-300 { color: #4338ca !important; } html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; } html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; } html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */ /* ── Pulsing ring for recording nodes ────────────────────────────────────── */
@keyframes pulse-ring { @keyframes pulse-ring {
@@ -191,6 +193,28 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
z-index: 0; z-index: 0;
} }
/* The legend floats above the map's stacking context, so it would cover the
* expanded layers list (both live in the right-hand column). Hide it while the
* list is open. */
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
visibility: hidden;
}
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
* overlay cards sat on a bright, busy map. Inverting the raster keeps the
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
* the overlays the dark ground their palette was chosen for. */
.leaflet-tile-pane .drb-tiles-dark {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.6);
}
/* Leaflet popups stay white in both themes (their body text is a fixed
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
* white — is pinned to a grey that holds AA on white (~6:1). */
.leaflet-popup-content .text-ink-muted {
color: #5F5F69;
}
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip /* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
* padding and let the tile's own theme colours show, so the tile is no bigger * padding and let the tile's own theme colours show, so the tile is no bigger
* than the video it holds. */ * than the video it holds. */
@@ -204,14 +228,22 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
} }
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
/* Placeholders must read as hints, not values: Tailwind's default #9ca3af on
* the dark surface looked like filled-in text (e.g. /systems Local Area showed
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
.dark input::placeholder,
.dark textarea::placeholder {
color: #6E6E78;
font-style: italic;
}
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
html:not(.dark) textarea { html:not(.dark) textarea {
color: #0f172a; color: #18181b;
} }
html:not(.dark) input::placeholder, html:not(.dark) input::placeholder,
html:not(.dark) textarea::placeholder { html:not(.dark) textarea::placeholder {
color: #94a3b8; color: #a1a1aa;
} }
/* ── Marketing/product surface additions (2026-08 overhaul) ───────────────── /* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
@@ -222,8 +254,8 @@ html:not(.dark) textarea::placeholder {
*/ */
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */ /* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; } html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; } html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; } html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; } html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; } html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
@@ -232,10 +264,10 @@ html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !imp
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */ /* Marketing hero background — subtle radial glow, brand-neutral in both themes */
.marketing-hero-bg { .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
} }
html:not(.dark) .marketing-hero-bg { html:not(.dark) .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
} }
/* Skeleton loading shimmer */ /* Skeleton loading shimmer */
+26 -12
View File
@@ -2,7 +2,8 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import Link from "next/link";
import { useParams } from "next/navigation";
import { useIncident } from "@/lib/useIncidents"; import { useIncident } from "@/lib/useIncidents";
import { useCallsByIncident } from "@/lib/useCalls"; import { useCallsByIncident } from "@/lib/useCalls";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
@@ -13,15 +14,17 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
import { isKnownSeverity } from "@/lib/severity"; import { isKnownSeverity } from "@/lib/severity";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { errorMessage } from "@/lib/c2error";
import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall"; import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const EARLIER_PAGE_SIZE = 8; const EARLIER_PAGE_SIZE = 8;
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string { function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
const start = new Date(startedAt).getTime(); const start = new Date(startedAt).getTime();
const end = active ? Date.now() : new Date(updatedAt).getTime(); const end = active ? Date.now() : new Date(endedAt).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000)); const mins = Math.max(0, Math.round((end - start) / 60000));
if (mins < 60) return `${mins}m`; if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60); const hrs = Math.floor(mins / 60);
@@ -31,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
export default function IncidentDetailPage() { export default function IncidentDetailPage() {
const params = useParams(); const params = useParams();
const id = params.id as string; const id = params.id as string;
const router = useRouter();
const { incident, loading } = useIncident(id); const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id); const { calls, loading: callsLoading } = useCallsByIncident(id);
@@ -39,6 +41,7 @@ export default function IncidentDetailPage() {
const [summarizing, setSummarizing] = useState(false); const [summarizing, setSummarizing] = useState(false);
const [resolving, setResolving] = useState(false); const [resolving, setResolving] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE); const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
// Stop numbers and path length come from the same liveWall.pathStops() the // Stop numbers and path length come from the same liveWall.pathStops() the
@@ -54,16 +57,20 @@ export default function IncidentDetailPage() {
); );
async function handleResolve() { async function handleResolve() {
// No reopen path exists (server-26#284), so resolving is final from the UI.
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
setResolving(true); setResolving(true);
setActionError(null);
try { await c2api.updateIncident(id, { status: "resolved" }); } try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); } catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
finally { setResolving(false); } finally { setResolving(false); }
} }
async function handleSummarize() { async function handleSummarize() {
setSummarizing(true); setSummarizing(true);
setActionError(null);
try { await c2api.summarizeIncident(id); } try { await c2api.summarizeIncident(id); }
catch (e) { console.error(e); } catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
finally { setSummarizing(false); } finally { setSummarizing(false); }
} }
@@ -75,18 +82,23 @@ export default function IncidentDetailPage() {
const unitsCleared = incident.units_cleared ?? []; const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? []; const vehicles = incident.vehicles ?? [];
const active = incident.status === "active"; const active = incident.status === "active";
// A manual resolve doesn't set resolved_at and updated_at can predate the
// last call, which showed "0m elapsed" on an 11-call incident — so a
// resolved incident runs to the latest of those and its newest call.
const resolvedEnd = [incident.resolved_at, incident.updated_at, newestFirst[0]?.ended_at, newestFirst[0]?.started_at]
.filter((t): t is string => !!t)
.reduce((a, b) => (b > a ? b : a), incident.started_at);
const visible = newestFirst.slice(0, earlierShown); const visible = newestFirst.slice(0, earlierShown);
const remaining = newestFirst.length - visible.length; const remaining = newestFirst.length - visible.length;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<button {/* A real link, not router.back(): arriving from Live or a shared URL,
onClick={() => router.back()} "back" went to the map or out of the app. */}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors" <Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
>
← Incidents ← Incidents
</button> </Link>
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */} {/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
@@ -102,7 +114,7 @@ export default function IncidentDetailPage() {
{incident.title ?? "Incident"} {incident.title ?? "Incident"}
</h1> </h1>
<p className="text-xs text-ink-muted font-mono"> <p className="text-xs text-ink-muted font-mono">
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed {elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>} {path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
{path?.mayBeMerged && ( {path?.mayBeMerged && (
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}> <span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
@@ -126,6 +138,8 @@ export default function IncidentDetailPage() {
)} )}
</div> </div>
{actionError && <ErrorBanner message={actionError} />}
{displayTags.length > 0 && ( {displayTags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{displayTags.map((t) => ( {displayTags.map((t) => (
+8 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents"; import { useIncidents } from "@/lib/useIncidents";
import { useActiveCalls } from "@/lib/useCalls"; import { useActiveCalls } from "@/lib/useCalls";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo); const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all"); const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent"); const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any"); const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
}, [filtered]); }, [filtered]);
async function handleResolve(id: string) { async function handleResolve(id: string) {
// No reopen path exists (server-26#284), so resolving is final from the UI.
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
setActionError(null);
try { await c2api.updateIncident(id, { status: "resolved" }); } try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); } catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
} }
return ( return (
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
chips are pipeline output, so the notice rides with the list. */} chips are pipeline output, so the notice rides with the list. */}
<MachineOutputNotice variant="inline" /> <MachineOutputNotice variant="inline" />
{actionError && <ErrorBanner message={actionError} />}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => ( {SEVERITY_FILTERS.map(({ key, label }) => (
+7 -7
View File
@@ -87,30 +87,30 @@ export default function LoginPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Sign in</h1> <h1 className="text-white text-lg font-bold">Sign in</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Password</label> <label className="text-xs text-ink-2 block mb-1">Password</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
autoComplete="current-password" autoComplete="current-password"
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"
/> />
</div> </div>
{error && ( {error && (
@@ -127,7 +127,7 @@ export default function LoginPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span> <span className="text-xs text-ink-muted">or</span>
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
</div> </div>
@@ -146,7 +146,7 @@ export default function LoginPage() {
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-ink-muted">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link> <Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
</p> </p>
+55 -28
View File
@@ -15,6 +15,8 @@ import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
function ApprovalBadge({ status }: { status: string | null }) { function ApprovalBadge({ status }: { status: string | null }) {
@@ -54,10 +56,14 @@ export default function NodeDetailPage() {
return unsub; return unsub;
}, [id]); }, [id]);
async function sendCommand(action: string) { async function sendCommand(action: string, label: string) {
setSending(true); setSending(true);
try { try {
await c2api.sendCommand(id, { action }); await c2api.sendCommand(id, { action });
alert(`${label} sent to the node.`);
} catch (e) {
// Was try/finally only: an unhandled rejection and no feedback (server-26#284).
alert(`Couldn't send ${label.toLowerCase()}: ${errorMessage(e)}`);
} finally { } finally {
setSending(false); setSending(false);
} }
@@ -125,7 +131,7 @@ export default function NodeDetailPage() {
} }
if (!node) { if (!node) {
return <p className="text-gray-500 font-mono text-sm">Loading node…</p>; return <p className="text-ink-muted font-mono text-sm">Loading node…</p>;
} }
const system = systemMap[node.assigned_system_id ?? ""]; const system = systemMap[node.assigned_system_id ?? ""];
@@ -135,7 +141,7 @@ export default function NodeDetailPage() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1> <h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
<p className="text-gray-500 text-sm font-mono">{node.node_id}</p> <p className="text-ink-muted text-sm font-mono">{node.node_id}</p>
</div> </div>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
@@ -147,7 +153,7 @@ export default function NodeDetailPage() {
<span className="text-base">⚠</span> <span className="text-base">⚠</span>
<span>Local System Override Active</span> <span>Local System Override Active</span>
</div> </div>
<p className="text-gray-400 text-xs leading-relaxed"> <p className="text-ink-2 text-xs leading-relaxed">
This node is operating on a local system override. This node is operating on a local system override.
{node.override_timeout_at ? ( {node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</> <> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
@@ -155,7 +161,8 @@ export default function NodeDetailPage() {
<> No timeout is currently enforced (permanent override).</> <> No timeout is currently enforced (permanent override).</>
)} )}
</p> </p>
<div className="flex gap-2"> {/* Override routes are require_service_key_or_admin (server-26#222). */}
{isAdmin && <div className="flex gap-2">
{node.override_timeout_at && ( {node.override_timeout_at && (
<button <button
onClick={async () => { onClick={async () => {
@@ -184,12 +191,12 @@ export default function NodeDetailPage() {
> >
Force Revert Config Force Revert Config
</button> </button>
</div> </div>}
</div> </div>
)} )}
{/* Info */} {/* Info */}
<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">
{[ {[
["System", system?.name ?? "Unassigned"], ["System", system?.name ?? "Unassigned"],
["Location", `${node.lat}, ${node.lon}`], ["Location", `${node.lat}, ${node.lon}`],
@@ -208,12 +215,23 @@ export default function NodeDetailPage() {
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []), ...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
].map(([label, value]) => ( ].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5"> <div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span> <span className="text-ink-muted">{label}</span>
<span className="text-gray-200">{value}</span> <span className="text-ink">{value}</span>
</div> </div>
))} ))}
{(() => {
const disk = diskState(node);
return (
<div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Disk</span>
<span className={disk ? DISK_TEXT_CLASS[disk.level] : "text-ink-muted"}>
{disk ? `${disk.level !== "ok" ? "⚠ " : ""}${disk.label}` : "not reported (node software too old)"}
</span>
</div>
);
})()}
<div className="flex justify-between px-4 py-2.5"> <div className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">Approval</span> <span className="text-ink-muted">Approval</span>
<ApprovalBadge status={node.approval_status ?? null} /> <ApprovalBadge status={node.approval_status ?? null} />
</div> </div>
</div> </div>
@@ -232,7 +250,7 @@ export default function NodeDetailPage() {
<button <button
onClick={handleReject} onClick={handleReject}
disabled={approving} disabled={approving}
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-gray-300 rounded-lg text-sm font-mono transition-colors" className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-ink-2 rounded-lg text-sm font-mono transition-colors"
> >
Reject Reject
</button> </button>
@@ -240,25 +258,34 @@ export default function NodeDetailPage() {
)} )}
{/* Actions */} {/* Actions */}
{/* Configure and Restart are admin-only on the server (server-26#222);
non-admins get a note instead of a button that 403s. */}
{!isAdmin && !node.configured && (
<p className="text-sm text-ink-2">This node isn&apos;t configured yet. An admin needs to assign it a radio system.</p>
)}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button {isAdmin && (
onClick={() => setShowConfig(true)} <button
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors" onClick={() => setShowConfig(true)}
> className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
{node.configured ? "Reassign System" : "Configure"} >
</button> {node.configured ? "Reassign System" : "Configure"}
<button </button>
disabled={sending} )}
onClick={() => sendCommand("op25_restart")} {isAdmin && (
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" <button
> disabled={sending}
Restart OP25 onClick={() => sendCommand("op25_restart", "Restart OP25")}
</button> className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Restart OP25
</button>
)}
{isAdmin && ( {isAdmin && (
<button <button
disabled={sending} disabled={sending}
onClick={handleRequestUpdate} onClick={handleRequestUpdate}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
Update Node Software Update Node Software
</button> </button>
@@ -283,16 +310,16 @@ export default function NodeDetailPage() {
{/* Recent calls */} {/* Recent calls */}
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Recent Calls</h2>
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */} {/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />} {nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
{nodeCalls.length === 0 ? ( {nodeCalls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p> <p className="text-ink-muted text-sm font-mono">No calls recorded from this node.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="px-4 py-2 text-left">Time</th> <th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th> <th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th> <th className="px-4 py-2 text-left">System</th>
+13 -7
View File
@@ -65,7 +65,7 @@ export default function NodesPage() {
<button <button
onClick={handleUpdateAll} onClick={handleUpdateAll}
disabled={updatingAll} disabled={updatingAll}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
{updatingAll ? "Requesting…" : "Update All Nodes"} {updatingAll ? "Requesting…" : "Update All Nodes"}
</button> </button>
@@ -79,22 +79,28 @@ export default function NodesPage() {
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => ( {pending.map((n) => (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer"> // Configuring is admin-only on the server (server-26#222); for
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} /> // everyone else the card links to the node page instead.
</div> isAdmin ? (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div>
) : (
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
)
))} ))}
</div> </div>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider"> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider">
All Nodes ({nodes.length}) All Nodes ({nodes.length})
</h2> </h2>
{loading ? ( {loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p> <p className="text-ink-muted text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => ( {nodes.map((n) => (
+4 -4
View File
@@ -55,17 +55,17 @@ export default function OnboardingPage() {
return ( return (
<div className="max-w-sm mx-auto pt-16"> <div className="max-w-sm mx-auto pt-16">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<div> <div>
<h1 className="text-white text-lg font-bold">Set up your organization</h1> <h1 className="text-white text-lg font-bold">Set up your organization</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed"> <p className="text-ink-2 text-xs mt-2 leading-relaxed">
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later. One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-ink-2 block mb-1">Organization name</label>
<input <input
type="text" type="text"
value={orgName} value={orgName}
@@ -73,7 +73,7 @@ export default function OnboardingPage() {
required required
autoFocus autoFocus
placeholder="e.g. Riverside County Scanner" placeholder="e.g. Riverside County Scanner"
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"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
+9 -9
View File
@@ -51,7 +51,7 @@ function MarketingHomePage() {
<h1 className="text-display-sm md:text-display mt-5 text-white"> <h1 className="text-display-sm md:text-display mt-5 text-white">
See what&apos;s happening on the radio, as an incident — not a wall of calls. See what&apos;s happening on the radio, as an incident — not a wall of calls.
</h1> </h1>
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed"> <p className="text-ink-2 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed, DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
bot to relay the audio live to your team. bot to relay the audio live to your team.
@@ -68,7 +68,7 @@ function MarketingHomePage() {
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="max-w-2xl mb-10"> <div className="max-w-2xl mb-10">
<h2 className="text-display-sm text-white">The unit of value is the incident</h2> <h2 className="text-display-sm text-white">The unit of value is the incident</h2>
<p className="text-gray-400 mt-3"> <p className="text-ink-2 mt-3">
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
about — and let you click into any one of them for the full picture. about — and let you click into any one of them for the full picture.
</p> </p>
@@ -77,14 +77,14 @@ function MarketingHomePage() {
{CAPABILITIES.map((c) => ( {CAPABILITIES.map((c) => (
<Card key={c.title} padding="lg" hover> <Card key={c.title} padding="lg" hover>
<h3 className="text-white font-semibold">{c.title}</h3> <h3 className="text-white font-semibold">{c.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p> <p className="text-ink-2 text-sm mt-2 leading-relaxed">{c.body}</p>
</Card> </Card>
))} ))}
</div> </div>
</section> </section>
{/* How it works */} {/* How it works */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<h2 className="text-display-sm text-white mb-10">How it works</h2> <h2 className="text-display-sm text-white mb-10">How it works</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@@ -92,7 +92,7 @@ function MarketingHomePage() {
<div key={s.n}> <div key={s.n}>
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p> <p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
<h3 className="text-white font-semibold mt-2">{s.title}</h3> <h3 className="text-white font-semibold mt-2">{s.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p> <p className="text-ink-2 text-sm mt-2 leading-relaxed">{s.body}</p>
</div> </div>
))} ))}
</div> </div>
@@ -100,12 +100,12 @@ function MarketingHomePage() {
</section> </section>
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */} {/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h2 className="text-display-sm text-white">Pricing is in development</h2> <h2 className="text-display-sm text-white">Pricing is in development</h2>
<p className="text-gray-400 mt-2 max-w-xl"> <p className="text-ink-2 mt-2 max-w-xl">
We would rather talk to you about what you need than post a number we would have to We would rather talk to you about what you need than post a number we would have to
walk back. Tell us about your coverage area and we will figure it out together. walk back. Tell us about your coverage area and we will figure it out together.
</p> </p>
@@ -118,10 +118,10 @@ function MarketingHomePage() {
</section> </section>
{/* Final CTA */} {/* Final CTA */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
<h2 className="text-display-sm text-white">Bring your first node online</h2> <h2 className="text-display-sm text-white">Bring your first node online</h2>
<p className="text-gray-400 mt-3 max-w-xl mx-auto"> <p className="text-ink-2 mt-3 max-w-xl mx-auto">
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call. Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
</p> </p>
<div className="mt-8"> <div className="mt-8">
+3 -3
View File
@@ -15,7 +15,7 @@ export default function PricingPage() {
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="text-center max-w-2xl mx-auto"> <div className="text-center max-w-2xl mx-auto">
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1> <h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
<p className="text-gray-400 mt-4"> <p className="text-ink-2 mt-4">
We are still working out what a fair price looks like for the people who actually use this. We are still working out what a fair price looks like for the people who actually use this.
Rather than post a number we would have to walk back, we would rather talk to you about what Rather than post a number we would have to walk back, we would rather talk to you about what
you need and what it is worth. you need and what it is worth.
@@ -24,7 +24,7 @@ export default function PricingPage() {
<Card padding="lg" className="mt-12 max-w-2xl mx-auto"> <Card padding="lg" className="mt-12 max-w-2xl mx-auto">
<h2 className="text-white text-lg font-bold">What you get today</h2> <h2 className="text-white text-lg font-bold">What you get today</h2>
<p className="text-gray-400 text-sm mt-2 leading-relaxed"> <p className="text-ink-2 text-sm mt-2 leading-relaxed">
The full incident pipeline — transcription, correlation into incidents, mapping, and the The full incident pipeline — transcription, correlation into incidents, mapping, and the
Discord relay. Node counts, seats, and history length are set per account while we work out Discord relay. Node counts, seats, and history length are set per account while we work out
the plan structure. the plan structure.
@@ -35,7 +35,7 @@ export default function PricingPage() {
</Card> </Card>
<div className="text-center mt-16"> <div className="text-center mt-16">
<p className="text-gray-400"> <p className="text-ink-2">
Questions?{" "} Questions?{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
{" "}or{" "} {" "}or{" "}
+3 -3
View File
@@ -63,18 +63,18 @@ export default function PrivacyPage() {
</div> </div>
<h1 className="text-display-sm text-white">Privacy Policy</h1> <h1 className="text-display-sm text-white">Privacy Policy</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p> <p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8"> <div className="mt-10 space-y-8">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<section key={s.heading}> <section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2> <h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p> <p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section> </section>
))} ))}
</div> </div>
<p className="text-gray-600 text-xs font-mono mt-16"> <p className="text-ink-muted text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "} Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>. <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p> </p>
+16 -16
View File
@@ -92,13 +92,13 @@ export default function ProfilePage() {
<div> <div>
<h1 className="text-white text-xl font-bold">{displayName}</h1> <h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && ( {user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p> <p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
)} )}
{role && ( {role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${ <span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" : role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" : role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400" "bg-raised text-ink-2"
}`}> }`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} {role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span> </span>
@@ -107,9 +107,9 @@ export default function ProfilePage() {
</div> </div>
{/* Firebase account */} {/* Firebase account */}
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <section className="bg-surface border border-line rounded-xl divide-y divide-line">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2"> <div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} /> <Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate /> <Row label="UID" value={user.uid} mono truncate />
@@ -125,12 +125,12 @@ export default function ProfilePage() {
</section> </section>
{/* Discord linking */} {/* Discord linking */}
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <section className="bg-surface border border-line rounded-xl overflow-hidden">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? ( {linkLoading ? (
<p className="text-gray-500 text-sm">Loading…</p> <p className="text-ink-muted text-sm">Loading…</p>
) : linkStatus?.linked ? ( ) : linkStatus?.linked ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -156,21 +156,21 @@ export default function ProfilePage() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-gray-400"> <p className="text-sm text-ink-2">
Link your Discord account to access private trips from both the web and Discord. Link your Discord account to access private trips from both the web and Discord.
</p> </p>
{code ? ( {code ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3"> <div className="bg-raised rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span> <span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div> </div>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes. Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p> </p>
<button <button
onClick={generateCode} onClick={generateCode}
disabled={generating} disabled={generating}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50" className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
> >
Generate new code Generate new code
</button> </button>
@@ -190,9 +190,9 @@ export default function ProfilePage() {
</section> </section>
{/* Sign out */} {/* Sign out */}
<section className="bg-gray-900 border border-gray-800 rounded-xl"> <section className="bg-surface border border-line rounded-xl">
<div className="px-4 py-3 flex items-center justify-between"> <div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-400">Sign out of this device</p> <p className="text-sm text-ink-2">Sign out of this device</p>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors" className="text-sm text-red-500 hover:text-red-400 transition-colors"
@@ -213,8 +213,8 @@ function Row({ label, value, mono = false, truncate = false }: {
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<span className="text-xs text-gray-500 shrink-0">{label}</span> <span className="text-xs text-ink-muted shrink-0">{label}</span>
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}> <span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value} {value}
</span> </span>
</div> </div>
+13 -13
View File
@@ -39,11 +39,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-lg space-y-4"> <Card padding="lg" className="w-full max-w-lg space-y-4">
<h2 className="text-white font-semibold">Key created</h2> <h2 className="text-white font-semibold">Key created</h2>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "} Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "}
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything. <code className="text-ink-2">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything.
</p> </p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p> <p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -61,11 +61,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<h2 className="text-white font-semibold mb-4">New API key</h2> <h2 className="text-white font-semibold mb-4">New API key</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<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 <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Ops dashboard integration" placeholder="e.g. Ops dashboard integration"
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"
/> />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -96,9 +96,9 @@ export default function ApiKeysSettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4"> <div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p> <p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
<p className="text-gray-400 text-xs mt-1 leading-relaxed"> <p className="text-ink-2 text-xs mt-1 leading-relaxed">
Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to demo module (<code className="text-ink-2">lib/apiKeys.ts</code>) so the flow can be reviewed end to
end. See that file for the exact backend routes a real integration needs. end. See that file for the exact backend routes a real integration needs.
</p> </p>
</div> </div>
@@ -108,7 +108,7 @@ export default function ApiKeysSettingsPage() {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p> <p className="text-sm text-ink-muted">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button> <Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
</div> </div>
@@ -118,7 +118,7 @@ export default function ApiKeysSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Label</th> <th className="px-4 py-3 text-left">Label</th>
<th className="px-4 py-3 text-left">Key</th> <th className="px-4 py-3 text-left">Key</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
@@ -129,11 +129,11 @@ export default function ApiKeysSettingsPage() {
</thead> </thead>
<tbody> <tbody>
{keys.map((k) => ( {keys.map((k) => (
<tr key={k.key_id} className="border-b border-gray-800 last:border-0"> <tr key={k.key_id} className="border-b border-line last:border-0">
<td className="px-4 py-3 text-white">{k.name}</td> <td className="px-4 py-3 text-white">{k.name}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td> <td className="px-4 py-3 text-ink-muted font-mono text-xs">{k.key_prefix}…</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>} {k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
</td> </td>
+12 -12
View File
@@ -54,12 +54,12 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
return ( return (
<div> <div>
<div className="flex items-baseline justify-between mb-1.5"> <div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs text-gray-400 font-mono">{label}</span> <span className="text-xs text-ink-2 font-mono">{label}</span>
<span className="text-xs font-mono text-gray-300"> <span className="text-xs font-mono text-ink-2">
{used} / {limit === "unlimited" ? "∞" : limit} {used} / {limit === "unlimited" ? "∞" : limit}
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-gray-800 overflow-hidden"> <div className="h-2 rounded-full bg-raised overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`} className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }} style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
@@ -142,7 +142,7 @@ export default function BillingSettingsPage() {
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
<p className="text-xs text-gray-600 font-mono"> <p className="text-xs text-ink-muted font-mono">
Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan. Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan.
</p> </p>
@@ -164,7 +164,7 @@ export default function BillingSettingsPage() {
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} /> <UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} /> <UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
</div> </div>
<div className="mt-5 pt-5 border-t border-gray-800"> <div className="mt-5 pt-5 border-t border-line">
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}> <Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
{portalBusy ? "Opening…" : "Manage payment method & invoices"} {portalBusy ? "Opening…" : "Manage payment method & invoices"}
</Button> </Button>
@@ -179,10 +179,10 @@ export default function BillingSettingsPage() {
return ( return (
<div <div
key={p.id} key={p.id}
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`} className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-line"}`}
> >
<p className="text-white font-semibold text-sm">{p.name}</p> <p className="text-white font-semibold text-sm">{p.name}</p>
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p> <p className="text-ink-muted text-xs mt-1 flex-1">{p.tagline}</p>
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && ( {(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => ( {(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
@@ -212,17 +212,17 @@ export default function BillingSettingsPage() {
<Card> <Card>
<CardHeader title="Invoice history" /> <CardHeader title="Invoice history" />
{invoices.length === 0 ? ( {invoices.length === 0 ? (
<p className="text-gray-600 text-sm">No invoices yet.</p> <p className="text-ink-muted text-sm">No invoices yet.</p>
) : ( ) : (
<div className="divide-y divide-gray-800"> <div className="divide-y divide-line">
{invoices.map((inv) => ( {invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between gap-4 py-3"> <div key={inv.id} className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-gray-200 text-sm">{inv.description}</p> <p className="text-ink text-sm">{inv.description}</p>
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p> <p className="text-ink-muted text-xs font-mono">{fmtDate(inv.date)}</p>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span> <span className="text-ink-2 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge> <Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
</div> </div>
</div> </div>
+3 -3
View File
@@ -43,15 +43,15 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
description="Organization profile, team access, node ownership, API keys, and billing." description="Organization profile, team access, node ownership, API keys, and billing."
/> />
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit max-w-full overflow-x-auto">
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => ( {[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
<Link <Link
key={t.href} key={t.href}
href={t.href} href={t.href}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
pathname === t.href || pathname.startsWith(t.href + "/") pathname === t.href || pathname.startsWith(t.href + "/")
? "bg-gray-800 text-white" ? "bg-raised text-white"
: "text-gray-500 hover:text-gray-300" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{t.label} {t.label}
+13 -13
View File
@@ -46,8 +46,8 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md space-y-4"> <Card padding="lg" className="w-full max-w-md space-y-4">
<h2 className="text-white font-semibold">Member invited</h2> <h2 className="text-white font-semibold">Member invited</h2>
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p> <p className="text-xs text-ink-2">Share this one-time invite link so they can set their password. It expires after use.</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p> <p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div> </div>
<Button onClick={onClose} fullWidth>Done</Button> <Button onClick={onClose} fullWidth>Done</Button>
@@ -62,18 +62,18 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
<h2 className="text-white font-semibold mb-4">Invite a member</h2> <h2 className="text-white font-semibold mb-4">Invite a member</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)} type="email" required value={email} onChange={(e) => setEmail(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"
placeholder="teammate@example.com" placeholder="teammate@example.com"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={role} onChange={(e) => setRole(e.target.value as UserRole)} value={role} onChange={(e) => setRole(e.target.value as UserRole)}
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"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -130,7 +130,7 @@ export default function MembersSettingsPage() {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-gray-500"> <p className="text-sm text-ink-muted">
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`} {loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
</p> </p>
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button> <Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
@@ -144,7 +144,7 @@ export default function MembersSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Member</th> <th className="px-4 py-3 text-left">Member</th>
<th className="px-4 py-3 text-left">Role</th> <th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
@@ -155,10 +155,10 @@ export default function MembersSettingsPage() {
{loading {loading
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />) ? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
: users.map((u) => ( : users.map((u) => (
<tr key={u.uid} className="border-b border-gray-800 last:border-0"> <tr key={u.uid} className="border-b border-line last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="text-white">{u.display_name || u.email}</p> <p className="text-white">{u.display_name || u.email}</p>
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>} {u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{u.uid === user?.uid ? ( {u.uid === user?.uid ? (
@@ -168,7 +168,7 @@ export default function MembersSettingsPage() {
value={u.role} value={u.role}
disabled={savingUid === u.uid} disabled={savingUid === u.uid}
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)} onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50" className="bg-raised border border-line-strong rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
> >
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="operator">Operator</option> <option value="operator">Operator</option>
@@ -176,7 +176,7 @@ export default function MembersSettingsPage() {
</select> </select>
)} )}
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell"> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">
{u.role === "operator" ? u.owned_node_ids.length : "—"} {u.role === "operator" ? u.owned_node_ids.length : "—"}
</td> </td>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
@@ -189,7 +189,7 @@ export default function MembersSettingsPage() {
</Card> </Card>
)} )}
<p className="text-xs text-gray-600 font-mono"> <p className="text-xs text-ink-muted font-mono">
Need to disable or delete a member? Use the full user admin panel under Admin → Users. Need to disable or delete a member? Use the full user admin panel under Admin → Users.
</p> </p>
</div> </div>
+14 -14
View File
@@ -114,7 +114,7 @@ function EnrollmentTokensPanel() {
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p> <p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-surface rounded px-2 py-1.5">{justMinted}</p>
<button <button
type="button" type="button"
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => { onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
@@ -133,7 +133,7 @@ function EnrollmentTokensPanel() {
: <>(check the broker host)</>}: : <>(check the broker host)</>}:
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre> <pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-surface rounded px-2 py-1.5">{installCmd}</pre>
<button <button
type="button" type="button"
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => { onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
@@ -163,7 +163,7 @@ function EnrollmentTokensPanel() {
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="Node ID, e.g. node-003" placeholder="Node ID, e.g. node-003"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 min-w-[12rem] bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<Button type="submit" size="sm" disabled={minting || !label.trim()}> <Button type="submit" size="sm" disabled={minting || !label.trim()}>
{minting ? "Minting…" : "New token"} {minting ? "Minting…" : "New token"}
@@ -176,11 +176,11 @@ function EnrollmentTokensPanel() {
<SkeletonRow cols={1} /> <SkeletonRow cols={1} />
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p> <p className="text-ink-muted text-xs">No enrollment tokens yet.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="py-2 text-left">Label</th> <th className="py-2 text-left">Label</th>
<th className="py-2 text-left hidden sm:table-cell">Created</th> <th className="py-2 text-left hidden sm:table-cell">Created</th>
<th className="py-2 text-left">Status</th> <th className="py-2 text-left">Status</th>
@@ -189,14 +189,14 @@ function EnrollmentTokensPanel() {
</thead> </thead>
<tbody> <tbody>
{tokens.map((t) => ( {tokens.map((t) => (
<tr key={t.token_id} className="border-b border-gray-800 last:border-0"> <tr key={t.token_id} className="border-b border-line last:border-0">
<td className="py-2 text-white">{t.label}</td> <td className="py-2 text-white">{t.label}</td>
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell"> <td className="py-2 text-ink-muted text-xs hidden sm:table-cell">
{new Date(t.created_at).toLocaleDateString()} {new Date(t.created_at).toLocaleDateString()}
</td> </td>
<td className="py-2 text-xs"> <td className="py-2 text-xs">
{t.revoked ? ( {t.revoked ? (
<span className="text-gray-600">Revoked</span> <span className="text-ink-muted">Revoked</span>
) : ( ) : (
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span> <span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
)} )}
@@ -283,7 +283,7 @@ export default function NodeOwnershipSettingsPage() {
<EnrollmentTokensPanel /> <EnrollmentTokensPanel />
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-ink-muted">
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here. Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
</p> </p>
@@ -292,7 +292,7 @@ export default function NodeOwnershipSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Node</th> <th className="px-4 py-3 text-left">Node</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
<th className="px-4 py-3 text-left">Owner</th> <th className="px-4 py-3 text-left">Owner</th>
@@ -302,17 +302,17 @@ export default function NodeOwnershipSettingsPage() {
{loading ? ( {loading ? (
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />) Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr> <tr><td colSpan={3} className="px-4 py-8 text-center text-ink-muted text-sm">No nodes registered yet.</td></tr>
) : ( ) : (
nodes.map((n) => { nodes.map((n) => {
const owner = ownerByNode.get(n.node_id); const owner = ownerByNode.get(n.node_id);
return ( return (
<tr key={n.node_id} className="border-b border-gray-800 last:border-0"> <tr key={n.node_id} className="border-b border-line last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors"> <Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
{n.name} {n.name}
</Link> </Link>
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p> <p className="text-ink-muted text-xs font-mono">{n.node_id}</p>
</td> </td>
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td> <td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -320,7 +320,7 @@ export default function NodeOwnershipSettingsPage() {
value={owner?.uid ?? UNASSIGNED} value={owner?.uid ?? UNASSIGNED}
disabled={savingNodeId === n.node_id} disabled={savingNodeId === n.node_id}
onChange={(e) => reassign(n.node_id, e.target.value)} onChange={(e) => reassign(n.node_id, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]" className="bg-raised border border-line-strong rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
> >
<option value={UNASSIGNED}>Unassigned</option> <option value={UNASSIGNED}>Unassigned</option>
{assignable.map((u) => ( {assignable.map((u) => (
@@ -16,7 +16,7 @@ import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
function StatTile({ label, value }: { label: string; value: string | number }) { function StatTile({ label, value }: { label: string; value: string | number }) {
return ( return (
<div> <div>
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono">{label}</p>
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p> <p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
</div> </div>
); );
@@ -69,12 +69,12 @@ export default function OrganizationSettingsPage() {
/> />
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-ink-2 block mb-1">Organization name</label>
<input <input
value={orgName} value={orgName}
disabled={orgLoading || !canEdit} disabled={orgLoading || !canEdit}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => setOrgName(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 disabled:opacity-50" 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 disabled:opacity-50"
/> />
</div> </div>
{saveError && <p className="text-red-400 text-xs">{saveError}</p>} {saveError && <p className="text-red-400 text-xs">{saveError}</p>}
@@ -110,7 +110,7 @@ export default function OrganizationSettingsPage() {
value={sub ? sub.status.replace("_", " ") : "—"} value={sub ? sub.status.replace("_", " ") : "—"}
/> />
</div> </div>
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm"> <div className="mt-5 pt-5 border-t border-line flex items-center gap-4 text-sm">
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors"> <Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
Manage plan & billing → Manage plan & billing →
</Link> </Link>
@@ -124,7 +124,7 @@ export default function OrganizationSettingsPage() {
<PreferredBotsCard canEdit={canEdit} /> <PreferredBotsCard canEdit={canEdit} />
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5"> <div className="bg-surface border border-red-800/60 rounded-xl p-5">
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." /> <CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support"> <Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
+7 -7
View File
@@ -61,23 +61,23 @@ export default function SignupPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Create your account</h1> <h1 className="text-white text-lg font-bold">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Password</label> <label className="text-xs text-ink-2 block mb-1">Password</label>
<input <input
type="password" type="password"
value={password} value={password}
@@ -85,7 +85,7 @@ export default function SignupPage() {
required required
minLength={6} minLength={6}
autoComplete="new-password" autoComplete="new-password"
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"
/> />
</div> </div>
{error && ( {error && (
@@ -102,7 +102,7 @@ export default function SignupPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span> <span className="text-xs text-ink-muted">or</span>
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
</div> </div>
@@ -121,7 +121,7 @@ export default function SignupPage() {
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-ink-muted">
Already have an account?{" "} Already have an account?{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link> <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p> </p>
+137 -126
View File
@@ -438,17 +438,17 @@ function RRImportModal({
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
{/* Header */} {/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-gray-800"> <div className="px-5 pt-5 pb-4 border-b border-line">
<h2 className="text-white font-semibold">{system.name}</h2> <h2 className="text-white font-semibold">{system.name}</h2>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-ink-muted mt-0.5">
{system.systemType}{system.location ? ` · ${system.location}` : ""} {system.systemType}{system.location ? ` · ${system.location}` : ""}
</p> </p>
{system.sysIds && ( {system.sysIds && (
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p> <p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
)} )}
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-ink-muted mt-1">
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups {system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
</p> </p>
</div> </div>
@@ -456,7 +456,7 @@ function RRImportModal({
{/* Category list */} {/* Category list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p> <p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
<div className="flex gap-3 text-xs text-indigo-400"> <div className="flex gap-3 text-xs text-indigo-400">
<button <button
type="button" type="button"
@@ -480,18 +480,18 @@ function RRImportModal({
type="checkbox" type="checkbox"
checked={selected.has(cat.name)} checked={selected.has(cat.name)}
onChange={() => toggle(cat.name)} onChange={() => toggle(cat.name)}
className="rounded border-gray-600 bg-gray-800 accent-indigo-500" className="rounded border-line-strong bg-raised accent-indigo-500"
/> />
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate"> <span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
{cat.name} {cat.name}
</span> </span>
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span> <span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
</label> </label>
))} ))}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3"> <div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
<button <button
type="button" type="button"
onClick={handleImport} onClick={handleImport}
@@ -503,7 +503,7 @@ function RRImportModal({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
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 Cancel
</button> </button>
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
multiline?: boolean; multiline?: boolean;
}) { }) {
const cls = const cls =
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500"; "w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
return ( return (
<label className="block"> <label className="block">
<span className="text-xs text-gray-500 font-sans">{label}</span> <span className="text-xs text-ink-muted font-sans">{label}</span>
{multiline ? ( {multiline ? (
<textarea <textarea
value={value} value={value}
@@ -656,8 +656,8 @@ function TalkgroupEditor({
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-gray-400"> <label className="text-xs text-ink-2">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>} Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
{/* RadioReference file import */} {/* RadioReference file import */}
@@ -704,10 +704,10 @@ function TalkgroupEditor({
)} )}
{showPaste && ( {showPaste && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700"> <div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
<p className="text-xs text-gray-500"> <p className="text-xs text-ink-muted">
Paste rows from RadioReference — tab- or comma-separated:{" "} Paste rows from RadioReference — tab- or comma-separated:{" "}
<span className="text-gray-400">ID, Name, Tag</span> <span className="text-ink-2">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other <br />Tags: fire · police · ems · transit · public works · other
</p> </p>
<textarea <textarea
@@ -715,7 +715,7 @@ function TalkgroupEditor({
onChange={(e) => setPasteText(e.target.value)} onChange={(e) => setPasteText(e.target.value)}
rows={6} rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"} placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="button" type="button"
@@ -729,10 +729,10 @@ function TalkgroupEditor({
)} )}
{talkgroups.length > 0 ? ( {talkgroups.length > 0 ? (
<div className="border border-gray-800 rounded-lg overflow-hidden"> <div className="border border-line rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-800 text-gray-400"> <tr className="bg-raised text-ink-2">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th> <th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th> <th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th> <th className="px-3 py-1.5 text-left w-28">Tag</th>
@@ -742,7 +742,7 @@ function TalkgroupEditor({
<tbody> <tbody>
{talkgroups.map((tg, i) => ( {talkgroups.map((tg, i) => (
<Fragment key={i}> <Fragment key={i}>
<tr className="border-t border-gray-800 hover:bg-gray-800/30"> <tr className="border-t border-line hover:bg-raised/30">
<td className="px-2 py-1"> <td className="px-2 py-1">
<input <input
value={tg.id} value={tg.id}
@@ -763,7 +763,7 @@ function TalkgroupEditor({
<select <select
value={tg.tag} value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)} onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1" className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
> >
{TG_TAGS.map((t) => ( {TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>{t}</option>
@@ -776,7 +776,7 @@ function TalkgroupEditor({
title="Local knowledge for the transcript corrector" title="Local knowledge for the transcript corrector"
onClick={() => setExpanded(expanded === i ? null : i)} onClick={() => setExpanded(expanded === i ? null : i)}
className={`transition-colors font-bold mr-2 ${ className={`transition-colors font-bold mr-2 ${
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300" hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{expanded === i ? "▾" : "▸"} {expanded === i ? "▾" : "▸"}
@@ -784,7 +784,7 @@ function TalkgroupEditor({
<button <button
type="button" type="button"
onClick={() => removeRow(i)} onClick={() => removeRow(i)}
className="text-gray-600 hover:text-red-400 transition-colors font-bold" className="text-ink-muted hover:text-red-400 transition-colors font-bold"
> >
× ×
</button> </button>
@@ -794,11 +794,11 @@ function TalkgroupEditor({
talkgroups and most inherit the system's context rather than talkgroups and most inherit the system's context rather than
override it. */} override it. */}
{expanded === i && ( {expanded === i && (
<tr className="border-t border-gray-800 bg-gray-900/60"> <tr className="border-t border-line bg-surface/60">
<td colSpan={4} className="px-3 py-3"> <td colSpan={4} className="px-3 py-3">
<p className="text-xs text-gray-500 mb-2 font-sans"> <p className="text-xs text-ink-muted mb-2 font-sans">
Given to the transcript corrector for this talkgroup only, ranked Given to the transcript corrector for this talkgroup only, ranked
<span className="text-gray-300"> above</span> the system&apos;s own list. <span className="text-ink-2"> above</span> the system&apos;s own list.
Leave blank to inherit the system&apos;s. Leave blank to inherit the system&apos;s.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -849,7 +849,7 @@ function TalkgroupEditor({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-600 italic py-1"> <p className="text-xs text-ink-muted italic py-1">
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button. No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
</p> </p>
)} )}
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label> <label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
<input <input
value={value.nac} value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })} onChange={(e) => onChange({ ...value, nac: e.target.value })}
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="0x293" placeholder="0x293"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">System ID</label> <label className="text-xs text-ink-2 block mb-1">System ID</label>
<input <input
value={value.system_id} value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })} onChange={(e) => onChange({ ...value, system_id: e.target.value })}
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="50513" placeholder="50513"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label> <label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
<input <input
value={value.wacn} value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })} onChange={(e) => onChange({ ...value, wacn: e.target.value })}
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="0xBEE00" placeholder="0xBEE00"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span> Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
</label> </label>
<input <input
value={value.control_channels} value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })} onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
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="851.0125, 851.5125, 852.0125" placeholder="851.0125, 851.5125, 852.0125"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span> Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
</label> </label>
<input <input
value={value.voice_channels} value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })} onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
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="Optional" placeholder="Optional"
/> />
</div> </div>
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
// ── Fixed-channel editors (node-26#24) ──────────────────────────────────────── // ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
const INPUT = "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"; const INPUT = "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";
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) { function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
const chs = value.channels; const chs = value.channels;
@@ -931,13 +931,13 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) }); onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500"> <p className="text-xs text-ink-muted">
Fixed voice channels with no control channel — RadioReference lists them as conventional Fixed voice channels with no control channel — RadioReference lists them as conventional
frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name. one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400"> <div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
<span>Frequency (MHz)</span> <span>Frequency (MHz)</span>
<span>Channel name</span> <span>Channel name</span>
<span>NAC (hex, optional)</span> <span>NAC (hex, optional)</span>
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
talkgroups={value.talkgroups} talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })} onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/> />
<p className="text-xs text-gray-600 -mt-2"> <p className="text-xs text-ink-muted -mt-2">
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named. Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
</p> </p>
</div> </div>
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
return ( return (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label> <label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
<input <input
id="analog-freq" id="analog-freq"
value={ch.freq} value={ch.freq}
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
/> />
</div> </div>
<div> <div>
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label> <label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
<input <input
id="analog-name" id="analog-name"
value={ch.name} value={ch.name}
@@ -1124,27 +1124,27 @@ function SystemForm({
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System"; const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return ( return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono"> <form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{title}</h3> <h3 className="text-white font-semibold">{title}</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
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"
placeholder="Westchester County P25" placeholder="Westchester County P25"
/> />
</div> </div>
<div> <div>
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label> <label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
<select <select
id="system-kind" id="system-kind"
value={kind} value={kind}
onChange={(e) => handleKindChange(e.target.value as SystemKind)} onChange={(e) => handleKindChange(e.target.value as SystemKind)}
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"
> >
{KIND_OPTIONS.map((o) => ( {KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option> <option key={o.value} value={o.value}>{o.label}</option>
@@ -1165,12 +1165,12 @@ function SystemForm({
<AnalogForm value={analog} onChange={setAnalog} /> <AnalogForm value={analog} onChange={setAnalog} />
) : ( ) : (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label> <label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
<textarea <textarea
value={rawJson} value={rawJson}
onChange={(e) => setRawJson(e.target.value)} onChange={(e) => setRawJson(e.target.value)}
rows={10} rows={10}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs 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-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
)} )}
@@ -1179,7 +1179,7 @@ function SystemForm({
<button <button
type="button" type="button"
onClick={toggleRaw} onClick={toggleRaw}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors" className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
> >
{showRaw ? "← Use structured form" : "Edit raw JSON →"} {showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button> </button>
@@ -1198,7 +1198,7 @@ function SystemForm({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
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 Cancel
</button> </button>
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
const currentToken = tokens?.find((t) => t.token_id === preferredId); const currentToken = tokens?.find((t) => t.token_id === preferredId);
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
{open && ( {open && (
<div className="mt-3 space-y-2 font-mono text-xs"> <div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? ( {tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p> <p className="text-ink-muted italic">Loading tokens…</p>
) : error ? ( ) : error ? (
<p className="text-red-400">{error}</p> <p className="text-red-400">{error}</p>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p> <p className="text-ink-muted italic">No tokens in pool.</p>
) : ( ) : (
<> <>
<p className="text-gray-600"> <p className="text-ink-muted">
When a node on this system joins a voice channel, this token is tried first. When a node on this system joins a voice channel, this token is tried first.
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
disabled={saving} disabled={saving}
className="accent-indigo-500" className="accent-indigo-500"
/> />
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}> <span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
{t.name} {t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>} {t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span> </span>
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
<button <button
onClick={handleClear} onClick={handleClear}
disabled={saving} disabled={saving}
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50" className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
> >
Clear preference (use any free token) Clear preference (use any free token)
</button> </button>
)} )}
{!preferredId && ( {!preferredId && (
<p className="text-gray-700">No preference — any free token will be used.</p> <p className="text-ink-muted">No preference — any free token will be used.</p>
)} )}
{currentToken && ( {currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p> <p className="text-indigo-500">Preferred: {currentToken.name}</p>
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
]; ];
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span>AI Flags</span> <span>AI Flags</span>
@@ -1400,23 +1400,23 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
> >
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} /> <span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
</button> </button>
<span className="text-gray-300 flex-1">{label}</span> <span className="text-ink-2 flex-1">{label}</span>
{isSet ? ( {isSet ? (
<button <button
onClick={() => handleClear(key)} onClick={() => handleClear(key)}
disabled={saving === key} disabled={saving === key}
className="text-gray-600 hover:text-gray-400 transition-colors" className="text-ink-muted hover:text-ink-2 transition-colors"
title="Clear override (inherit global)" title="Clear override (inherit global)"
> >
reset reset
</button> </button>
) : ( ) : (
<span className="text-gray-700">inherits global</span> <span className="text-ink-muted">inherits global</span>
)} )}
</div> </div>
); );
})} })}
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p> <p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
</div> </div>
)} )}
</div> </div>
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
{loading ? "loading…" : open ? "▲ source" : "▶ source"} {loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button> </button>
{open && call && ( {open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700"> <div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
{call.audio_url ? ( {call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} /> <audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : ( ) : (
<p className="text-gray-600 italic">No audio</p> <p className="text-ink-muted italic">No audio</p>
)} )}
{transcript && ( {transcript && (
<> <>
<p className="text-gray-500 italic line-clamp-2">{transcript}</p> <p className="text-ink-muted italic line-clamp-2">{transcript}</p>
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */} {/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
<MachineOutputNotice variant="inline" className="text-[10px]" /> <MachineOutputNotice variant="inline" className="text-[10px]" />
</> </>
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
: 0; : 0;
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Local Area Local Area
{area !== null && ( {area !== null && (
<span className="text-gray-600 ml-1"> <span className="text-ink-muted ml-1">
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"}) ({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
</span> </span>
)} )}
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 text-xs"> <div className="mt-3 space-y-3 text-xs">
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>} {loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
{area && ( {area && (
<> <>
<p className="text-gray-500"> <p className="text-ink-muted">
Given to the transcript corrector for every talkgroup on this system. Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets Whisper mishears local names constantly — naming them here is what lets
them be put back. them be put back.
</p> </p>
<p className="text-gray-500"> <p className="text-ink-muted">
Fill this in <span className="text-gray-300">only if it is true of every Fill this in <span className="text-ink-2">only if it is true of every
talkgroup</span> on the system. One town, one department — fill it once here. talkgroup</span> on the system. One town, one department — fill it once here.
A system spanning several counties — leave it blank and describe each A system spanning several counties — leave it blank and describe each
talkgroup in the edit form instead. talkgroup in the edit form instead.
@@ -1592,7 +1592,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{/* The anchor is the backend's answer to what was typed above, and {/* The anchor is the backend's answer to what was typed above, and
it decides whether location checking runs at all — so it is it decides whether location checking runs at all — so it is
worth showing rather than leaving as invisible state. */} worth showing rather than leaving as invisible state. */}
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p> <p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
<button <button
type="button" type="button"
onClick={save} onClick={save}
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0); const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Proposed Terms Proposed Terms
{rows !== null && ( {rows !== null && (
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}> <span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
({total > 0 ? `${total} awaiting review` : "none"}) ({total > 0 ? `${total} awaiting review` : "none"})
</span> </span>
)} )}
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 text-xs"> <div className="mt-3 space-y-3 text-xs">
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>} {rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
{rows !== null && total === 0 && ( {rows !== null && total === 0 && (
<p className="text-gray-600 italic"> <p className="text-ink-muted italic">
Nothing proposed yet. Terms appear here when a corrected place name Nothing proposed yet. Terms appear here when a corrected place name
turns out to be real nearby, or when the induction loop spots one in turns out to be real nearby, or when the induction loop spots one in
recent traffic. recent traffic.
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
)} )}
{(rows ?? []).map((row) => ( {(rows ?? []).map((row) => (
<div key={row.talkgroup_id}> <div key={row.talkgroup_id}>
<p className="text-gray-400 font-mono mb-1"> <p className="text-ink-2 font-mono mb-1">
{row.talkgroup_name || `TGID ${row.talkgroup_id}`} {row.talkgroup_name || `TGID ${row.talkgroup_id}`}
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{row.pending.map((p) => ( {row.pending.map((p) => (
<li <li
key={p.term} key={p.term}
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1" className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
> >
<span className="flex-1"> <span className="flex-1">
<span className="text-white font-mono">{p.term}</span> <span className="text-white font-mono">{p.term}</span>
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>} {p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
<span className="text-gray-700 ml-2">{p.source}</span> <span className="text-ink-muted ml-2">{p.source}</span>
</span> </span>
<button <button
type="button" type="button"
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
type="button" type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`} disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, false)} onClick={() => act(row.talkgroup_id, p.term, false)}
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold" className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
> >
Drop Drop
</button> </button>
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
} }
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Vocabulary Vocabulary
{vocab !== null && ( {vocab !== null && (
<span className="text-gray-600 ml-1"> <span className="text-ink-muted ml-1">
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""}) ({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
</span> </span>
)} )}
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 font-mono text-xs"> <div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading…</p>} {loading && <p className="text-ink-muted italic">Loading…</p>}
{!loading && vocab !== null && ( {!loading && vocab !== null && (
<> <>
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
> >
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"} {bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button> </button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span> <span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div> </div>
<div> <div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p> <p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? ( {vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{vocab.map((term) => ( {vocab.map((term) => (
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"> <span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
{term} {term}
<button <button
onClick={() => handleRemove(term)} onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none" className="text-ink-muted hover:text-red-400 transition-colors leading-none"
> >
× ×
</button> </button>
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p> <p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
)} )}
</div> </div>
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
value={newTerm} value={newTerm}
onChange={(e) => setNewTerm(e.target.value)} onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)" placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="submit" type="submit"
disabled={adding || !newTerm.trim()} disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors" className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
> >
Add Add
</button> </button>
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{pending.length > 0 && ( {pending.length > 0 && (
<div> <div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5"> <p className="text-ink-muted uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length}) Induction suggestions ({pending.length})
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{pending.map((p) => ( {pending.map((p) => (
<div key={p.term} className="space-y-1"> <div key={p.term} className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span> <span className="text-ink-2 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span> <span className="text-ink-muted">{p.source}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button> <button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button> <button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
</div> </div>
{p.source_call_ids && p.source_call_ids.length > 0 && ( {p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1"> <div className="pl-1 space-y-1">
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
if (authLoading || (!isAdmin && !isOperator)) return null; if (authLoading || (!isAdmin && !isOperator)) return null;
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return; if (!confirm("Delete this system?")) return;
await c2api.deleteSystem(id); try { await c2api.deleteSystem(id); }
catch (e) { alert(`Couldn't delete the system: ${e instanceof Error ? e.message : String(e)}`); }
} }
function openEdit(s: SystemRecord) { function openEdit(s: SystemRecord) {
@@ -1952,12 +1953,16 @@ export default function SystemsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1> <h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<button {/* Every systems write route is require_admin_token (server-26#283);
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }} operators get a read-only view. */}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors" {isAdmin && (
> <button
+ New System onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
</button> className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ New System
</button>
)}
</div> </div>
{editing && ( {editing && (
@@ -1970,9 +1975,9 @@ export default function SystemsPage() {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : systems.length === 0 ? ( ) : systems.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p> <p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{systems.map((s) => { {systems.map((s) => {
@@ -1987,13 +1992,13 @@ export default function SystemsPage() {
? (s.config.channels as unknown[]).length ? (s.config.channels as unknown[]).length
: null; : null;
return ( return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono"> <div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{s.name}</p> <p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p> <p className="text-xs text-ink-muted">{s.system_id}</p>
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && ( {(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2"> <p className="text-xs text-ink-muted mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>} {!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>} {ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>} {chCount !== null && <span>· {chCount} ch</span>}
@@ -2001,11 +2006,11 @@ export default function SystemsPage() {
</p> </p>
)} )}
</div> </div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded"> <span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
{kindLabel(s)} {kindLabel(s)}
</span> </span>
</div> </div>
<div className="mt-3 flex gap-3"> {isAdmin && <div className="mt-3 flex gap-3">
<button <button
onClick={() => openEdit(s)} onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
@@ -2014,7 +2019,7 @@ export default function SystemsPage() {
</button> </button>
<button <button
onClick={() => openDuplicate(s)} onClick={() => openDuplicate(s)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors" className="text-xs text-ink-muted hover:text-ink transition-colors"
> >
Duplicate Duplicate
</button> </button>
@@ -2024,15 +2029,21 @@ export default function SystemsPage() {
> >
Delete Delete
</button> </button>
</div> </div>}
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only {/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(server-26#204 item 6), so for anyone else this panel could (server-26#204 item 6), so for anyone else this panel could
neither list the pool nor save a choice (server-26#213 item 4). */} neither list the pool nor save a choice (server-26#213 item 4). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />} {isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} /> {/* All admin-only routes (server-26#283), incl. area-context and
<AreaContextPanel systemId={s.system_id} /> talkgroup-pending GETs and every vocabulary write. */}
<TalkgroupPendingPanel systemId={s.system_id} /> {isAdmin && (
<VocabularyPanel systemId={s.system_id} /> <>
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} />
</>
)}
</div> </div>
); );
})} })}
+3 -3
View File
@@ -69,18 +69,18 @@ export default function TermsPage() {
</div> </div>
<h1 className="text-display-sm text-white">Terms of Service</h1> <h1 className="text-display-sm text-white">Terms of Service</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p> <p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8"> <div className="mt-10 space-y-8">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<section key={s.heading}> <section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2> <h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p> <p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section> </section>
))} ))}
</div> </div>
<p className="text-gray-600 text-xs font-mono mt-16"> <p className="text-ink-muted text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "} Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>. <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p> </p>
+80 -77
View File
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types"; import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -141,7 +142,7 @@ function PlaceSearch({
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
placeholder="Search a place…" placeholder="Search a place…"
className="flex-1 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="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="button" type="button"
@@ -153,7 +154,7 @@ function PlaceSearch({
</button> </button>
</div> </div>
{results.length > 0 && ( {results.length > 0 && (
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700"> <div className="bg-raised border border-line-strong rounded-lg overflow-hidden divide-y divide-line-strong">
{results.map((p) => ( {results.map((p) => (
<button <button
key={p.place_id} key={p.place_id}
@@ -162,7 +163,7 @@ function PlaceSearch({
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors" className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
> >
<p className="text-white text-sm font-medium">{p.name}</p> <p className="text-white text-sm font-medium">{p.name}</p>
<p className="text-gray-400 text-xs">{p.address}</p> <p className="text-ink-2 text-xs">{p.address}</p>
{p.rating && ( {p.rating && (
<p className="text-yellow-400 text-xs">★ {p.rating}</p> <p className="text-yellow-400 text-xs">★ {p.rating}</p>
)} )}
@@ -240,60 +241,60 @@ function AddEventModal({
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto" className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2> <h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Title</label> <label className="text-xs text-ink-2 block mb-1">Title</label>
<input <input
required value={title} onChange={(e) => setTitle(e.target.value)} required value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="Dinner at the bar" placeholder="Dinner at the bar"
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"
/> />
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Date</label> <label className="text-xs text-ink-2 block mb-1">Date</label>
<input <input
required type="date" required type="date"
min={trip.start_date} max={trip.end_date} min={trip.start_date} max={trip.end_date}
value={date} onChange={(e) => setDate(e.target.value)} value={date} onChange={(e) => setDate(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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Start</label> <label className="text-xs text-ink-2 block mb-1">Start</label>
<input <input
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} type="time" value={startTime} onChange={(e) => setStartTime(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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">End</label> <label className="text-xs text-ink-2 block mb-1">End</label>
<input <input
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
min={startTime} min={startTime}
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"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Find a place</label> <label className="text-xs text-ink-2 block mb-1">Find a place</label>
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} /> <PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Location{" "} Location{" "}
<span className="text-gray-600">(auto-filled from search, or type manually)</span> <span className="text-ink-muted">(auto-filled from search, or type manually)</span>
</label> </label>
<input <input
value={location} onChange={(e) => setLocation(e.target.value)} value={location} onChange={(e) => setLocation(e.target.value)}
placeholder={`Inherits: ${trip.location}`} placeholder={`Inherits: ${trip.location}`}
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"
/> />
</div> </div>
@@ -307,16 +308,16 @@ function AddEventModal({
)} )}
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label> <label className="text-xs text-ink-2 block mb-1">Notes (optional)</label>
<textarea <textarea
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
/> />
</div> </div>
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Tags</label> <label className="text-xs text-ink-2 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => { {availableTags.map((tag) => {
const active = tags.includes(tag); const active = tags.includes(tag);
@@ -326,7 +327,7 @@ function AddEventModal({
type="button" type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])} onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${ className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400" active ? tagColor(tag, availableTags) : "bg-raised text-ink-muted border-line-strong hover:border-line-strong hover:text-ink-2"
}`} }`}
> >
{tag} {tag}
@@ -340,7 +341,7 @@ function AddEventModal({
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1"> <div className="flex gap-3 justify-end pt-1">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2"> <button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel Cancel
</button> </button>
<button <button
@@ -389,7 +390,7 @@ function DayTimeline({
if (timed.length === 0 && untimed.length === 0) { if (timed.length === 0 && untimed.length === 0) {
return ( return (
<p className="text-gray-600 text-sm font-mono py-8 text-center"> <p className="text-ink-muted text-sm font-mono py-8 text-center">
No events on this day yet. No events on this day yet.
</p> </p>
); );
@@ -424,10 +425,10 @@ function DayTimeline({
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }} style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
className="absolute inset-x-0 flex items-center pointer-events-none" className="absolute inset-x-0 flex items-center pointer-events-none"
> >
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2"> <span className="w-14 text-right pr-3 text-xs text-ink-muted font-mono shrink-0 -translate-y-2">
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`} {h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
</span> </span>
<div className="flex-1 border-t border-gray-800/70" /> <div className="flex-1 border-t border-line/70" />
</div> </div>
))} ))}
@@ -444,7 +445,7 @@ function DayTimeline({
{e.end_time && ( {e.end_time && (
<div <div
style={{ top, height, left: 60, right: 0 }} style={{ top, height, left: 60, right: 0 }}
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none" className="absolute bg-raised/30 border-l-2 border-line-strong/30 z-0 pointer-events-none"
/> />
)} )}
{/* Dashed marker line + label */} {/* Dashed marker line + label */}
@@ -452,15 +453,15 @@ function DayTimeline({
style={{ top, left: 60, right: 0 }} style={{ top, left: 60, right: 0 }}
className="absolute z-10 flex items-center gap-2" className="absolute z-10 flex items-center gap-2"
> >
<div className="flex-1 border-t border-dashed border-gray-600/40" /> <div className="flex-1 border-t border-dashed border-line-strong/40" />
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1"> <span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)} {fmtTime(e.start_time)}
</span> </span>
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span> <span className="text-ink-muted text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
{isAdmin && ( {isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button> <button onClick={() => onEdit(e)} className="text-ink-muted hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button> <button onClick={() => onDelete(e.event_id)} className="text-ink-muted hover:text-red-400 text-xs leading-none">×</button>
</div> </div>
)} )}
</div> </div>
@@ -483,25 +484,27 @@ function DayTimeline({
style={{ top, height, left: 60, right: 0 }} style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${ className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict isConflict
? "bg-red-950/70 border border-red-700/70" // Theme tokens: the old bg-indigo-950/60 had no light-mode
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70" // override, so light mode got dark text on a dark block.
? "bg-sev-major/15 border border-sev-major/50"
: "bg-accent/15 border border-accent/40 hover:border-accent/70"
}`} }`}
> >
<div className="flex items-start justify-between gap-2 h-full"> <div className="flex items-start justify-between gap-2 h-full">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p> <p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
{height >= 44 && ( {height >= 44 && (
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}> <p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-sev-major" : "text-accent"}`}>
{fmtTime(e.start_time)} {fmtTime(e.start_time)}
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""} {e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
{isConflict ? " ⚠ conflict" : ""} {isConflict ? " ⚠ conflict" : ""}
</p> </p>
)} )}
{height >= 60 && !e.location_inherited && e.location && ( {height >= 60 && !e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p> <p className="text-ink-muted text-xs mt-0.5 truncate">{e.location}</p>
)} )}
{height >= 60 && e.notes && ( {height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p> <p className="text-ink-muted text-xs mt-0.5 italic truncate">{e.notes}</p>
)} )}
{e.tags?.length > 0 && ( {e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
@@ -525,13 +528,13 @@ function DayTimeline({
<> <>
<button <button
onClick={() => onEdit(e)} onClick={() => onEdit(e)}
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity" className="text-ink-muted hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
> >
Edit Edit
</button> </button>
<button <button
onClick={() => onDelete(e.event_id)} onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity" className="text-ink-muted hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
> >
× ×
</button> </button>
@@ -543,9 +546,9 @@ function DayTimeline({
{drive && ( {drive && (
<div <div
style={{ top: top + height + 2, left: 60 }} style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20" className="absolute text-xs text-ink-muted font-mono flex items-center gap-1 z-20"
> >
<span className="text-gray-700">↓</span> {drive} drive <span className="text-ink-muted">↓</span> {drive} drive
</div> </div>
)} )}
</div> </div>
@@ -557,18 +560,18 @@ function DayTimeline({
{/* Untimed / all-day events */} {/* Untimed / all-day events */}
{untimed.length > 0 && ( {untimed.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p> <p className="text-xs font-mono text-ink-muted uppercase tracking-wider">Unscheduled</p>
{untimed.map((e) => ( {untimed.map((e) => (
<div <div
key={e.event_id} key={e.event_id}
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group" className="bg-surface border border-line rounded-lg px-4 py-2.5 flex items-center justify-between group"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-white text-sm">{e.title}</p> <p className="text-white text-sm">{e.title}</p>
{!e.location_inherited && e.location && ( {!e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p> <p className="text-ink-muted text-xs mt-0.5">{e.location}</p>
)} )}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>} {e.notes && <p className="text-ink-muted text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && ( {e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)} {e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
@@ -585,11 +588,11 @@ function DayTimeline({
{isAdmin && ( {isAdmin && (
<> <>
<button onClick={() => onEdit(e)} <button onClick={() => onEdit(e)}
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity"> className="text-xs text-ink-muted hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
Edit Edit
</button> </button>
<button onClick={() => onDelete(e.event_id)} <button onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity"> className="text-ink-muted hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
× ×
</button> </button>
</> </>
@@ -728,17 +731,17 @@ function AssistantPanel({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2"> <div className="px-4 py-3 border-b border-line shrink-0 flex items-start justify-between gap-2">
<div> <div>
<p className="text-white text-sm font-semibold">Trip Assistant</p> <p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-gray-500 text-xs mt-0.5"> <p className="text-ink-muted text-xs mt-0.5">
Tell me what you want to do — I can search places and suggest events. Tell me what you want to do — I can search places and suggest events.
</p> </p>
</div> </div>
{messages.length > 0 && ( {messages.length > 0 && (
<button <button
onClick={clearChat} onClick={clearChat}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5" className="text-xs text-ink-muted hover:text-ink-2 transition-colors shrink-0 mt-0.5"
> >
Clear Clear
</button> </button>
@@ -758,7 +761,7 @@ function AssistantPanel({
<button <button
key={prompt} key={prompt}
onClick={() => { setInput(prompt); }} onClick={() => { setInput(prompt); }}
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors" className="w-full text-left bg-raised/60 hover:bg-raised border border-line-strong/50 rounded-lg px-3 py-2 text-ink-2 text-xs transition-colors"
> >
{prompt} {prompt}
</button> </button>
@@ -772,7 +775,7 @@ function AssistantPanel({
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${ className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
msg.role === "user" msg.role === "user"
? "bg-indigo-600 text-white" ? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-200" : "bg-raised text-ink"
}`} }`}
> >
{msg.role === "user" ? msg.content : ( {msg.role === "user" ? msg.content : (
@@ -804,11 +807,11 @@ function AssistantPanel({
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${ className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
s.added s.added
? "border-green-800/50 bg-green-950/30" ? "border-green-800/50 bg-green-950/30"
: "border-gray-700 bg-gray-900" : "border-line-strong bg-surface"
}`} }`}
> >
<p className="text-white text-sm font-semibold">{s.title}</p> <p className="text-white text-sm font-semibold">{s.title}</p>
<div className="text-xs text-gray-400 space-y-0.5"> <div className="text-xs text-ink-2 space-y-0.5">
{s.date && ( {s.date && (
<p> <p>
{fmtDate(s.date)} {fmtDate(s.date)}
@@ -817,7 +820,7 @@ function AssistantPanel({
</p> </p>
)} )}
{s.location && <p className="truncate">{s.location}</p>} {s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>} {s.notes && <p className="text-ink-muted italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && ( {s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5"> <div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)} {s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
@@ -840,7 +843,7 @@ function AssistantPanel({
</button> </button>
<button <button
onClick={() => dismissSuggestion(msg.id, idx)} onClick={() => dismissSuggestion(msg.id, idx)}
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors" className="px-3 text-ink-muted hover:text-ink text-xs transition-colors"
> >
Skip Skip
</button> </button>
@@ -856,7 +859,7 @@ function AssistantPanel({
{loading && ( {loading && (
<div className="flex items-start"> <div className="flex items-start">
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm"> <div className="bg-raised rounded-xl px-3 py-2 text-ink-2 text-sm">
<span className="animate-pulse">Thinking…</span> <span className="animate-pulse">Thinking…</span>
</div> </div>
</div> </div>
@@ -865,7 +868,7 @@ function AssistantPanel({
</div> </div>
{/* Input */} {/* Input */}
<div className="px-3 py-3 border-t border-gray-800 shrink-0"> <div className="px-3 py-3 border-t border-line shrink-0">
<div className="flex gap-2"> <div className="flex gap-2">
<textarea <textarea
ref={inputRef} ref={inputRef}
@@ -875,7 +878,7 @@ function AssistantPanel({
placeholder="What do you want to do?" placeholder="What do you want to do?"
disabled={loading} disabled={loading}
rows={1} rows={1}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
/> />
<button <button
onClick={send} onClick={send}
@@ -955,7 +958,7 @@ export default function TripDetailPage() {
async function handleDeleteTrip() { async function handleDeleteTrip() {
if (!trip) return; if (!trip) return;
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); } try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
catch (e) { console.error(e); } catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
} }
async function handleDeleteEvent(eventId: string) { async function handleDeleteEvent(eventId: string) {
@@ -963,7 +966,7 @@ export default function TripDetailPage() {
try { try {
await c2api.deleteTripEvent(trip.trip_id, eventId); await c2api.deleteTripEvent(trip.trip_id, eventId);
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev); setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
} catch (e) { console.error(e); } } catch (e) { alert(`Couldn't delete the event: ${errorMessage(e)}`); }
} }
async function handleUpdateEvent(body: object) { async function handleUpdateEvent(body: object) {
@@ -1055,8 +1058,8 @@ export default function TripDetailPage() {
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev); setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
} }
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>; if (loading) return <p className="text-ink-muted text-sm font-mono">Loading…</p>;
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>; if (!trip) return <p className="text-ink-muted text-sm font-mono">Trip not found.</p>;
const days = dateRange(trip.start_date, trip.end_date); const days = dateRange(trip.start_date, trip.end_date);
const attendees = Object.values(trip.attendees ?? {}); const attendees = Object.values(trip.attendees ?? {});
@@ -1070,7 +1073,7 @@ export default function TripDetailPage() {
<div className="space-y-4"> <div className="space-y-4">
<button <button
onClick={() => router.push("/trips")} onClick={() => router.push("/trips")}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" className="text-sm font-mono text-ink-muted hover:text-ink transition-colors"
> >
← Trips ← Trips
</button> </button>
@@ -1078,7 +1081,7 @@ export default function TripDetailPage() {
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-white text-2xl font-bold">{trip.name}</h1> <h1 className="text-white text-2xl font-bold">{trip.name}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
<span>{trip.location}</span> <span>{trip.location}</span>
{trip.maps_link && ( {trip.maps_link && (
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer" <a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
@@ -1086,11 +1089,11 @@ export default function TripDetailPage() {
Maps Maps
</a> </a>
)} )}
<span className="text-gray-700">•</span> <span className="text-ink-muted">•</span>
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span> <span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
{attendees.length > 0 && ( {attendees.length > 0 && (
<> <>
<span className="text-gray-700">•</span> <span className="text-ink-muted">•</span>
<span className="text-xs">{attendees.join(", ")}</span> <span className="text-xs">{attendees.join(", ")}</span>
</> </>
)} )}
@@ -1114,7 +1117,7 @@ export default function TripDetailPage() {
</button> </button>
<button <button
onClick={handleToggleVisibility} onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2" className="text-xs text-ink-muted hover:text-ink transition-colors border border-line-strong rounded-lg px-3 py-2"
> >
{trip.visibility === "private" ? "Make public" : "Make private"} {trip.visibility === "private" ? "Make public" : "Make private"}
</button> </button>
@@ -1159,9 +1162,9 @@ export default function TripDetailPage() {
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…" placeholder="Add tag…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24" className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-24"
/> />
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button> <button onClick={handleAddTag} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div> </div>
)} )}
</div> </div>
@@ -1170,13 +1173,13 @@ export default function TripDetailPage() {
{/* Invite management — admin only, only when private */} {/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && ( {isAdmin && trip.visibility === "private" && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && ( {(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span> <span className="text-xs text-ink-muted">No invites yet</span>
)} )}
{(trip.invited_discord_ids ?? []).map((discordId) => ( {(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300"> <span key={discordId} className="inline-flex items-center gap-1 text-xs bg-raised border border-line-strong rounded-full px-2.5 py-0.5 text-ink-2">
<span className="font-mono">{discordId}</span> <span className="font-mono">{discordId}</span>
<button <button
onClick={() => handleRevokeInvite(discordId)} onClick={() => handleRevokeInvite(discordId)}
@@ -1192,9 +1195,9 @@ export default function TripDetailPage() {
onChange={(e) => setInviteInput(e.target.value)} onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…" placeholder="Discord user ID…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36" className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-36"
/> />
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button> <button onClick={handleInvite} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1218,13 +1221,13 @@ export default function TripDetailPage() {
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${ className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
day === selectedDay day === selectedDay
? "bg-indigo-600 text-white" ? "bg-indigo-600 text-white"
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200" : "bg-surface border border-line text-ink-2 hover:border-line-strong hover:text-ink"
}`} }`}
> >
<span className="font-mono font-bold">{short}</span> <span className="font-mono font-bold">{short}</span>
<span className="font-mono">{date}</span> <span className="font-mono">{date}</span>
{count > 0 && ( {count > 0 && (
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}> <span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-ink-muted"}`}>
{count} {count}
</span> </span>
)} )}
@@ -1238,7 +1241,7 @@ export default function TripDetailPage() {
{/* Selected day */} {/* Selected day */}
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider"> <p className="text-sm font-mono text-ink-2 uppercase tracking-wider">
{fmtDayHeading(selectedDay)} {fmtDayHeading(selectedDay)}
</p> </p>
<DayTimeline <DayTimeline
@@ -1254,7 +1257,7 @@ export default function TripDetailPage() {
</div> </div>
{/* Right: AI Assistant — sticky */} {/* Right: AI Assistant — sticky */}
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-surface border border-line rounded-xl overflow-hidden">
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} /> <AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
</div> </div>
</div> </div>
+24 -22
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips"; import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripRecord } from "@/lib/types"; import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) { function fmtDate(iso: string) {
@@ -29,25 +30,25 @@ function TripCard({ trip, isAdmin, onDelete }: {
return ( return (
<div <div
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors" className="bg-surface border border-line rounded-xl p-5 cursor-pointer hover:border-line-strong transition-colors"
onClick={() => router.push(`/trips/${trip.trip_id}`)} onClick={() => router.push(`/trips/${trip.trip_id}`)}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ <span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500" upcoming ? "bg-indigo-900 text-indigo-300" : "bg-raised text-ink-muted"
}`}> }`}>
{upcoming ? "Upcoming" : "Past"} {upcoming ? "Upcoming" : "Past"}
</span> </span>
</div> </div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3> <h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-gray-400 text-xs mt-1">{trip.location}</p> <p className="text-ink-2 text-xs mt-1">{trip.location}</p>
<p className="text-gray-500 text-xs font-mono mt-1"> <p className="text-ink-muted text-xs font-mono mt-1">
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)} {fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
</p> </p>
{attendeeCount > 0 && ( {attendeeCount > 0 && (
<p className="text-gray-500 text-xs mt-1"> <p className="text-ink-muted text-xs mt-1">
{attendeeCount} going {attendeeCount} going
</p> </p>
)} )}
@@ -102,59 +103,59 @@ function CreateModal({ onClose, onCreate }: {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto" className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold">New Trip</h2> <h2 className="text-white font-bold">New Trip</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville" placeholder="Road trip to Nashville"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Location</label> <label className="text-xs text-ink-2 block mb-1">Location</label>
<input <input
required value={location} onChange={(e) => setLocation(e.target.value)} required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN" placeholder="Nashville, TN"
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"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Start date</label> <label className="text-xs text-ink-2 block mb-1">Start date</label>
<input <input
required type="date" value={start} onChange={(e) => setStart(e.target.value)} required type="date" value={start} onChange={(e) => setStart(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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">End date</label> <label className="text-xs text-ink-2 block mb-1">End date</label>
<input <input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)} required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start} min={start}
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"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label> <label className="text-xs text-ink-2 block mb-1">Google Maps link (optional)</label>
<input <input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)} type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…" placeholder="https://maps.google.com/…"
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"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2"> <button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel Cancel
</button> </button>
<button <button
@@ -179,8 +180,9 @@ export default function TripsPage() {
const past = trips.filter((t) => t.end_date < today); const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Delete this trip?")) return;
try { await c2api.deleteTrip(id); } try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); } catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
} }
return ( return (
@@ -198,12 +200,12 @@ export default function TripsPage() {
</div> </div>
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<> <>
{upcoming.length > 0 && ( {upcoming.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2> <h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Upcoming</h2>
<div className="space-y-3"> <div className="space-y-3">
{upcoming.map((t) => ( {upcoming.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} /> <TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
@@ -214,7 +216,7 @@ export default function TripsPage() {
{past.length > 0 && ( {past.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2> <h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Past</h2>
<div className="space-y-3"> <div className="space-y-3">
{past.map((t) => ( {past.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} /> <TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
@@ -224,7 +226,7 @@ export default function TripsPage() {
)} )}
{trips.length === 0 && ( {trips.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No trips yet.</p> <p className="text-ink-muted text-sm font-mono">No trips yet.</p>
)} )}
</> </>
)} )}
+9 -9
View File
@@ -40,11 +40,11 @@ export default function WaitlistPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
{done ? ( {done ? (
<> <>
<h1 className="text-white text-lg font-bold">You&apos;re on the list</h1> <h1 className="text-white text-lg font-bold">You&apos;re on the list</h1>
<p className="text-gray-400 text-sm leading-relaxed"> <p className="text-ink-2 text-sm leading-relaxed">
Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "} Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>. <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
</p> </p>
@@ -53,39 +53,39 @@ export default function WaitlistPage() {
<> <>
<div> <div>
<h1 className="text-white text-lg font-bold">Request access</h1> <h1 className="text-white text-lg font-bold">Request access</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed"> <p className="text-ink-2 text-xs mt-2 leading-relaxed">
Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up. Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label> <label className="text-xs text-ink-2 block mb-1">Organization (optional)</label>
<input <input
type="text" type="text"
value={orgName} value={orgName}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => setOrgName(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"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label> <label className="text-xs text-ink-2 block mb-1">Anything else? (optional)</label>
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
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 resize-none" 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 resize-none"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
+31 -31
View File
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
return ( return (
<> <>
<tr <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)} onClick={() => hasDetails && setExpanded((v) => !v)}
> >
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap"> <td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span> <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" })} {new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</td> </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> <span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
{call.tags && call.tags.length > 0 && ( {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]} {call.tags[0]}
</span> </span>
)} )}
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<span className="ml-2">{severityBadge(call.severity)}</span> <span className="ml-2">{severityBadge(call.severity)}</span>
)} )}
</td> </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-ink-2 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">{call.node_id}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{isActive ? ( {isActive ? (
<span className="text-orange-400 animate-pulse">● live</span> <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>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs">
{hasAudio ? ( {hasAudio ? (
<span className="text-blue-400">▶</span> <span className="text-blue-400">▶</span>
) : ( ) : (
<span className="text-gray-700">—</span> <span className="text-ink-muted">—</span>
)} )}
</td> </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 ? "▲" : "▼")} {hasDetails && (expanded ? "▲" : "▼")}
</td> </td>
</tr> </tr>
{expanded && hasDetails && ( {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"> <td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */} {/* Audio player */}
{hasAudio && ( {hasAudio && (
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
onClick={(e) => e.stopPropagation()} 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) => ( {call.tags.map((tag) => (
<span <span
key={tag} 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} {tag}
</span> </span>
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{/* Incident links — one per scene detected in the recording */} {/* Incident links — one per scene detected in the recording */}
{incidentIds.length > 0 && ( {incidentIds.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400"> <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) => ( {incidentIds.map((id) => (
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300"> <a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
{id.slice(0, 8)}… {id.slice(0, 8)}…
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{/* Correlation debug — admin only */} {/* Correlation debug — admin only */}
{isAdmin && call.corr_path && ( {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>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 && ( {call.corr_incident_idle_min != null && (
<span>idle {call.corr_incident_idle_min}min</span> <span>idle {call.corr_incident_idle_min}min</span>
)} )}
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) => setEditText(e.target.value)}
rows={4} 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"> <div className="flex items-center gap-2 flex-wrap">
<button <button
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); setEditing(false); }} 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 Cancel
</button> </button>
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
) : hasSegments ? ( ) : hasSegments ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <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`} {hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
</span> </span>
{isAdmin && ( {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> </div>
{hasBoth && !showOriginal ? ( {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} {call.transcript_corrected}
</pre> </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) => ( {call.segments!.map((seg, i) => (
<div key={i} className="flex gap-3 text-xs font-mono"> <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-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
<span className="text-gray-300">{seg.text}</span> <span className="text-ink-2">{seg.text}</span>
</div> </div>
))} ))}
</div> </div>
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{hasBoth && ( {hasBoth && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }} 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 ↓"} {showOriginal ? "show corrected ↑" : "show original ↓"}
</button> </button>
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "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-gray-600 font-mono">corrected</span>} {!hasBoth && call.transcript_corrected && <span className="text-xs text-ink-muted font-mono">corrected</span>}
</div> </div>
{isAdmin && ( {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> </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} {displayTranscript}
</pre> </pre>
{hasBoth && ( {hasBoth && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }} 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 ↓"} {showOriginal ? "show corrected ↑" : "show original ↓"}
</button> </button>
)} )}
</div> </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> </td>
</tr> </tr>
+2 -2
View File
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
{camera.image_url ? "…" : "No still for this camera"} {camera.image_url ? "…" : "No still for this camera"}
</div> </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} {badge.text}
</span> </span>
{credit?.attribution && ( {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 ? ( {credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a> <a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution} ) : credit.attribution}
+49 -4
View File
@@ -24,13 +24,18 @@
* and merges them in, so a long incident's path and spine are complete. An * and merges them in, so a long incident's path and spine are complete. An
* `#inc=` that is not active is fetched by id (resolved → banner; missing or * `#inc=` that is not active is fetched by id (resolved → banner; missing or
* another org → "not found"). The scrubber hatches time the window didn't load. * another org → "not found"). The scrubber hatches time the window didn't load.
*
* Attention cues (server-26#238): lib/attention.ts diffs successive snapshots
* (Major opened, severity up, pursuit path stop, priority keyword) after a
* silent baseline. Cues, the NEW lane and pin pulses only ever cover the
* FILTERED incidents; tone and notifications are opt-in (AttentionControls).
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls"; import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
import { useActiveIncidents, useIncident } from "@/lib/useIncidents"; import { useActiveIncidentsState, useIncident } from "@/lib/useIncidents";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useAircraft } from "@/lib/useAircraft"; import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels"; import { useVessels } from "@/lib/useVessels";
@@ -54,6 +59,9 @@ import { CameraDrill } from "@/components/live/CameraDrill";
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels"; import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
import { TruthStrip } from "@/components/live/TruthStrip"; import { TruthStrip } from "@/components/live/TruthStrip";
import { usePipelineHealth } from "@/lib/usePipelineHealth"; import { usePipelineHealth } from "@/lib/usePipelineHealth";
import { DEFAULT_ATTENTION_PREFS, loadAttentionPrefs, saveAttentionPrefs, useAttention, type AttentionPrefs } from "@/lib/attention";
import { NewLane } from "@/components/live/NewLane";
import { AttentionControls, useAttentionCues } from "@/components/live/AttentionControls";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
@@ -66,7 +74,7 @@ const RECENT_CALLS = 500;
const KIOSK_TYPE = [ const KIOSK_TYPE = [
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug", "[&[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]_.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-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
"[&[data-kiosk]_.w-56]:w-80", "[&[data-kiosk]_.w-56]:w-80",
@@ -140,7 +148,7 @@ function Kpi({ value, label }: { value: string | number; label: string }) {
export function LiveView() { export function LiveView() {
const { nodes, loading: nodesLoading } = useNodes(); const { nodes, loading: nodesLoading } = useNodes();
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const activeIncidents = useActiveIncidents(); const { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState();
// Wide-ish recent window: feeds incident paths, the call spine, the ticker // Wide-ish recent window: feeds incident paths, the call spine, the ticker
// and the scrubber's density bars. Real recent calls, not a fixture. // and the scrubber's density bars. Real recent calls, not a fixture.
const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS); const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
@@ -399,6 +407,32 @@ export function LiveView() {
// ── Navigation ──────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────
const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]); const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
// ── Attention cues (server-26#238) ────────────────────────────────────────
// Diffed over everything loaded (so widening the filter is never "new");
// emitted, laned and pulsed only for what the filter shows.
const [attnPrefs, setAttnPrefs] = useState<AttentionPrefs>(DEFAULT_ATTENTION_PREFS);
useEffect(() => { setAttnPrefs(loadAttentionPrefs()); }, []);
const changeAttnPrefs = useCallback((p: AttentionPrefs) => { setAttnPrefs(p); saveAttentionPrefs(p); }, []);
const cues = useAttentionCues(attnPrefs, selectIncident);
const attentionCalls = useMemo(() => {
if (!incidentCalls.length) return recentCalls;
const have = new Set(recentCalls.map((c) => c.call_id));
const extra = incidentCalls.filter((c) => !have.has(c.call_id));
return extra.length ? [...recentCalls, ...extra] : recentCalls;
}, [recentCalls, incidentCalls]);
const visibleCallIds = useMemo(() => new Set(filtered.calls.map((c) => c.call_id)), [filtered.calls]);
const attention = useAttention({
ready: incidentsLoaded && !!orgId,
resetKey: orgId ?? null,
incidents: activeIncidents,
calls: attentionCalls,
keywords: attnPrefs.keywords,
visible: filtered.incidents,
visibleIds: filtered.ids,
visibleCallIds,
onEvents: cues.fire,
});
const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]); const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]);
const stepUp = useCallback(() => { const stepUp = useCallback(() => {
if (link.cam) patchLink({ cam: null, tour: false }); if (link.cam) patchLink({ cam: null, tour: false });
@@ -502,6 +536,7 @@ export function LiveView() {
className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite"> className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite">
{copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"} {copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"}
</button> </button>
<AttentionControls prefs={attnPrefs} onChange={changeAttnPrefs} armed={cues.armed} />
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} /> <KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
<span className="font-mono text-ink-2 tabular-nums">{clock}</span> <span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div> </div>
@@ -525,7 +560,11 @@ export function LiveView() {
{/* Degraded → grey the map and its incident rail (the rail lives inside {/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */} MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}> <div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading || !mapState ? ( {/* Mount the map as soon as its starting view is known. The 500-call
window took ~9s on prod and used to hold the whole map back;
calls and paths now stream in once it lands. Nodes are only
awaited when there's no saved view, since they pick the centre. */}
{!mapState || (!mapState.view && nodesLoading) ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div> <div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : ( ) : (
<MapView <MapView
@@ -539,6 +578,8 @@ export function LiveView() {
onOpenCamera={openCamera} onOpenCamera={openCamera}
onAir={onAir} onAir={onAir}
camCountByIncident={camCountByIncident} camCountByIncident={camCountByIncident}
pulseIncidentIds={attention.pulseIds}
filteredFrom={filtered.active ? activeIncidents.length : undefined}
key={mapGen} key={mapGen}
initialLayers={mapState.layers} initialLayers={mapState.layers}
initialBase={mapState.base} initialBase={mapState.base}
@@ -548,6 +589,10 @@ export function LiveView() {
)} )}
</div> </div>
{!nodesLoading && !callsLoading && mapState && (
<NewLane entries={attention.lane} now={attention.now} onSelect={selectIncident} />
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's {/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */} Activation screen handles the zero-node case). */}
{configuredButQuiet && ( {configuredButQuiet && (
+122 -42
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
FeatureGroup, FeatureGroup,
@@ -440,7 +440,7 @@ function RadarBadge({ meta, loop, loopClock, onLoop }: {
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`; else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
return ( 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"}`}> <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>} 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> <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>} {meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
@@ -754,12 +754,18 @@ interface OpenCamera { cam: Camera; mode: CameraMode }
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5"; const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
function CameraDock({ map, open, credits, onMode, onClose }: { function CameraDock({ map, open, credits, onMode, onClose, footer }: {
map: L.Map; map: L.Map;
open: Camera[]; open: Camera[];
credits: Map<string, CameraCredit>; credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void; onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => void; onClose: (id: string) => void;
/**
* Stacked under the tiles (server-26#278): the weather status chip and radar
* badge live in the same bottom-centre spot, so while the dock is open they
* ride in its column instead of being covered by it.
*/
footer?: ReactNode;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
return createPortal( return createPortal(
<div <div
ref={ref} ref={ref}
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none" className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-col items-center gap-2 max-h-[calc(100%-8rem)] pointer-events-none"
> >
<div className="flex flex-wrap-reverse justify-center gap-2 min-h-0 max-w-full overflow-y-auto">
{open.map((c) => ( {open.map((c) => (
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg"> <div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
<div className="flex items-center gap-1 px-1.5 h-5"> <div className="flex items-center gap-1 px-1.5 h-5">
@@ -784,12 +791,19 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" /> <CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
</div> </div>
))} ))}
</div>
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
</div>, </div>,
map.getContainer(), map.getContainer(),
); );
} }
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) { function DotCameraLayer({ onOpenCamera, dockFooter, onDockedChange }: {
onOpenCamera?: (c: Camera) => void;
dockFooter?: ReactNode;
/** How many tiles are docked bottom-centre, so MapView can move what sits there (server-26#278). */
onDockedChange?: (n: number) => void;
}) {
const map = useMap(); const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map); const zoom = useMapZoom(map);
@@ -818,6 +832,9 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
const setMode = (id: string, mode: CameraMode) => const setMode = (id: string, mode: CameraMode) =>
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o))); setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id)); const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
const docked = useMemo(() => open.filter((o) => o.mode === "dock").map((o) => o.cam), [open]);
useEffect(() => { onDockedChange?.(docked.length); }, [docked.length, onDockedChange]);
useEffect(() => () => onDockedChange?.(0), [onDockedChange]);
return ( return (
<> <>
@@ -849,7 +866,7 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
</Tooltip> </Tooltip>
</Marker> </Marker>
))} ))}
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} /> <CameraDock map={map} open={docked} credits={feeds} onMode={setMode} onClose={close} footer={dockFooter} />
</> </>
); );
} }
@@ -870,7 +887,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
const hot = highlightIds.has(c.id); const hot = highlightIds.has(c.id);
return ( return (
<Polygon key={`vs-${c.id}`} positions={wedge} <Polygon key={`vs-${c.id}`} positions={wedge}
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}> pathOptions={{ color: hot ? "#8B93F0" : "#A9A9B3", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
<Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip> <Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip>
</Polygon> </Polygon>
); );
@@ -885,7 +902,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
function nearCameraIcon(): L.DivIcon { function nearCameraIcon(): L.DivIcon {
return L.divIcon({ return L.divIcon({
className: "", className: "",
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`, html: `<div style="width:20px;height:16px;border-radius:4px;background:#1C1F42;border:2px solid #8B93F0;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
iconSize: [20, 16], iconSize: [20, 16],
iconAnchor: [10, 8], iconAnchor: [10, 8],
}); });
@@ -900,7 +917,7 @@ function NearCameraLayer({ cameras, head, onOpenCamera }: {
<> <>
{head && ( {head && (
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false} <Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} /> pathOptions={{ color: "#8B93F0", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
)} )}
{cameras.map(({ cam, km: d }) => ( {cameras.map(({ cam, km: d }) => (
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600} <Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
@@ -1029,9 +1046,32 @@ function teardropSvg(type: string | null, severity: string | null | undefined, p
</svg>`; </svg>`;
} }
// Incident icons are cached by their visual inputs (server-26#279). react-leaflet
// calls setIcon whenever the icon prop's identity changes, and DivIcon rewrites
// innerHTML — so building a fresh icon on every render replaced every pin's DOM
// on each parent re-render and restarted the 1.8s pulse ring before it finished.
// Same inputs → same L.DivIcon object → no setIcon. Bounded: cleared when large
// (keys are a handful of type/severity/precision/pulse combos plus fan stacks).
const INCIDENT_ICON_CACHE_MAX = 500;
const incidentIconCache = new Map<string, L.DivIcon>();
function cachedIcon(key: string, build: () => L.DivIcon): L.DivIcon {
let icon = incidentIconCache.get(key);
if (!icon) {
if (incidentIconCache.size >= INCIDENT_ICON_CACHE_MAX) incidentIconCache.clear();
icon = build();
incidentIconCache.set(key, icon);
}
return icon;
}
function incidentIcon( function incidentIcon(
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false, type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
): L.DivIcon { ): L.DivIcon {
return cachedIcon(`pin|${type ?? ""}|${severity ?? ""}|${precision}|${pulse ? 1 : 0}`,
() => buildIncidentIcon(type, severity, precision, pulse));
}
function buildIncidentIcon(type: string | null, severity: string | null | undefined, precision: PinPrecision, pulse: boolean): L.DivIcon {
const ring = pulse const ring = pulse
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>` ? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
: ""; : "";
@@ -1044,6 +1084,11 @@ function incidentIcon(
} }
function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon { function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon {
const key = `fan|${pulse ? 1 : 0}|${members.map((m) => `${m.type ?? ""}:${m.severity ?? ""}`).join(",")}`;
return cachedIcon(key, () => buildIncidentFanIcon(members, pulse));
}
function buildIncidentFanIcon(members: IncidentRecord[], pulse: boolean): L.DivIcon {
const n = members.length; const n = members.length;
const CARD = 16; const CARD = 16;
const STEP = 9; const STEP = 9;
@@ -1188,8 +1233,8 @@ function FanNodeLayer({
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""} 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="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 text-ink-muted font-mono">{node.node_id}</p>
<p className="text-xs capitalize text-gray-700">{node.status}</p> <p className="text-xs capitalize text-ink-muted">{node.status}</p>
{activeByNode[node.node_id] && ( {activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-0.5"> <p className="text-xs text-orange-600 mt-0.5">
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "} ● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
@@ -1301,8 +1346,8 @@ function FanIncidentLayer({
<p className="text-xs capitalize font-medium" style={{ color }}> <p className="text-xs capitalize font-medium" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"} {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
</p> </p>
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>} {inc.location && <p className="text-xs text-ink-muted">{inc.location}</p>}
<p className="text-[11px] text-gray-500">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p> <p className="text-[11px] text-ink-muted">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
<a <a
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }} onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
@@ -1445,19 +1490,19 @@ function IncidentPathLayer({
> >
<Popup minWidth={160}> <Popup minWidth={160}>
<div className="text-gray-900"> <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> <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" && ( {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 && ( {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) && ( {hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1"> <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"} /> <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> </div>
)} )}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1"> <a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
@@ -1534,6 +1579,11 @@ interface Props {
* view last seen with nothing selected is reported instead. * view last seen with nothing selected is reported instead.
*/ */
onViewStateChange?: (state: MapViewState) => void; onViewStateChange?: (state: MapViewState) => void;
/**
* Set while the caller's incident filter is on (server-26#268): the
* unfiltered active count, so the rail header reads "N of M · filtered".
*/
filteredFrom?: number;
} }
export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport } export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport }
@@ -1545,10 +1595,15 @@ function currentViewport(map: L.Map): MapViewport {
return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng }); return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng });
} }
export default function MapView({ // Memoised (server-26#279): the live wall re-renders every second for its
// clock; with stable props that tick no longer re-renders the whole map tree.
// MapView keeps its own 10s tick (agoClock) for the rail's relative ages.
export default memo(MapView);
function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated, nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident, selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom,
}: Props) { }: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null); const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -1565,6 +1620,9 @@ export default function MapView({
const [wxShown, setWxShown] = useState(() => startLayers.has("wx")); const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
const loopClock = useMemo(createLoopClock, []); const loopClock = useMemo(createLoopClock, []);
// Short viewports (laptops, the 352px incident-page map) start with the
// legend folded to a chip so it doesn't cover the right side of the map.
const [legendOpenAtStart] = useState(() => typeof window === "undefined" || window.innerHeight >= 760);
// The altitude key only belongs in the legend while the opt-in Aircraft // The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185); same for the outage key (#184). // overlay is actually on (server-26#185); same for the outage key (#184).
@@ -1607,6 +1665,14 @@ export default function MapView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const [dockedCams, setDockedCams] = useState(0);
const weatherStatus = radarShown || wxShown ? (
<>
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</>
) : null;
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]); const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]); const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]); const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
@@ -1739,6 +1805,7 @@ export default function MapView({
<TileLayer <TileLayer
url={MAP_TILE_URL} url={MAP_TILE_URL}
attribution={MAP_TILE_ATTRIBUTION} attribution={MAP_TILE_ATTRIBUTION}
className={MAP_TILE_URL.includes("dark") ? undefined : "drb-tiles-dark"}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}> <LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}>
@@ -1792,7 +1859,7 @@ export default function MapView({
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */} {/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}> <LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup> <FeatureGroup>
<DotCameraLayer onOpenCamera={onOpenCamera} /> <DotCameraLayer onOpenCamera={onOpenCamera} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </LayersControl.Overlay>
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}> <LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
@@ -1844,11 +1911,12 @@ export default function MapView({
)} )}
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail {/* ── Weather status (server-26#237) — bottom centre, clear of the rail
(left), the legend (right) and the 511/outage banners (bottom-left). */} (left), the legend (right) and the 511/outage banners (bottom-left).
{(radarShown || wxShown) && ( While camera tiles are docked in that same spot it moves into the
dock's column, under the tiles, so STALE / feed errors stay visible (#278). */}
{weatherStatus && dockedCams === 0 && (
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none"> <div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
{wxShown && <WeatherWarningsStatus state={wxAlerts} />} {weatherStatus}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</div> </div>
)} )}
@@ -1866,7 +1934,14 @@ export default function MapView({
</div> </div>
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */} {/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto"> {/* Collapsible (one click to a "Legend" chip) and hidden while the Leaflet
layers list is expanded: the map sits in a z-0 stacking context, so the
legend at z-1001 used to paint over the bottom of that list. */}
<details open={legendOpenAtStart} className="drb-legend group/legend absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg text-xs pointer-events-none max-h-[calc(100%-4rem)] overflow-y-auto">
<summary className="pointer-events-auto cursor-pointer list-none select-none px-3 py-1.5 text-ink-muted font-medium text-[10px] uppercase tracking-wide flex items-center gap-1.5">
<span className="group-open/legend:hidden">▸</span><span className="hidden group-open/legend:inline">▾</span> Legend
</summary>
<div className="px-3 pb-2.5 space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p> <p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => ( {(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
@@ -1932,7 +2007,7 @@ export default function MapView({
<span className="text-ink-2">Call location (not a pursuit)</span> <span className="text-ink-2">Call location (not a pursuit)</span>
</div> </div>
<div className="flex items-center gap-2"> <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 &gt; {MAY_BE_MERGED_KM} km — may be 2+ events</span> <span className="text-ink-2">Path &gt; {MAY_BE_MERGED_KM} km — may be 2+ events</span>
</div> </div>
</div> </div>
@@ -2005,7 +2080,8 @@ export default function MapView({
</div> </div>
))} ))}
</div> </div>
</div> </div>
</details>
{/* ── Incident overlay panel ───────────────────────────────────────────── */} {/* ── Incident overlay panel ───────────────────────────────────────────── */}
{incidents.length > 0 && ( {incidents.length > 0 && (
@@ -2019,7 +2095,10 @@ export default function MapView({
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and {/* Gate A / A2 (server-26#46) — the rail's titles, locations and
unit counts are pipeline output. Pinned above the scroll area unit counts are pipeline output. Pinned above the scroll area
so it cannot be scrolled off the screen it qualifies. */} so it cannot be scrolled off the screen it qualifies. */}
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto"> <div className="bg-surface border border-line-strong rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto shadow">
{filteredFrom != null && (
<p className="text-[11px] text-ink-2 mb-1"><b className="text-ink font-semibold tabular-nums">{incidents.length} of {filteredFrom}</b> · filtered</p>
)}
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" /> <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div> </div>
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto"> <div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
@@ -2037,15 +2116,16 @@ export default function MapView({
const latest = (callsByIncident.get(inc.incident_id) ?? []) const latest = (callsByIncident.get(inc.incident_id) ?? [])
.filter(hasAudio) .filter(hasAudio)
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null); .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" : ""}`; // Opaque, not glass (#136): any see-through over a busy basemap put text on map colour.
const baseClass = `w-full text-left bg-surface border shadow rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
const cardBody = ( const cardBody = (
<> <>
<div className="flex items-center gap-1.5 mb-0.5"> <div className="flex items-center gap-1.5 mb-0.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} /> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} />
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}> <span className="uppercase tracking-wide font-semibold text-[11px]" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span> </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> </div>
<p className="text-ink font-semibold leading-snug truncate"> <p className="text-ink font-semibold leading-snug truncate">
{inc.title ?? "Incident"} {inc.title ?? "Incident"}
@@ -2056,9 +2136,9 @@ export default function MapView({
{onSelectIncident && units.length > 0 && ( {onSelectIncident && units.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{units.slice(0, 4).map((u) => ( {units.slice(0, 4).map((u) => (
<span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span> <span key={u} className="font-mono text-[11px] px-1 rounded bg-raised text-ink-2">{u}</span>
))} ))}
{units.length > 4 && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">+{units.length - 4}</span>} {units.length > 4 && <span className="font-mono text-[11px] px-1 rounded bg-raised text-ink-2">+{units.length - 4}</span>}
</div> </div>
)} )}
<div className="flex items-center justify-between mt-0.5"> <div className="flex items-center justify-between mt-0.5">
@@ -2070,7 +2150,7 @@ export default function MapView({
)} )}
</div> </div>
{!inc.location_coords && !onSelectIncident && ( {!inc.location_coords && !onSelectIncident && (
<p className="text-[10px] text-accent mt-1">View details →</p> <p className="text-[11px] text-accent mt-1">View details →</p>
)} )}
</> </>
); );
@@ -2086,7 +2166,7 @@ export default function MapView({
onClick={() => handleIncidentSelect(inc)} onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected} aria-pressed={selected}
className={`${baseClass} ${latest ? "pr-10" : ""}`} className={`${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }} style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
> >
{cardBody} {cardBody}
</button> </button>
@@ -2099,7 +2179,7 @@ export default function MapView({
<a <a
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass} ${latest ? "pr-10" : ""}`} className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }} style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
> >
{cardBody} {cardBody}
</a> </a>
@@ -2114,13 +2194,13 @@ export default function MapView({
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden"> <div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
<button <button
onClick={() => setDrawerOpen((v: boolean) => !v)} onClick={() => setDrawerOpen((v: boolean) => !v)}
className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between" className="w-full bg-surface border-t border-line-strong px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
> >
<span>Incidents ({incidents.length})</span> <span>Incidents ({filteredFrom != null ? `${incidents.length} of ${filteredFrom} · filtered` : incidents.length})</span>
<span>{drawerOpen ? "▼" : "▲"}</span> <span>{drawerOpen ? "▼" : "▲"}</span>
</button> </button>
{drawerOpen && ( {drawerOpen && (
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5"> <div className="bg-surface border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{/* Gate A / A2 (server-26#46) */} {/* Gate A / A2 (server-26#46) */}
<MachineOutputNotice variant="inline" className="text-[10px] items-start" /> <MachineOutputNotice variant="inline" className="text-[10px] items-start" />
{railIncidents.map((inc) => { {railIncidents.map((inc) => {
@@ -2143,7 +2223,7 @@ export default function MapView({
handleIncidentSelect(inc); handleIncidentSelect(inc);
}} }}
className="w-full text-left border rounded px-2 py-1.5 text-xs" className="w-full text-left border rounded px-2 py-1.5 text-xs"
style={{ borderColor: color + "55" }} style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
> >
{label} {label}
</button> </button>
@@ -2154,7 +2234,7 @@ export default function MapView({
key={inc.incident_id} key={inc.incident_id}
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
className="block w-full text-left border rounded px-2 py-1.5 text-xs" className="block w-full text-left border rounded px-2 py-1.5 text-xs"
style={{ borderColor: color + "55" }} style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
> >
{label} {label}
</a> </a>
+20 -10
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import type { NodeRecord, SystemRecord } from "@/lib/types"; import type { NodeRecord, SystemRecord } from "@/lib/types";
import { diskState } from "@/lib/nodeDisk";
interface Props { interface Props {
node: NodeRecord; node: NodeRecord;
@@ -19,22 +20,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
: "never"; : "never";
const body = ( 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 className="flex items-start justify-between mb-3">
<div> <div>
<p className="font-mono font-semibold text-white">{node.name}</p> <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> </div>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </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"> <div className="flex justify-between">
<span>System</span> <span>System</span>
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span> <span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Location</span> <span>Location</span>
<span className="text-gray-300"> <span className="text-ink-2">
{node.lat != null && node.lon != null {node.lat != null && node.lon != null
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}` ? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
: "Unknown"} : "Unknown"}
@@ -42,23 +43,32 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Last seen</span> <span>Last seen</span>
<span className="text-gray-300">{lastSeen}</span> <span className="text-ink-2">{lastSeen}</span>
</div> </div>
</div> </div>
{(() => {
const disk = diskState(node);
if (!disk || disk.level === "ok") return null;
return (
<div className={`mt-3 text-xs font-mono border-t border-line pt-2 ${disk.level === "critical" ? "text-sev-major" : "text-sev-moderate"}`}>
⚠ Disk {disk.level === "critical" ? "nearly full" : "low"}: {disk.label}
</div>
);
})()}
{!node.configured && ( {!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 ⚠ Needs configuration
</div> </div>
)} )}
{node.is_overridden && ( {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> <span>⚠ Local Override</span>
{node.override_timeout_at ? ( {node.override_timeout_at ? (
<span className="text-gray-500"> <span className="text-ink-muted">
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()} Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
</span> </span>
) : ( ) : (
<span className="text-gray-500">Permanent</span> <span className="text-ink-muted">Permanent</span>
)} )}
</div> </div>
)} )}
+21 -17
View File
@@ -51,20 +51,24 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <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> <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. {node.configured ? (
Assign it a radio system to begin monitoring. <><span className="text-indigo-400">{node.node_id}</span>: choose the radio system it should monitor.</>
) : (
<><span className="text-indigo-400">{node.node_id}</span> connected for the first time.
Assign it a radio system to begin monitoring.</>
)}
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <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 <select
value={systemId} value={systemId}
onChange={(e) => setSystemId(e.target.value)} 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 required
> >
<option value="">Select a system…</option> <option value="">Select a system…</option>
@@ -77,41 +81,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
</div> </div>
<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 <select
value={preset} value={preset}
onChange={(e) => setPreset(e.target.value)} 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) => ( {PRESETS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option> <option key={p.value} value={p.value}>{p.label}</option>
))} ))}
</select> </select>
{selectedPreset && ( {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>
<div> <div>
<label className="block text-xs text-gray-400 mb-1"> <label className="block text-xs text-ink-2 mb-1">
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span> PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
</label> </label>
<input <input
type="number" type="number"
value={ppm} value={ppm}
onChange={(e) => setPpm(e.target.value)} onChange={(e) => setPpm(e.target.value)}
step="0.1" 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" placeholder="0"
/> />
</div> </div>
<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 <select
value={nodeType} value={nodeType}
onChange={(e) => setNodeType(e.target.value)} 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 required
> >
<option value="">Select node type...</option> <option value="">Select node type...</option>
@@ -127,9 +131,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
id="enforceTimeout" id="enforceTimeout"
checked={enforceTimeout} checked={enforceTimeout}
onChange={(e) => setEnforceTimeout(e.target.checked)} 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) Enforce Local Override Timeout (24 hours)
</label> </label>
</div> </div>
@@ -148,7 +152,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
<button <button
type="button" type="button"
onClick={onClose} 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 Cancel
</button> </button>
+5 -5
View File
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`; : `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
return ( return (
<section> <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 && ( {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"> <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&apos;t be decoded, so nothing from{" "} {subject} encrypted — those calls can&apos;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"> <ul className="space-y-1 font-mono text-xs">
{channels.map((c, i) => ( {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> <span className="text-white">{c.name || mhz(c.freq)}</span>
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>} {c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>} {c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
{c.encrypted ? ( {c.encrypted ? (
<span className="text-red-400"> <span className="text-red-400">
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""} ✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""} {c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
</span> </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"} {c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
</span> </span>
)} )}
+15 -15
View File
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
} }
const selectClass = 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({ function DeviceSelect({
value, devices, devicesKnown, autoLabel, label, disabled, onChange, value, devices, devicesKnown, autoLabel, label, disabled, onChange,
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
<p className="text-xs text-gray-500 font-mono mb-3"> <p className="text-xs text-ink-muted font-mono mb-3">
{devicesKnown {devicesKnown
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.` ? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
: "This node hasn't reported its SDRs yet."} : "This node hasn't reported its SDRs yet."}
</p> </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 flex-wrap items-center gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-gray-200">OP25 SDR</div> <div className="text-ink">OP25 SDR</div>
<div className="text-xs text-gray-500"> <div className="text-xs text-ink-muted">
{op25Moving {op25Moving
? "Saving restarts OP25 on the selected SDR." ? "Saving restarts OP25 on the selected SDR."
: node.op25_sdr_serial : node.op25_sdr_serial
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
: "Waiting for SDR"; : "Waiting for SDR";
return ( return (
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <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 <input
type="checkbox" type="checkbox"
checked={row.enabled} checked={row.enabled}
disabled={!canEdit} disabled={!canEdit}
aria-label={`Enable ${meta.name}`} aria-label={`Enable ${meta.name}`}
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))} 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="flex-1 min-w-[7rem]">
<div className="text-gray-200">{meta.name}</div> <div className="text-ink">{meta.name}</div>
<div className="text-xs text-gray-500">{meta.hint}</div> <div className="text-xs text-ink-muted">{meta.hint}</div>
</div> </div>
<DeviceSelect <DeviceSelect
value={row.pin} value={row.pin}
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
disabled={i === 0} disabled={i === 0}
aria-label={`Move ${meta.name} up`} 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> </button>
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
disabled={i === rows.length - 1} disabled={i === rows.length - 1}
aria-label={`Move ${meta.name} down`} 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> </button>
</div> </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} {state}
</span> </span>
</div> </div>
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
})} })}
</div> </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&apos;s runs the next enabled service, top first. Pin a service to the SDR with its Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its
antenna, or leave it on &quot;Any spare SDR&quot;. antenna, or leave it on &quot;Any spare SDR&quot;.
</p> </p>
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
> >
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</button> </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> </div>
)} )}
</section> </section>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
const styles: Record<NodeStatus, string> = { const styles: Record<NodeStatus, string> = {
online: "bg-green-950 text-green-400 border border-green-800", online: "bg-green-950 text-green-400 border border-green-800",
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse", 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", unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
}; };
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
return ( return (
<section className="space-y-3"> <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 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. 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. Required attributions render on every camera tile.
</p> </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>} {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 ? ( {!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) => { {providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status]; 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; 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"> <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> <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-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p> <p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p> <p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<button <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"}`} /> <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button> </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.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p> </p>
</div> </div>
</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>cameras: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</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.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> <span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
</div> </div>
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>} {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.acknowledgement && (
<p className="text-[11px] text-amber-300"> <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()} Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p> </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> <summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p> <p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p> <p className="mt-1">{p.notes}</p>
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
{confirming && ( {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="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> <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-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>
<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" /> <input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span> <span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span>
</label> </label>
<div className="flex justify-end gap-2"> <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)} <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> className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div> </div>
@@ -60,7 +60,7 @@ export function OutageProvidersTab() {
return ( return (
<section className="space-y-3"> <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 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 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 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> </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>} {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 ? ( {!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) => { {providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status]; 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; 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"> <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> <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-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p> <p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p> <p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<button <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"}`} /> <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button> </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.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p> </p>
</div> </div>
</div> </div>
{p.implemented && ( {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>counties out: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</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.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
</div> </div>
)} )}
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>} {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.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>} {p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with the data: “{p.licence.attribution}”</p>}
{p.acknowledgement && ( {p.acknowledgement && (
<p className="text-[11px] text-amber-300"> <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()} Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p> </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> <summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p> <p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p> <p className="mt-1">{p.notes}</p>
@@ -134,17 +134,17 @@ export function OutageProvidersTab() {
{confirming && ( {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="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> <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-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>} {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" /> <input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span> <span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span>
</label> </label>
<div className="flex justify-end gap-2"> <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)} <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> className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div> </div>
+39 -39
View File
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"]; const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
const input = 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 = 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 { function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0"); 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); const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
return ( 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 className="flex flex-wrap items-end gap-4">
<div> <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} /> <input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
</div> </div>
<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} /> <input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
</div> </div>
<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`} /> <input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
</div> </div>
</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"> <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" /> <input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
<span className="text-white">{m.label}</span> <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> </label>
))} ))}
</div> </div>
{mode === "reuse" && ( {mode === "reuse" && (
<div> <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}> <select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
<option value="">— pick a finished run —</option> <option value="">— pick a finished run —</option>
{sources.map((r) => ( {sources.map((r) => (
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
</option> </option>
))} ))}
</select> </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> </div>
)} )}
{systems.length > 1 && ( {systems.length > 1 && (
<div className="flex flex-wrap gap-3"> <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) => ( {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)} /> <input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
{s.name} {s.name}
</label> </label>
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
</div> </div>
{est && ( {est && (
<p className="text-sm font-mono text-gray-300"> <p className="text-sm font-mono text-ink-2">
{est.truncated ? ( {est.truncated ? (
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span> <span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
) : ( ) : (
@@ -193,15 +193,15 @@ function RunsTable({
onChanged(); 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"; const td = "px-2 py-1.5 whitespace-nowrap";
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm font-mono"> <table className="w-full text-sm font-mono">
<thead> <thead>
<tr className="border-b border-gray-800"> <tr className="border-b border-line">
<th className={th}>Run</th> <th className={th}>Run</th>
<th className={th}>Range</th> <th className={th}>Range</th>
<th className={th}>Status</th> <th className={th}>Status</th>
@@ -222,13 +222,13 @@ function RunsTable({
<tr <tr
key={r.run_id} key={r.run_id}
onClick={() => onSelect(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}> <td className={td}>
<div className="text-white">{r.label || r.run_id}</div> <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>
<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}> <td className={td}>
{running ? ( {running ? (
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span> <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-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> </td>
@@ -255,7 +255,7 @@ function RunsTable({
{running ? ( {running ? (
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button> <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> </td>
</tr> </tr>
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
} catch { /* audio is a convenience */ } } catch { /* audio is a convenience */ }
} }
return ( return (
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono"> <div className="py-1.5 border-t border-line/60 text-xs font-mono">
<div className="flex flex-wrap gap-2 text-gray-500"> <div className="flex flex-wrap gap-2 text-ink-muted">
<span>{fmtTime(call.started_at)}</span> <span>{fmtTime(call.started_at)}</span>
<span>{call.talkgroup_name}</span> <span>{call.talkgroup_name}</span>
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</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.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.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 ? (
<audio src={audio} controls autoPlay className="h-6" /> <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>
<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> </div>
); );
} }
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via); const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
return ( 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"> <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-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-ink-muted 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-ink-muted 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-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>} {inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
</button> </button>
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>} {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 units cleared", m.incidents_with_units_cleared],
["With map pin", m.incidents_with_coords], ["With map pin", m.incidents_with_coords],
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2"> <div key={k as string} className="bg-surface border border-line rounded-lg p-2">
<div className="text-gray-500">{k}</div> <div className="text-ink-muted">{k}</div>
<div className="text-white text-base">{v}</div> <div className="text-white text-base">{v}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{m && ( {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(" · ")} resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
<br /> <br />
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")} paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
</p> </p>
)} )}
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && ( {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:{" "} Gemini tokens:{" "}
{Object.entries(m.gemini_usage) {Object.entries(m.gemini_usage)
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`) .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>)} {run.errors.map((e, i) => <div key={i}>{e}</div>)}
</details> </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>} {error && <p className="text-red-400 text-sm font-mono">{error}</p>}
{data && ( {data && (
<> <>
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
<button <button
key={f} key={f}
onClick={() => setFilter(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"} {f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
</button> </button>
))} ))}
<button <button
onClick={() => setShowOrphans(!showOrphans)} 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}) Orphans ({data.orphans.length})
</button> </button>
</div> </div>
{showOrphans ? ( {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} />)} {data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
</div> </div>
) : ( ) : (
@@ -443,7 +443,7 @@ export function ReplayTab() {
return ( return (
<div className="space-y-5"> <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&apos; original order, into a sandbox — live incidents are never Re-run the pipeline over a past time range, in the calls&apos; 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 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. open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
@@ -0,0 +1,253 @@
"use client";
/**
* Attention cues (server-26#238) — the opt-in half: an audible tone and
* browser notifications, both OFF by default.
*
* Tone: Web Audio beep, no asset. Browsers only let audio start from a user
* gesture, so the AudioContext is created by the click that turns the tone
* on (and that click plays one confirmation beep). After a reload with the
* tone already on, the first click/key anywhere on the page re-arms it; until
* then nothing plays and the button says so.
*
* Notifications: Notification.requestPermission() on click; fired only while
* the tab is hidden (a visible wall already shows the lane and pulse). The
* body ends with the Gate A machine-output notice — the title it carries is
* pipeline output. No transcript text goes into a notification.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention";
import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice";
const TONE_GAP_MS = 4_000;
const MAX_NOTIFICATIONS = 3;
// ── Audio (one context per page, only ever created inside a user gesture) ────
let audioCtx: AudioContext | null = null;
function createAudio(): AudioContext | null {
try {
if (!audioCtx) {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return null;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined);
return audioCtx;
} catch {
return null;
}
}
/** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */
function beep(n: number) {
const a = audioCtx;
if (!a || a.state !== "running") return;
try {
for (let i = 0; i < n; i++) {
const t0 = a.currentTime + i * 0.22;
const osc = a.createOscillator();
const gain = a.createGain();
osc.type = "sine";
osc.frequency.value = i % 2 ? 660 : 880;
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
osc.connect(gain).connect(a.destination);
osc.start(t0);
osc.stop(t0 + 0.2);
}
} catch { /* audio unavailable */ }
}
type NotifyState = "unsupported" | "default" | "granted" | "denied";
function notifyState(): NotifyState {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return Notification.permission as NotifyState;
}
const HEADLINE: Record<AttentionEvent["kind"], string> = {
major: "Major incident",
escalated: "Severity escalated",
stop: "Pursuit path: new stop",
keyword: "Priority keyword",
};
/**
* Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass
* to useAttention's onEvents) and the live audio state for the controls.
*/
export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) {
const [armed, setArmed] = useState(false);
const lastTone = useRef(0);
const prefsRef = useRef(prefs);
prefsRef.current = prefs;
const openRef = useRef(onOpenIncident);
openRef.current = onOpenIncident;
// Tone on: track the context's state. Already on from an earlier visit →
// the next gesture anywhere on the page creates/resumes the context.
useEffect(() => {
if (!prefs.tone) { setArmed(false); return; }
const sync = () => setArmed(audioCtx?.state === "running");
const arm = () => {
const a = createAudio();
if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); }
};
if (audioCtx) { audioCtx.onstatechange = sync; sync(); }
window.addEventListener("pointerdown", arm, { once: true });
window.addEventListener("keydown", arm, { once: true });
return () => {
window.removeEventListener("pointerdown", arm);
window.removeEventListener("keydown", arm);
};
}, [prefs.tone]);
const fire = useCallback((events: AttentionEvent[]) => {
if (!events.length) return;
const p = prefsRef.current;
if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) {
lastTone.current = Date.now();
// Three beeps for anything reaching Major or a priority keyword, one otherwise.
beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1);
}
if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return;
const send = (title: string, body: string, tag: string, incidentId: string | null) => {
try {
const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag });
n.onclick = () => {
window.focus();
if (incidentId) openRef.current(incidentId);
n.close();
};
} catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ }
};
for (const e of events.slice(0, MAX_NOTIFICATIONS)) {
const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind];
send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null);
}
if (events.length > MAX_NOTIFICATIONS) {
send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null);
}
}, []);
return { fire, armed };
}
interface Props {
prefs: AttentionPrefs;
onChange: (p: AttentionPrefs) => void;
armed: boolean;
}
export function AttentionControls({ prefs, onChange, armed }: Props) {
const [open, setOpen] = useState(false);
const [perm, setPerm] = useState<NotifyState>("default");
const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", "));
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => { setPerm(notifyState()); }, [open]);
useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
}, [open]);
const toggleTone = () => {
if (prefs.tone) { onChange({ ...prefs, tone: false }); return; }
// This click is the gesture that unlocks audio; confirm with one beep.
const a = createAudio();
onChange({ ...prefs, tone: true });
if (a) a.resume().then(() => beep(1), () => undefined);
};
const toggleNotify = async () => {
if (prefs.notify) { onChange({ ...prefs, notify: false }); return; }
if (notifyState() === "unsupported") { setPerm("unsupported"); return; }
let p: NotifyState = notifyState();
if (p === "default") {
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); }
}
if (p === "granted") {
// Confirm with one notification (as the tone confirms with a beep). It
// doubles as the support check: mobile Chrome grants permission but
// throws on `new Notification()` without a service worker, which would
// otherwise leave a switched-on control that never delivers anything.
try {
new Notification("DRB · Notifications on", {
body: `Cues will appear here while the live wall tab is hidden.\n${MACHINE_OUTPUT_NOTICE_TEXT}`,
tag: "drb-attn:on",
});
} catch {
p = "unsupported";
}
}
setPerm(p);
if (p === "granted") onChange({ ...prefs, notify: true });
};
const commitKeywords = () => {
const next = parseKeywords(kwDraft);
if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next });
else setKwDraft(prefs.keywords.join(", "));
};
const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported";
const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off";
const on = prefs.tone || notifyOn;
return (
<div ref={boxRef} className="relative">
<button type="button" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((v) => !v)}
title="Attention cues: an audible tone and browser notifications for new Majors, escalations, pursuit stops and priority keywords"
className={`px-2 py-0.5 rounded border whitespace-nowrap ${on ? "border-accent text-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Cues: {summary}
</button>
{open && (
<div role="dialog" aria-label="Attention cues"
className="absolute right-0 top-full mt-1 z-[1100] w-72 max-w-[calc(100vw-2rem)] bg-surface border border-line rounded-lg shadow-lg p-3 space-y-3 text-xs text-ink-2">
<p className="text-ink-muted leading-snug">
Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new
call. Only for incidents the current filter shows.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={prefs.tone} onChange={toggleTone} />
<span>
<span className="text-ink font-medium">Sound a tone</span>
{prefs.tone && !armed && (
<span className="block text-ink-muted">Click anywhere on the page to arm it — browsers block sound until you do.</span>
)}
</span>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOn} disabled={perm === "unsupported"} onChange={() => { void toggleNotify(); }} />
<span>
<span className="text-ink font-medium">Browser notifications while this tab is hidden</span>
{perm === "denied" && <span className="block text-ink-muted">Blocked in this browser&apos;s site settings — allow notifications there first.</span>}
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn&apos;t show notifications from a web page (mobile browsers often only allow them from installed apps).</span>}
</span>
</label>
<div>
<label htmlFor="attn-kw" className="block text-ink font-medium mb-1">Priority keywords</label>
<input id="attn-kw" type="text" value={kwDraft} onChange={(e) => setKwDraft(e.target.value)} onBlur={commitKeywords}
onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }}
placeholder="comma-separated"
className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
<div className="mt-1 flex items-center justify-between text-ink-muted">
<span>Matched against new call transcripts.</span>
<button type="button" className="underline hover:text-ink"
onClick={() => onChange({ ...prefs, keywords: [...DEFAULT_KEYWORDS] })}>
Reset
</button>
</div>
</div>
</div>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
/**
* "NEW · last 15 min" (server-26#238). The rail is worst-first, so a fresh
* minor that is escalating sits under stale Majors; this lane is the
* recency view of the same FILTERED incidents, newest first, with why each
* one is here ("New", "↑ Major", "+1 stop", a priority keyword).
*
* Desktop: overlay at the map's top-left, right of the zoom stack and above
* MapView's rail (the rail starts at top-[9.5rem], so the lane is capped
* just short of it and its rows scroll). The Gate A notice is pinned below
* the rows, never inside the scroll area — same rule as the rail (#46).
* Mobile: a compact chip under MapView's centred "Live" chip (same top-3
* centre slot would cover it), right of the fit-all button; it expands.
* Hidden when there is nothing new.
*/
import { useState } from "react";
import type { LaneEntry } from "@/lib/attention";
import { SEVERITY_COLORS, isKnownSeverity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
function ago(at: number, now: number): string {
const m = Math.max(0, Math.floor((now - at) / 60_000));
return m < 1 ? "now" : `${m}m`;
}
function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: string) => void }) {
const color = isKnownSeverity(e.severity) ? SEVERITY_COLORS[e.severity] : "var(--ink-muted)";
const body = (
<>
<span className="w-1 self-stretch rounded-full shrink-0" style={{ background: color }} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-ink">{e.title}</span>
<span className="shrink-0 px-1 rounded bg-accent/15 text-accent font-semibold">{e.reason}</span>
<span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span>
</>
);
const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-xs text-left";
return (
<li>
{e.incidentId ? (
<button type="button" className={`${cls} hover:bg-raised`} onClick={() => onSelect(e.incidentId!)}
title={`${e.title} — ${e.reason}`}>
{body}
</button>
) : (
<div className={cls} title={`${e.title} — ${e.reason} (call not linked to an incident yet)`}>{body}</div>
)}
</li>
);
}
export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: number; onSelect: (id: string) => void }) {
const [open, setOpen] = useState(false);
if (!entries.length) return null;
const notice = <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start px-1.5 pt-1" />;
return (
<>
<section aria-label="New in the last 15 minutes"
className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.5rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto">
<h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span>
</h2>
<ul className="min-h-0 overflow-y-auto px-0.5">
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
</ul>
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
</section>
<div className="absolute top-[5.25rem] left-14 z-[1002] md:hidden flex flex-col items-start">
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}
className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow">
● {entries.length} new · 15 min {open ? "▴" : "▾"}
</button>
{open && (
<div className="mt-1 w-64 max-w-[calc(100vw-4.5rem)] bg-surface/95 border border-line rounded-lg shadow p-0.5 flex flex-col">
<ul className="max-h-40 overflow-y-auto">
{entries.map((e) => (
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
))}
</ul>
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
</div>
)}
</div>
</>
);
}
+23 -8
View File
@@ -32,8 +32,10 @@ function sevColor(s: string | null | undefined) {
function sevLabel(s: string | null | undefined) { function sevLabel(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown"; return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
} }
function elapsed(iso: string): string { /** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000)); function elapsed(iso: string, endIso?: string | null): string {
const end = endIso ? new Date(endIso).getTime() : Date.now();
const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000));
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`; return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
} }
function hhmm(iso: string): string { function hhmm(iso: string): string {
@@ -49,7 +51,7 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came
return ( 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"> <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" /> <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> <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> </button>
); );
@@ -275,7 +277,7 @@ function WindContext({ head }: { head: LatLon | null }) {
{calm ? "" : ` ${dir}`} {calm ? "" : ` ${dir}`}
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away {" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
{obsLabel ? ` · observed ${obsLabel}` : ""} {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} <SourceCredit credit={METAR_CREDIT}
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} /> updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
</li> </li>
@@ -348,7 +350,7 @@ export function RegionPanel({
{wallCameras.map(({ cam, km }) => ( {wallCameras.map(({ cam, km }) => (
<div key={cam.id} className="relative"> <div key={cam.id} className="relative">
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} /> <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>
))} ))}
</div> </div>
@@ -422,6 +424,13 @@ export function IncidentPanel({
onOpenCamera: (c: Camera) => void; onOpenCamera: (c: Camera) => void;
}) { }) {
const color = sevColor(inc.severity); const color = sevColor(inc.severity);
// A resolved incident opened from an #inc= link (server-26#275): nothing in
// the panel may read as still running. Its duration freezes at resolved_at
// (the correlator writes it; the frontend type doesn't declare it yet), and
// the live camera / context feeds are labelled as "now", not incident time.
const resolved = inc.status === "resolved";
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]); const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
const head = points.at(-1) ?? null; const head = points.at(-1) ?? null;
const radar = useRadarDbz(head); const radar = useRadarDbz(head);
@@ -455,13 +464,17 @@ export function IncidentPanel({
<div className="flex items-center gap-2 text-[11px] mb-1"> <div className="flex items-center gap-2 text-[11px] mb-1">
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span> <span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
<span className="text-ink-muted">{inc.type ?? "other"}</span> <span className="text-ink-muted">{inc.type ?? "other"}</span>
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>} {resolved ? <span className="text-ink-muted">resolved</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> <span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
</div> </div>
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2> <h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3"> <div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
{inc.location && <span>{inc.location}</span>} {inc.location && <span>{inc.location}</span>}
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span> {!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
: null}
<span>{inc.call_ids?.length ?? 0} calls</span> <span>{inc.call_ids?.length ?? 0} calls</span>
{path.mayBeMerged && ( {path.mayBeMerged && (
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2" <span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
@@ -480,6 +493,7 @@ export function IncidentPanel({
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3> <h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
{near.length ? ( {near.length ? (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{near.map(({ cam, km }) => ( {near.map(({ cam, km }) => (
@@ -498,6 +512,7 @@ export function IncidentPanel({
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Situational context · fused layers</h3> <h3 className={H3}>Situational context · fused layers</h3>
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
<ul className="space-y-1 text-xs text-ink-2"> <ul className="space-y-1 text-xs text-ink-2">
{ctx.aircraft.map(({ a, km }) => ( {ctx.aircraft.map(({ a, km }) => (
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span> <li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
@@ -533,7 +548,7 @@ export function IncidentPanel({
<div className={`${SEC} grid grid-cols-2 gap-3`}> <div className={`${SEC} grid grid-cols-2 gap-3`}>
<div> <div>
<h3 className={H3}>On scene</h3> <h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div> <div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
</div> </div>
<div> <div>
+1 -1
View File
@@ -7,7 +7,7 @@ interface EmptyStateProps {
action?: ReactNode; 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) { export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl"> <div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
@@ -50,6 +50,14 @@ function InfoGlyph({ className }: { className?: string }) {
} }
const LEAD = "Machine-generated and unverified"; const LEAD = "Machine-generated and unverified";
const SHORT_BODY = "automated output, may contain errors.";
/**
* The short notice as plain text, for surfaces that cannot render this
* component — e.g. a browser notification body (server-26#238). Same copy
* rules as above; it is the "popup"/"inline" wording verbatim.
*/
export const MACHINE_OUTPUT_NOTICE_TEXT = `${LEAD} — ${SHORT_BODY}`;
interface Props { interface Props {
variant?: Variant; variant?: Variant;
@@ -62,7 +70,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
const body = const body =
detail ?? detail ??
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them."; "transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
const shortBody = detail ?? "automated output, may contain errors."; const shortBody = detail ?? SHORT_BODY;
if (variant === "popup") { if (variant === "popup") {
// Leaflet popups render on a white wrapper regardless of theme, so this // Leaflet popups render on a white wrapper regardless of theme, so this
@@ -70,7 +78,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
return ( return (
<p <p
role="note" 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) .filter(Boolean)
.join(" ")} .join(" ")}
> >
+419
View File
@@ -0,0 +1,419 @@
/**
* Attention cues for the live wall (server-26#238): what changed between two
* snapshots that a person should look at, the "NEW · last 15 min" lane, and
* which pins pulse.
*
* `diffAttention` is pure: (previous state, snapshot) → (next state, events).
* The first snapshot is a BASELINE and emits nothing, so opening the page
* never fires a cue for every incident already open. Calls baseline
* separately, on the first non-empty call snapshot, because the incident and
* call subscriptions land independently.
*
* The diff runs over ALL active incidents, not the filtered list: diffing the
* filtered list would make widening a filter look like a burst of "new"
* Majors. Visibility is applied when an event is emitted (`isVisibleEvent`),
* against the filter in force at that moment — so a cue or notification
* never fires for an incident filtered out of view.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { CallRecord, IncidentRecord } from "@/lib/types";
import { SEVERITY_LABEL, isKnownSeverity, severityRank } from "@/lib/severity";
import { groupCallsByIncident, pathStops } from "@/lib/liveWall";
/** How long something counts as "new" on the lane. */
export const LANE_WINDOW_MS = 15 * 60_000;
/** How long a new / changed incident's pin pulses. */
export const PULSE_MS = 2 * 60_000;
export const DEFAULT_KEYWORDS: readonly string[] = ["MAYDAY", "10-13", "shots fired", "officer down"];
const MAJOR = 3;
/** A stop on an incident with no known stops counts as new only if it started after (first seen − this). */
const STOP_GRACE_MS = 2 * 60_000;
/** Forget incidents gone from the active list this long (state stays bounded on an all-day wall). */
const FORGET_MS = 6 * 3600_000;
/** Checked-call memory: anything older than the lane window is never cued anyway. */
const CALL_MEMORY_MS = LANE_WINDOW_MS + 30 * 60_000;
const MAX_LANE = 12;
const MAX_LOG = 200;
export type AttentionKind = "major" | "escalated" | "stop" | "keyword";
export interface AttentionEvent {
/** Stable key (also the notification tag's suffix). */
id: string;
kind: AttentionKind;
/** Incidents this concerns; empty = a call not linked to any incident yet. */
incidentIds: string[];
callId: string | null;
at: number;
/** Short reason chip, e.g. "Major", "↑ Moderate", "+1 stop", "MAYDAY". */
label: string;
/** Incident title, or the talkgroup for an unlinked call. */
title: string;
}
interface IncState {
rank: number;
/** When this incident first counted as new: its start for the baseline, else when the wall first saw it. */
seenAt: number;
/** When the wall started tracking it (stop-grace floor). */
knownSince: number;
lastSeen: number;
stopIds: Set<string>;
newestStopAt: number | null;
}
export interface AttentionState {
baselineAt: number;
callsBaselined: boolean;
incidents: Map<string, IncState>;
/** call_id → started_at ms, for calls whose transcript has been keyword-checked. */
checkedCalls: Map<string, number>;
}
const ms = (iso: string | null | undefined): number => {
const t = iso ? Date.parse(iso) : NaN;
return Number.isNaN(t) ? 0 : t;
};
function callIncidentIds(c: CallRecord): string[] {
return c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
}
function transcriptOf(c: CallRecord): string {
return c.transcript_corrected || c.transcript || "";
}
/** Non-ghost geocoded stops of a movement incident (pursuit path), oldest first. Empty otherwise. */
function movementStops(inc: IncidentRecord, calls: CallRecord[] | undefined): { id: string; at: number }[] {
const path = pathStops(inc, calls);
if (!path.movement) return [];
return path.stops.filter((s) => !s.ghost).map((s) => ({ id: s.call.call_id, at: ms(s.call.started_at) }));
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Configured keywords found in `text`, case-insensitive, whole-word: "10-13"
* does not fire on "210-135". Spaces match any whitespace and a hyphen may be
* a space or absent ("10 13", "1013"), since transcription renders codes both ways.
*/
export function matchKeywords(text: string, keywords: readonly string[]): string[] {
if (!text) return [];
const t = text.toLowerCase();
const hits: string[] = [];
for (const k of keywords) {
const kw = k.trim().toLowerCase();
if (!kw) continue;
const body = kw.split(/\s+/).map((w) => escapeRe(w).replace(/-/g, "[-\\s]?")).join("\\s+");
if (new RegExp(`(^|[^a-z0-9])${body}($|[^a-z0-9])`).test(t)) hits.push(k.trim());
}
return hits;
}
function sevLabel(sev: string | null | undefined): string {
return isKnownSeverity(sev) ? SEVERITY_LABEL[sev] : "Unrated";
}
/**
* One snapshot step. `prev === null` → baseline (no events). Never mutates `prev`.
* `incidents` = every active incident (unfiltered); `calls` = every call the
* wall has loaded (recent window + the selected incident's own calls).
*/
export function diffAttention(
prev: AttentionState | null,
incidents: IncidentRecord[],
calls: CallRecord[],
keywords: readonly string[],
now: number = Date.now(),
): { state: AttentionState; events: AttentionEvent[] } {
const byInc = groupCallsByIncident(calls);
const events: AttentionEvent[] = [];
if (!prev) {
const map = new Map<string, IncState>();
for (const inc of incidents) {
const stops = movementStops(inc, byInc.get(inc.incident_id));
map.set(inc.incident_id, {
rank: severityRank(inc.severity),
seenAt: ms(inc.started_at) || now,
knownSince: now,
lastSeen: now,
stopIds: new Set(stops.map((s) => s.id)),
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
});
}
const checked = new Map<string, number>();
for (const c of calls) if (transcriptOf(c)) checked.set(c.call_id, ms(c.started_at));
return { state: { baselineAt: now, callsBaselined: calls.length > 0, incidents: map, checkedCalls: checked }, events };
}
// The first non-empty call snapshot is the calls' own baseline: record what
// is there, and re-seed every known incident's stops from it, silently.
const baselineCalls = !prev.callsBaselined && calls.length > 0;
const map = new Map<string, IncState>();
const present = new Set<string>();
for (const inc of incidents) {
const id = inc.incident_id;
present.add(id);
const rank = severityRank(inc.severity);
const stops = movementStops(inc, byInc.get(id));
const p = prev.incidents.get(id);
const title = inc.title || "Untitled incident";
if (!p) {
map.set(id, {
rank, seenAt: now, knownSince: now, lastSeen: now,
stopIds: new Set(stops.map((s) => s.id)),
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
});
if (rank === MAJOR) events.push({ id: `major:${id}`, kind: "major", incidentIds: [id], callId: null, at: now, label: "Major", title });
continue;
}
const st: IncState = { ...p, rank, lastSeen: now, stopIds: new Set(p.stopIds) };
// An unrated incident getting its first rating is classification, not
// escalation — unless that first rating is Major.
if (rank > p.rank && (p.rank >= 0 || rank === MAJOR)) {
events.push({
id: `sev:${id}:${rank}`,
kind: rank === MAJOR && p.rank < 0 ? "major" : "escalated",
incidentIds: [id], callId: null, at: now,
label: p.rank < 0 ? sevLabel(inc.severity) : `↑ ${sevLabel(inc.severity)}`,
title,
});
}
if (stops.length) {
const floor = st.newestStopAt ?? st.knownSince - STOP_GRACE_MS;
const fresh = baselineCalls ? [] : stops.filter((s) => !p.stopIds.has(s.id) && s.at > floor);
for (const s of stops) st.stopIds.add(s.id);
st.newestStopAt = Math.max(st.newestStopAt ?? 0, ...stops.map((s) => s.at));
if (fresh.length) {
const newest = fresh[fresh.length - 1];
events.push({
id: `stop:${id}:${newest.id}`, kind: "stop", incidentIds: [id], callId: newest.id, at: now,
label: fresh.length === 1 ? "+1 stop" : `+${fresh.length} stops`, title,
});
}
}
map.set(id, st);
}
// Keep incidents that dropped out for a while, so one that flickers out of
// the active list and back is not "new" again.
prev.incidents.forEach((st, id) => {
if (!present.has(id) && now - st.lastSeen < FORGET_MS) map.set(id, st);
});
const checked = new Map<string, number>();
prev.checkedCalls.forEach((t, id) => { if (now - t < CALL_MEMORY_MS) checked.set(id, t); });
const incTitle = new Map(incidents.map((i) => [i.incident_id, i.title || "Untitled incident"]));
for (const c of calls) {
const text = transcriptOf(c);
if (!text || checked.has(c.call_id)) continue;
const started = ms(c.started_at);
checked.set(c.call_id, started || now);
if (baselineCalls || !prev.callsBaselined) continue;
if (now - started > LANE_WINDOW_MS) continue; // an old call pulled in late (e.g. by selection) is not news
const hits = matchKeywords(text, keywords);
if (!hits.length) continue;
const ids = callIncidentIds(c);
events.push({
id: `kw:${c.call_id}`, kind: "keyword", incidentIds: ids, callId: c.call_id, at: now,
label: hits.join(" · ").toUpperCase(),
title: ids.map((i) => incTitle.get(i)).find(Boolean) ?? c.talkgroup_name ?? (c.talkgroup_id != null ? `TG ${c.talkgroup_id}` : "Unlinked call"),
});
}
return {
state: { baselineAt: prev.baselineAt, callsBaselined: prev.callsBaselined || baselineCalls, incidents: map, checkedCalls: checked },
events,
};
}
/**
* In view under the current filter: a linked event needs one of its incidents
* in the filtered list (never the call alone — a filtered-out incident's call
* must not cue); an unlinked call needs the call itself in the filtered calls.
*/
export function isVisibleEvent(e: AttentionEvent, visibleIds: ReadonlySet<string>, visibleCallIds: ReadonlySet<string>): boolean {
if (e.incidentIds.length) return e.incidentIds.some((id) => visibleIds.has(id));
return !!e.callId && visibleCallIds.has(e.callId);
}
export interface LaneEntry {
key: string;
incidentId: string | null;
title: string;
severity: string | null;
/** Most recent reason to be on the lane (ms). */
at: number;
/** "New" or the latest event's label. */
reason: string;
}
/** The lane: visible incidents new in the window or with a cue in it, plus unlinked keyword calls; newest first. */
export function laneEntries(
visible: IncidentRecord[],
seenAt: ReadonlyMap<string, number>,
log: AttentionEvent[],
now: number,
visibleCallIds: ReadonlySet<string>,
): LaneEntry[] {
const since = now - LANE_WINDOW_MS;
const latest = new Map<string, AttentionEvent>();
for (const e of log) {
if (e.at < since) continue;
for (const id of e.incidentIds) {
const cur = latest.get(id);
if (!cur || e.at >= cur.at) latest.set(id, e);
}
}
const out: LaneEntry[] = [];
for (const inc of visible) {
const seen = seenAt.get(inc.incident_id) ?? 0;
const ev = latest.get(inc.incident_id);
const isNew = seen >= since;
if (!isNew && !ev) continue;
const at = Math.max(isNew ? seen : 0, ev?.at ?? 0);
out.push({
key: inc.incident_id, incidentId: inc.incident_id, title: inc.title || "Untitled incident", severity: inc.severity,
at, reason: ev && ev.at >= (isNew ? seen : 0) ? ev.label : "New",
});
}
for (const e of log) {
if (e.at < since || e.incidentIds.length || !e.callId || !visibleCallIds.has(e.callId)) continue;
out.push({ key: e.id, incidentId: null, title: e.title, severity: null, at: e.at, reason: e.label });
}
return out.sort((a, b) => b.at - a.at).slice(0, MAX_LANE);
}
/** Visible incidents first seen, or cued, within PULSE_MS. */
export function pulseIds(visibleIds: ReadonlySet<string>, seenAt: ReadonlyMap<string, number>, log: AttentionEvent[], now: number): Set<string> {
const since = now - PULSE_MS;
const out = new Set<string>();
seenAt.forEach((t, id) => { if (t >= since && visibleIds.has(id)) out.add(id); });
for (const e of log) if (e.at >= since) for (const id of e.incidentIds) if (visibleIds.has(id)) out.add(id);
return out;
}
// ── Preferences (per browser; a convenience, so storage failure = defaults) ──
export interface AttentionPrefs {
/** Audible cue. OFF until the viewer turns it on (autoplay policy + no surprise noise). */
tone: boolean;
/** Browser notifications while the tab is hidden. OFF until granted and turned on. */
notify: boolean;
keywords: string[];
}
const PREFS_KEY = "drb.wall.attention";
export const DEFAULT_ATTENTION_PREFS: AttentionPrefs = { tone: false, notify: false, keywords: [...DEFAULT_KEYWORDS] };
export function loadAttentionPrefs(): AttentionPrefs {
try {
const raw = window.localStorage.getItem(PREFS_KEY);
if (!raw) return DEFAULT_ATTENTION_PREFS;
const v = JSON.parse(raw) as Partial<AttentionPrefs>;
return {
tone: v.tone === true,
notify: v.notify === true,
keywords: Array.isArray(v.keywords)
? v.keywords.filter((k): k is string => typeof k === "string" && !!k.trim()).slice(0, 30)
: [...DEFAULT_KEYWORDS],
};
} catch {
return DEFAULT_ATTENTION_PREFS;
}
}
export function saveAttentionPrefs(p: AttentionPrefs): void {
try { window.localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* storage blocked */ }
}
/** "MAYDAY, 10-13 , ,shots fired" → ["MAYDAY", "10-13", "shots fired"] (deduped, case-insensitively). */
export function parseKeywords(s: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const part of s.split(",")) {
const k = part.trim().replace(/\s+/g, " ");
if (!k || seen.has(k.toLowerCase())) continue;
seen.add(k.toLowerCase());
out.push(k);
}
return out.slice(0, 30);
}
// ── Hook ─────────────────────────────────────────────────────────────────────
interface UseAttentionInput {
/** True once the incident list's first server snapshot has landed (and the org is known). */
ready: boolean;
/** Changing this (e.g. the org) starts a fresh baseline. */
resetKey: string | null;
incidents: IncidentRecord[];
calls: CallRecord[];
keywords: readonly string[];
/** The FILTERED incidents, their ids, and the filtered call ids — what is in view right now. */
visible: IncidentRecord[];
visibleIds: ReadonlySet<string>;
visibleCallIds: ReadonlySet<string>;
/** Called with each snapshot's new, in-view events (tone / notification). */
onEvents?: (events: AttentionEvent[]) => void;
}
export function useAttention({ ready, resetKey, incidents, calls, keywords, visible, visibleIds, visibleCallIds, onEvents }: UseAttentionInput) {
const stateRef = useRef<AttentionState | null>(null);
const keyRef = useRef<string | null>(resetKey);
const [log, setLog] = useState<AttentionEvent[]>([]);
const [seenAt, setSeenAt] = useState<Map<string, number>>(new Map());
const [now, setNow] = useState(() => Date.now());
const visRef = useRef({ visibleIds, visibleCallIds });
visRef.current = { visibleIds, visibleCallIds };
const onEventsRef = useRef(onEvents);
onEventsRef.current = onEvents;
const kwKey = keywords.join("\u0001");
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 15_000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (keyRef.current !== resetKey) {
keyRef.current = resetKey;
stateRef.current = null;
setLog([]);
setSeenAt(new Map());
}
if (!ready) return;
const t = Date.now();
const { state, events } = diffAttention(stateRef.current, incidents, calls, keywords, t);
stateRef.current = state;
const seen = new Map<string, number>();
state.incidents.forEach((s, id) => seen.set(id, s.seenAt));
setSeenAt(seen);
setNow(t);
if (!events.length) return;
setLog((cur) => [...cur.filter((e) => t - e.at < LANE_WINDOW_MS), ...events].slice(-MAX_LOG));
const { visibleIds: vi, visibleCallIds: vc } = visRef.current;
const shown = events.filter((e) => isVisibleEvent(e, vi, vc));
if (shown.length) onEventsRef.current?.(shown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, resetKey, incidents, calls, kwKey]);
// A keyword usually fires on the transcript, before correlation links the
// call. Re-resolve unlinked events against the calls as they are now, so
// the lane row becomes the (clickable) incident and its pin pulses.
const linkedLog = useMemo(() => {
if (!log.some((e) => !e.incidentIds.length && e.callId)) return log;
const links = new Map<string, string[]>();
for (const c of calls) { const ids = callIncidentIds(c); if (ids.length) links.set(c.call_id, ids); }
return log.map((e) => (e.incidentIds.length || !e.callId || !links.has(e.callId) ? e : { ...e, incidentIds: links.get(e.callId)! }));
}, [log, calls]);
const lane = useMemo(() => laneEntries(visible, seenAt, linkedLog, now, visibleCallIds), [visible, seenAt, linkedLog, now, visibleCallIds]);
const pulse = useMemo(() => pulseIds(visibleIds, seenAt, linkedLog, now), [visibleIds, seenAt, linkedLog, now]);
return { lane, pulseIds: pulse, now };
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
import type { import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending, AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
WeatherAlertsResponse, WindResponse, WeatherAlertsResponse, WindResponse,
@@ -19,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...(options?.headers as Record<string, string> | undefined), ...(options?.headers as Record<string, string> | undefined),
}, },
}); });
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`); if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }
+64
View File
@@ -0,0 +1,64 @@
/**
* Error thrown by every C2 API request (server-26#286).
*
* `message` is a sentence a user can read — FastAPI's `detail` when the
* server sent one, otherwise a plain mapping of the status. The raw status
* and body stay on the error for callers that need the structured parts
* (the Discord parsers read `invite`/`code` out of `body`).
*/
export class C2ApiError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string) {
super(humanMessage(status, body));
this.name = "C2ApiError";
this.status = status;
this.body = body;
}
// Call sites render String(e); without this it reads "C2ApiError: …".
toString(): string {
return this.message;
}
}
function detailOf(body: string): string | null {
try {
const parsed = JSON.parse(body);
const d = parsed?.detail;
if (typeof d === "string" && d.trim()) return d;
// pydantic validation errors: [{loc, msg, type}, ...]
if (Array.isArray(d)) {
const msgs = d.map((x: { msg?: unknown }) => (typeof x?.msg === "string" ? x.msg : null)).filter(Boolean);
if (msgs.length) return msgs.join("; ");
}
} catch {
/* not JSON */
}
return null;
}
function humanMessage(status: number, body: string): string {
const detail = detailOf(body);
if (status === 401) return "Your session has expired. Sign in again.";
if (status === 403) return detail ? `Not allowed: ${detail}` : "You don't have permission to do that.";
if (detail) return detail;
if (status === 404) return "Not found. It may have been deleted.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (status >= 500) return "The server hit an error. Try again in a moment.";
return `Request failed (${status}).`;
}
/** Message for any caught value — use in catch blocks instead of String(e). */
export function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
/** Rebuilds the legacy `C2 API error <status>: <body>` string for the
* Discord parsers, which predate C2ApiError and match on that shape. */
export function legacyErrorText(err: unknown): string {
if (err instanceof C2ApiError) return `C2 API error ${err.status}: ${err.body}`;
return err instanceof Error ? err.message : String(err);
}
+3 -1
View File
@@ -2,6 +2,8 @@
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and // (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
// small helpers shared by /settings/discord and /tokens (server-26#213). // small helpers shared by /settings/discord and /tokens (server-26#213).
import { legacyErrorText } from "@/lib/c2error";
export interface RadioBotLease { export interface RadioBotLease {
node_id: string; node_id: string;
lease_id?: string; lease_id?: string;
@@ -160,7 +162,7 @@ export const SNOWFLAKE = /^\d{15,21}$/;
* so "no bot in this server" can render its invite links. * so "no bot in this server" can render its invite links.
*/ */
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } { export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
const raw = err instanceof Error ? err.message : String(err); const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/); const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
if (m) { if (m) {
try { try {
+3 -2
View File
@@ -9,6 +9,7 @@
// browser: the code exchange happens in c2-core. // browser: the code exchange happens in c2-core.
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -24,7 +25,7 @@ async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: o
}, },
...(body ? { body: JSON.stringify(body) } : {}), ...(body ? { body: JSON.stringify(body) } : {}),
}); });
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`); if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
try { try {
raw = await post("/discord/oauth/start", req); raw = await post("/discord/oauth/start", req);
} catch (err) { } catch (err) {
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message); if (err instanceof C2ApiError && err.status === 404) throw new OAuthNotConfigured(err.message);
throw err; throw err;
} }
const url = raw.url ?? raw.authorize_url ?? ""; const url = raw.url ?? raw.authorize_url ?? "";
+3 -1
View File
@@ -2,6 +2,8 @@
// Shapes returned by c2-core app/routers/discord_web.py. No token strings — // Shapes returned by c2-core app/routers/discord_web.py. No token strings —
// the server never sends them to the browser. // the server never sends them to the browser.
import { legacyErrorText } from "@/lib/c2error";
export type StreamState = export type StreamState =
| "pending" | "pending"
| "connecting" | "connecting"
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the /** c2api's request() throws `C2 API error <status>: <body>`. Pull the
* detail, code and invite links back out of a JSON body. */ * detail, code and invite links back out of a JSON body. */
export function parseC2Error(err: unknown): ParsedC2Error { export function parseC2Error(err: unknown): ParsedC2Error {
const raw = err instanceof Error ? err.message : String(err); const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/); const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
const status = m ? Number(m[1]) : null; const status = m ? Number(m[1]) : null;
const idx = raw.indexOf(": "); const idx = raw.indexOf(": ");
+39
View File
@@ -0,0 +1,39 @@
import type { NodeRecord } from "@/lib/types";
// Free disk on the node's host (node-26#41). A full disk stopped node-002
// recording for 17 days while every surface still showed it Online, so low
// space is flagged wherever a node is shown.
export type DiskLevel = "ok" | "low" | "critical";
export interface DiskState {
level: DiskLevel;
freePct: number;
label: string; // e.g. "43.2 GB free of 98 GB (44%)"
}
const LOW_PCT = 15;
const CRITICAL_PCT = 5;
const CRITICAL_BYTES = 2 * 1024 ** 3; // a 2 GB floor regardless of disk size
function gb(bytes: number): string {
const v = bytes / 1024 ** 3;
return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)} GB`;
}
/** null when the node has never reported (older node software). */
export function diskState(node: Pick<NodeRecord, "disk_free_bytes" | "disk_total_bytes">): DiskState | null {
const free = node.disk_free_bytes;
const total = node.disk_total_bytes;
if (free == null || !total) return null;
const freePct = Math.max(0, Math.min(100, (free / total) * 100));
const level: DiskLevel =
freePct < CRITICAL_PCT || free < CRITICAL_BYTES ? "critical" : freePct < LOW_PCT ? "low" : "ok";
return { level, freePct, label: `${gb(free)} free of ${gb(total)} (${Math.round(freePct)}%)` };
}
export const DISK_TEXT_CLASS: Record<DiskLevel, string> = {
ok: "text-ink-2",
low: "text-sev-moderate",
critical: "text-sev-major",
};
+5
View File
@@ -82,6 +82,9 @@ export interface NodeRecord {
/** The dongle OP25 is actually using, per the node. */ /** The dongle OP25 is actually using, per the node. */
op25_sdr_serial?: string | null; op25_sdr_serial?: string | null;
sdr_count?: number | null; // null = never reported sdr_count?: number | null; // null = never reported
/** Host disk the node records to (node-26#41); null/absent = not reported. */
disk_free_bytes?: number | null;
disk_total_bytes?: number | null;
/** node-26#24: conventional channels from the last checkin; [] on a trunked system. */ /** node-26#24: conventional channels from the last checkin; [] on a trunked system. */
radio_channels?: RadioChannel[] | null; radio_channels?: RadioChannel[] | null;
enforce_override_timeout?: boolean; enforce_override_timeout?: boolean;
@@ -487,6 +490,8 @@ export interface IncidentRecord {
title: string | null; title: string | null;
type: string | null; type: string | null;
status: "active" | "resolved"; status: "active" | "resolved";
/** Set by the idle sweep; a manual resolve (PUT /incidents) doesn't set it. */
resolved_at?: string | null;
location: string | null; location: string | null;
location_coords: GeoPoint | null; location_coords: GeoPoint | null;
/** /**
+25 -3
View File
@@ -128,15 +128,24 @@ export function useIncident(incidentId: string | null) {
return { incident, loading: loading || (!!incidentId && settledId !== incidentId) }; return { incident, loading: loading || (!!incidentId && settledId !== incidentId) };
} }
export function useActiveIncidents() { /**
* Active incidents plus whether the first SERVER snapshot has landed
* (server-26#238). `loaded` is what lets the wall tell "nothing active" from
* "not loaded yet" — attention cues baseline on it, so opening the page never
* fires a cue for every incident already open. Reset on sign-out / org change.
*/
export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded: boolean } {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loaded, setLoaded] = useState(false);
const { orgId } = useAuth(); const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
setLoaded(false);
const unsubAuth = onAuthStateChanged(auth, (user) => { const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
setLoaded(false);
if (!user) { if (!user) {
setIncidents([]); setIncidents([]);
@@ -148,7 +157,16 @@ export function useActiveIncidents() {
} }
const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active")); const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active"));
unsubFirestore = onSnapshot(q, (snap) => { // includeMetadataChanges: the fromCache → server flip arrives as a
// metadata-only snapshot when the data didn't change (e.g. an org with
// nothing active, or a start while offline). Without it `loaded` would
// stay false until some incident changed, and that change would be
// swallowed into the baseline instead of cued.
let first = true;
unsubFirestore = onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
if (!snap.metadata.fromCache) setLoaded(true);
if (!first && snap.docChanges().length === 0) return; // metadata only — no new array, no re-render storm
first = false;
setIncidents(snap.docs.map((d) => { setIncidents(snap.docs.map((d) => {
const data = d.data(); const data = d.data();
return { return {
@@ -166,5 +184,9 @@ export function useActiveIncidents() {
}; };
}, [orgId]); }, [orgId]);
return incidents; return { incidents, loaded };
}
export function useActiveIncidents() {
return useActiveIncidentsState().incidents;
} }
+12 -1
View File
@@ -22,6 +22,17 @@ const config: Config = {
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the // work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
// class silently generates no rule (map overlays rendered see-through). // class silently generates no rule (map overlays rendered see-through).
colors: { colors: {
// "Iris" palette: the legacy raw gray-*/indigo-* classes (~350 uses)
// resolve to graphite + the brand accent instead of Tailwind's slate-blue
// defaults, so un-migrated components match the tokens above.
gray: {
50: "#F7F7F8", 100: "#EDEDEF", 200: "#E4E4E7", 300: "#C9C9D0", 400: "#A1A1AA",
500: "#7A7A85", 600: "#4A4B53", 700: "#2A2B31", 800: "#1A1B1F", 900: "#111214", 950: "#08090A",
},
indigo: {
50: "#F0F1FD", 100: "#E2E4FB", 200: "#C8CCF6", 300: "#A8AEF1", 400: "#8B93F0",
500: "#6F79E0", 600: "#5E6AD2", 700: "#4E58B8", 800: "#3F4794", 900: "#313770", 950: "#1C1F42",
},
page: tok("page"), page: tok("page"),
surface: tok("surface"), surface: tok("surface"),
raised: tok("raised"), raised: tok("raised"),
@@ -51,7 +62,7 @@ const config: Config = {
boxShadow: { boxShadow: {
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)", card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)", "card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)", glow: "0 0 0 1px rgb(94 106 210 / 0.4), 0 0 24px 0 rgb(94 106 210 / 0.25)",
}, },
animation: { animation: {
"fade-in": "fade-in 0.4s ease-out", "fade-in": "fade-in 0.4s ease-out",