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 cde86a8ff9 Merge pull request 'Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237)' (#277) from feat/237-weather-map into main
Build & Deploy / Build & push images (push) Successful in 4m44s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 4s
Build & Deploy / Deploy to VM (push) Successful in 10s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:53:34 -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 da55114eef Radar loop: tick the frame outside MapView state (#237)
The 1 h loop set MapView state every 600 ms, re-rendering the whole map
component and rebuilding (setIcon) every marker icon on each tick. The
frame now lives in a small external store that only the radar frames and
the valid-time badge subscribe to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:51:07 -04:00
Logan CusanoandClaude Opus 5.5 1a84bf46d7 Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237)
- Weather Warnings overlay (layer key `wx`, saved in the view hash): NWS alert
  polygons coloured by event (NWS hazard-map colours), outline dash by kind,
  popup with event/headline/expiry/office and National Weather Service credit.
  Polls GET /weather/alerts every 2 min while on and visible, refetches on pan.
  A status chip always says what the layer shows; feed errors use the 511
  FeedProblem wording; zone-only alerts are listed, never dropped.
- Radar: IEM nexrad-n0r -> n0q. Valid-time badge from IEM's n0q_0.json,
  STALE past 15 min or when the time can't be read; dBZ legend; optional 1 h
  loop (nexrad-n0q-900913-m05m..m55m), off by default. Still refreshed in
  place, never remounted.
- liveWall: sampleRadarDbz reads the n0q tile with IEM's published 255-colour
  ramp (verified against live tiles); pointInPolygon/alertsAt, wxStyle,
  wantsWind helpers.
- Wall incident panel: "inside <warning>" chips, wind arrow + speed/gust +
  station/obs time on fire/hazmat incidents; region Feeds gains an NWS row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:05 -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
logan 1d6e79ba25 Merge pull request 'Live wall: selected incident loads its own calls, resolved deep links, scrubber marks unloaded time (#239)' (#272) from fix/239-call-window into main
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 26s
Build & Deploy / Deploy to VM (push) Successful in 1m28s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:33:27 -04:00
logan 0d613019e0 Merge pull request 'Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)' (#274) from feat/236-honest-pins into main
Build & Deploy / Build & push images (push) Failing after 15s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Deploy Firestore rules & indexes (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 2s
2026-10-05 03:33:20 -04:00
Logan CusanoandClaude Opus 5.5 e1bd3feac5 Incident page spine: number stops from pathStops, not its own index (#236)
The /incidents/[id] page kept its own stop numbering (every geocoded call,
1..N) and path length, while its MapView now numbers only movement
incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops
1-3 on the spine and unnumbered dots on the map. Use the shared
pathStops()/stopNumbers(), show 'km path' only for movement incidents,
and surface the 'may be merged' flag in the header.

Legend: a plain pin is also what an older (pre-#236) pin with no recorded
precision looks like; say so instead of claiming 'Street address'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:32:36 -04:00
Logan CusanoandClaude Opus 5.5 7a0e86decf Live wall review fixes: no not-found flash or map whiplash when the selected incident resolves (#239)
useIncident reports loading until its answer belongs to the current id
(the render before the effect read the previous id's loading=false), and
clears a stale doc on a denied read. LiveView holds the last selected doc
for the same id while the by-id lookup is pending, so a watched incident
resolving no longer deselects (fly to region) and reselects (fly back).

Follow-up for the panel's 'active'/elapsed copy on resolved incidents: #275.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:29:58 -04:00
logan 3ef8d586ea Merge pull request 'Discord: main DRB bot optional end to end + owner-flow integration tests (#225 S8)' (#271) from feat/225-s8-main-bot-optional into main
Build & Deploy / Build & push images (push) Successful in 4m26s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 3s
Build & Deploy / Deploy to VM (push) Successful in 11s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:29:55 -04:00
Logan CusanoandClaude Opus 5.5 6bf072973c Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.

Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:28:00 -04:00
Logan CusanoandClaude Opus 5.5 a267fdf982 Live wall: selected incident loads its own calls; resolved deep links; scrubber marks unloaded time (#239)
- LiveView: the selected incident merges useCallsByIncident (org_id +
  incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed
  to IncidentPanel and MapView, so a long incident keeps its full path and
  spine past the 500-call window. Stale rows from the previous selection are
  re-checked against the current id before merging.
- #inc= not in the active set is fetched with useIncident: a resolved one is
  selected, drawn on the map and shown under a "Resolved HH:MM" banner; a
  missing or other-org id shows "Incident not found". Auto-tour stays on
  active incidents.
- TimeScrubber: new loadedSince prop (oldest call when the window came back
  full). The axis before it is hatched and labelled "not loaded" instead of
  reading as a quiet stretch.
- useCalls returns hasMore (full page, counted before the duplicate_of
  filter); useIncident resets loading on a new id so a lookup doesn't flash
  "not found".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:25:13 -04:00
Logan CusanoandClaude Opus 5.5 9c124d4c8b S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
  /help and the server-limit refusal no longer imply the DRB server bot is
  required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
  directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
  (plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
  answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
  preferred, OAuth link, picker, start, reconciler) and full setup (server
  bot + 4 radio bots: placement, shared channel, every refusal explained),
  against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:24:44 -04:00
logan 6e462720a1 Merge pull request 'Power Outages map layer + wires-down cross-match on the live wall (#184)' (#270) from feat/184-outage-map into main
Build & Deploy / Build & push images (push) Successful in 4m33s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m26s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:17:54 -04:00
Logan CusanoandClaude Opus 5.5 e7070a0afa Outages review fixes: reset on new target, memoize marker icons, coverage note in popup (#184)
- useOutagesIn kept the previous incident's answer while fetching the next
  one (IncidentPanel isn't keyed), so a newly selected incident briefly read
  'none reported here'. Drop the old answer when the target changes.
- OutageLayer built a fresh divIcon per marker every render; MapView
  re-renders on live updates, so Leaflet rebuilt every marker's DOM. Memoize.
- Popup now carries the provider's coverage note (ODIN: non-reporting
  utilities, e.g. Con Ed/O&R, show nothing even during an outage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:16:58 -04:00
Logan CusanoandClaude Opus 5.5 d10c2f732a Power Outages map layer + wires-down cross-match on the live wall (#184)
- lib/useOutages.ts: GET /outages for the map view (pan/zoom debounced) or a
  fixed bbox; polls every 3 min while visible, refetches when a hidden tab
  returns.
- components/map/OutageLayer.tsx: county markers at the label point sized by
  meters out, stale-only counties dimmed/dashed; popup with count, % served
  when known, since/ETR, per-utility counts, SourceCredit + fetch time, and
  "county centre, not the outage location". An always-on status chip says
  what the layer shows: loading, N counties, none reported in view (with the
  source's coverage note), no source enabled (admin-only link to
  /admin#outages), no licensed source covers this area, or feed error.
- MapView: "Power Outages" overlay, off by default, plus a legend entry while
  it is on. Not hash-persisted yet: LAYER_NAMES lives in lib/liveWall.ts,
  which #235 owns this batch.
- lib/outageMatch.ts (pure): county data matches by county bbox and says
  "county has N out" ("in or near" when two bboxes hold the point); point
  data matches within 500 m; stale rows never count as live; flags
  wires-down/transformer/manhole/pole/tree-on-wires incidents.
- WallPanels: OUTAGE lines under the incident's situational context (hazard
  chip, source credit, fetch time) and an OUTAGE row in the region Feeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:12:32 -04:00
78 changed files with 4830 additions and 1049 deletions
+3 -1
View File
@@ -441,7 +441,9 @@ ERROR_TEXT = {
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice " "pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
"channel per server, and a bot carrying radio on another node can't be shared. Add another " "channel per server, and a bot carrying radio on another node can't be shared. Add another "
"radio bot to DRB and invite it to this server, or stop a radio channel here."), "radio bot to DRB and invite it to this server, or stop a radio channel here."),
"limit": "This server already has its maximum number of radio channels. Stop one with /leave first.", # Reaches the web picker and /join alike: name both ways to stop one.
"limit": "This server already has its maximum number of radio channels. Stop one first (Stop on a node's "
"Discord radio panel, or /leave in Discord).",
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to " "preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
"another organization). Pick another bot, or let DRB choose any free one.", "another organization). Pick another bot, or let DRB choose any free one.",
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its " "pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
+10 -3
View File
@@ -690,8 +690,15 @@ async def _geocode_location(
""" """
Geocode using Google Maps Geocoding API, biased toward the channel's area. Geocode using Google Maps Geocoding API, biased toward the channel's area.
Returns {"lat": float, "lng": float}, or None if geocoding fails or the Returns {"lat": float, "lng": float, "location_type": str}, or None if
result lands outside the area this channel covers. geocoding fails or the result lands outside the area this channel covers.
server-26#236: `location_type` (ROOFTOP / RANGE_INTERPOLATED /
GEOMETRIC_CENTER) rides along with the coordinates so the map can show how
precise a pin is — a route centroid must not draw the same teardrop as a
rooftop. It is display-only: every consumer of these coords reads only
"lat"/"lng", and the dict is stored as-is on the call doc and, through
incident_correlator._resolve_location_pair, on the incident pin.
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
used to reject anything more than geocode_max_km (40km) from the receiving used to reject anything more than geocode_max_km (40km) from the receiving
@@ -801,7 +808,7 @@ async def _geocode_location(
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — " f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
f"accepted anyway: the query names its own region, not the node's" f"accepted anyway: the query names its own region, not the node's"
) )
coords = {"lat": lat, "lng": lng} coords = {"lat": lat, "lng": lng, "location_type": location_type}
logger.info( logger.info(
f"Geocoded '{location_str}' → {coords} " f"Geocoded '{location_str}' → {coords} "
f"({dist_km:.1f}km from {ref_label}) [{location_type}]" f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
+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:
+4 -24
View File
@@ -100,31 +100,11 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
return gcs_uri return gcs_uri
async def download_audio(gcs_uri: str) -> Optional[bytes]:
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _download() -> bytes:
from google.cloud import storage
if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
client = storage.Client()
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
try:
return await asyncio.to_thread(_download)
except Exception as e:
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
return None
# ── Ranged reads for /media playback (server-26#233) ──────────────────────── # ── Ranged reads for /media playback (server-26#233) ────────────────────────
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object # A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
# into memory, so /media used to make the browser wait for all of it before # memory (the old download_audio(), removed once nothing called it — #225 S8),
# the first sample played. These two read only what a request asks for: the # so the browser waited for all of it before the first sample played. These
# two read only what a request asks for: the
# object size (one metadata GET) and then an inclusive byte span, which the # object size (one metadata GET) and then an inclusive byte span, which the
# router streams out in chunks. One cached client, because the router issues # router streams out in chunks. One cached client, because the router issues
# several span reads per playback and a fresh Client() per read re-resolves # several span reads per playback and a fresh Client() per read re-resolves
+17 -1
View File
@@ -269,6 +269,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
Radio bots come from ``GET /tokens``; no token string is returned here. Radio bots come from ``GET /tokens``; no token string is returned here.
Discord user ids (``created_by_discord_id``) and the linking admin's uid Discord user ids (``created_by_discord_id``) and the linking admin's uid
are deliberately left out. are deliberately left out.
Per server, ``main_bot_present`` (the DRB server bot — optional since
server-26#225: True/False as it last reported, None = never heard from it
there) and ``radio_bots`` (names of radio bots Discord last said are in
it). A server with radio bots and no server bot is a normal setup.
""" """
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS) links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
if g.get("guild_id")} if g.get("guild_id")}
@@ -284,6 +289,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")), guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"), "org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
"linked_via": link.get("linked_via"),
# The DRB server bot is optional (server-26#225 S8): True/False
# = it said it is / is not in the server, None = never heard.
"main_bot_present": link.get("bot_present"),
"directory_source": link.get("directory_source"),
# Explicitly unlinked servers get no fallback org (mirrors # Explicitly unlinked servers get no fallback org (mirrors
# streams.resolve_guild_org once server-26#218 lands). # streams.resolve_guild_org once server-26#218 lands).
"_no_fallback": bool(link.get("unlinked_at"))} "_no_fallback": bool(link.get("unlinked_at"))}
@@ -291,7 +301,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
for s in all_streams: for s in all_streams:
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or "")) gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False, g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
"org_id": None, "linked_at": None}) "org_id": None, "linked_at": None, "linked_via": None,
"main_bot_present": None, "directory_source": None})
if s.get("guild_name") and not g.get("guild_name"): if s.get("guild_name") and not g.get("guild_name"):
g["guild_name"] = s["guild_name"] g["guild_name"] = s["guild_name"]
node = nodes.get(s.get("node_id")) or {} node = nodes.get(s.get("node_id")) or {}
@@ -305,6 +316,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
g["acting_org_id"] = acting g["acting_org_id"] = acting
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on") g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
# Radio bots Discord last said are in the server (names only). Any one
# of them is enough to list channels, check permissions and stream.
g["radio_bots"] = sorted(str(t.get("name") or tid) for tid, t in tokens.items()
if t.get("valid") is not False and isinstance(t.get("guild_ids"), list)
and g["guild_id"] in t["guild_ids"])
# Streams sharing a voice channel sit together, oldest first. # Streams sharing a voice channel sit together, oldest first.
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "", rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
+14 -3
View File
@@ -21,9 +21,20 @@ Where the directory comes from (server-26#225 S5 — the main bot is optional):
stored list is missing (inline, inside the permission-check time budget) stored list is missing (inline, inside the permission-check time budget)
or older than ``discord_directory_refresh_seconds`` (in the background). or older than ``discord_directory_refresh_seconds`` (in the background).
So C2 does call Discord when a signed-in user opens the picker — with a This reverses the old rule "C2 never calls Discord on the browser's behalf"
radio bot's token, for a server already linked to the caller's org, through on purpose (server-26#225 plan finding 5): C2 does call Discord when a
the rate-limited, cached client. No token string ever reaches the browser. signed-in user opens the picker, presses Start or Re-check — with a radio
bot's token, only for a server already linked to the caller's org, through
discord_rest's rate-limited, cached, breaker-guarded client (refresh=1 at
most once per 10 s per user and server). No token string ever reaches the
browser, and REST answers never create or re-link a ``discord_guilds`` doc.
The DRB server bot is optional end to end (server-26#225 S8). One radio bot
in the server is enough to link it (per-bot OAuth, routers/discord_oauth.py),
list its voice channels (here), check permissions (routers/discord_checks.py)
and stream (streams.start_stream). The server bot adds its slash commands
(/join, /leave, /status, /drb link) and a gateway-fed channel list; every
path it uses keeps working.
Which servers an org sees (``_org_guilds``): Which servers an org sees (``_org_guilds``):
@@ -0,0 +1,506 @@
"""
The two owner setups from server-26#225, end to end (S8).
1. One-bot org: no DRB server bot anywhere. A platform admin adds the org's
own radio bot and its OAuth secret; the owner marks it preferred for the
org's system, connects the Discord server through that bot's invite,
opens the node page's picker and starts a stream.
2. Full setup: the DRB server bot is in the server (directory push, /drb
link, /join) beside several radio bots. C2 places each stream by
membership, Discord permissions on the voice channel, one voice channel
per bot per server and the system's preferred bot, lets several bots
share one voice channel, and explains when nothing fits.
Every route handler here is the real one, and so is every module under it
(discord_streams, discord_leases, discord_rest, discord_oauth,
discord_guild_link, discord_perms). Only the edges are fake: Firestore is
tests/fake_firestore.py, MQTT records what would be published, and Discord is
``DiscordWorld`` behind httpx.MockTransport — a small model of servers, roles,
channel overrides and bot applications that answers the same REST and OAuth2
calls Discord does.
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from typing import Optional
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from fastapi.security import HTTPAuthorizationCredentials
from app.config import settings
from app.internal import discord_perms as perms
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.routers import discord as discord_router
from app.routers import discord_checks as checks
from app.routers import discord_link as link_router
from app.routers import discord_oauth as oauth_router
from app.routers import discord_web as web
from app.routers import tokens as tokens_router
from tests.fake_firestore import FakeFirestore
ORG = "org-a"
VOICE = perms.VIEW_CHANNEL | perms.CONNECT | perms.SPEAK
REDIRECT = "https://drb.example.test/discord/connected"
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
OWNER = {"uid": "owner-a", "email": "owner@example.test", "org_id": ORG, "org_role": "owner"}
SERVICE = {"service": True}
# ---------------------------------------------------------------------------
# A small Discord
# ---------------------------------------------------------------------------
@dataclass
class BotApp:
name: str
token: str
user_id: str
app_id: str
secret: str
@dataclass
class Guild:
gid: str
name: str
roles: dict = field(default_factory=dict) # role id -> permission int (@everyone id = gid)
role_names: dict = field(default_factory=dict)
channels: list = field(default_factory=list)
members: dict = field(default_factory=dict) # user id -> [role ids]
class DiscordWorld:
"""Answers Discord REST (``Authorization: Bot …``) and OAuth2
(``Authorization: Basic client_id:secret``) for the bots and servers in
it. Adding a bot through the OAuth code exchange makes it a member, like
the real "Authorize" click does."""
def __init__(self):
self.bots: dict[str, BotApp] = {} # token -> app
self.guilds: dict[str, Guild] = {}
self.pending: dict[str, tuple] = {} # oauth code -> (app_id, guild_id)
self.requests: list[httpx.Request] = []
# --- world building ---------------------------------------------------
def bot(self, name: str, n: int) -> BotApp:
b = BotApp(name=name, token=f"bot-token-{n}", user_id=f"9000{n}", app_id=f"7000{n}",
secret=f"client-secret-{n}-0123456789abcdef")
self.bots[b.token] = b
return b
def guild(self, gid: str, name: str) -> Guild:
g = Guild(gid=gid, name=name, roles={gid: VOICE}, role_names={gid: "@everyone"})
self.guilds[gid] = g
return g
def authorize(self, app: BotApp, gid: str) -> str:
"""The owner clicks Authorize in Discord for ``gid``: Discord sends
the browser back with a code."""
code = f"code-{app.app_id}-{gid}"
self.pending[code] = (app.app_id, gid)
return code
def auth_kinds(self) -> list[str]:
return [r.headers.get("authorization", "").split(" ", 1)[0] for r in self.requests]
def bot_tokens_used(self) -> set[str]:
return {r.headers["authorization"].split(" ", 1)[1] for r in self.requests
if r.headers.get("authorization", "").startswith("Bot ")}
# --- transport --------------------------------------------------------
def __call__(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
path = request.url.path.removeprefix("/api/v10")
auth = request.headers.get("authorization", "")
if path.startswith("/oauth2/token"):
return self._oauth(request, path, auth)
if not auth.startswith("Bot "):
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
app = self.bots.get(auth[4:])
if app is None:
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
return self._rest(app, request, path)
def _oauth(self, request: httpx.Request, path: str, auth: str) -> httpx.Response:
if not auth.startswith("Basic "):
return httpx.Response(401, json={"error": "invalid_client"})
client_id, _, secret = base64.b64decode(auth[6:]).decode().partition(":")
app = next((a for a in self.bots.values() if a.app_id == client_id), None)
if app is None or app.secret != secret:
return httpx.Response(401, json={"error": "invalid_client"})
if path == "/oauth2/token/revoke":
return httpx.Response(200, json={})
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
if form.get("grant_type") == "client_credentials":
return httpx.Response(200, json={"access_token": "cc-token", "token_type": "Bearer"})
if form.get("grant_type") != "authorization_code" or form.get("redirect_uri") != REDIRECT:
return httpx.Response(400, json={"error": "invalid_grant"})
pending = self.pending.pop(form.get("code"), None)
if not pending or pending[0] != client_id:
return httpx.Response(400, json={"error": "invalid_grant"})
g = self.guilds[pending[1]]
g.members.setdefault(app.user_id, []) # the bot joins the server
return httpx.Response(200, json={"access_token": "user-access-token", "token_type": "Bearer",
"scope": "bot", "guild": {"id": g.gid, "name": g.name}})
def _rest(self, app: BotApp, request: httpx.Request, path: str) -> httpx.Response:
parts = path.strip("/").split("/")
if path == "/users/@me":
return httpx.Response(200, json={"id": app.user_id, "username": app.name, "bot": True})
if path == "/oauth2/applications/@me":
return httpx.Response(200, json={"id": app.app_id, "name": app.name})
if path == "/users/@me/guilds":
after = int(request.url.params.get("after") or 0)
mine = sorted((g for g in self.guilds.values() if app.user_id in g.members), key=lambda g: int(g.gid))
return httpx.Response(200, json=[{"id": g.gid, "name": g.name} for g in mine if int(g.gid) > after])
if parts[0] == "guilds" and len(parts) >= 2:
g = self.guilds.get(parts[1])
if g is None or app.user_id not in g.members:
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_GUILD, "message": "Unknown Guild"})
if len(parts) == 2:
return httpx.Response(200, json={
"id": g.gid, "name": g.name, "owner_id": "human-owner",
"roles": [{"id": rid, "name": g.role_names.get(rid, rid), "permissions": str(p)}
for rid, p in g.roles.items()]})
if parts[2] == "members" and len(parts) == 4:
if parts[3] not in g.members:
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_MEMBER})
return httpx.Response(200, json={"user": {"id": parts[3]}, "roles": g.members[parts[3]]})
if parts[2] == "channels":
return httpx.Response(200, json=g.channels)
return httpx.Response(404, json={"code": 0, "message": "404: Not Found"})
def voice(cid: str, name: str, position: int, *overwrites: dict, parent: Optional[str] = None) -> dict:
return {"id": cid, "name": name, "type": 2, "position": position, "user_limit": 0,
"parent_id": parent, "permission_overwrites": list(overwrites)}
def deny(role_id: str, bits: int) -> dict:
return {"id": role_id, "type": 0, "allow": "0", "deny": str(bits)}
# ---------------------------------------------------------------------------
# Fixture: fake Firestore + MQTT + Discord, one org
# ---------------------------------------------------------------------------
@pytest.fixture
def env(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
world = DiscordWorld()
discord_rest.reset()
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(world))
published: list = []
from app.internal.mqtt_handler import mqtt_handler
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: published.append((n, p)) or True)
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
streams._publish_locks.clear()
streams._guild_locks.clear()
checks._last_refresh.clear()
web._directory_tasks.clear()
for lim in (oauth_router.start_limiter, oauth_router.complete_limiter, link_router.mint_limiter,
link_router.redeem_guild_limiter, link_router.redeem_user_limiter):
lim._log.clear()
fake.put("organizations", ORG, {"org_id": ORG, "name": "Riverton"})
fake.put("org_members", OWNER["uid"], {"uid": OWNER["uid"], "org_id": ORG, "org_role": "owner"})
yield fake, world, published
discord_rest.reset()
def _node(fake, nid: str, system_id: str):
fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": ORG, "status": "online",
"approval_status": "approved", "assigned_system_id": system_id})
def _body(resp):
return json.loads(resp.body) if hasattr(resp, "body") else resp
def _status(resp):
return getattr(resp, "status_code", 200)
def _no_secrets(world: DiscordWorld, *payloads) -> None:
text = json.dumps(payloads, default=str)
for app in world.bots.values():
assert app.token not in text and app.secret not in text
# ---------------------------------------------------------------------------
# 1. One-bot org — no DRB server bot
# ---------------------------------------------------------------------------
async def test_one_bot_org_links_lists_checks_and_streams_without_the_server_bot(env):
fake, world, published = env
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
_node(fake, "n1", "sys-a")
fake.put("node_keys", "n1", {"api_key": "node-key-n1"})
g = world.guild("400000000000000001", "Riverton FD")
g.channels = [
{"id": "400000000000000010", "name": "Radio", "type": 4, "position": 0, "permission_overwrites": []},
voice("400000000000000011", "dispatch", 0, parent="400000000000000010"),
{"id": "400000000000000012", "name": "general", "type": 0, "position": 1, "permission_overwrites": []},
]
riverton = world.bot("Riverton Radio", 1)
# A pool bot exists too (no OAuth secret, not in the server): never chosen here.
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "pool-token",
"org_id": None, "application_id": "60001", "guild_ids": [], "valid": True,
"lease": None})
# Platform admin: add the org's own bot, then its OAuth client secret.
added = await tokens_router.add_token(
tokens_router.TokenCreate(name="Riverton Radio", token=riverton.token, org_id=ORG), ADMIN)
tid = added["token_id"]
assert added["valid"] is True and added["guild_ids"] == []
out = await tokens_router.set_oauth_secret(tid, tokens_router.OAuthSecret(client_secret=riverton.secret), ADMIN)
assert out["oauth_enabled"] is True
# Owner (Q2): their own bot, preferred for their own system.
await tokens_router.set_preferred_system(tid, "sys-a", OWNER)
assert fake.get("systems", "sys-a")["preferred_token_id"] == tid
# Owner: Connect a Discord server from the node — the preferred bot is invited.
opts = await oauth_router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER)
assert opts["bot"] == {"name": "Riverton Radio", "reason": "preferred", "own_bot": True}
start = await oauth_router.oauth_start(oauth_router.StartBody(node_id="n1", return_to="/nodes/n1"),
org_id=None, decoded=OWNER)
q = {k: v[0] for k, v in parse_qs(urlparse(start["authorize_url"]).query).items()}
assert q["client_id"] == riverton.app_id and q["scope"] == "bot" and q["redirect_uri"] == REDIRECT
# In Discord: pick Riverton FD, Authorize. Discord sends the browser back.
code = world.authorize(riverton, g.gid)
done = _body(await oauth_router.oauth_complete(oauth_router.CompleteBody(code=code, state=q["state"]),
decoded=OWNER))
assert done["ok"] is True and done["guild_id"] == g.gid and done["guild_name"] == "Riverton FD"
assert done["return_to"] == "/nodes/n1" and done["bot_name"] == "Riverton Radio"
# Permissions were checked in the same request, from the radio bot's view.
assert done["checked"] is True and done["channels_ready"] == 1 and done["channels_blocked"] == 0
assert [c["name"] for c in done["channels"]] == ["dispatch"]
link = fake.get("discord_guilds", g.gid)
assert link["org_id"] == ORG and link["linked_via"] == "oauth"
assert "bot_present" not in link # the server bot never said anything
assert g.gid in fake.get("bot_tokens", tid)["guild_ids"]
# Node page: the server is listed, its channels read through the radio bot.
opts = await web.join_options("n1", OWNER)
[gopt] = opts["guilds"]
assert gopt["guild_id"] == g.gid and gopt["linked"] is True
assert gopt["main_bot_present"] is False and gopt["directory_source"] == "radio_bot"
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch"]
assert gopt["bot"]["ok"] is True
vc = await checks.node_voice_check("n1", g.gid, refresh=0, channel_id=None, decoded=OWNER)
[row] = vc["channels"]
assert row["state"] == "ready" and [b["name"] for b in row["bots"]] == ["Riverton Radio"]
# Start: the preferred bot carries it, on this node.
started = await web.start_node_stream(
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
assert _status(started) == 200, _body(started)
assert started["token_id"] == tid and started["delivered"] is True
stream = fake.get("discord_streams", started["stream_id"])
assert stream["node_id"] == "n1" and stream["guild_name"] == "Riverton FD"
assert stream["channel_name"] == "dispatch" and stream["mode"] == "persistent"
node_id, payload = published[-1]
assert node_id == "n1"
assert [(s["stream_id"], s["token_id"]) for s in payload["streams"]] == [(started["stream_id"], tid)]
# The node picks the token string up with its own key — the only place it leaves C2.
got = await discord_router.node_token(
"n1", tid, HTTPAuthorizationCredentials(scheme="Bearer", credentials="node-key-n1"))
assert got["token"] == riverton.token
# Nothing but this org's radio bot ever talked to Discord, and nothing a
# browser saw carries a token or a client secret.
assert world.bot_tokens_used() == {riverton.token}
assert set(world.auth_kinds()) <= {"Bot", "Basic"}
_no_secrets(world, opts, vc, done, started, start, await tokens_router.list_tokens(ADMIN))
async def test_one_bot_org_reconciler_replaces_the_stream_without_the_server_bot(env):
"""Persistent stream, node lost: the reconciler re-places it on another
node of the same system with the same radio bot — no server bot involved."""
fake, world, published = env
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
_node(fake, "n1", "sys-a")
_node(fake, "n2", "sys-a")
g = world.guild("400000000000000001", "Riverton FD")
g.channels = [voice("400000000000000011", "dispatch", 0)]
riverton = world.bot("Riverton Radio", 1)
g.members[riverton.user_id] = []
fake.put("bot_tokens", "own", {"token_id": "own", "name": "Riverton Radio", "token": riverton.token,
"org_id": ORG, "application_id": riverton.app_id, "bot_user_id": riverton.user_id,
"guild_ids": [g.gid], "valid": True, "lease": None})
fake.put("discord_guilds", g.gid, {"guild_id": g.gid, "org_id": ORG, "linked_via": "oauth"})
started = await web.start_node_stream(
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
sid = started["stream_id"]
# n1 goes away for good (past the lease grace).
fake.put("nodes", "n1", {**fake.get("nodes", "n1"), "status": "offline",
"last_seen": "2026-01-01T00:00:00+00:00"})
await streams.reconcile_once()
s = fake.get("discord_streams", sid)
assert s["desired"] == "on" and s["node_id"] == "n2" and s["token_id"] == "own"
assert world.bot_tokens_used() == {riverton.token}
# ---------------------------------------------------------------------------
# 2. Full setup — DRB server bot + several radio bots
# ---------------------------------------------------------------------------
GID = "500000000000000001"
DISPATCH, FIREGROUND, LOBBY = "500000000000000011", "500000000000000012", "500000000000000013"
MUTED = "500000000000000099"
@pytest.fixture
def full(env):
fake, world, published = env
for sid, name in (("sys-a", "County P25"), ("sys-b", "City Fire"), ("sys-c", "EMS")):
fake.put("systems", sid, {"system_id": sid, "name": name, "org_id": ORG})
_node(fake, "n1", "sys-a")
_node(fake, "n2", "sys-b")
_node(fake, "n3", "sys-c")
g = world.guild(GID, "Station 7")
g.roles[MUTED], g.role_names[MUTED] = 0, "Muted"
g.channels = [
voice(DISPATCH, "dispatch", 0, deny(MUTED, perms.SPEAK)), # @Muted can't Speak here
voice(FIREGROUND, "fireground", 1, deny(GID, perms.CONNECT)), # nobody can Connect here
voice(LOBBY, "lobby", 2),
]
bots = {}
for n, (tid, name, org, member, roles) in enumerate((
("own-a", "Riverton Radio", ORG, True, [MUTED]),
("pool-1", "DRB Radio 1", None, True, []),
("pool-2", "DRB Radio 2", None, True, []),
("pool-3", "DRB Radio 3", None, False, []),
), start=1):
app = world.bot(name, n)
if member:
g.members[app.user_id] = roles
fake.put("bot_tokens", tid, {"token_id": tid, "name": name, "token": app.token, "org_id": org,
"application_id": app.app_id, "bot_user_id": app.user_id,
"guild_ids": [GID] if member else [], "valid": True, "lease": None})
bots[tid] = app
# A platform admin pinned DRB Radio 2 to City Fire.
fake.put("systems", "sys-b", {**fake.get("systems", "sys-b"), "preferred_token_id": "pool-2"})
fake.bots = bots
return fake, world, published
async def _server_bot_sets_up(fake):
"""The DRB server bot is in Station 7: it pushes its channel list, and
someone with Manage Server runs /drb link with the owner's code."""
await web.put_guild_directory(GID, web.GuildDirectory(name="Station 7", voice_channels=[
web.VoiceChannelIn(channel_id=DISPATCH, name="dispatch", category="Radio", position=0),
web.VoiceChannelIn(channel_id=FIREGROUND, name="fireground", category="Radio", position=1),
web.VoiceChannelIn(channel_id=LOBBY, name="lobby", category="Radio", position=2),
]), SERVICE)
minted = await link_router.create_link_code(org_id=None, decoded=OWNER)
out = _body(await link_router.redeem_link_code(
GID, link_router.RedeemBody(code=minted["code"], discord_user_id="123456", guild_name="Station 7"),
SERVICE))
assert out["ok"] is True and out["org_id"] == ORG
async def test_full_setup_places_each_stream_and_explains_every_refusal(full):
fake, world, published = full
await _server_bot_sets_up(fake)
link = fake.get("discord_guilds", GID)
assert link["org_id"] == ORG and link["bot_present"] is True and link["directory_source"] == "main_bot"
# Before anything plays: nobody may Connect in #fireground. Every member
# bot is named with what removed the permission; the bot that isn't in
# the server yet is offered as an invite.
r = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=FIREGROUND), OWNER)
body = _body(r)
assert _status(r) == 409 and body["code"] == "missing_permissions"
assert sorted(d["name"] for d in body["denied"]) == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
assert all(d["missing"][0] == "Connect" for d in body["denied"])
assert body["detail"].startswith("No radio bot can play in #fireground:")
assert "#fireground's override for @everyone" in body["detail"]
assert [i["name"] for i in body["invite"]] == ["DRB Radio 3"]
assert fake.data.get("discord_streams") in (None, {})
# /join County P25 in #dispatch (DRB server bot): the org's own bot comes
# first but @Muted can't Speak there, so DRB Radio 1 takes it.
a = _body(await discord_router.start_stream(discord_router.StreamStart(
guild_id=GID, channel_id=DISPATCH, system_id="sys-a", channel_name="dispatch", created_by="111"), SERVICE))
assert a["token_id"] == "pool-1" and a["node_id"] == "n1"
# /join City Fire in the SAME channel: its preferred bot, beside the first.
b = _body(await discord_router.start_stream(discord_router.StreamStart(
guild_id=GID, channel_id=DISPATCH, system_id="sys-b", channel_name="dispatch", created_by="111"), SERVICE))
assert b["token_id"] == "pool-2" and b["node_id"] == "n2" and b["stream_id"] != a["stream_id"]
on_dispatch = [s for s in fake.data["discord_streams"].values()
if s["channel_id"] == DISPATCH and s["desired"] == "on"]
assert sorted(s["token_id"] for s in on_dispatch) == ["pool-1", "pool-2"]
# EMS on #dispatch from the web picker: both pool bots already hold a
# voice channel in this server, and the org's own bot is muted there.
vc = await checks.node_voice_check("n3", GID, refresh=0, channel_id=DISPATCH, decoded=OWNER)
[row] = vc["channels"]
assert row["state"] == "needs_fix"
by_name = {b["name"]: b for b in row["bots"]}
assert by_name["DRB Radio 1"]["unavailable_reason"] == "busy_here"
assert by_name["Riverton Radio"]["missing"] == ["Speak"]
assert "@Muted" in by_name["Riverton Radio"]["reason"]
r = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=DISPATCH), OWNER)
body = _body(r)
assert _status(r) == 409 and body["code"] == "missing_permissions"
assert body["detail"].startswith("Riverton Radio can't Speak in #dispatch:")
assert "Edit Channel → Permissions → add Riverton Radio → allow Speak" in body["detail"]
# EMS on #lobby: nothing overrides @Muted there, so the org's own bot plays it.
c = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
assert _status(c) == 200 and c["token_id"] == "own-a"
# Naming a bot that already holds a channel here is refused, not swapped.
r = await discord_router.start_stream(discord_router.StreamStart(
guild_id=GID, channel_id=LOBBY, system_id="sys-a", preferred_token_id="pool-1"), SERVICE)
body = _body(r)
assert _status(r) == 409 and body["code"] == "preferred_busy"
assert "DRB Radio 1 is already playing County P25 in #dispatch" in body["detail"]
# Each node was told only about its own stream, by token id.
last = {n: p for n, p in published}
assert {n: [s["token_id"] for s in p["streams"]] for n, p in last.items()} == {
"n1": ["pool-1"], "n2": ["pool-2"], "n3": ["own-a"]}
# /status and the admin page see both kinds of bot.
ov = await discord_router.guild_overview(GID, SERVICE)
assert ov["linked"] is True and len(ov["streams"]) == 3
adm = await discord_router.admin_overview(ADMIN)
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
assert ga["main_bot_present"] is True and ga["linked_via"] == "code"
assert ga["radio_bots"] == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
# Only radio bots ever called Discord; the server bot works over the gateway.
assert world.bot_tokens_used() <= {app.token for app in fake.bots.values()}
_no_secrets(world, ov, adm, vc)
async def test_full_setup_server_bot_leaving_keeps_radio_working(full):
"""The DRB server bot is kicked: the server stays usable through its
radio bots (picker, permission check, start) — the main bot is optional."""
fake, world, published = full
await _server_bot_sets_up(fake)
await web.put_guild_directory(GID, web.GuildDirectory(present=False), SERVICE)
assert fake.get("discord_guilds", GID)["bot_present"] is False
opts = await web.join_options("n1", OWNER)
[gopt] = opts["guilds"]
assert gopt["main_bot_present"] is False
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch", "fireground", "lobby"]
c = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
assert _status(c) == 200 and c["token_id"] == "own-a"
adm = await discord_router.admin_overview(ADMIN)
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
assert ga["main_bot_present"] is False and len(ga["radio_bots"]) == 3
@@ -0,0 +1,56 @@
"""
server-26#236: geocode precision must travel with the coordinates.
`_geocode_location` already judged a result by Google's `location_type` but
returned only {lat, lng}, so a GEOMETRIC_CENTER route centroid and a ROOFTOP
address drew the same pin. The type is now part of the returned dict, which
is stored as-is on the call doc and carried unchanged onto the incident pin by
`incident_correlator._resolve_location_pair`.
Display-only: every correlator read of location_coords indexes ["lat"] and
["lng"] directly, so the extra key cannot change a correlation decision.
"""
from unittest.mock import patch
import pytest
import app.internal.intelligence as intel
from app.config import settings
from tests.test_geocode_named_region import _maps_result
@pytest.fixture(autouse=True)
def _api_key():
with patch.object(settings, "google_maps_api_key", "test-key"):
yield
ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
@pytest.mark.asyncio
@pytest.mark.parametrize("location_type", ["ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"])
async def test_accepted_location_type_is_returned_with_the_coords(location_type):
with _maps_result(41.161, -73.861, location_type=location_type):
coords = await intel._geocode_location("1 Main Street, Ossining", anchor=ANCHOR)
assert coords == {"lat": 41.161, "lng": -73.861, "location_type": location_type}
@pytest.mark.asyncio
@pytest.mark.parametrize("location_type", ["APPROXIMATE", "", "SOMETHING_NEW"])
async def test_rejected_location_type_still_returns_none(location_type):
with _maps_result(41.161, -73.861, location_type=location_type):
coords = await intel._geocode_location("Ossining", anchor=ANCHOR)
assert coords is None
def test_location_type_survives_onto_the_incident_pin():
"""`_resolve_location_pair` stores the call's coords dict as-is, so the
precision reaches the incident without any correlator change."""
from app.internal.incident_correlator import _resolve_location_pair
pin = {"lat": 41.161, "lng": -73.861, "location_type": "GEOMETRIC_CENTER"}
fields = _resolve_location_pair({}, "Route 9 and Main Street", pin)
assert fields["location_coords"] == pin
assert fields["location_coords_source"] == "Route 9 and Main Street"
@@ -69,7 +69,7 @@ async def test_named_region_geocode_accepted_beyond_node_distance():
node_lat=NODE_LAT, node_lon=NODE_LON, node_lat=NODE_LAT, node_lon=NODE_LON,
trust_named_region=True, trust_named_region=True,
) )
assert coords == {"lat": FAR_LAT, "lng": FAR_LNG} assert coords == {"lat": FAR_LAT, "lng": FAR_LNG, "location_type": "ROOFTOP"}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -110,7 +110,7 @@ async def test_named_region_geocode_within_node_distance_is_unaffected():
node_lat=NODE_LAT, node_lon=NODE_LON, node_lat=NODE_LAT, node_lon=NODE_LON,
trust_named_region=True, trust_named_region=True,
) )
assert coords == {"lat": near_lat, "lng": near_lng} assert coords == {"lat": near_lat, "lng": near_lng, "location_type": "ROOFTOP"}
@pytest.mark.asyncio @pytest.mark.asyncio
+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 */
+39 -47
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,25 +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 type { CallRecord } from "@/lib/types"; import { ErrorBanner } from "@/components/ui/EmptyState";
import { errorMessage } from "@/lib/c2error";
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 haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function elapsedLabel(startedAt: string, active: boolean, updatedAt: 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);
@@ -41,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);
@@ -49,31 +41,15 @@ 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);
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop // Stop numbers and path length come from the same liveWall.pathStops() the
// number shown on a spine entry matches the number on its map marker. // map's IncidentPathLayer draws from (server-26#236), so the spine numbers
const geocodedCalls = useMemo( // exactly what the map numbers: only a movement incident (police/accident
() => // with a pursuit tag) is numbered, and an impossible-speed stop never is.
calls const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords) const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map<string, number>()), [path]);
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at)),
[calls]
);
const stopNumberByCallId = useMemo(() => {
const m = new Map<string, number>();
geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
return m;
}, [geocodedCalls]);
const pathLengthKm = useMemo(() => {
let total = 0;
for (let i = 1; i < geocodedCalls.length; i++) {
total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
}
return total;
}, [geocodedCalls]);
const newestFirst = useMemo( const newestFirst = useMemo(
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), () => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
@@ -81,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); }
} }
@@ -102,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">
@@ -129,8 +114,13 @@ 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
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>} {path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
{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`}>
{" · "}may be merged
</span>
)}
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} {" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
@@ -148,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>
+8 -2
View File
@@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
function HowItWorks() { function HowItWorks() {
const steps = [ const steps = [
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."], ["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."],
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."], ["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — in one transaction."],
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."], ["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."], ["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."], ["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
@@ -54,6 +54,12 @@ function HowItWorks() {
title="How a radio channel stays up" title="How a radio channel stays up"
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab." note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
> >
<p className="px-4 pt-4 text-xs text-ink-2">
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
through <i>Connect a Discord server</i> (Settings → Organization) links the server, and C2 reads its voice
channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status,
/drb link) and pushes its own channel list; both ways keep working side by side.
</p>
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
{steps.map(([t, d]) => ( {steps.map(([t, d]) => (
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted"> <div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
+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}
@@ -329,12 +329,13 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted"> <div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
{restricted ? ( {restricted ? (
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </> <>Where the DRB server bot is present, an unlinked server sees only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
) : isAdmin ? ( ) : isAdmin ? (
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </> <>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
) : null} ) : null}
Every command and autocomplete is scoped to the linked organization — no server sees another Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no
organization&apos;s nodes, systems or bots. server sees another organization&apos;s nodes, systems or bots. The DRB server bot is optional: a radio bot
alone is enough to stream.
</div> </div>
</Card> </Card>
); );
+163 -19
View File
@@ -18,19 +18,31 @@
* applied ONCE here and the filtered lists feed the map (pins, paths, rail), * applied ONCE here and the filtered lists feed the map (pins, paths, rail),
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline * the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
* health stays on the unfiltered data — a filter must never hide an outage. * health stays on the unfiltered data — a filter must never hide an outage.
*
* Call window (server-26#239): the 500-call window feeds the ticker, scrubber
* and region; the SELECTED incident also loads its own calls by incident id
* 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
* 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 } from "@/lib/useCalls"; import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents"; import { useActiveIncidentsState, useIncident } from "@/lib/useIncidents";
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";
import { useCamerasInBbox } from "@/lib/useCameras"; import { useCamerasInBbox } from "@/lib/useCameras";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { severityRank } from "@/lib/severity"; import { severityRank } from "@/lib/severity";
import type { Camera, Ny511Response } from "@/lib/types"; import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types";
import { import {
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers, DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
@@ -47,17 +59,22 @@ 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 });
const TOUR_MS = 20_000; const TOUR_MS = 20_000;
/** The wall's recent-call window (server-26#239: a selected incident loads past it). */
const RECENT_CALLS = 500;
// 10-foot type for kiosk walls (server-26#234): scales every small text class // 10-foot type for kiosk walls (server-26#234): scales every small text class
// under the wall root — including MapView's rail and WallPanels, which are not // under the wall root — including MapView's rail and WallPanels, which are not
// edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them. // edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them.
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",
@@ -67,6 +84,18 @@ const MAX_TAG_CHIPS = 8;
const MAX_PINNED = 6; const MAX_PINNED = 6;
const EVENTS_POLL_MS = 2 * 60 * 1000; const EVENTS_POLL_MS = 2 * 60 * 1000;
/** Incident-doc fields the frontend type doesn't declare yet (backend writes both). */
type IncidentDoc = IncidentRecord & { org_id?: string | null; resolved_at?: string | null };
/** "Resolved 14:32", with the date when it wasn't today. */
function resolvedLabel(inc: IncidentDoc): string {
const d = inc.resolved_at ? new Date(inc.resolved_at) : null;
if (!d || Number.isNaN(d.getTime())) return "Resolved";
const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const today = d.toDateString() === new Date().toDateString();
return today ? `Resolved ${hm}` : `Resolved ${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${hm}`;
}
function timeAgo(date: Date): string { function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000); const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`; if (s < 60) return `${s}s ago`;
@@ -119,10 +148,11 @@ 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 } = useCalls(500); const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
const { orgId } = useAuth();
const { systems } = useSystems(); const { systems } = useSystems();
const { aircraft } = useAircraft(); const { aircraft } = useAircraft();
const { vessels } = useVessels(); const { vessels } = useVessels();
@@ -250,10 +280,53 @@ export function LiveView() {
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id))); for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
return m; return m;
}, [activeIncidents, callsByIncident]); }, [activeIncidents, callsByIncident]);
// ── Selection (server-26#239) ─────────────────────────────────────────────
// Active first; an #inc= that isn't active (resolved, or the active list is
// still loading) is looked up by id. Another org's incident never selects:
// the rules should deny the read, and the org check here covers a project
// where they aren't deployed yet (DEFERRED.md, server-26#13).
const activeSelected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
const lookupId = link.inc && !activeSelected ? link.inc : null;
const { incident: lookedUp, loading: lookupLoading } = useIncident(lookupId);
const fetchedSelected: IncidentDoc | null =
lookupId && lookedUp && lookedUp.incident_id === lookupId && !!orgId && (lookedUp as IncidentDoc).org_id === orgId
? lookedUp
: null;
const lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId);
const notFound = !!lookupId && !fetchedSelected && !lookupPending;
// The incident on screen resolving drops it from the active list before its
// by-id lookup lands. Hold the last doc for that same id meanwhile, so the
// wall doesn't fly back to the region and straight back again.
const lastSelected = useRef<IncidentDoc | null>(null);
const held = lookupPending && lastSelected.current?.incident_id === link.inc ? lastSelected.current : null;
const selected: IncidentDoc | null = activeSelected ?? fetchedSelected ?? held;
lastSelected.current = selected;
const resolvedSelected = fetchedSelected?.status === "resolved" ? fetchedSelected : null;
// The selected incident's own calls, by incident id — no limit, so a long
// incident keeps every stop the 500-call window has already dropped. The
// hook keeps the previous incident's rows until its new snapshot lands, so
// rows are re-checked against the current id before merging.
const { calls: incidentCalls } = useCallsByIncident(link.inc);
const selectedCalls = useMemo<CallRecord[]>(() => {
if (!selected) return [];
const id = selected.incident_id;
const byId = new Map<string, CallRecord>();
for (const c of callsByIncident.get(id) ?? []) byId.set(c.call_id, c);
for (const c of incidentCalls) {
if (c.incident_ids?.includes(id) || c.incident_id === id) byId.set(c.call_id, c);
}
return Array.from(byId.values());
}, [selected, callsByIncident, incidentCalls]);
const selectedPoints = useMemo(() => (selected ? incidentPoints(selected, selectedCalls) : []), [selected, selectedCalls]);
const regionBox = useMemo(() => { const regionBox = useMemo(() => {
const pts = Array.from(pointsById.values()).flat(); const pts = Array.from(pointsById.values()).flat();
// A resolved incident opened by link sits outside the active set; count it
// so its nearby cameras load.
if (resolvedSelected) pts.push(...selectedPoints);
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5); return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
}, [pointsById, nodes]); }, [pointsById, nodes, resolvedSelected, selectedPoints]);
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox); const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]); const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
// Pins live in this browser and carry their image URLs, so a pin must not // Pins live in this browser and carry their image URLs, so a pin must not
@@ -276,19 +349,20 @@ export function LiveView() {
}); });
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
// A drilled-into incident stays on the map even if the filter excludes it // A drilled-into incident stays on the map even if the filter excludes it
// (a shared #inc= link must still land); it is not added to the tour. // or it has resolved (a shared #inc= link must still land); it is not added
// to the tour. Its full call list rides along so the path is complete.
const keepSelected = !!selected && !filtered.ids.has(selected.incident_id); const keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
const mapIncidents = useMemo( const mapIncidents = useMemo(
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents), () => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
[keepSelected, selected, filtered.incidents], [keepSelected, selected, filtered.incidents],
); );
const mapCalls = useMemo(() => { const mapCalls = useMemo(() => {
if (!keepSelected || !selected) return filtered.calls; if (!selectedCalls.length) return filtered.calls;
const have = new Set(filtered.calls.map((c) => c.call_id)); const have = new Set(filtered.calls.map((c) => c.call_id));
return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))]; const extra = selectedCalls.filter((c) => !have.has(c.call_id));
}, [keepSelected, selected, filtered.calls, callsByIncident]); return extra.length ? [...filtered.calls, ...extra] : filtered.calls;
}, [selectedCalls, filtered.calls]);
// Filter choices come from what the wall has loaded (plus system names). // Filter choices come from what the wall has loaded (plus system names).
const filterOptions = useMemo(() => { const filterOptions = useMemo(() => {
@@ -315,8 +389,7 @@ export function LiveView() {
tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t), tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t),
}; };
}, [systems, recentCalls, activeIncidents]); }, [systems, recentCalls, activeIncidents]);
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
const drillCam: Camera | null = link.cam const drillCam: Camera | null = link.cam
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null ? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
: null; : null;
@@ -334,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 });
@@ -370,6 +469,14 @@ export function LiveView() {
return () => { clearInterval(id); clearInterval(prog); }; return () => { clearInterval(id); clearInterval(prog); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [link.tour, tourList.join(","), tourMs]); }, [link.tour, tourList.join(","), tourMs]);
// Scrubber honesty (server-26#239): when the window came back full, nothing
// older than its oldest call was loaded — that span is unknown, not quiet.
const loadedSince = useMemo(() => {
if (!windowFull) return null;
let oldest: string | null = null;
for (const c of recentCalls) if (c.started_at && (!oldest || c.started_at < oldest)) oldest = c.started_at;
return oldest;
}, [windowFull, recentCalls]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0; const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
// ── KPIs ────────────────────────────────────────────────────────────────── // ── KPIs ──────────────────────────────────────────────────────────────────
@@ -429,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>
@@ -452,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
@@ -466,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}
@@ -475,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 && (
@@ -487,11 +605,35 @@ export function LiveView() {
</div> </div>
<aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`} <aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`}
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}> aria-live="polite" aria-label={link.inc ? "Incident detail" : "Region overview"}>
{selected ? ( {link.inc && !selected ? (
<div className="p-4 text-sm">
{lookupPending ? (
<p className="text-ink-muted">Loading incident…</p>
) : notFound ? (
<>
<p className="font-semibold text-ink">Incident not found</p>
<p className="mt-1 text-xs text-ink-muted">
This link points to an incident that doesn&apos;t exist or isn&apos;t in your organization.
</p>
<button type="button" onClick={() => patchLink({ inc: null, cam: null, tour: false })}
className="mt-3 px-2 py-0.5 rounded border border-line text-xs text-ink-2 hover:text-ink">
Back to region
</button>
</>
) : null}
</div>
) : selected ? (
<>
{resolvedSelected && (
<div role="status" className="px-3 py-1.5 border-b border-line bg-raised text-xs text-ink-2">
<b className="text-ink font-semibold">{resolvedLabel(resolvedSelected)}</b>
{" · no longer active — opened from a link"}
</div>
)}
<IncidentPanel <IncidentPanel
inc={selected} inc={selected}
calls={callsByIncident.get(selected.incident_id) ?? []} calls={selectedCalls}
points={selectedPoints} points={selectedPoints}
near={near} near={near}
feeds={feeds} feeds={feeds}
@@ -503,6 +645,7 @@ export function LiveView() {
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }} eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
onOpenCamera={openCamera} onOpenCamera={openCamera}
/> />
</>
) : ( ) : (
<RegionPanel <RegionPanel
incidents={filtered.incidents} incidents={filtered.incidents}
@@ -524,7 +667,8 @@ export function LiveView() {
</div> </div>
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} /> <TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} /> <TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident} loadedSince={loadedSince} windowSize={RECENT_CALLS} />
{drillCam && ( {drillCam && (
<CameraDrill <CameraDrill
File diff suppressed because it is too large Load Diff
+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>
+12 -8
View File
@@ -2,9 +2,11 @@
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13). // Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only // Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
// the Discord servers linked to this node's org and the voice channels the // the Discord servers linked to this node's org and their voice channels, as
// DRB bot reported in them; a pick becomes a radio channel pinned to this node // read by a radio bot in the server or pushed by the DRB server bot — either
// (desired state — the node reconciles and reports back, shown per row). // is enough; the server bot is optional (server-26#225). A pick becomes a
// radio channel pinned to this node (desired state — the node reconciles and
// reports back, shown per row).
// A voice channel can carry several radio streams, each its own bot playing // A voice channel can carry several radio streams, each its own bot playing
// its own system (server-26#213 follow-up): picking a channel that already // its own system (server-26#213 follow-up): picking a channel that already
// plays another system ADDS this node's system beside it; only a stream of // plays another system ADDS this node's system beside it; only a stream of
@@ -211,8 +213,9 @@ function DiscordPicker({
<legend className="text-xs text-ink-muted mb-2">Discord server</legend> <legend className="text-xs text-ink-muted mb-2">Discord server</legend>
{usable.length === 0 ? ( {usable.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2"> <p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and No Discord server is linked to your organization yet. The organization owner connects one under
link it to your organization; it shows up here once the bot reports its channels. Settings → Organization → <b className="text-ink">Connect a Discord server</b>: adding a radio bot
to the server links it, and its voice channels show up here.
</p> </p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -259,12 +262,13 @@ function DiscordPicker({
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend> <legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
{!guild.synced ? ( {!guild.synced ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2"> <p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
The DRB bot has not reported this server&apos;s channels yet. It does when it starts and every 15 DRB hasn&apos;t read this server&apos;s voice channels yet. A radio bot in the server lists them
minutes — check back shortly. (or the DRB server bot, if it&apos;s there) — close this and open it again in a moment. If it
stays empty, no radio bot is in this server yet — invite one with the link under the server above.
</p> </p>
) : guild.voice_channels.length === 0 ? ( ) : guild.voice_channels.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2"> <p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
This server has no voice channels the DRB bot can see. This server has no voice channels DRB&apos;s bots can see.
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
+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",
}; };
+48 -1
View File
@@ -26,12 +26,20 @@ const BUCKETS = 48;
* `incidents` (optional) adds one band per active incident from its start to * `incidents` (optional) adds one band per active incident from its start to
* its last update, tinted by severity — click a band to drill into it (live * its last update, tinted by severity — click a band to drill into it (live
* ops wall). The playhead still does not move; see the header. * ops wall). The playhead still does not move; see the header.
*
* `loadedSince` (server-26#239): ISO time of the oldest call the caller's
* window loaded when that window came back FULL (null = everything in range
* was loaded). The axis before it is hatched and labelled "not loaded", so a
* 7d view never reads as a quiet week for time nobody fetched.
*/ */
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: { export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident, loadedSince = null, windowSize }: {
calls: CallRecord[]; calls: CallRecord[];
incidents?: IncidentRecord[]; incidents?: IncidentRecord[];
selectedIncidentId?: string | null; selectedIncidentId?: string | null;
onSelectIncident?: (id: string) => void; onSelectIncident?: (id: string) => void;
loadedSince?: string | null;
/** Size of the caller's call window, for the "not loaded" tooltip. */
windowSize?: number;
}) { }) {
const [rangeIdx, setRangeIdx] = useState(2); // default 24h const [rangeIdx, setRangeIdx] = useState(2); // default 24h
@@ -56,6 +64,24 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
const maxCount = Math.max(1, ...buckets.map((b) => b.count)); const maxCount = Math.max(1, ...buckets.map((b) => b.count));
// Share of the axis (0-100) before the loaded window starts; 0 = all loaded.
const unloaded = useMemo(() => {
const t = loadedSince ? new Date(loadedSince).getTime() : NaN;
if (Number.isNaN(t)) return { pct: 0, firstLoaded: 0, since: null as Date | null };
const start = Date.now() - range.ms;
if (t <= start) return { pct: 0, firstLoaded: 0, since: null as Date | null };
const frac = Math.min(1, (t - start) / range.ms);
// Buckets wholly before the window are unknown, not empty: drawn blank.
return { pct: frac * 100, firstLoaded: Math.floor(frac * BUCKETS), since: new Date(t) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadedSince, rangeIdx, calls]);
const sinceLabel = unloaded.since
? unloaded.since.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
: "";
const unloadedTitle = unloaded.since
? `Not loaded: only the latest ${windowSize ?? "few hundred"} calls are on this page (back to ${sinceLabel}). Earlier activity is unknown, not quiet.`
: "";
const bands = useMemo(() => { const bands = useMemo(() => {
const now = Date.now(); const now = Date.now();
const start = now - range.ms; const start = now - range.ms;
@@ -88,7 +114,28 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
})} })}
</div> </div>
)} )}
{unloaded.pct > 0 && (
<div
role="img"
aria-label={unloadedTitle}
title={unloadedTitle}
className="absolute left-0 top-0 bottom-0 z-[1] flex items-center justify-center overflow-hidden rounded-sm border border-dashed border-line"
style={{
width: `${unloaded.pct}%`,
background: "repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 7px)",
}}
>
{unloaded.pct >= 8 && (
<span className="px-1.5 rounded bg-surface/90 text-[10px] font-mono uppercase tracking-wide text-ink-muted whitespace-nowrap">
not loaded
</span>
)}
</div>
)}
{buckets.map((b, i) => { {buckets.map((b, i) => {
if (i < unloaded.firstLoaded) {
return <div key={i} aria-hidden="true" style={{ flex: 1, minWidth: 2 }} />;
}
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100); const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
const color = const color =
b.worstRank === 3 b.worstRank === 3
@@ -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.
@@ -8,8 +8,12 @@ import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// Linked Discord servers = discord_guilds docs, plus any server that has had a // Linked Discord servers = discord_guilds docs, plus any server that has had a
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID). // radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side // Linking here is PUT /discord/guilds/{id}/link (platform admin, support).
// "/drb link code:" flow is server-26#213 item 1 and is not built here. // Org owners link their own from Settings → Organization: "Connect a Discord
// server" (a radio bot's OAuth invite, no DRB server bot needed) or a
// /drb link code (needs the server bot) — server-26#213 item 1, #225.
// The Bots column says which bots are in each server: the DRB server bot is
// optional (server-26#225 S8), one radio bot is enough to stream.
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then // Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
// deleted, and the server is unlinked with no fallback org. // deleted, and the server is unlinked with no fallback org.
@@ -85,6 +89,30 @@ function LinkForm({
); );
} }
// Which bots are in the server. The DRB server bot is optional (server-26#225):
// "not there" is only a problem when no radio bot is either.
function BotsCell({ guild }: { guild: DiscordGuild }) {
const radio = guild.radio_bots ?? [];
const main = guild.main_bot_present;
return (
<>
<div>
{radio.length > 0 ? (
<span title={radio.join(", ")}>
{radio.length} radio bot{radio.length === 1 ? "" : "s"}
</span>
) : (
<span className={main ? DIM : "text-sev-moderate"}>no radio bot</span>
)}
</div>
<div className={DIM}>
DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"}
{guild.directory_source === "radio_bot" && " · channels via radio bot"}
</div>
</>
);
}
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] }; type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
function RemoveForm({ function RemoveForm({
@@ -131,7 +159,7 @@ function RemoveForm({
</span> </span>
</label> </label>
<p className="text-ink-muted"> <p className="text-ink-muted">
The DRB bot itself stays in the Discord server until someone kicks it there. The DRB server bot, if it is in the Discord server, stays until someone kicks it there.
</p> </p>
{error && <p className="text-sev-major">{error}</p>} {error && <p className="text-sev-major">{error}</p>}
<div className="flex gap-2"> <div className="flex gap-2">
@@ -171,8 +199,9 @@ export function ServersPanel({
fallback ? ( fallback ? (
<> <>
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any <b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
server the DRB bot is invited to can use that org&apos;s systems until it is linked here. Set the variable server the DRB server bot is invited to can use that org&apos;s systems until it is linked here (a radio
to an empty string once every real server is linked. bot alone never puts an unlinked server here). Set the variable to an empty string once every real server
is linked.
</> </>
) : ( ) : (
<>Unlinked servers can&apos;t use radio commands until they are linked here.</> <>Unlinked servers can&apos;t use radio commands until they are linked here.</>
@@ -189,14 +218,16 @@ export function ServersPanel({
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}. {removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
</> </>
)}{" "} )}{" "}
The DRB bot is still in that Discord server until someone kicks it. The DRB server bot, if it was there, stays in that Discord server until someone kicks it.
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button> <button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
</div> </div>
)} )}
{overview.guilds.length === 0 ? ( {overview.guilds.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p> <p className="px-4 py-6 text-sm text-ink-muted">
No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization.
</p>
) : ( ) : (
<Table head={["Server", "Acts for", "Radio channels", "Linked", "Status", ""]}> <Table head={["Server", "Acts for", "Bots", "Radio channels", "Linked", "Status", ""]}>
{overview.guilds.flatMap((g) => { {overview.guilds.flatMap((g) => {
const row = ( const row = (
<tr key={g.guild_id}> <tr key={g.guild_id}>
@@ -208,6 +239,9 @@ export function ServersPanel({
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>} {g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>} {!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
</td> </td>
<td className={TD}>
<BotsCell guild={g} />
</td>
<td className={TD}> <td className={TD}>
{g.live_streams} / {max} {g.live_streams} / {max}
</td> </td>
@@ -251,7 +285,7 @@ export function ServersPanel({
return [ return [
row, row,
<tr key={`${g.guild_id}-remove`}> <tr key={`${g.guild_id}-remove`}>
<td colSpan={6} className="p-0 border-b border-line"> <td colSpan={7} className="p-0 border-b border-line">
<RemoveForm <RemoveForm
guild={g} guild={g}
onCancel={() => setRemoving(null)} onCancel={() => setRemoving(null)}
@@ -269,7 +303,7 @@ export function ServersPanel({
return [ return [
row, row,
<tr key={`${g.guild_id}-edit`}> <tr key={`${g.guild_id}-edit`}>
<td colSpan={6} className="p-0 border-b border-line"> <td colSpan={7} className="p-0 border-b border-line">
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} /> <LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
</td> </td>
</tr>, </tr>,
@@ -348,7 +348,8 @@ export function StreamsPanel({
)} )}
{overview.streams.length === 0 ? ( {overview.streams.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted"> <p className="px-4 py-6 text-sm text-ink-muted">
No radio channels yet. Start one here, or run <span className="font-mono">/join</span> in a linked Discord server. No radio channels yet. Start one here or from a node&apos;s Discord radio panel, or run{" "}
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
</p> </p>
) : ( ) : (
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}> <Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
@@ -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>
</>
);
}
+293 -19
View File
@@ -16,7 +16,15 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { InlinePlayer } from "@/components/InlinePlayer"; import { InlinePlayer } from "@/components/InlinePlayer";
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue"; import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall"; import {
CONTEXT_KM, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, PRECIP_ECHO_DBZ, alertsAt, bboxAround, compass, contextFor, pathStops,
sampleRadarDbz, stopNumbers, wantsWind, wxStyle, wxTime,
type LatLon, type NearCamera,
} from "@/lib/liveWall";
import { useWeatherAlertsIn, useWind, type Polled } from "@/lib/useWeather";
import type { WeatherAlertsResponse } from "@/lib/types";
import { useOutagesIn, type OutagesState } from "@/lib/useOutages";
import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
function sevColor(s: string | null | undefined) { function sevColor(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)"; return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
@@ -24,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 {
@@ -41,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>
); );
@@ -61,6 +71,241 @@ function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm:
); );
} }
// ── Power outages (server-26#184) ────────────────────────────────────────────
/**
* Why the outage answer is empty or unusable, in one line — or null when the
* data is fine to read. Shared by the region Feeds row and the incident panel.
*/
function OutageProblem({ state, where }: { state: OutagesState; where: string }) {
const { isAdmin } = useAuth();
const { data, error, loaded } = state;
if (!loaded) return <>checking…</>;
if (!data) return <>could not reach server{error ? ` (${error})` : ""}</>;
if (data.status === "none_enabled") {
return isAdmin
? <>no outage source enabled (<Link href="/admin#outages" className="underline">Admin → Outage providers</Link>)</>
: <>no outage source enabled — a platform admin can turn one on</>;
}
if (data.status === "no_source") return <>no licensed outage source covers {where}</>;
if (data.status === "error") {
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
return <>feed error{bad.length ? `: ${bad.map((p) => `${p.name} (${p.error})`).join("; ")}` : ""}</>;
}
return null;
}
function outageIsUsable(state: OutagesState): boolean {
return !!state.data && state.data.status === "ok";
}
/** Incident panel "OUTAGE" lines: the cross-match, with source credit and fetch time. */
function OutageContext({ inc, points }: { inc: IncidentRecord; points: LatLon[] }) {
// 1 km pad: county data is matched by the county bbox containing a point, so
// any county the incident touches intersects this box.
const box = useMemo(() => bboxAround(points, 1), [points]);
const state = useOutagesIn(box);
const hazard = isUtilityHazard(inc);
const matches = useMemo(
() => (state.data ? matchOutages(inc, points, state.data.areas, state.data.providers) : []),
[inc, points, state.data],
);
const staleHere = useMemo(() => (state.data ? staleCountiesAt(points, state.data.areas) : []), [points, state.data]);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
if (!points.length) return <li>{tag}—</li>;
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this location" /></li>;
const covering = state.data!.providers.filter((p) => p.covers_bbox);
const refreshNote = state.error ? " · could not refresh, showing the last answer" : "";
const feedErr = covering.find((p) => p.error);
return (
<>
{hazard && (
<li>
<span className={`inline-block text-[10px] font-semibold px-1.5 py-0.5 rounded ${matches.length ? "bg-accent text-white" : "bg-raised text-ink-2"}`}>
{matches.length
? matches[0].kind === "point"
? `⚡ Utility-hazard call within ${OUTAGE_POINT_KM} km of a reported outage`
: "⚡ Utility-hazard call in a county with reported outages"
: "⚡ Utility-hazard call · no reported outage matched"}
</span>
</li>
)}
{matches.map((m) => (
<li key={m.area.id}>
{tag}{m.text}
{m.area.etr ? ` · est. restoration ${new Date(m.area.etr).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : ""}
<SourceCredit credit={m.feed} fallbackName={m.feed?.name ?? m.area.provider}
updated={m.feed ? `fetched ${fetchedLabel(m.feed.fetched_at) ?? "—"}${refreshNote}` : null} />
</li>
))}
{!matches.length && (
<li>
{tag}
{feedErr
? `${feedErr.name} feed error${feedErr.fetched_at ? `, last good data ${fetchedLabel(feedErr.fetched_at)}` : ", no data yet"} — none matched`
: `none reported here by ${covering.map((p) => p.name).join(", ") || "the enabled source"}`}
{staleHere.length > 0 && ` (${staleHere.map(countyName).join(", ")}: stale upstream rows only, not shown as live)`}
{covering.map((p) => (
<SourceCredit key={p.id} credit={p} fallbackName={p.name}
updated={`fetched ${fetchedLabel(p.fetched_at) ?? "—"}${refreshNote}${p.coverage_note ? ` · ${p.coverage_note}` : ""}`} />
))}
</li>
)}
</>
);
}
/** Region Feeds "OUTAGE" row: provider status for the wall's region. */
function OutageFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) {
const pts = useMemo(() => {
const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon);
return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon);
}, [incidents, nodes]);
// Counties are large; pad the region so a county just outside the pins still counts.
const box = useMemo(() => bboxAround(pts, 10), [pts]);
const state = useOutagesIn(box);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this region" /></li>;
const { areas, providers } = state.data!;
return (
<>
{providers.filter((p) => p.covers_bbox).map((p) => {
const mine = areas.filter((a) => a.provider === p.id);
const live = mine.filter((a) => !a.stale && a.customers_out > 0);
const out = live.reduce((t, a) => t + a.customers_out, 0);
const stale = mine.filter((a) => a.stale).length;
return (
<li key={p.id}>{tag}{p.name}: {live.length
? `${live.length} count${live.length !== 1 ? "ies" : "y"} with outages, ${out.toLocaleString()} meters/customers out`
: "no outages reported in region"}
{stale ? ` · ${stale} stale` : ""}{p.error ? " · feed error" : ""}{p.fetched_at ? ` · fetched ${fetchedLabel(p.fetched_at)}` : ""}
{state.error ? " · could not refresh" : ""} · Power Outages layer (map menu)</li>
);
})}
</>
);
}
// ── Weather (server-26#237) ──────────────────────────────────────────────────
const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null };
const METAR_CREDIT: CameraCredit = { attribution: "NOAA Aviation Weather Center (METAR)", attribution_url: "https://aviationweather.gov/", disclaimer: null };
/**
* Why the NWS answer is unusable, in one line (same wording as the map's
* banner) — or null when it is fine to read.
*/
function nwsProblem(state: Polled<WeatherAlertsResponse>): string | null {
const { data, error, loaded } = state;
if (!loaded) return "checking…";
if (!data) return `could not reach server${error ? ` (${error})` : ""}`;
if (data.error && !data.fetched_at) return "NWS feed error, no data yet";
return null;
}
function nwsFreshness(state: Polled<WeatherAlertsResponse>): string {
const d = state.data!;
return `fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · NWS feed error, showing the last good data" : ""}${state.error ? " · could not refresh" : ""}`;
}
/** Incident panel "NWS" lines: the warning polygons the incident's head point sits inside. */
function WeatherWarningContext({ head }: { head: LatLon | null }) {
const box = useMemo(() => (head ? bboxAround([head], 1) : null), [head]);
const state = useWeatherAlertsIn(box);
const inside = useMemo(() => (head && state.data ? alertsAt(head, state.data.alerts) : []), [head, state.data]);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
if (!head) return <li>{tag}—</li>;
const problem = nwsProblem(state);
if (problem) return <li>{tag}{problem}</li>;
const zoneOnly = state.data!.zone_only.length;
return (
<li>
{tag}
{inside.length ? (
<span className="inline-flex flex-wrap gap-1 align-middle">
{inside.map((a) => {
const st = wxStyle(a.event);
return (
<span key={a.id} title={a.headline ?? a.event}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-raised text-ink"
style={{ borderLeft: `3px ${st.dashArray ? "dashed" : "solid"} ${st.color}` }}>
inside {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
</span>
);
})}
</span>
) : (
<>not inside any active NWS alert area</>
)}
{zoneOnly > 0 && <span className="text-ink-muted"> · {zoneOnly} zone-based alert{zoneOnly !== 1 ? "s" : ""} in the served area can&apos;t be placed (Weather Warnings layer lists them)</span>}
<SourceCredit credit={NWS_CREDIT} updated={nwsFreshness(state)} />
</li>
);
}
/** Arrow pointing where the wind blows TO (METAR gives where it comes FROM). */
function WindArrow({ fromDeg }: { fromDeg: number }) {
return (
<svg width="14" height="14" viewBox="0 0 14 14" className="inline-block align-[-2px] mr-1" aria-hidden>
<g transform={`rotate(${(fromDeg + 180) % 360} 7 7)`}>
<line x1="7" y1="12" x2="7" y2="2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<polyline points="3.8,5.6 7,2.2 10.2,5.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</g>
</svg>
);
}
/** Incident panel "WIND" line for fire/hazmat incidents: nearest METAR station. */
function WindContext({ head }: { head: LatLon | null }) {
const state = useWind(head);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">WIND</span>;
if (!head) return <li>{tag}— (no geocoded location)</li>;
if (!state.loaded) return <li>{tag}checking…</li>;
const d = state.data;
if (!d) return <li>{tag}could not reach server{state.error ? ` (${state.error})` : ""}</li>;
if (!d.available || !d.wind) return <li>{tag}{d.reason ?? "no wind report"}</li>;
const w = d.wind;
const calm = w.wind_kt === 0;
const dir = calm ? "calm" : w.variable || w.wind_dir_deg == null ? "variable" : `from ${compass(w.wind_dir_deg)} (${Math.round(w.wind_dir_deg)}°)`;
const obs = w.obs_time ? new Date(w.obs_time) : null;
const obsLabel = obs && !isNaN(obs.getTime()) ? obs.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
return (
<li>
{tag}
{!calm && !w.variable && w.wind_dir_deg != null && <WindArrow fromDeg={w.wind_dir_deg} />}
<b>{calm ? "Calm" : `${Math.round(w.wind_kt)} kt`}</b>
{w.gust_kt != null && !calm ? <b> G {Math.round(w.gust_kt)} kt</b> : null}
{calm ? "" : ` ${dir}`}
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
{obsLabel ? ` · observed ${obsLabel}` : ""}
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
<SourceCredit credit={METAR_CREDIT}
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
</li>
);
}
/** Region Feeds "NWS" row: active alerts over the wall's region, with fetch time or the error. */
function WeatherFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) {
const pts = useMemo(() => {
const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon);
return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon);
}, [incidents, nodes]);
const box = useMemo(() => bboxAround(pts, 10), [pts]);
const state = useWeatherAlertsIn(box);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
const problem = nwsProblem(state);
if (problem) return <li>{tag}{problem}</li>;
const d = state.data!;
const warnings = d.alerts.filter((a) => wxStyle(a.event).kind === "warning").length;
return (
<li>{tag}{d.alerts.length
? `${d.alerts.length} active alert${d.alerts.length !== 1 ? "s" : ""} in region${warnings ? ` (${warnings} warning${warnings !== 1 ? "s" : ""})` : ""}`
: "no active alert areas in region"}
{d.zone_only.length ? ` · ${d.zone_only.length} zone-based in served area` : ""} · {nwsFreshness(state)} · Weather Warnings layer (map menu)</li>
);
}
// ── Level 1: region ────────────────────────────────────────────────────────── // ── Level 1: region ──────────────────────────────────────────────────────────
export function RegionPanel({ export function RegionPanel({
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount, incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
@@ -105,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>
@@ -123,7 +368,9 @@ export function RegionPanel({
{providers.map((p) => ( {providers.map((p) => (
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li> <li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
))} ))}
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li> <OutageFeedRow incidents={incidents} nodes={nodes} />
<WeatherFeedRow incidents={incidents} nodes={nodes} />
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu, IEM n0q, with valid time); sampled per incident</li>
</ul> </ul>
</div> </div>
<div className={SEC}> <div className={SEC}>
@@ -177,15 +424,22 @@ 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 radar = useRadarDbz(points.at(-1) ?? null); const head = points.at(-1) ?? null;
// Stop numbers share the index with the map path: geocoded calls, oldest first. const radar = useRadarDbz(head);
const stopOf = useMemo(() => { // Stop numbers come from the same pathStops() the map draws from, so the
const m = new Map<string, number>(); // spine numbers exactly what the map numbers (server-26#236): only movement
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at)) // incidents get numbers, and a ghosted (impossible-speed) stop gets none.
.forEach((c, i) => m.set(c.call_id, i + 1)); const path = useMemo(() => pathStops(inc, calls), [inc, calls]);
return m; const stopOf = useMemo(() => stopNumbers(path), [path]);
}, [calls]); const ghostIds = useMemo(() => new Set(path.stops.filter((s) => s.ghost).map((s) => s.call.call_id)), [path]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]); const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
// "Play incident" (server-26#233): every loaded call with a recording, oldest first. // "Play incident" (server-26#233): every loaded call with a recording, oldest first.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]); const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
@@ -210,14 +464,24 @@ 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 && (
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
may be merged · {path.lengthKm.toFixed(0)} km
</span>
)}
</div> </div>
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link> <Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
</div> </div>
@@ -229,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 }) => (
@@ -247,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>
@@ -268,8 +534,12 @@ export function IncidentPanel({
<li> <li>
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span> <span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable" {!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`} : radar.dbz < PRECIP_ECHO_DBZ ? "no precipitation echo within ~5 km"
: `up to ~${Math.round(radar.dbz)} dBZ within ~5 km (approx., read from the radar layer's n0q tile)`}
</li> </li>
<WeatherWarningContext head={head} />
{wantsWind(inc) && <WindContext head={head} />}
<OutageContext inc={inc} points={points} />
</ul> </ul>
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && ( {!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p> <p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
@@ -278,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>
@@ -322,6 +592,7 @@ export function IncidentPanel({
<ol className="space-y-2"> <ol className="space-y-2">
{shown.map((c) => { {shown.map((c) => {
const stop = stopOf.get(c.call_id); const stop = stopOf.get(c.call_id);
const ghost = ghostIds.has(c.call_id);
const text = c.transcript_corrected || c.transcript; const text = c.transcript_corrected || c.transcript;
const isPlaying = c.call_id === playingCallId; const isPlaying = c.call_id === playingCallId;
return ( return (
@@ -329,7 +600,10 @@ export function IncidentPanel({
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`} className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
aria-current={isPlaying ? "true" : undefined}> aria-current={isPlaying ? "true" : undefined}>
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]" <span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span> title={ghost ? "Geocoded too far from the previous stop to reach in time — left off the path" : undefined}
style={stop ? { background: color, color: "var(--page)" }
: ghost ? { border: "1.5px dashed var(--ink-muted)", color: "var(--ink-muted)" }
: { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? (ghost ? "?" : "·")}</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex gap-1.5 items-center"> <div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span> <span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
+187
View File
@@ -0,0 +1,187 @@
"use client";
/**
* "Power Outages" map overlay (server-26#184). County-level areas from the
* admin-enabled, licence-gated outage providers, drawn at the county's label
* point and sized by meters out. The marker is a county, not an outage
* location, and every popup says so.
*
* Never a silently empty layer: while the overlay is on, a status chip always
* says what the layer is showing — loading, N counties, nothing reported in
* view (with the source's coverage caveat), no provider enabled, no licensed
* source covering this area, or a feed error.
*/
import Link from "next/link";
import { useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Marker, Popup, useMap } from "react-leaflet";
import L from "leaflet";
import type { OutageArea, OutageProviderFeed } from "@/lib/types";
import { useAuth } from "@/components/AuthProvider";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { useMapOutages, type OutagesState } from "@/lib/useOutages";
import { countyName } from "@/lib/outageMatch";
/** The outage glyph (lightning bolt in a ring), shared with the map legend. */
export function outageGlyphSvg(size: number, stale = false): string {
const ring = stale
? `<circle cx="12" cy="12" r="10.5" fill="#1f2937" fill-opacity="0.55" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="3 2"/>`
: `<circle cx="12" cy="12" r="10.5" fill="#1f2937" stroke="#facc15" stroke-width="1.75"/>`;
const bolt = `<path d="M13.2 4.5 7.5 13.2h4l-1 6.3 5.9-9h-4.1z" fill="${stale ? "#9ca3af" : "#facc15"}"/>`;
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24">${ring}${bolt}</svg>`;
}
/** Marker diameter in px: grows with log10(meters out), 16–34. */
function sizeFor(out: number): number {
return Math.round(Math.min(34, 16 + 4 * Math.log10(Math.max(1, out))));
}
function outageIcon(a: OutageArea): L.DivIcon {
const stale = a.stale;
const out = stale ? a.stale_customers_out : a.customers_out;
const s = sizeFor(out);
const label = out >= 1000 ? `${(out / 1000).toFixed(out >= 10_000 ? 0 : 1)}k` : String(out);
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${s}px;height:${s}px;opacity:${stale ? 0.6 : 1}">${outageGlyphSvg(s, stale)}`
+ `<span style="position:absolute;top:-7px;left:${s - 6}px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid ${stale ? "#9ca3af" : "#facc15"};font:700 9px/12px sans-serif;white-space:nowrap">${label}</span></div>`,
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
function fmt(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function OutagePopupBody({ a, feed }: { a: OutageArea; feed?: OutageProviderFeed }) {
const since = fmt(a.since);
const etr = fmt(a.etr);
const updated = fmt(a.updated_at);
const pct = a.customers_served ? (100 * a.customers_out) / a.customers_served : null;
return (
<div className="space-y-1 text-xs">
<div className="font-semibold text-sm">{countyName(a)}</div>
{a.stale ? (
<div className="text-ink-muted">
Stale upstream rows only: {a.stale_customers_out.toLocaleString()} meters on events whose own restoration
estimate passed more than 24 h ago. The source still lists them; they are not shown as live outages.
</div>
) : (
<div>
<b>{a.customers_out.toLocaleString()}</b> meters/customers out (utility-reported)
{pct != null ? ` · ${pct < 0.1 ? "<0.1" : pct.toFixed(1)}% of ${a.customers_served!.toLocaleString()} served` : " · % served not known"}
{a.stale_customers_out > 0 && <span className="text-ink-muted"> · plus {a.stale_customers_out.toLocaleString()} on stale rows</span>}
</div>
)}
{(since || etr) && (
<div>{since ? `Out since ${since}` : ""}{since && etr ? " · " : ""}{etr ? `Est. restoration ${etr}` : ""}</div>
)}
{!etr && !a.stale && <div className="text-ink-muted">No restoration estimate given.</div>}
{a.utilities.length > 0 && (
<ul className="text-ink-2">
{a.utilities.map((u, i) => (
<li key={u.eia_id ?? i}>
{u.name ?? (u.eia_id ? `EIA ${u.eia_id}` : "Utility")}: {u.customers_out.toLocaleString()} out
{u.stale_customers_out ? ` (+${u.stale_customers_out.toLocaleString()} stale)` : ""}
</li>
))}
</ul>
)}
<div className="text-[10px] text-ink-muted">County-level count — the marker is the county&apos;s centre, not where the outage is.</div>
{feed?.coverage_note && <div className="text-[10px] text-ink-muted">{feed.coverage_note}</div>}
<SourceCredit credit={feed} fallbackName={feed?.name ?? a.provider}
updated={[updated && `utility data ${updated}`, feed && `fetched ${fetchedLabel(feed.fetched_at) ?? "—"}`].filter(Boolean).join(" · ") || null} />
</div>
);
}
/** The layer's always-on status chip. Returns the message, or null while there's nothing to say yet. */
function statusFor(state: OutagesState, isAdmin: boolean, liveCount: number, staleCount: number): { text: string; adminLink?: boolean; problem: boolean } {
const { data, error, loaded } = state;
if (!loaded) return { text: "Power outages: loading…", problem: false };
if (error && !data) return { text: "Power outages: could not reach server", problem: true };
if (!data) return { text: "Power outages: no data", problem: true };
const stale = error ? " (could not refresh — showing the last answer)" : "";
switch (data.status) {
case "none_enabled":
return isAdmin
? { text: "Power outages: no outage source is enabled —", adminLink: true, problem: true }
: { text: "Power outages: no outage source is enabled. A platform admin can turn one on.", problem: true };
case "no_source":
return { text: `Power outages: ${data.message ?? "No licensed outage source covers this area."}`, problem: true };
case "error": {
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
return { text: `Power outages: ${bad.map((p) => `${p.name} feed error (${p.error})`).join("; ") || data.message || "feed error"}`, problem: true };
}
}
const covering = data.providers.filter((p) => p.covers_bbox);
const names = covering.map((p) => p.name).join(", ");
const bad = covering.find((p) => p.error);
if (bad) {
return {
text: `Power outages: ${bad.name} feed error, ${bad.fetched_at ? `showing data fetched ${fetchedLabel(bad.fetched_at)}` : "no data yet"}${stale}`,
problem: true,
};
}
if (covering.some((p) => p.truncated)) return { text: "Power outages: too many areas in view — zoom in", problem: true };
const fetched = covering.map((p) => fetchedLabel(p.fetched_at)).find(Boolean);
const when = fetched ? ` · fetched ${fetched}` : "";
if (!liveCount) {
const note = covering.map((p) => p.coverage_note).find(Boolean);
return {
text: `Power outages: none reported in view by ${names || "the enabled source"}${staleCount ? ` (${staleCount} stale count${staleCount !== 1 ? "ies" : "y"} dimmed)` : ""}${when}.${note ? ` ${note}` : ""}${stale}`,
problem: false,
};
}
return {
text: `Power outages: ${liveCount} count${liveCount !== 1 ? "ies" : "y"} with outages · ${names}${when}${staleCount ? ` · ${staleCount} stale dimmed` : ""}${stale}`,
problem: false,
};
}
function OutageStatus({ map, state, liveCount, staleCount }: { map: L.Map; state: OutagesState; liveCount: number; staleCount: number }) {
const { isAdmin } = useAuth();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
});
const s = statusFor(state, isAdmin, liveCount, staleCount);
return createPortal(
<div ref={ref} role="status"
className={`absolute bottom-[3.75rem] left-3 z-[1001] max-w-[min(28rem,calc(100%-1.5rem))] bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 ${s.problem ? "border-accent" : "border-line"}`}>
{s.text}
{s.adminLink && <> <Link href="/admin#outages" className="underline">Admin → Outage providers</Link></>}
</div>,
map.getContainer(),
);
}
export function OutageLayer({ shown }: { shown: boolean }) {
const map = useMap();
const state = useMapOutages(map, shown);
const areas = useMemo(() => state.data?.areas ?? [], [state.data]);
const feeds = useMemo(() => new Map((state.data?.providers ?? []).map((p) => [p.id, p])), [state.data]);
const drawable = useMemo(() => areas.filter((a) => a.lat != null && a.lon != null), [areas]);
// One icon per area per response: a fresh divIcon each render would make
// Leaflet rebuild every marker's DOM whenever MapView re-renders.
const icons = useMemo(() => new Map(drawable.map((a) => [a.id, outageIcon(a)])), [drawable]);
const liveCount = areas.filter((a) => !a.stale && a.customers_out > 0).length;
const staleCount = areas.filter((a) => a.stale).length;
if (!shown) return null;
return (
<>
<OutageStatus map={map} state={state} liveCount={liveCount} staleCount={staleCount} />
{drawable.map((a) => (
<Marker key={a.id} position={[a.lat!, a.lon!]} icon={icons.get(a.id)!} zIndexOffset={a.stale ? -100 : 100}
title={`${countyName(a)}: ${a.stale ? "stale rows only" : `${a.customers_out.toLocaleString()} out`}`}>
<Popup minWidth={240} maxWidth={320}>
<OutagePopupBody a={a} feed={feeds.get(a.provider)} />
</Popup>
</Marker>
))}
</>
);
}
+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 };
}
+13 -1
View File
@@ -1,6 +1,8 @@
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,
} from "@/lib/types"; } from "@/lib/types";
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
@@ -18,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();
} }
@@ -532,6 +534,16 @@ export const c2api = {
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}), }),
// Weather context (server-26#237): active NWS alerts intersecting a bbox (bbox
// optional — omitted, the whole served area), and nearest-METAR wind. Both are
// cached server-side; an upstream failure comes back as `error`, not an HTTP error.
getWeatherAlerts: (bbox?: { south: number; west: number; north: number; east: number } | null) =>
request<WeatherAlertsResponse>(`/weather/alerts${bbox ? `?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
})}` : ""}`),
getWind: (lat: number, lon: number) =>
request<WindResponse>(`/weather/wind?${new URLSearchParams({ lat: String(lat), lon: String(lon) })}`),
// Public waitlist — no auth, see routers/waitlist.py // Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
+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);
}
+11 -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;
@@ -85,6 +87,14 @@ export interface DiscordGuild {
acting_org_id: string | null; acting_org_id: string | null;
acting_org_name: string | null; acting_org_name: string | null;
live_streams: number; live_streams: number;
/** How it was linked: "admin" | "code" | "oauth" (absent on older links). */
linked_via?: string | null;
/** The DRB server bot (optional, server-26#225): true/false as it last reported, null = never heard. */
main_bot_present?: boolean | null;
/** Who listed its voice channels: "main_bot" | "radio_bot". */
directory_source?: string | null;
/** Radio bots Discord last said are in this server (names). */
radio_bots?: string[];
} }
export interface DiscordAdminOverview { export interface DiscordAdminOverview {
@@ -152,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(": ");
+348 -30
View File
@@ -3,7 +3,9 @@
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in * gods-eye-view-notes.md top 5). No React here, so the logic is testable in
* isolation and shared by MapView and LiveView. * isolation and shared by MapView and LiveView.
*/ */
import type { AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack } from "@/lib/types"; import type {
AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack, WeatherAlert, WeatherGeometry,
} from "@/lib/types";
import { severityRank } from "@/lib/severity"; import { severityRank } from "@/lib/severity";
export type LatLon = [number, number]; export type LatLon = [number, number];
@@ -34,18 +36,125 @@ export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecor
/** /**
* An incident's geography, oldest first: its geocoded call stops (the path — * An incident's geography, oldest first: its geocoded call stops (the path —
* same order as the call spine) or, failing that, its pin. Empty = not plotted. * same order as the call spine) or, failing that, its pin. Empty = not plotted.
* Ghosted stops (an implied speed no vehicle makes, server-26#236) are left
* out, so one bad geocode can't drag "nearest cameras" across the county.
*/ */
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] { export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
const stops = (calls ?? []) const stops = pathStops(inc, calls).stops.filter((s) => !s.ghost).map((s) => s.at);
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
if (stops.length >= 2) return stops; if (stops.length >= 2) return stops;
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]]; if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
return stops; return stops;
} }
// ── Honest paths (server-26#236) ─────────────────────────────────────────────
// Pure, and shared by MapView and the call spine (WallPanels), so a stop's
// number is identical on both — and only what the map numbers is numbered.
/**
* Mirrors `_PURSUIT_TAGS` in drb-c2-core/app/internal/incident_correlator.py.
* Copied, not imported: keep the two in step by hand.
*/
export const PURSUIT_TAGS: ReadonlySet<string> = new Set([
"vehicle-pursuit", "pursuit", "foot-pursuit", "chase",
"fleeing-vehicle", "suspect-vehicle", "eluding",
]);
/** Mirrors `_MAX_PURSUIT_SPEED_KM_PER_MIN` (incident_correlator.py) — ~300 km/h. */
export const MAX_PATH_SPEED_KM_PER_MIN = 8.0;
/**
* Mirrors settings.location_proximity_km: the backend only runs its speed
* check past this distance, so two geocodes of one scene a few seconds apart
* are never called impossible.
*/
export const SPEED_CHECK_MIN_KM = 0.5;
/** A segment spanning a longer silence than this is drawn dashed — we don't know the route. */
export const PATH_GAP_DASH_MIN = 10;
/** Mirrors `_PURSUIT_PROXIMITY_KM`: a path longer than a pursuit's own link radius is a merge suspect. */
export const MAY_BE_MERGED_KM = 20;
/** Police or accident incident carrying a pursuit tag — the only kind that draws a numbered path. */
export function isMovementIncident(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
if (inc.type !== "police" && inc.type !== "accident") return false;
return (inc.tags ?? []).some((t) => PURSUIT_TAGS.has(t.toLowerCase()));
}
export interface PathStop {
call: CallRecord;
at: LatLon;
/** Shared map/spine stop number; null = not numbered (ghosted, or not a movement incident). */
n: number | null;
/** Reaching this stop from the previous kept one implies > MAX_PATH_SPEED_KM_PER_MIN. */
ghost: boolean;
/** The segment INTO this stop is dashed: a long silence or a talkgroup change. Never set on the first kept stop. */
dashedIn: boolean;
}
export interface IncidentPath {
movement: boolean;
/** Every geocoded call, oldest first — ghosts included, so the map can still show them faintly. */
stops: PathStop[];
/** Sum of the kept (non-ghost) segments. */
lengthKm: number;
mayBeMerged: boolean;
}
function minutesBetween(a: CallRecord, b: CallRecord): number {
return Math.abs(Date.parse(b.started_at) - Date.parse(a.started_at)) / 60_000;
}
function impossible(a: PathStop, b: PathStop): boolean {
const d = km(a.at, b.at);
if (d <= SPEED_CHECK_MIN_KM) return false;
// Same floor the backend uses (incident_correlator.py: max(idle, 0.1)).
return d / Math.max(minutesBetween(a.call, b.call), 0.1) > MAX_PATH_SPEED_KM_PER_MIN;
}
/**
* An incident's geocoded calls as path stops, oldest first.
*
* Ghosting walks forward from the last kept stop: a stop that could only be
* reached faster than a pursuit can drive is ghosted and does not move the
* anchor. A bad FIRST geocode would otherwise ghost everything after it, so
* when stop 1 is impossible from stop 0 but stop 2 agrees with stop 1 and not
* with stop 0, stop 0 is the one ghosted.
*/
export function pathStops(inc: IncidentRecord, calls: CallRecord[] | undefined): IncidentPath {
const movement = isMovementIncident(inc);
const stops: PathStop[] = (calls ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => ({ call: c, at: [c.location_coords!.lat, c.location_coords!.lng] as LatLon, n: null, ghost: false, dashedIn: false }));
let start = 0;
if (stops.length >= 3 && impossible(stops[0], stops[1]) && !impossible(stops[1], stops[2]) && impossible(stops[0], stops[2])) {
stops[0].ghost = true;
start = 1;
}
let prev: PathStop | null = null;
let lengthKm = 0;
let n = 0;
for (let i = start; i < stops.length; i++) {
const s = stops[i];
if (prev && impossible(prev, s)) { s.ghost = true; continue; }
if (prev) {
lengthKm += km(prev.at, s.at);
s.dashedIn = minutesBetween(prev.call, s.call) > PATH_GAP_DASH_MIN
|| (prev.call.talkgroup_id != null && s.call.talkgroup_id != null && prev.call.talkgroup_id !== s.call.talkgroup_id);
}
if (movement) s.n = ++n;
prev = s;
}
return { movement, stops, lengthKm, mayBeMerged: lengthKm > MAY_BE_MERGED_KM };
}
/** call_id → stop number, for the spine. Empty for a non-movement incident. */
export function stopNumbers(path: IncidentPath): Map<string, number> {
const m = new Map<string, number>();
for (const s of path.stops) if (s.n != null) m.set(s.call.call_id, s.n);
return m;
}
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */ /** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] { export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
return incidents return incidents
@@ -154,7 +263,7 @@ export function bboxAround(points: LatLon[], padKm: number) {
// ── Deep links / saved views ──────────────────────────────────────────────── // ── Deep links / saved views ────────────────────────────────────────────────
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk // #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light // &layers=nodes,inc,radar,wx&z=11&lat=41.12345&lon=-73.98765&base=light
// &f.<name>=<value> // &f.<name>=<value>
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour // Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
// only) still parses and a newer one degrades on an older build. Pure string // only) still parses and a newer one degrades on an older build. Pure string
@@ -171,6 +280,7 @@ export const LAYER_NAMES = {
vs: "Camera Viewsheds (approx.)", vs: "Camera Viewsheds (approx.)",
traffic: "Traffic Events", traffic: "Traffic Events",
radar: "Weather Radar", radar: "Weather Radar",
wx: "Weather Warnings",
} as const; } as const;
export type LayerKey = keyof typeof LAYER_NAMES; export type LayerKey = keyof typeof LAYER_NAMES;
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[]; export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
@@ -300,28 +410,103 @@ export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallR
); );
} }
// ── NEXRAD sample near an incident ─────────────────────────────────────────── // ── NEXRAD (IEM n0q composite) ───────────────────────────────────────────────
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM // The Weather Radar layer, its valid-time badge, its legend and the per-incident
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale. // sample all use ONE product: IEM's n0q (base reflectivity, 0.5 dBZ steps,
// Approximate by construction — label it so. // -32..95 dBZ). Verified first-hand 2026-10-05:
const N0R_PALETTE: [number, number, number, number][] = [ // tiles https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0], // (RGBA PNG, ACAO: *), and nexrad-n0q-900913-m05m … -m55m for the
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0], // 5-minute frames of the last hour (-m60m is a 404);
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253], // valid https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json
]; // → {"meta": {"valid": "2026-10-05T07:35:00Z", "product": "N0Q", …}}
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913"; // (n0q_<k>.json is k × 5 min earlier; ACAO: *);
// ramp https://mesonet.agron.iastate.edu/GIS/rasters.php?rid=2 — 255 colours,
// index i (1..255) = -32 + (i-1)/2 dBZ. Every opaque pixel of five
// sampled live tiles matched this table exactly.
export const RADAR_TILE_BASE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913";
export const RADAR_META_URL = "https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json";
/** A frame older than this is flagged STALE on the map (#237 acceptance). */
export const RADAR_STALE_MIN = 15;
/** Loop frames, oldest first, as minutes before the current composite (IEM keeps -m05m … -m55m). */
export const RADAR_LOOP_OFFSETS_MIN = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] as const;
/** Light rain starts around here; weaker returns are clear-air / clutter. */
export const PRECIP_ECHO_DBZ = 15;
function pixelDbz(r: number, g: number, b: number): number { /** Tile URL for the composite `offsetMin` minutes before the current one (0 = current). */
let best = 0; export function radarTileUrl(offsetMin = 0): string {
let bestD = Infinity; const layer = offsetMin > 0 ? `${RADAR_TILE_BASE}-m${String(offsetMin).padStart(2, "0")}m` : RADAR_TILE_BASE;
for (const [dbz, pr, pg, pb] of N0R_PALETTE) { return `${layer}/{z}/{x}/{y}.png`;
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
if (d < bestD) { bestD = d; best = dbz; }
}
return bestD < 60 * 60 ? best : 0;
} }
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */ /** The composite's valid time from IEM's n0q metadata JSON, or null if the shape isn't the verified one. */
export function parseRadarMeta(body: unknown): Date | null {
const valid = (body as { meta?: { valid?: unknown } } | null)?.meta?.valid;
if (typeof valid !== "string") return null;
const d = new Date(valid);
return isNaN(d.getTime()) ? null : d;
}
/** IEM's published n0q colour table, 6 hex digits per entry, index 1..255 (index 0 = missing). */
const N0Q_RAMP_HEX =
"85718f85728f86738d87758b87768b887789897987897a878a7b858b7d848b7e848c7f828d81808d82808e837e8f847c" +
"8f857c90877b918879918979928b77938d759691539894579b975b9d9a60a09d64a3a068a5a36da8a671aaa976adac7a" +
"b0af7eb2b283b7b88cbabb90bdbe94bfc199c2c49dc4c7a2c7caa6cacdaaccd0afd2d4b4cfd2b4c9ccb4c6c9b4c3c7b4" +
"c0c4b4bdc1b4b9beb4b6bbb4b3b9b4b0b6b4adb3b4aab0b4a4abb4a0a8b49da5b49aa2b497a0b4949db4919ab4949bb5" +
"9098b48c95b38892b2808cb07c89af7886ae7483ac7080ab6c7daa6779a96376a85f73a75b70a6576da44f67a24b64a1" +
"4761a0435e9f415b9e4361a24568a6486faa4a76ae4d7db24f84b6518bbb5699c3599fc75ba6cb5eadcf60b4d462bbd8" +
"65c2dc67c9e06ad0e46fd6e868d6d759d6b352d6a24bd69043d67e3cd66d35d65b11d51811d11710cd1710c81610c416" +
"0fbc150fb7140eb3140eaf130eab130da6120da2120d9e110c99110c95100c91100b880f0b840e0a800e0a7c0d0a770d" +
"09730c096f0c096b0b08660b08620a095e09327308467d085b88076f9207849d0698a806adb205c1bd05d6c704ead204" +
"ffe200ffd800ffd300ffce00ffc900ffc400ffc000ffbb00ffb600ffb100ffac00ffa700ffa200ff9900ff9400ff8f00" +
"ff8a00ff8500ff8000ff0000f80000f10000ea0000e30000d50000cd0000c60000bf0000b80000b10000aa0000a30000" +
"9b00009400008d00007f0000780000710000fffffffff5ffffeaffffdfffffd4ffffc9ffffbeffffb3ffff9dffff92ff" +
"ff75fffc6bfdf960faf656f7f34bf4f040f1ed36efea2bece720e9e10be3b200ffac00fca400f79b00f49300ef8800ea" +
"8300e87900e27200dd6900db05ecf005ebf005eaf005dde005dce005dbe005cdd005ccd004bdc004bcc004bbc004aeb0" +
"04adb0049ea0049da0049ca0038e90038d90038c90037e80037d80036f70036e70036d70025f60025e60024f50024e50" +
"024d50023f40023e40023d40013030012f30012020011f20011e203a67b53a66b53a65b53a64b53a63b53a62b5";
function n0qIndex(dbz: number): number {
return Math.min(255, Math.max(1, Math.round((dbz + 32) * 2) + 1));
}
/** "#rrggbb" the radar layer draws for a reflectivity. */
export function n0qColor(dbz: number): string {
const i = n0qIndex(dbz) - 1;
return `#${N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6)}`;
}
let rgbToDbz: Map<number, number> | null = null;
function n0qLookup(): Map<number, number> {
if (!rgbToDbz) {
rgbToDbz = new Map();
for (let i = 0; i < 255; i++) rgbToDbz.set(parseInt(N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6), 16), -32 + i / 2);
}
return rgbToDbz;
}
/** dBZ for a radar-tile pixel. Exact table hit (tiles are lossless); else nearest colour within a small distance; else null. */
export function pixelDbz(r: number, g: number, b: number): number | null {
const lut = n0qLookup();
const exact = lut.get((r << 16) | (g << 8) | b);
if (exact != null) return exact;
let best: number | null = null;
let bestD = 12 * 12;
lut.forEach((dbz, rgb) => {
const d = (r - ((rgb >> 16) & 255)) ** 2 + (g - ((rgb >> 8) & 255)) ** 2 + (b - (rgb & 255)) ** 2;
if (d < bestD) { bestD = d; best = dbz; }
});
return best;
}
/** No opaque radar pixel near the point (the tile is transparent there). */
export const NO_ECHO_DBZ = -Infinity;
/**
* Max reflectivity (dBZ) within ~radiusKm of a point, read from the same n0q
* tile the Weather Radar layer draws (cache-busted on the same 5-min clock).
* Approximate by construction — label it so. NO_ECHO_DBZ when nothing is
* drawn there; null when the tile can't be read.
*/
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> { export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
const z = 9; const z = 9;
const n = 2 ** z; const n = 2 ** z;
@@ -332,7 +517,7 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
const ty = Math.floor(yf); const ty = Math.floor(yf);
const img = new Image(); const img = new Image();
img.crossOrigin = "anonymous"; img.crossOrigin = "anonymous";
img.src = `${RADAR_TILE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`; img.src = `${RADAR_TILE_BASE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
try { try {
await img.decode(); await img.decode();
} catch { } catch {
@@ -359,10 +544,143 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
} catch { } catch {
return null; // tainted canvas — CORS changed upstream return null; // tainted canvas — CORS changed upstream
} }
let max = 0; let max = NO_ECHO_DBZ;
for (let i = 0; i < data.length; i += 4) { for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 32) continue; if (data[i + 3] < 128) continue; // transparent: nothing drawn
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2])); const dbz = pixelDbz(data[i], data[i + 1], data[i + 2]);
if (dbz != null && dbz > max) max = dbz;
} }
return max; return max;
} }
// ── NWS warnings (server-26#237) ─────────────────────────────────────────────
/** Ray-cast point-in-ring; `ring` is GeoJSON [lon, lat] pairs. */
function inRing(lat: number, lon: number, ring: number[][]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/**
* True when [lat, lon] is inside a GeoJSON Polygon / MultiPolygon (holes
* respected: inside the outer ring and outside every hole).
*/
export function pointInPolygon(point: LatLon, geom: WeatherGeometry | null | undefined): boolean {
if (!geom) return false;
const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.type === "MultiPolygon" ? geom.coordinates : [];
const [lat, lon] = point;
return polys.some((rings) =>
rings.length > 0 && inRing(lat, lon, rings[0]) && !rings.slice(1).some((hole) => inRing(lat, lon, hole)));
}
/** Active alerts whose drawn area contains the point, worst-looking (Warning) first. */
export function alertsAt(point: LatLon, alerts: WeatherAlert[]): WeatherAlert[] {
return alerts
.filter((a) => pointInPolygon(point, a.geometry))
.sort((a, b) => wxKindRank(a.event) - wxKindRank(b.event));
}
export type WxKind = "warning" | "watch" | "advisory" | "statement";
export function wxKind(event: string): WxKind {
const e = event.toLowerCase();
if (e.includes("warning") || e.includes("emergency")) return "warning";
if (e.includes("watch")) return "watch";
if (e.includes("advisory") || e.includes("alert")) return "advisory";
return "statement";
}
function wxKindRank(event: string): number {
return ["warning", "watch", "advisory", "statement"].indexOf(wxKind(event));
}
/**
* NWS hazard-map colours (weather.gov/help-map) for the events this region
* sees most. Unlisted events fall back by kind. Colour is never the only cue:
* the outline dash says the kind (warning solid, watch dashed, advisory and
* statement dotted), and the legend explains it.
*/
const NWS_EVENT_COLORS: Record<string, string> = {
"tornado warning": "#ff0000",
"severe thunderstorm warning": "#ffa500",
"flash flood warning": "#8b0000",
"flood warning": "#00ff00",
"flood advisory": "#00ff7f",
"flood watch": "#2e8b57",
"flash flood watch": "#2e8b57",
"tornado watch": "#ffff00",
"severe thunderstorm watch": "#db7093",
"special marine warning": "#ffa500",
"winter storm warning": "#ff69b4",
"winter storm watch": "#4682b4",
"winter weather advisory": "#7b68ee",
"blizzard warning": "#ff4500",
"ice storm warning": "#8b008b",
"lake effect snow warning": "#008b8b",
"high wind warning": "#daa520",
"high wind watch": "#b8860b",
"wind advisory": "#d2b48c",
"heat advisory": "#ff7f50",
"excessive heat warning": "#c71585",
"extreme heat warning": "#c71585",
"excessive heat watch": "#800000",
"extreme heat watch": "#800000",
"coastal flood warning": "#228b22",
"coastal flood watch": "#66cdaa",
"coastal flood advisory": "#7cfc00",
"dense fog advisory": "#708090",
"special weather statement": "#ffe4b5",
"red flag warning": "#ff1493",
"fire weather watch": "#ffdead",
"freeze warning": "#483d8b",
"frost advisory": "#6495ed",
"small craft advisory": "#d8bfd8",
"gale warning": "#dda0dd",
"storm warning": "#9400d3",
"hurricane warning": "#dc143c",
"tropical storm warning": "#b22222",
"air quality alert": "#808080",
};
const WX_KIND_FALLBACK: Record<WxKind, string> = {
warning: "#dc2626", watch: "#eab308", advisory: "#60a5fa", statement: "#a3a3a3",
};
export const WX_KIND_DASH: Record<WxKind, string | undefined> = {
warning: undefined, watch: "8 5", advisory: "2 4", statement: "2 4",
};
export function wxStyle(event: string): { color: string; kind: WxKind; dashArray: string | undefined; weight: number } {
const kind = wxKind(event);
return {
color: NWS_EVENT_COLORS[event.toLowerCase()] ?? WX_KIND_FALLBACK[kind],
kind,
dashArray: WX_KIND_DASH[kind],
weight: kind === "warning" ? 2.5 : kind === "watch" ? 2 : 1.5,
};
}
/** Local "Oct 5, 2:30 PM" for an NWS ISO timestamp (they carry their own offset). */
export function wxTime(iso: string | null | undefined): string | null {
if (!iso) return null;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
// ── Wind at the incident (server-26#237) ─────────────────────────────────────
// Fire and hazmat are where wind direction changes the call (smoke / plume
// drift, approach side). There is no hazmat incident type, so tags decide.
const WIND_TAG_RE = /(^|-)(hazmat|haz-mat|gas-leak|natural-gas|chemical|spill|smoke|fumes|odor|odour|plume|brush|wildfire|propane|ammonia|chlorine|carbon-monoxide)(-|$)/;
export function wantsWind(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
if (inc.type === "fire") return true;
return (inc.tags ?? []).some((t) => WIND_TAG_RE.test(t.toLowerCase()));
}
/** 16-point compass name for the direction the wind blows FROM. */
export function compass(deg: number): string {
const pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
return pts[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16];
}
+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",
};
+121
View File
@@ -0,0 +1,121 @@
/**
* Incident ↔ power-outage cross-match (server-26#184). Pure — no React, no
* fetch — so it can be unit-tested once a JS runner exists.
*
* Two granularities, two claims:
* - point/polygon data: an outage within OUTAGE_POINT_KM of the incident is
* "outage ~0.3 km away".
* - county data (ODIN): the incident sits inside an affected county's
* bounding box, which only supports "<county> has N meters out" — never
* "outage here". A county bbox is not the county shape, so a point near a
* border can fall in two; both are listed and the wording says "in or near".
*
* Stale rows (the source still lists them but their own ETR passed >24 h ago)
* are never presented as live outages.
*/
import type { IncidentRecord, OutageArea, OutageProviderFeed } from "@/lib/types";
import { km, type LatLon } from "@/lib/liveWall";
/** Point-level outages closer than this count as "at" the incident. */
export const OUTAGE_POINT_KM = 0.5;
// Wires-down / transformer / manhole / pole / tree-on-wires: the calls an
// outage cluster should line up with. Matched against type, tags, title, summary.
const UTILITY_HAZARD_RE = new RegExp(
[
String.raw`wires?[\s_-]*down`,
String.raw`downed[\s_-]+(?:power[\s_-]+|electric(?:al)?[\s_-]+)?(?:wires?|lines?)`,
String.raw`(?:power|electric(?:al)?|utility|primary|live)[\s_-]*lines?[\s_-]*down`,
String.raw`transformer`,
String.raw`manhole`,
String.raw`(?:utility|telephone|electric|power)[\s_-]*pole`,
String.raw`pole[\s_-]*(?:fire|down|damage|struck|hit)`,
String.raw`tree[\s_-]+(?:on|in|into|across|against|through)[\s_-]+(?:the[\s_-]+)?(?:wires?|lines?)`,
String.raw`arcing`,
].join("|"),
"i",
);
export function isUtilityHazard(inc: Pick<IncidentRecord, "type" | "tags" | "title" | "summary">): boolean {
const text = [inc.type, ...(inc.tags ?? []), inc.title, inc.summary].filter(Boolean).join(" \n ");
return UTILITY_HAZARD_RE.test(text);
}
export function isCountyLevel(feed: OutageProviderFeed | undefined, area: OutageArea): boolean {
if (feed) return feed.granularity.toLowerCase().startsWith("county");
return !!area.fips; // unknown provider: assume the weaker (county) claim
}
export function countyName(a: OutageArea): string {
const c = a.county ? (/\b(county|parish|borough)\b/i.test(a.county) ? a.county : `${a.county} County`) : `FIPS ${a.fips}`;
return a.state ? `${c}, ${a.state}` : c;
}
function inBbox(p: LatLon, b: [number, number, number, number]): boolean {
const [s, w, n, e] = b;
return p[0] >= s && p[0] <= n && p[1] >= w && p[1] <= e;
}
export interface OutageMatch {
area: OutageArea;
feed?: OutageProviderFeed;
kind: "county" | "point";
/** Distance to the nearest incident point (point data only). */
km: number | null;
/** More than one county bbox holds the incident — the county is uncertain. */
ambiguous: boolean;
/** The incident reads as a wires-down/transformer/manhole/pole/tree-on-wires call. */
hazard: boolean;
/** Honest one-line wording for the panel. */
text: string;
}
const n = (v: number) => v.toLocaleString();
/** The cross-match for one incident. Live (non-stale) outages only, nearest/biggest first. */
export function matchOutages(
inc: Pick<IncidentRecord, "type" | "tags" | "title" | "summary">,
points: LatLon[],
areas: OutageArea[],
providers: OutageProviderFeed[],
): OutageMatch[] {
if (!points.length) return [];
const feeds = new Map(providers.map((p) => [p.id, p]));
const hazard = isUtilityHazard(inc);
const county: { area: OutageArea; feed?: OutageProviderFeed }[] = [];
const point: OutageMatch[] = [];
for (const area of areas) {
if (area.stale || area.customers_out <= 0) continue;
const feed = feeds.get(area.provider);
if (isCountyLevel(feed, area)) {
const hit = area.bbox
? points.some((p) => inBbox(p, area.bbox!))
: false; // no county extent → no containment claim
if (hit) county.push({ area, feed });
} else if (area.lat != null && area.lon != null) {
const d = Math.min(...points.map((p) => km(p, [area.lat!, area.lon!])));
if (d <= OUTAGE_POINT_KM) {
point.push({
area, feed, kind: "point", km: d, ambiguous: false, hazard,
text: `${n(area.customers_out)} meters/customers out ~${d < 0.1 ? "<0.1" : d.toFixed(1)} km away (utility-reported)`,
});
}
}
}
const ambiguous = county.length > 1;
const countyMatches: OutageMatch[] = county
.sort((a, b) => b.area.customers_out - a.area.customers_out)
.map(({ area, feed }) => ({
area, feed, kind: "county", km: null, ambiguous, hazard,
text: `${ambiguous ? "In or near " : ""}${countyName(area)}: county has ${n(area.customers_out)} meters/customers out (utility-reported, county-level — not a location)`,
}));
return [...point.sort((a, b) => (a.km ?? 0) - (b.km ?? 0)), ...countyMatches];
}
/** Stale-only counties the incident sits in — said out loud so they aren't mistaken for clear. */
export function staleCountiesAt(points: LatLon[], areas: OutageArea[]): OutageArea[] {
return areas.filter((a) => a.stale && a.bbox && points.some((p) => inBbox(p, a.bbox!)));
}
+98 -2
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;
@@ -319,6 +322,77 @@ export interface Ny511Response {
credit?: CameraCredit; credit?: CameraCredit;
} }
// ── Weather context (server-26#237) — GET /weather/alerts, GET /weather/wind ──
/** GeoJSON (Multi)Polygon, [lon, lat] order. */
export type WeatherGeometry =
| { type: "Polygon"; coordinates: number[][][] }
| { type: "MultiPolygon"; coordinates: number[][][][] };
/** One active NWS alert as c2-core normalises it (app/internal/weather.py normalize_alert). */
export interface WeatherAlertBase {
id: string;
event: string;
severity: string | null;
urgency: string | null;
headline: string | null;
onset: string | null;
expires: string | null;
ends: string | null;
/** An NWS office ("NWS Upton NY"), never a person. */
sender_name: string | null;
area_desc: string | null;
zones: string[];
}
export interface WeatherAlert extends WeatherAlertBase {
geometry: WeatherGeometry;
/** "alert": NWS drew the polygon; "zones": assembled from the affected forecast/county zones. */
geometry_source: "alert" | "zones";
/** [south, west, north, east]. */
bbox: [number, number, number, number] | null;
/** >0: some zones could not be fetched, so the drawn area is smaller than the real one. */
zones_unresolved?: number;
}
export interface WeatherAlertsResponse {
alerts: WeatherAlert[];
/** Alerts with no resolvable shape — listed, never dropped, never bbox-filtered. */
zone_only: WeatherAlertBase[];
/** Epoch s of the server's last SUCCESSFUL NWS fetch; null = never. */
fetched_at: number | null;
/** Set when the latest refresh failed; `alerts` is then the last good data. */
error: string | null;
credit: string;
}
export interface WindObs {
station: string;
station_name: string;
lat: number;
lon: number;
obs_time: string | null;
/** Direction the wind blows FROM, degrees true; null when variable or calm. */
wind_dir_deg: number | null;
variable: boolean;
wind_kt: number;
gust_kt: number | null;
raw: string | null;
distance_km: number;
/** Report older than 2 h. */
stale: boolean;
}
export interface WindResponse {
available: boolean;
/** Why there is no wind, when `available` is false. */
reason: string | null;
wind: WindObs | null;
fetched_at: number | null;
error: string | null;
credit: string;
}
export interface VocabularyPendingTerm { export interface VocabularyPendingTerm {
term: string; term: string;
source: "induction" | "correction"; source: "induction" | "correction";
@@ -344,6 +418,20 @@ export interface TranscriptSegment {
text: string; text: string;
} }
/**
* Google Geocoding precision, persisted with the coords since server-26#236.
* ROOFTOP = a street address; RANGE_INTERPOLATED = estimated between two
* address points; GEOMETRIC_CENTER = the centre of a road, intersection or
* named place — can be hundreds of metres off. Absent on older docs.
*/
export type GeoLocationType = "ROOFTOP" | "RANGE_INTERPOLATED" | "GEOMETRIC_CENTER";
export interface GeoPoint {
lat: number;
lng: number;
location_type?: GeoLocationType | string | null;
}
export interface CallRecord { export interface CallRecord {
call_id: string; call_id: string;
node_id: string; node_id: string;
@@ -370,7 +458,7 @@ export interface CallRecord {
incident_id?: string | null; incident_id?: string | null;
location: string | null; location: string | null;
/** Per-call geocode, written by intelligence.py. Powers the incident-path polyline on the map. */ /** Per-call geocode, written by intelligence.py. Powers the incident-path polyline on the map. */
location_coords?: { lat: number; lng: number } | null; location_coords?: GeoPoint | null;
/** Unit callsigns mentioned in this specific transmission (e.g. "15-9", "E-41"). */ /** Unit callsigns mentioned in this specific transmission (e.g. "15-9", "E-41"). */
units?: string[]; units?: string[];
vehicles?: string[]; vehicles?: string[];
@@ -402,8 +490,16 @@ 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: { lat: number; lng: number } | null; location_coords: GeoPoint | null;
/**
* The label the pin was geocoded from (server-26#23). Absent on incidents
* written before that fix — their pin cannot be tied to their label and is
* drawn ghosted (DEFERRED.md `_verified_pin` row, server-26#28).
*/
location_coords_source?: string | null;
call_ids: string[]; call_ids: string[];
system_ids: string[]; system_ids: string[];
talkgroup_ids: string[]; talkgroup_ids: string[];
+6 -1
View File
@@ -11,6 +11,10 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// True when the query returned a full page (snap.size, counted BEFORE the
// duplicate_of filter below) — there may be older calls past the limit.
// calls.length can't answer that: dedup drops rows from a full page.
const [hasMore, setHasMore] = useState(false);
const { orgId } = useAuth(); const { orgId } = useAuth();
// Stable ms values so the effect dependency doesn't fire on every render // Stable ms values so the effect dependency doesn't fire on every render
@@ -63,6 +67,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
// index alongside the existing org_id/started_at query. // index alongside the existing org_id/started_at query.
.filter((c) => !c.duplicate_of); .filter((c) => !c.duplicate_of);
setCalls(docs); setCalls(docs);
setHasMore(snap.size >= limitCount);
setLoading(false); setLoading(false);
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); }); }, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
}); });
@@ -73,7 +78,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
}; };
}, [limitCount, dateFromMs, dateToMs, orgId]); }, [limitCount, dateFromMs, dateToMs, orgId]);
return { calls, loading, error }; return { calls, loading, error, hasMore };
} }
export function useCallsByIncident(incidentId: string | null) { export function useCallsByIncident(incidentId: string | null) {
+35 -5
View File
@@ -86,14 +86,19 @@ export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
export function useIncident(incidentId: string | null) { export function useIncident(incidentId: string | null) {
const [incident, setIncident] = useState<IncidentRecord | null>(null); const [incident, setIncident] = useState<IncidentRecord | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// The id the current `incident`/`loading` answer belongs to. On the render
// where the id changes the effect hasn't run yet, so `loading` alone would
// still read false from the previous id and a caller would flash "not found".
const [settledId, setSettledId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!incidentId) { setLoading(false); return; } if (!incidentId) { setLoading(false); return; }
setLoading(true);
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => { const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; } if (!user) { setLoading(false); setSettledId(incidentId); return; }
const ref = doc(db, "incidents", incidentId); const ref = doc(db, "incidents", incidentId);
unsubFirestore = onSnapshot(ref, (snap) => { unsubFirestore = onSnapshot(ref, (snap) => {
@@ -108,27 +113,39 @@ export function useIncident(incidentId: string | null) {
setIncident(null); setIncident(null);
} }
setLoading(false); setLoading(false);
setSettledId(incidentId);
}, (err: FirestoreError) => { }, (err: FirestoreError) => {
console.error("useIncident:", err); console.error("useIncident:", err);
setIncident(null); // a denied/failed read must not leave the previous id's doc
setLoading(false); setLoading(false);
setSettledId(incidentId);
}); });
}); });
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId]); }, [incidentId]);
return { incident, loading }; 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([]);
@@ -140,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 {
@@ -158,5 +184,9 @@ export function useActiveIncidents() {
}; };
}, [orgId]); }, [orgId]);
return incidents; return { incidents, loaded };
}
export function useActiveIncidents() {
return useActiveIncidentsState().incidents;
} }
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type L from "leaflet";
import { c2api } from "@/lib/c2api";
import type { OutagesResponse } from "@/lib/types";
import type { BBox } from "@/lib/useCameras";
// Power outages from every provider an admin has enabled (GET /outages,
// server-26#184). The server caches each provider's feed; this only re-asks
// for a bbox. Polls while enabled and the tab is visible, and refetches as
// soon as a hidden tab comes back, so a wall left in a background tab never
// shows hours-old counts as current.
const POLL_MS = 3 * 60 * 1000;
const MOVE_DEBOUNCE_MS = 400;
const BBOX_PAD = 0.2;
export interface OutagesState {
/** null until the first response. */
data: OutagesResponse | null;
/** Last request failure (network/HTTP), cleared by the next success. */
error: string | null;
loaded: boolean;
}
function useOutageFetch(
getBox: (() => BBox | null) | null,
deps: unknown[],
subscribe?: (reload: () => void) => () => void,
): OutagesState {
const [data, setData] = useState<OutagesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const seq = useRef(0);
useEffect(() => {
// A new target (another incident, overlay re-enabled) must not be judged
// against the previous target's answer: drop it and show "checking…".
setData(null); setError(null); setLoaded(false);
if (!getBox) return;
const boxFn = getBox;
let last = 0;
const load = async () => {
const box = boxFn();
if (!box) return;
const mine = ++seq.current;
last = Date.now();
try {
const res = await c2api.getOutages(box);
if (mine !== seq.current) return; // a newer request's response wins
setData(res);
setError(null);
} catch (e) {
if (mine !== seq.current) return;
console.error("useOutages:", e);
setError(e instanceof Error ? e.message : String(e));
}
if (mine === seq.current) setLoaded(true);
};
const onVisible = () => { if (!document.hidden && Date.now() - last >= POLL_MS) load(); };
load();
const unsub = subscribe?.(load);
const poll = setInterval(() => { if (!document.hidden) load(); }, POLL_MS);
document.addEventListener("visibilitychange", onVisible);
return () => {
unsub?.();
clearInterval(poll);
document.removeEventListener("visibilitychange", onVisible);
seq.current++; // drop any in-flight response
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, error, loaded };
}
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
/** Outages for the current map view, refetched on pan/zoom while `enabled` (the overlay is on). */
export function useMapOutages(map: L.Map, enabled: boolean): OutagesState {
return useOutageFetch(
enabled
? () => {
// Leaflet bounds run past ±180/±90 zoomed out; GET /outages answers 422 to that.
const b = map.getBounds().pad(BBOX_PAD);
return { south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180) };
}
: null,
[map, enabled],
(reload) => {
let t: ReturnType<typeof setTimeout> | undefined;
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
map.on("moveend", onMove);
return () => { map.off("moveend", onMove); clearTimeout(t); };
},
);
}
/** Outages inside a fixed bbox (an incident, the wall's region). `null` = don't fetch. */
export function useOutagesIn(box: BBox | null): OutagesState {
// Key on a rounded box so a re-render with an equal-but-new object doesn't refetch.
const key = box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(3)).join(",") : "";
const boxRef = useRef(box);
boxRef.current = box;
return useOutageFetch(key ? () => boxRef.current : null, [key]);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type L from "leaflet";
import { c2api } from "@/lib/c2api";
import type { WeatherAlertsResponse, WindResponse } from "@/lib/types";
import type { BBox } from "@/lib/useCameras";
import { RADAR_META_URL, parseRadarMeta, type LatLon } from "@/lib/liveWall";
// Weather context (server-26#237). NWS alerts and METAR wind come from c2-core
// (cached server-side, shared by every viewer — NWS rate-limits and wants a
// contact User-Agent); the radar valid time comes straight from IEM's n0q
// metadata JSON, the same product the radar tiles are cut from.
//
// Everything polls only while wanted AND the tab is visible, and refetches as
// soon as a hidden tab comes back if a poll was missed — a wall left in a
// background tab must never show an hour-old warning set as current.
const ALERTS_POLL_MS = 2 * 60 * 1000;
const RADAR_META_POLL_MS = 2 * 60 * 1000;
const WIND_POLL_MS = 5 * 60 * 1000;
const MOVE_DEBOUNCE_MS = 400;
const BBOX_PAD = 0.2;
export interface Polled<T> {
/** Last successful answer for the current target; null until the first one. */
data: T | null;
/** Last request failure (network/HTTP), cleared by the next success. */
error: string | null;
/** At least one request for the current target has finished. */
loaded: boolean;
}
/**
* Fetch `fetcher()` now, every `pollMs` while the tab is visible, and on return
* to a visible tab once a poll is due. A newer request's answer always wins.
* A new target (deps change) drops the previous answer, so it is never shown
* as if it were about the new one.
*/
function usePolled<T>(
fetcher: (() => Promise<T> | null) | null,
deps: unknown[],
pollMs: number,
subscribe?: (reload: () => void) => () => void,
): Polled<T> {
const [state, setState] = useState<Polled<T>>({ data: null, error: null, loaded: false });
const seq = useRef(0);
useEffect(() => {
setState({ data: null, error: null, loaded: false });
if (!fetcher) return;
const run = fetcher;
let last = 0;
const load = async () => {
const p = run();
if (!p) return;
const mine = ++seq.current;
last = Date.now();
try {
const data = await p;
if (mine !== seq.current) return;
setState({ data, error: null, loaded: true });
} catch (e) {
if (mine !== seq.current) return;
console.error("useWeather:", e);
setState((s) => ({ ...s, error: e instanceof Error ? e.message : String(e), loaded: true }));
}
};
const onVisible = () => { if (!document.hidden && Date.now() - last >= pollMs) load(); };
load();
const unsub = subscribe?.(load);
const poll = setInterval(() => { if (!document.hidden) load(); }, pollMs);
document.addEventListener("visibilitychange", onVisible);
return () => {
unsub?.();
clearInterval(poll);
document.removeEventListener("visibilitychange", onVisible);
seq.current++; // drop any in-flight answer
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
/** Active NWS alerts intersecting the current map view, while `enabled` (the Weather Warnings overlay is on). */
export function useMapWeatherAlerts(map: L.Map | null, enabled: boolean): Polled<WeatherAlertsResponse> {
return usePolled(
map && enabled
? () => {
// Leaflet bounds run past ±180/±90 zoomed out; the API answers 422 to that.
const b = map.getBounds().pad(BBOX_PAD);
return c2api.getWeatherAlerts({
south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180),
north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180),
});
}
: null,
[map, enabled],
ALERTS_POLL_MS,
map
? (reload) => {
let t: ReturnType<typeof setTimeout> | undefined;
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
map.on("moveend", onMove);
return () => { map.off("moveend", onMove); clearTimeout(t); };
}
: undefined,
);
}
/** Active NWS alerts intersecting a fixed bbox (an incident, the wall's region). `null` = don't fetch. */
export function useWeatherAlertsIn(box: BBox | null): Polled<WeatherAlertsResponse> {
const key = box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(3)).join(",") : "";
const boxRef = useRef(box);
boxRef.current = box;
return usePolled(key ? () => (boxRef.current ? c2api.getWeatherAlerts(boxRef.current) : null) : null, [key], ALERTS_POLL_MS);
}
/** Nearest-METAR wind at a point. `null` = don't fetch. */
export function useWind(point: LatLon | null): Polled<WindResponse> {
// ~100 m key: a re-render with an equal point (new array) must not refetch.
const key = point ? `${point[0].toFixed(3)},${point[1].toFixed(3)}` : "";
const ref = useRef(point);
ref.current = point;
return usePolled(key ? () => (ref.current ? c2api.getWind(ref.current[0], ref.current[1]) : null) : null, [key], WIND_POLL_MS);
}
export interface RadarMeta {
/** Valid time of the n0q composite (last one successfully read); null when never read. */
valid: Date | null;
/** Latest metadata refresh failure (fetch failed, or IEM changed the JSON shape). */
error: string | null;
loaded: boolean;
}
async function fetchRadarMeta(): Promise<Date> {
// IEM serves this with ACAO: *, so the browser reads it directly — one tiny
// JSON per viewer per poll, like the tiles themselves.
const res = await fetch(RADAR_META_URL, { cache: "no-store" });
if (!res.ok) throw new Error(`IEM radar metadata HTTP ${res.status}`);
const valid = parseRadarMeta(await res.json());
if (!valid) throw new Error("IEM radar metadata has no meta.valid — format changed?");
return valid;
}
/** The radar composite's valid time, polled while `enabled` (the Weather Radar overlay is on). */
export function useRadarMeta(enabled: boolean): RadarMeta {
const s = usePolled(enabled ? fetchRadarMeta : null, [enabled], RADAR_META_POLL_MS);
// On a failed refresh the last known valid time is kept AND the error is
// reported; the badge shows both, and the stale check keeps ageing it.
return { valid: s.data, error: s.error, loaded: s.loaded };
}
+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",
@@ -4,15 +4,22 @@ Server directory push (server-26#213 item 3).
The web node page picks a Discord server + voice channel instead of taking raw The web node page picks a Discord server + voice channel instead of taking raw
IDs. It needs each server's name and voice channels, and this bot already has IDs. It needs each server's name and voice channels, and this bot already has
them in its gateway cache (default intents include ``guilds``), so it pushes them in its gateway cache (default intents include ``guilds``), so it pushes
them to C2 (PUT /discord/guilds/{id}/directory, service key). C2 never calls them to C2 (PUT /discord/guilds/{id}/directory, service key).
Discord on the browser's behalf and no token reaches the browser.
This bot is optional (server-26#225): in a server it is not in, C2 reads the
same list over Discord REST with a radio bot's token (routers/discord_web.py).
Where it is present, its push wins while fresh and also tells C2 to drop its
cached Discord answers for the server, so a permission fixed in Discord shows
up on the next check instead of after the cache expires. No token ever
reaches the browser either way.
Pushed: server name, and per voice channel its id, name, category name and Pushed: server name, and per voice channel its id, name, category name and
order. Nothing about members, messages or roles. order. Nothing about members, messages or roles.
When: every server on ready, on join/leave/rename, on any voice-channel or When: every server on ready, on join/leave/rename, on any voice-channel or
category create/delete/update (debounced per server), and a full re-push every category create/delete/update — including a permission-overwrite change — and
15 minutes in case C2 missed one (it was down, or redeployed). on a role permission change or role delete (debounced per server), and a full
re-push every 15 minutes in case C2 missed one (it was down, or redeployed).
""" """
import asyncio import asyncio
from typing import Optional from typing import Optional
@@ -123,10 +130,22 @@ class DirectorySync(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel): async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel):
# Overwrites too: the push makes C2 drop its cached permission answers
# for this server (server-26#225), so "Re-check" sees the fix at once.
if _relevant(after) and (before.name != after.name or before.position != after.position if _relevant(after) and (before.name != after.name or before.position != after.position
or getattr(before, "category_id", None) != getattr(after, "category_id", None)): or getattr(before, "category_id", None) != getattr(after, "category_id", None)
or before.overwrites != after.overwrites):
self.schedule(after.guild) self.schedule(after.guild)
@commands.Cog.listener()
async def on_guild_role_update(self, before: discord.Role, after: discord.Role):
if before.permissions != after.permissions:
self.schedule(after.guild)
@commands.Cog.listener()
async def on_guild_role_delete(self, role: discord.Role):
self.schedule(role.guild)
async def setup(bot: commands.Bot): async def setup(bot: commands.Bot):
await bot.add_cog(DirectorySync(bot)) await bot.add_cog(DirectorySync(bot))
+6 -2
View File
@@ -284,7 +284,9 @@ class RadioCommands(commands.Cog):
value=( value=(
"Link this server to your DRB organization — needed before anything below works. " "Link this server to your DRB organization — needed before anything below works. "
"An organization owner gets the code in DRB under Settings → Organization → Discord servers; " "An organization owner gets the code in DRB under Settings → Organization → Discord servers; "
"someone with Manage Server runs this." "someone with Manage Server runs this.\n"
"Or skip this bot entirely: **Connect a Discord server** on that same page adds a radio bot "
"and links the server in one step, and radio channels can be started from a node's page in DRB."
), ),
inline=False, inline=False,
) )
@@ -314,7 +316,9 @@ class RadioCommands(commands.Cog):
name="Radio bots", name="Radio bots",
value=( value=(
"A radio bot has to be a member of this server before it can join a channel — " "A radio bot has to be a member of this server before it can join a channel — "
"if none is, `/join` replies with an invite link.\n" "if none is, `/join` replies with an invite link. It needs View Channel, Connect and Speak "
"there; if it can't, `/join` says which permission is missing and where to allow it.\n"
"Radio bots work without this bot: it only adds these commands.\n"
"The bot's green speaking ring lights up only while radio is transmitting." "The bot's green speaking ring lights up only while radio is transmitting."
), ),
inline=False, inline=False,