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 fdcedf175c Merge pull request 'Live wall: filter bar, discipline presets and text search (#235)' (#267) from feat/235-filter-bar into main
Build & Deploy / Build & push images (push) Successful in 4m31s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 6s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:17:48 -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 2aaf474828 Live wall filter: preset tags match word starts; keep typed trailing space (#235)
- Storm/Traffic preset tag terms now match at the start of a word, so
  "tree" no longer pulls "street-racing"/"street-fight" into Storm mode.
- FilterBar search compared the trimmed hash q against the untrimmed
  typed text, so pausing after "main " reset the box to "main" and the
  next word ran on ("mainst"). Compare trimmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:16:01 -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
logan f49dc86111 Merge pull request 'Discord: Connect a Discord server — OAuth UI (#225 S7) — merge after S6' (#259) from feat/225-s7-oauth-ui into main
Build & Deploy / Build & push images (push) Successful in 4m11s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 1m34s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:11:23 -04:00
Logan CusanoandClaude Opus 5.5 f479578942 Live wall: filter bar, discipline presets and text search (#235)
lib/liveFilter.ts (pure): WallFilter {types, sevFloor, systems, talkgroups,
ageH, tags, q, preset}, applyFilter over loaded incidents + calls, parse /
serialise to the f.* hash keys #234 reserved. Presets: Fire (fire + EMS
assists heard on a fire channel; Radar on), Storm (weather/wires/tree/flood
tags; Radar on), Traffic (accident / MVA tags; Traffic Events on).

components/live/FilterBar.tsx: one wrapping row under the wall bar; less-used
controls fold behind a toggle below md; read-only summary in kiosk.

LiveView applies the filter once and feeds the filtered lists to MapView
(pins, paths, rail), RegionPanel, TranscriptTicker, TimeScrubber and the
auto-tour; KPIs count the filtered set and say "filtered". Presets turn
layers on through the saved-view layer state, not a MapView edit. Pipeline
health stays unfiltered. liveWall.ts keeps commas literal in f.* values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:11:02 -04:00
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:09:28 -04:00
logan f3b21cd432 Merge pull request 'Weather backend: NWS active alerts + nearest-METAR wind, cached (Refs #237)' (#263) from feat/237-weather-api into main
Build & Deploy / Build & push images (push) Successful in 5m21s
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Deploy Firestore rules & indexes (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:03:32 -04:00
Logan Cusano 13a3525e4c Merge remote-tracking branch 'origin/main' into feat/225-s7-oauth-ui 2026-10-05 03:03:26 -04:00
logan 6d0d3f79ce Merge pull request 'Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234)' (#265) from feat/234-saved-views into main
Build & Deploy / Build & push images (push) Successful in 5m27s
Build & Deploy / Deploy to VM (push) Successful in 3s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:03:26 -04:00
Logan CusanoandClaude Opus 5.5 b6055019fc Live wall: report the starting view once so a copied link carries the viewport (#234)
Without it the URL had no z/lat/lon until the first pan, so a link copied
from an untouched wall opened at the other browser's remembered view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:02:44 -04:00
logan 2c254c1f57 Merge pull request 'Discord: per-bot OAuth "Connect a Discord server" backend (#225 S6)' (#260) from feat/225-s6-oauth-link into main
Build & Deploy / Build & push images (push) Successful in 5m35s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:01:14 -04:00
logan c30b2ca33e Merge pull request 'Discord: channel list and presence without the main bot (#225 S5, fixes #251)' (#262) from feat/225-s5-channels into main
Build & Deploy / Build & push images (push) Successful in 5m37s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 12s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:01:07 -04:00
Logan CusanoandClaude Opus 5.5 bdbde359a1 Weather review fixes: zone-fetch time budget, prod NWS_USER_AGENT (Refs #237)
- Cap one refresh's NWS zone fetches at 15 s wall clock. They run under the
  feed lock, so a hung zone endpoint could otherwise stall every
  /weather/alerts request for minutes; unfinished zones retry next refresh.
- Prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the
  contact-less placeholder. Default it to the site URL (overridable via
  vault_nws_user_agent); warn on the .env.example example.com placeholder too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:58:32 -04:00
Logan CusanoandClaude Opus 5.5 afd2bc463d Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234)
- liveWall.ts: WallLink carries layers (short stable keys), z/lat/lon,
  base, tourMs, kiosk and an f.<name> filter pass-through for #235;
  parse/serialise stay pure and round-trip; old inc/cam/tour links parse.
- MapView: overlays/basemap/viewport come from initialLayers/initialBase/
  initialView (defaults unchanged); onViewStateChange reports layer, base
  and region-view changes (debounced). Initially-checked overlays now start
  "shown" (react-leaflet adds a checked overlay before the control can fire
  overlayadd). Closing an incident flies back to the pre-drill view (#137).
- wallPrefs.ts: per-browser last layers/base/view in localStorage, every
  access wrapped; precedence hash > remembered > defaults.
- LiveView: one effect mirrors wall + map state into the hash; hash edits
  remount the map with the new view; Copy view link; configurable tour.
- Kiosk (?kiosk=1 or #kiosk): no nav, fullscreen button (auto on first
  touch), Screen Wake Lock re-acquired on visibility, reconnect banner
  (offline / useCalls error) with backoff reload, 18-20 px type via a
  data-kiosk attribute and Tailwind arbitrary variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:58:22 -04:00
Logan CusanoandClaude Opus 5.5 fb1b6956c2 Weather backend: NWS active alerts + nearest-METAR wind, cached (Refs #237)
GET /weather/alerts — api.weather.gov/alerts/active for settings.nws_area,
sent with the NWS-required User-Agent, cached weather_alerts_ttl_s (90 s)
with single-flight refresh. Zone-based alerts (geometry null) get their
polygons from affectedZones (24 h zone cache, api.weather.gov/zones/ only);
any still unresolved are returned in zone_only, never dropped. Failed
refresh keeps last-good data + error + fetched_at; a payload where no
feature parses is a schema-change error. Optional bbox filter.

GET /weather/wind — nearest METAR (aviationweather.gov /api/data/metar,
bbox query) within 75 km, cached 5 min per 0.5-degree cell; VRB wind and
stale reports flagged; available:false with a reason otherwise.

Both behind require_service_or_firebase_token like /traffic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:54:33 -04:00
Logan CusanoandClaude Opus 5.5 a976464e72 Discord: channel list and presence without the main bot (server-26#225 S5)
A server linked to an org now shows in the node picker while a radio bot the
org may use is in it, even when the DRB server bot is absent or left. Its
voice-channel list is read from that radio bot's cached REST snapshot
(discord_rest.guild_directory, the same snapshot the permission check reads,
so no extra Discord request): inline when no list is stored, in the
background when the list is older than discord_directory_refresh_seconds.
The server bot's own fresh list still wins (back-compat) and its directory
push now drops the server's cached snapshots.

Guards: a server acting only through the unlinked-server fallback org still
needs the server bot; REST never creates a discord_guilds doc, never writes
org_id or bot_present, and never writes onto a server unlinked meanwhile.

Also fixes server-26#251: an interactive start (/join, admin page, web
picker, node discord_join) re-reads a cached "denied" snapshot older than
10 s once before refusing, so "fix it in Discord, then try again" works on
the next try. The reconciler never re-reads (no REST loop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:53:57 -04:00
Logan CusanoandClaude Opus 5.5 db4e326332 Discord: per-bot OAuth "Connect a Discord server" backend (server-26#225 S6)
An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.

- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
  POST /complete. 404 while discord_oauth_redirect_uri is empty or no
  usable bot has a secret. Bot order: explicit token_id, the node system's
  preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
  state.uid must be the caller; any presentation burns it), code exchange,
  revoke, client-credentials secret check. Through discord_rest's shared
  client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
  settle step that stops a server's streams when the org it acted for
  changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
  all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
  server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
  Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:52:57 -04:00
logan 7bee87b147 Merge pull request 'Outage providers: licence-gated registry, ODIN adapter, /outages API, admin tab (Refs #184)' (#255) from feat/184-outage-providers into main
Build & Deploy / Build & push images (push) Successful in 4m13s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m39s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 02:47:15 -04:00
logan 18c2aeeff2 Merge pull request 'Click-to-listen on the live wall + HTTP Range on /media (#233)' (#253) from feat/233-wall-audio into main
Build & Deploy / Build & push images (push) Failing after 10s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Deploy Firestore rules & indexes (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-10-05 02:47:08 -04:00
logan b615cce30c Merge pull request 'Live wall: truth strip keyed to the pipeline, not node heartbeats (#232)' (#252) from feat/232-truth-strip 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 02:47:01 -04:00
Logan CusanoandClaude Opus 5.5 0efcd676c3 Review: ODIN stale events and 0001-01-01 no-ETR sentinel (Refs #184)
ODIN still lists Avangrid rows from April 2026 (ETR April/May) as active on
2026-10-05; parse_odin now counts events whose own ETR passed >24h ago as
stale_customers_out, not customers_out, and since/etr come from live events.
Puget Sound Energy sends 0001-01-01T00:00:00Z for no ETR; treat as None.
Ack dialog now shows a refused enable's error instead of hiding it behind
the overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:45:25 -04:00
Logan CusanoandClaude Opus 5.5 ebeabe342e Outage providers: licence-gated registry, ODIN adapter, /outages API, admin tab (Refs #184)
Part 1 of 2 for server-26#184 (map layer + incident cross-match follow in batch 3).

- app/internal/outages/: same gate as the camera registry -- settings doc
  config/outage_providers, every provider OFF by default, unreadable settings
  fail closed, stubs never enable, gated licences need acknowledge_licence
  stored with who/when plus an audit_log write as a precondition, and the
  acknowledgement is bound to the licence status it was given for.
  Reuses cameras.base Licence/LicenceInfo/get_json/safe_error/require_parsed.
- ODIN adapter (Licence.UNVERIFIED, gated): public county-level API verified
  first-hand against https://odin.ornl.gov/v3/api-docs -- /odi?format=JSON
  (counts, start, ETR), /odi/map?scale=COUNTY (county name, bbox, label
  point), /odi/status (utility freshness). Schema canary on all three.
  metersServed is utility-wide, so customers_served stays None.
- Stubs: poweroutage_us (paid), coned_oru / nyseg_rge / central_hudson
  storm centers and eagle_i_live (agreement required).
- GET /outages (signed-in) returns areas + per-provider credit/health and a
  status (ok / none_enabled / no_source / error) so an empty layer is never
  ambiguous. /admin/outage-providers GET + PUT, admin only, 409 with
  requires_acknowledgement like cameras.
- Frontend: c2api getOutages/listOutageProviders/setOutageProvider, types,
  Admin -> Outage providers tab (deep link /admin#outages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:38:51 -04:00
Logan CusanoandClaude Opus 5.5 824b0f6f5a Truth strip review fixes: all-offline is not Listening, hearing bounded (#232)
- Every configured node offline now degrades the Radio stage ("all N nodes
  offline") so the wall no longer shows "Listening" with nothing up.
- nodes-hearing only counts configured nodes, so it can't exceed N/N.
- AI chip copy: "no incident writes in the recent window" instead of
  "last incident write none in window".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:37:39 -04:00
Logan CusanoandClaude Opus 5.5 c836986b89 Click-to-listen on the live wall + HTTP Range on /media (server-26#233)
Backend: /media/calls/{id}/audio now honours a single HTTP Range
(bytes=a-b, a-, -n) with 206 + Content-Range, 416 for unsatisfiable,
and a 200 full body (Accept-Ranges: bytes) otherwise. The body is
streamed from GCS in 512 KiB span reads (storage.audio_object_size +
download_audio_range) instead of downloading the whole object first,
so a 600 s FLAC starts after one span and seeks fetch only what they
need. HMAC check and Firestore lookup unchanged and still first.

Frontend: InlinePlayer moved to components/InlinePlayer.tsx and now
drives lib/audioQueue.ts, a useSyncExternalStore store over ONE audio
element (one call plays at a time). Wall spine rows get play buttons
and a "Play incident" queue (oldest first) with the playing row
highlighted; map path-stop popups get a play button and the playing
stop gets an accent ring; rail cards get "play latest". Playback is
gated by canPlayAudio() (BUSINESS_MODEL section 3 / Gate A hook).
Nothing plays until clicked. Spine truncation is now stated
("showing latest 30 of M", show-all toggle, count outside the loaded
window) for server-26#239.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:33:25 -04:00
Logan CusanoandClaude Opus 5.5 f252e3eb61 Live wall: truth strip keyed to the pipeline, not node heartbeats (#232)
The '● Live · Ns ago' pill was reset by any node/activeCalls/incident change,
so heartbeats kept it green while STT/correlation were dead (#196).

- lib/usePipelineHealth.ts: polls /health/ai (auth-free) every 60 s and
  derives stage staleness from data too, since /health/ai resets on restart.
  Radio = newest call; STT = calls heard after the newest transcript, dry when
  >=3 are waiting and the oldest is > N min; AI = newest incident write /
  linked call + any degraded /health/ai tier. N = NEXT_PUBLIC_WALL_STALE_MIN
  (default 10); radio silence and AI absence get 3N (weaker evidence).
- components/live/TruthStrip.tsx: per-stage chips; full-width amber
  DEGRADED bar naming the dry stage and any degraded tier.
- LiveView: heartbeat lastUpdated removed and no longer passed to MapView
  (hides the old pill without touching MapView); 'Listening' only when every
  stage is healthy; map + rail greyed when degraded; nodes KPI is now
  'nodes hearing' (a call in the last N min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:32:00 -04:00
121 changed files with 14682 additions and 1349 deletions
+4
View File
@@ -31,6 +31,10 @@ NY511_API_KEY=
NY511_API_BASE= NY511_API_BASE=
# Ontario 511 developer key (camera provider "ontario511"; providers default OFF) # Ontario 511 developer key (camera provider "ontario511"; providers default OFF)
ONTARIO511_API_KEY= ONTARIO511_API_KEY=
# NWS weather alerts / METAR wind (server-26#237). NWS requires app name + contact in the User-Agent
NWS_USER_AGENT=DRB (you@example.com)
# Comma list of NWS state/marine area codes served
NWS_AREA=NY
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries # OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
OPENAI_API_KEY= OPENAI_API_KEY=
+8
View File
@@ -59,6 +59,14 @@ class Settings(BaseSettings):
# Camera providers (app/internal/cameras). Keys only; every provider is still # Camera providers (app/internal/cameras). Keys only; every provider is still
# OFF until an admin enables it, and licence-gated ones need an acknowledgement. # OFF until an admin enables it, and licence-gated ones need an acknowledgement.
ontario511_api_key: Optional[str] = None ontario511_api_key: Optional[str] = None
# Weather context (server-26#237). api.weather.gov requires a User-Agent that
# names the app AND a contact (e.g. "DRB (ops@example.com)"); the placeholder
# default must be overridden in every deploy -- NWS may block anonymous agents.
nws_user_agent: str = "DRB scanner map (contact: set NWS_USER_AGENT)"
# Comma list of NWS state/marine area codes for /alerts/active (the areas served).
nws_area: str = "NY"
# NWS alerts cache TTL; the issue asks 60-120 s (floored at 30 s in code).
weather_alerts_ttl_s: int = 90
# Gemini (intelligence extraction, embeddings, incident summaries) # Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None gemini_api_key: Optional[str] = None
+109 -25
View File
@@ -27,6 +27,16 @@ Firestore layout:
unlinked_at|None, unlinked_by|None unlinked_at|None, unlinked_by|None
Person names are never stored: the redeemer is kept as a Discord user id. Person names are never stored: the redeemer is kept as a Discord user id.
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
through ONE transaction body (``_apply_link``) and then runs ONE settle step
(``_settle``): a server that was acting for a different org (in practice the
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
stopped, because they were placed on that org's nodes. The org it acted for
before is read inside the same transaction as the write, so the stop decision
can't race a concurrent link.
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,6 +45,7 @@ import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from app.internal import discord_streams as streams
from app.internal import firestore as fstore from app.internal import firestore as fstore
CODES = "discord_link_codes" CODES = "discord_link_codes"
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
"expires_minutes": CODE_TTL_MINUTES} "expires_minutes": CODE_TTL_MINUTES}
# Stop reasons the settle step stamps on the streams it stops.
RELINK_STOP_REASON = "relinked"
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
allow_move: bool = False, extra: Optional[dict] = None):
"""The guild-side half of every link, inside a transaction whose reads are
all done. Returns a LinkError (nothing written) or the result dict:
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
org the server acted for before (its link, else the fallback org).
``allow_move`` is the platform admin's support override; every self-serve
path refuses to move a server another org has linked."""
current = (guild or {}).get("org_id")
if current and current != org_id and not allow_move:
return LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")
iso = now.isoformat()
name = (guild_name or "").strip()[:100]
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": name or (guild or {}).get("guild_name"),
"linked_by": linked_by,
"linked_via": via,
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
**(extra or {}),
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"previous_org_id": streams.acting_org_of(guild)}
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
db = fstore.db
when = now or _now()
def _txn(txn):
guild_ref = db.collection(GUILDS).document(guild_id)
snap = guild_ref.get(transaction=txn)
guild = snap.to_dict() if snap.exists else None
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
return {"error": out} if isinstance(out, LinkError) else out
return _txn
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime): def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
"""Build the transaction body. Every read happens before any write.""" """Build the transaction body. Every read happens before any write."""
db = fstore.db db = fstore.db
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
return {"error": LinkError(410, "code_expired", return {"error": LinkError(410, "code_expired",
"That link code has expired. Generate a new one in DRB → " "That link code has expired. Generate a new one in DRB → "
"Settings → Organization.")} "Settings → Organization.")}
org_id = doc["org_id"] out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
current = (guild or {}).get("org_id") guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
if current and current != org_id: if isinstance(out, LinkError):
return {"error": LinkError(409, "linked_elsewhere", return {"error": out} # the code is NOT consumed: its org can use it elsewhere
"This server is already linked to a different DRB organization. " txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
"That organization has to unlink it first.")}
iso = now.isoformat()
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
"redeemed_by_discord_id": discord_user_id}) "redeemed_by_discord_id": discord_user_id})
txn.set(guild_ref, { return {**out, "created_by_uid": doc.get("created_by_uid")}
"guild_id": guild_id,
"org_id": org_id,
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
"linked_by": f"discord:{discord_user_id}",
"linked_via": "code",
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")}
return _txn return _txn
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict: async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.""" """Link ``guild_id`` to the code's org. Raises LinkError on refusal.
Does NOT stop anything — routes call ``redeem_and_settle``."""
code = normalize(raw_code) code = normalize(raw_code)
if len(code) != _CODE_LEN: if len(code) != _CODE_LEN:
raise LinkError(404, "invalid_code", raise LinkError(404, "invalid_code",
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
return result return result
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
"""After a link committed: a server that was acting for another org (the
fallback org, or — admin override only — another linked org) has its live
radio channels stopped; they were placed on that org's nodes, and left
running the reconciler would keep re-placing them there
(server-26#225 plan finding 3: this lived in two routers before)."""
before = result.get("previous_org_id")
stopped: list = []
if before and before != result["org_id"]:
stopped = await streams.stop_guild(str(guild_id), stop_reason)
return {**result, "streams_stopped": stopped}
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None,
stop_reason: str = RELINK_STOP_REASON) -> dict:
"""Link a server to an org directly (admin, OAuth) and settle. Raises
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
result = await fstore.run_transaction(
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
if "error" in result:
raise result["error"]
return await _settle(guild_id, result, stop_reason)
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
discord_user_id: str) -> dict:
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
return await _settle(guild_id, result, RELINK_STOP_REASON)
async def list_org_guilds(org_id: str) -> list[dict]: async def list_org_guilds(org_id: str) -> list[dict]:
return [ return [
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"), {"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
+246
View File
@@ -0,0 +1,246 @@
"""
Per-bot OAuth "Connect a Discord server" (server-26#225 S6, owner decision Q1).
An org owner links a Discord server by inviting ONE radio bot through
Discord's OAuth2 bot invite. The invite's ``state`` carries a single-use,
short-lived nonce bound to the org and the user who started it; Discord
redirects back with ``code`` (and ``guild_id``), C2 exchanges the code with
THAT bot application's client secret, and the exchange's ``guild`` object —
never the query string — says which server to link.
Secrets:
bot_oauth/{token_id} server-only (Firestore rules deny all
client_secret, enabled, client reads; c2-core's admin SDK only)
set_by, set_at, checked_at
The secret is never on ``bot_tokens`` (GET /tokens returns an allowlist of
those fields), never returned by any route, never logged, never put in an
error string. Firestore encrypts it at rest like every other document; the
codebase has no application-level encryption pattern (bot tokens are
stored the same way), so none is invented here.
States:
discord_oauth_states/{sha256(nonce)} the nonce itself is never stored
org_id, uid, token_id, node_id, return_to, created_at, expires_at,
used_at|None, used_by|None
OAuth is off while ``settings.discord_oauth_redirect_uri`` is empty: every
route 404s and the UI hides the button.
HTTP goes through discord_rest's shared client (User-Agent, transport for
tests) and counts 401/403/429 answers against its process-wide breaker, so a
wrong secret retried in a loop can't contribute to an IP ban either.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlencode
import httpx
from app.config import settings
from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import firestore as fstore
OAUTH = "bot_oauth"
STATES = "discord_oauth_states"
AUTHORIZE_URL = "https://discord.com/oauth2/authorize"
# Discord client secrets are 32 url-safe characters today; accept a margin.
SECRET_MIN, SECRET_MAX = 16, 128
RETURN_TO_MAX = 200
def enabled() -> bool:
return bool((settings.discord_oauth_redirect_uri or "").strip())
def _now() -> datetime:
return datetime.now(timezone.utc)
def state_key(nonce: str) -> str:
return hashlib.sha256((nonce or "").encode()).hexdigest()
def safe_return_to(value: Optional[str]) -> Optional[str]:
"""A same-site path only (``/nodes/abc``), never an absolute or
protocol-relative URL — the callback page redirects to it."""
v = (value or "").strip()
if not v or len(v) > RETURN_TO_MAX or not v.startswith("/") or v.startswith("//") or "\\" in v:
return None
if any(ord(ch) < 0x20 for ch in v):
return None
return v
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
async def enabled_token_ids() -> set[str]:
"""Bots whose OAuth secret is set and enabled."""
return {d.get("token_id") for d in await fstore.collection_list(OAUTH)
if d.get("token_id") and d.get("enabled") and d.get("client_secret")}
async def load_secret(token_id: str) -> Optional[str]:
doc = await fstore.doc_get(OAUTH, token_id)
if not doc or not doc.get("enabled") or not doc.get("client_secret"):
return None
return doc["client_secret"]
async def store_secret(token_id: str, client_secret: str, uid: str) -> None:
now = _now().isoformat()
await fstore.doc_set(OAUTH, token_id, {
"token_id": token_id, "client_secret": client_secret, "enabled": True,
"set_by": uid, "set_at": now, "checked_at": now,
}, merge=False)
async def clear_secret(token_id: str) -> bool:
if not await fstore.doc_get(OAUTH, token_id):
return False
await fstore.doc_delete(OAUTH, token_id)
return True
# ---------------------------------------------------------------------------
# Discord OAuth2 HTTP (client_id + client_secret, basic auth)
# ---------------------------------------------------------------------------
async def _post_form(path: str, data: dict, client_id: str, client_secret: str) -> discord_rest.RestResult:
"""POST an application-authenticated form. Never raises for a Discord or
network failure; the error string never holds the secret, the code or a
token (only Discord's short ``error`` field)."""
now = time.monotonic()
if discord_rest._breaker.tripped(now):
return discord_rest.RestResult(None, error="Discord REST paused (invalid-request budget reached)")
try:
r = await discord_rest._client().post(path, data=data, auth=(client_id, client_secret))
except httpx.HTTPError as e:
return discord_rest.RestResult(None, error=type(e).__name__)
status = r.status_code
if status in (401, 403) or (status == 429 and r.headers.get("x-ratelimit-scope") != "shared"):
discord_rest._breaker.record(time.monotonic())
body: Any = None
try:
body = r.json() if r.content else None
except ValueError:
body = None
if 200 <= status < 300:
return discord_rest.RestResult(status, body)
err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
return discord_rest.RestResult(status, None, error=f"Discord answered {status}" + (f" ({err[:40]})" if err else ""))
async def exchange_code(client_id: str, client_secret: str, code: str) -> discord_rest.RestResult:
return await _post_form("/oauth2/token", {
"grant_type": "authorization_code", "code": code,
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
}, client_id, client_secret)
async def revoke(client_id: str, client_secret: str, access_token: Optional[str]) -> None:
"""Best effort: C2 never uses the user's access token, so it is revoked
as soon as the guild has been read from the exchange."""
if access_token:
await _post_form("/oauth2/token/revoke", {"token": access_token, "token_type_hint": "access_token"},
client_id, client_secret)
async def check_secret(client_id: str, client_secret: str) -> discord_rest.RestResult:
"""Prove ``client_secret`` belongs to ``client_id`` with a client-
credentials grant (scope ``identify`` — the bot owner's own token), then
revoke the token it returns. Nothing is stored from the answer."""
r = await _post_form("/oauth2/token", {"grant_type": "client_credentials", "scope": "identify"},
client_id, client_secret)
if r.ok and isinstance(r.data, dict):
await revoke(client_id, client_secret, r.data.get("access_token"))
return r
# ---------------------------------------------------------------------------
# State + authorize URL
# ---------------------------------------------------------------------------
def authorize_url(application_id: str, nonce: str) -> str:
return AUTHORIZE_URL + "?" + urlencode({
"client_id": application_id,
"scope": "bot",
"permissions": str(leases.RADIO_BOT_PERMISSIONS),
"response_type": "code",
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
"state": nonce,
})
async def create_state(*, org_id: str, uid: str, token_id: str, node_id: Optional[str],
return_to: Optional[str]) -> tuple[str, datetime]:
nonce = secrets.token_urlsafe(32)
now = _now()
expires_at = now + timedelta(seconds=max(60, settings.discord_oauth_state_ttl_seconds))
await fstore.doc_set(STATES, state_key(nonce), {
"org_id": org_id, "uid": uid, "token_id": token_id, "node_id": node_id,
"return_to": return_to, "created_at": now.isoformat(), "expires_at": expires_at.isoformat(),
"used_at": None, "used_by": None,
}, merge=False)
return nonce, expires_at
class StateError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(message)
self.status, self.code, self.message = status, code, message
def consume_state_txn(nonce: str, uid: str, now: datetime):
"""Transaction body: the state must exist, be unexpired and unused, and
belong to ``uid`` (login-CSRF: someone else's state never links into the
caller's org, and the caller's own code can't be replayed by another
account). Any presentation of a live state burns it — a failure later in
the flow means starting again, never retrying the same state."""
db = fstore.db
key = state_key(nonce)
def _txn(txn):
ref = db.collection(STATES).document(key)
snap = ref.get(transaction=txn)
doc = snap.to_dict() if snap.exists else None
if not doc:
return {"error": StateError(400, "invalid_state",
"That Discord link is not valid. Start again from DRB.")}
if doc.get("used_at"):
return {"error": StateError(409, "state_used",
"That Discord link was already used. Start again from DRB.")}
txn.update(ref, {"used_at": now.isoformat(), "used_by": uid})
try:
expired = now > datetime.fromisoformat(doc.get("expires_at") or "")
except ValueError:
expired = True
if expired:
return {"error": StateError(410, "state_expired",
"That Discord link expired. Start again from DRB.")}
if doc.get("uid") != uid:
return {"error": StateError(403, "wrong_user",
"That Discord link was started by a different DRB account. "
"Sign in as that account, or start again.")}
return {"state": doc}
return _txn
async def consume_state(nonce: str, uid: str) -> dict:
if not nonce or len(nonce) > 200:
raise StateError(400, "invalid_state", "That Discord link is not valid. Start again from DRB.")
out = await fstore.run_transaction(consume_state_txn(nonce, uid, _now()))
if "error" in out:
raise out["error"]
return out["state"]
+97
View File
@@ -655,3 +655,100 @@ async def guild_snapshot(token_doc: dict, guild_id: str, *, refresh: bool = Fals
error=f"check failed ({type(e).__name__})") error=f"check failed ({type(e).__name__})")
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh) return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
# ---------------------------------------------------------------------------
# Voice-channel directory from a radio bot's view (server-26#225 S5)
# ---------------------------------------------------------------------------
DIRECTORY_MAX_CHANNELS = 200 # same cap as PUT /discord/guilds/{id}/directory
_DIRECTORY_NAME_MAX = 100
VOICE_CHANNEL = 2
CATEGORY_CHANNEL = 4
def _int_or(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def directory_from_snapshot(snap: Optional[GuildSnapshot]) -> Optional[dict]:
"""A server's name and voice channels as one radio bot's snapshot sees
them, in the shape the DRB server bot pushes (``PUT
/discord/guilds/{id}/directory``): ``{name, voice_channels: [{channel_id,
name, category, position}]}``, in Discord's sidebar order (channels with
no category first, then each category by position; channels by position,
then id). None unless the snapshot is OK.
Every voice channel in the list is kept, including any this bot can't
View, exactly like the server bot's gateway list: whether a bot may join
is the permission check's answer (discord_streams.evaluate_access), and
the picker shows it per channel. Stage channels are not voice channels
here either."""
if snap is None or snap.status != OK or not isinstance(snap.channels, list):
return None
chans = [c for c in snap.channels if isinstance(c, dict) and c.get("id") is not None]
cats = {str(c["id"]): c for c in chans if c.get("type") == CATEGORY_CHANNEL}
def parent(c: dict) -> Optional[dict]:
return cats.get(str(c.get("parent_id") or ""))
def order(c: dict) -> tuple:
p = parent(c)
top = (0, 0, 0) if p is None else (1, _int_or(p.get("position")), _int_or(p.get("id")))
return (*top, _int_or(c.get("position")), _int_or(c.get("id")))
out: list[dict] = []
for c in sorted((c for c in chans if c.get("type") == VOICE_CHANNEL), key=order)[:DIRECTORY_MAX_CHANNELS]:
p = parent(c)
category = str(p.get("name") or "")[:_DIRECTORY_NAME_MAX] if p else ""
out.append({
"channel_id": str(c["id"]),
"name": str(c.get("name") or "")[:_DIRECTORY_NAME_MAX],
"category": category or None,
"position": len(out),
})
name = snap.guild.get("name") if isinstance(snap.guild, dict) else None
return {"name": str(name)[:_DIRECTORY_NAME_MAX] if name else None, "voice_channels": out}
@dataclass
class DirectoryResult:
"""``guild_directory``'s answer. ``status``: OK (``name`` and
``voice_channels`` from ``token_id``'s snapshot), NOT_MEMBER (every bot
asked turned out not to be in the server, or its token is invalid) or
UNKNOWN (no usable answer — keep what is stored)."""
guild_id: str
status: str
name: Optional[str] = None
voice_channels: Optional[list] = None
token_id: Optional[str] = None
fetched_at: Optional[float] = None
async def guild_directory(token_docs: list[dict], guild_id: str, *, refresh: bool = False,
max_bots: int = 3) -> DirectoryResult:
"""The voice-channel list of one server, read from the first of
``token_docs`` (radio bots believed to be in it, best first) whose cached
snapshot is OK. It is the same snapshot the permission check reads, so
listing channels costs no extra Discord request. At most ``max_bots``
bots are asked, one after another. Never raises for a Discord failure.
The caller decides which server and which bots: this never looks a
server up by itself, so it can't discover one (a server DRB doesn't
already know is never listed from a radio bot's membership)."""
guild_id = str(guild_id)
statuses: list[str] = []
for doc in token_docs[:max(1, max_bots)]:
snap = await guild_snapshot(doc, guild_id, refresh=refresh)
statuses.append(snap.status)
d = directory_from_snapshot(snap)
if d is not None:
return DirectoryResult(guild_id=guild_id, status=OK, name=d["name"],
voice_channels=d["voice_channels"], token_id=snap.token_id,
fetched_at=snap.fetched_at)
if statuses and all(s in (NOT_MEMBER, INVALID) for s in statuses):
return DirectoryResult(guild_id=guild_id, status=NOT_MEMBER)
return DirectoryResult(guild_id=guild_id, status=UNKNOWN)
+55 -13
View File
@@ -53,6 +53,7 @@ A retained MQTT message therefore never stores a bot credential on the broker.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
@@ -93,6 +94,17 @@ def _parse_ts(value) -> Optional[datetime]:
# Guild -> org # Guild -> org
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def acting_org_of(link: Optional[dict]) -> Optional[str]:
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
no doc). Pure, so a link transaction can decide from the snapshot it read
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def resolve_guild_org(guild_id: str) -> Optional[str]: async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else """The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty). ``settings.discord_unlinked_guild_org_id`` (None when that is empty).
@@ -100,12 +112,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213) A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org.""" to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id)) return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]: async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
@@ -352,14 +359,39 @@ async def check_bots(tokens: dict[str, dict], token_ids: list[str], guild_id: st
for t in token_ids} for t in token_ids}
# server-26#251: an interactive start (a person pressed Start / ran /join)
# re-reads a bot's snapshot that says "denied" once it is this old, so a
# permission fixed in Discord a moment ago is seen without waiting out the
# cache. The reconciler never does (plan finding 4: no REST loop).
RECHECK_DENIED_AFTER_SECONDS = 10.0
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str, async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
preferred_token_id: Optional[str], node_ids: set) -> Optional[dict]: preferred_token_id: Optional[str], node_ids: set,
recheck_denied: bool = False) -> Optional[dict]:
if not settings.discord_perm_check_enabled: if not settings.discord_perm_check_enabled:
return None return None
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")} tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids, ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id) exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
return await check_bots(tokens, ids, guild_id, channel_id) snaps = await gather_snapshots(tokens, ids, guild_id)
def verdict(t: str) -> dict:
return evaluate_access(snaps[t], channel_id, bot=tokens[t].get("name") or t) if snaps.get(t) \
else _unanswered()
out = {t: verdict(t) for t in ids}
if recheck_denied:
now = time.time()
stale = [t for t in ids if out[t]["status"] == leases.PERM_DENIED
and now - (getattr(snaps.get(t), "fetched_at", None) or now) >= RECHECK_DENIED_AFTER_SECONDS]
if stale:
fresh = await gather_snapshots(tokens, stale, guild_id, refresh=True)
for t, snap in fresh.items():
if snap is not None: # not answered in time: keep the cached verdict
snaps[t] = snap
out[t] = verdict(t)
return out
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str: def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
@@ -409,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 "
@@ -484,6 +518,7 @@ async def start_stream(
preferred_token_id: Optional[str] = None, preferred_token_id: Optional[str] = None,
max_streams: Optional[int] = None, max_streams: Optional[int] = None,
stream_id: Optional[str] = None, stream_id: Optional[str] = None,
interactive: bool = False,
) -> dict: ) -> dict:
"""Put ``system_id`` on a voice channel. """Put ``system_id`` on a voice channel.
@@ -504,6 +539,12 @@ async def start_stream(
channel first (``_perm_check``; server-26#225). Every entry point — the channel first (``_perm_check``; server-26#225). Every entry point — the
bot's /join, the web picker, the node command and the reconciler — bot's /join, the web picker, the node command and the reconciler —
comes through here, so none can hand a node a bot Discord will refuse. comes through here, so none can hand a node a bot Discord will refuse.
``interactive``: a person asked for this start (the routes pass True,
the reconciler never does). A bot whose cached check says "denied" is
then asked again if that answer is more than
``RECHECK_DENIED_AFTER_SECONDS`` old (server-26#251), so "fix it in
Discord, then try again" works on the next try.
""" """
if mode not in ("persistent", "adhoc"): if mode not in ("persistent", "adhoc"):
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400) raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
@@ -527,14 +568,14 @@ async def start_stream(
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode, org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id, channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id, preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
existing=existing, existing=existing, interactive=interactive,
) )
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str, async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str], channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int], node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
stream_id: Optional[str], existing: dict) -> dict: stream_id: Optional[str], existing: dict, interactive: bool = False) -> dict:
already_on = False already_on = False
if stream_id: if stream_id:
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
@@ -612,7 +653,8 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
node_ids = {n["node_id"] for n in candidates} node_ids = {n["node_id"] for n in candidates}
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id, perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
preferred_token_id=preferred_token_id, node_ids=node_ids) preferred_token_id=preferred_token_id, node_ids=node_ids,
recheck_denied=interactive)
if perms and any(p.get("status") == "bad_channel" for p in perms.values()): if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. " raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
"Radio can only play in a voice channel — pick one from the list.", "Radio can only play in a voice channel — pick one from the list.",
@@ -638,7 +680,7 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
# Bots that just turned out to be members get checked too. # Bots that just turned out to be members get checked too.
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
stream_id=stream_id, preferred_token_id=preferred_token_id, stream_id=stream_id, preferred_token_id=preferred_token_id,
node_ids=node_ids) node_ids=node_ids, recheck_denied=interactive)
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id, res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
preferred_token_id=preferred_token_id, stream_fields=fields, preferred_token_id=preferred_token_id, stream_fields=fields,
soft_preferred_token_id=soft_pref, perms=perms) soft_preferred_token_id=soft_pref, perms=perms)
+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:
@@ -0,0 +1,8 @@
"""Pluggable power-outage providers (map layer, server-26#184).
Same shape as ``app.internal.cameras``: every source is one adapter carrying
its catalogued licence status, every provider defaults OFF, and a licence-gated
one serves only after an admin acknowledgement recorded in audit_log
(``registry``). Scraped utility storm-center JSON is never shipped -- those
sources are stubs until a data-sharing agreement exists.
"""
+201
View File
@@ -0,0 +1,201 @@
"""Outage provider interface, normalised record and the per-provider feed cache.
The licence model (``Licence``, ``LicenceInfo``, the GATED set) is the camera
layer's, imported -- one catalogue vocabulary for every licence-gated source.
Only the acknowledgement wording differs (outage data, not imagery), so
``OutageLicenceInfo`` overrides ``warning()`` and nothing else.
Outage feeds are public and identical for every org, so -- like the camera
catalogs -- they are cached in memory, fetched lazily on request, and never
written to Firestore. A failed refresh keeps serving the last good feed and
reports the error and its age: a dead feed must show as an error, never as an
empty layer (the AI-silent-failures lesson).
"""
import asyncio
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from app.internal.cameras.base import ( # noqa: F401 (re-exported for adapters)
BBox, Licence, LicenceInfo, bbox_intersects, finite, get_json, require_parsed, safe_error,
)
from app.internal.logger import logger
RETRY_AFTER_FAILURE_S = 60
DEFAULT_FEED_TTL_S = 5 * 60 # ODIN asks utilities for updates every 5-15 min
USER_AGENT = "DRB-c2-core/1.0 (+public power-outage data; contact via odin.ornl.gov)"
OutageArea = Dict[str, Any]
@dataclass(frozen=True)
class OutageLicenceInfo(LicenceInfo):
"""``LicenceInfo`` with the acknowledgement worded for outage data."""
def warning(self) -> Optional[str]:
if self.status == Licence.PROHIBITED:
return f"{self.label}. This source's terms forbid this use; it cannot be enabled."
if not self.gated:
return None
return (f"{self.label}. {self.terms} Enabling it means DRB shows this source's outage data "
f"to every signed-in user. Only confirm if DRB actually holds the right to do so.")
@dataclass(frozen=True)
class OutageProviderMeta:
id: str
name: str
region: str
coverage: BBox # rough bbox; a query outside it never triggers a fetch
licence: LicenceInfo
granularity: str # "county", "point/polygon", ...
implemented: bool
notes: str # what is verified vs unverified about this adapter
homepage: Optional[str] = None
ttl_s: int = DEFAULT_FEED_TTL_S
class OutageProvider:
"""Base adapter. Subclasses set ``meta`` and implement ``fetch``."""
meta: OutageProviderMeta
async def fetch(self) -> List[OutageArea]:
raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})")
def coverage_note(self) -> Optional[str]:
"""What this source can and cannot see, shown next to its data."""
return None
class StubProvider(OutageProvider):
"""A catalogued source with no adapter -- listed so Admin shows its licence
status, but it can never be enabled."""
def __init__(self, meta: OutageProviderMeta):
assert not meta.implemented
self.meta = meta
class Feed:
"""In-memory cache of one provider's normalised outage areas."""
def __init__(self, provider: OutageProvider):
self.provider = provider
self.items: List[OutageArea] = []
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
self.last_attempt_at: Optional[float] = None
self.error: Optional[str] = None
self._next_attempt = 0.0
self._lock = asyncio.Lock()
async def get(self) -> "Feed":
if time.time() < self._next_attempt:
return self
async with self._lock:
if time.time() < self._next_attempt:
return self
ttl = self.provider.meta.ttl_s
self.last_attempt_at = time.time()
try:
items = await self.provider.fetch()
self.items = items
self.fetched_at = time.time()
self.error = None
self._next_attempt = time.time() + ttl
except Exception as e:
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
self.error = safe_error(e)
logger.warning(f"outages/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
return self
def status(self) -> dict:
return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at,
"error": self.error, "count": len(self.items)}
# ── helpers shared by adapters ───────────────────────────────────────────────
def make_area(provider_id: str, fips: str, *, county: Optional[str], state: Optional[str], customers_out: int,
customers_served: Optional[int] = None, etr: Optional[str] = None, since: Optional[str] = None,
updated_at: Optional[str] = None, lat: Optional[float] = None, lon: Optional[float] = None,
bbox: Optional[BBox] = None, utilities: Optional[List[dict]] = None,
stale_customers_out: int = 0) -> OutageArea:
"""One normalised county outage record. ``id`` is provider-prefixed.
``customers_out`` counts only live events. ``stale_customers_out`` counts
events the source still lists whose own restoration estimate passed long
ago (ODIN carries April rows in October) -- kept visible, never summed in.
``stale`` is True when every event in the county is stale."""
return {
"id": f"{provider_id}:{fips}",
"provider": provider_id,
"fips": fips, "county": county, "state": state,
"customers_out": customers_out,
"stale_customers_out": stale_customers_out,
"stale": customers_out == 0 and stale_customers_out > 0,
"customers_served": customers_served,
"etr": etr, # latest estimated restoration across the county's outages, if any
"since": since, # earliest reported start across the county's outages
"updated_at": updated_at, # newest time a reporting utility in this county last sent data
# Approximate: the centre of the county's bounding box, for placing a label.
"lat": lat, "lon": lon,
"bbox": list(bbox) if bbox else None, # south, west, north, east
"utilities": utilities or [],
}
def area_in_bbox(area: OutageArea, bbox: BBox) -> bool:
"""County bbox intersects the query; falls back to the label point."""
b = area.get("bbox")
if b and len(b) == 4:
return bbox_intersects((b[0], b[1], b[2], b[3]), bbox)
lat, lon = area.get("lat"), area.get("lon")
if lat is None or lon is None:
return False
s, w, n, e = bbox
return s <= lat <= n and w <= lon <= e
def parse_iso(v: Any) -> Optional[datetime]:
"""ISO-8601 (with Z or offset) -> aware UTC datetime; None when unparseable."""
if not isinstance(v, str) or not v:
return None
try:
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
return None
return (d if d.tzinfo else d.replace(tzinfo=timezone.utc)).astimezone(timezone.utc)
def iso(d: Optional[datetime]) -> Optional[str]:
return d.isoformat().replace("+00:00", "Z") if d else None
def geometry_bbox(geom: Any) -> Optional[BBox]:
"""Bounding box (south, west, north, east) of a GeoJSON Polygon/MultiPolygon."""
if not isinstance(geom, dict):
return None
lats: List[float] = []
lons: List[float] = []
def walk(c: Any) -> None:
if isinstance(c, list) and len(c) >= 2 and not isinstance(c[0], list):
lon, lat = finite(c[0]), finite(c[1])
if lon is not None and lat is not None and -180 <= lon <= 180 and -90 <= lat <= 90:
lons.append(lon)
lats.append(lat)
elif isinstance(c, list):
for x in c:
walk(x)
walk(geom.get("coordinates"))
if not lats:
return None
return (min(lats), min(lons), max(lats), max(lons))
def bbox_centre(b: Optional[BBox]) -> Tuple[Optional[float], Optional[float]]:
if not b:
return None, None
return (round((b[0] + b[2]) / 2, 5), round((b[1] + b[3]) / 2, 5))
@@ -0,0 +1,273 @@
"""Outage sources -- server-26#184 (source-terms table in its 2026-10-05 comment).
Only ODIN has an adapter. Every other source is listed so Admin shows its
licence status, and is a stub because DRB holds no right to use it: the utility
storm-center maps are undocumented JSON with no reuse licence (the panel ruling
stands -- do not ship scraped utility data commercially), PowerOutage.us
licenses its API for personal/non-commercial use only, and EAGLE-I's live feed
is a DOE partner system. Do not "fix" a stub by scraping.
ODIN, verified first-hand 2026-10-05 against https://odin.ornl.gov/v3/api-docs
(the OpenAPI document the ODIN developer guide points at) and live responses:
GET /odi?format=JSON public, no token. ``{"outage": [CIM Outage, ...]}``,
one row per outage event, every row aggregated to a
county: ``communityDescriptor`` = 5-digit county FIPS,
``metersAffected``, ``reportedStartTime``,
``estimatedRestorationTime.ert``, ``names`` carrying
``UtilityName`` / ``UtilityId`` (EIA id).
GET /odi/map?scale=COUNTY public. One row per county with active outages:
``id`` (FIPS), ``countyName``, ``stateName``,
``metersOut`` (= sum of /odi metersAffected, checked),
``feature`` (county Polygon/MultiPolygon).
GET /odi/status public. One row per reporting utility: ``eiaId``,
``name``, ``receivedDate``, ``hasError``.
``metersServed`` on /odi/map is the utility's system-wide total (National Grid
NY carries 1,686,884 on every county), so it is NOT a county denominator and
``customers_served`` stays None.
"""
import asyncio
import re
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple
from app.internal.outages.base import (
Licence, OutageArea, OutageLicenceInfo, OutageProvider, OutageProviderMeta, StubProvider, USER_AGENT,
bbox_centre, finite, geometry_bbox, get_json, iso, make_area, parse_iso, require_parsed,
)
US_BBOX = (17.5, -180.0, 72.0, -64.0) # CONUS + AK + HI + PR, rough
NY_BBOX = (40.4, -80.0, 45.1, -71.7)
ODIN_BASE = "https://odin.ornl.gov"
ODIN_OPENAPI = "https://odin.ornl.gov/v3/api-docs"
_FIPS = re.compile(r"^\d{5}$")
# An event whose own estimated restoration passed this long ago is stale: ODIN
# still listed Avangrid rows from April 2026 (ETR April/May) on 2026-10-05.
# Those are not live outages and must not be summed into one or set "since".
STALE_AFTER_ETR = timedelta(hours=24)
class OdinProvider(OutageProvider):
meta = OutageProviderMeta(
id="odin",
name="ODIN (Oak Ridge National Laboratory / DOE)",
region="United States -- participating utilities only (NY: National Grid, Central Hudson, Avangrid reporting 2026-10-05)",
coverage=US_BBOX,
licence=OutageLicenceInfo(
status=Licence.UNVERIFIED,
label="Licence unverified -- public county-level API, but no terms of use or reuse licence found",
terms=("ODIN documents GET /odi, /odi/map and /odi/status as public, non-authenticated endpoints "
"returning current outages aggregated to county FIPS (OpenAPI read first-hand 2026-10-05). "
"That document carries no licence or terms-of-service field, and no terms-of-use page was "
"found on odin.ornl.gov. ORNL is operated for DOE by a contractor and the data is "
"utility-reported, so federal public-domain status cannot be assumed. Confirm reuse terms "
"with ODIN (odin.ornl.gov/pages/contact.html) before relying on it commercially."),
terms_url=ODIN_OPENAPI,
attribution="Outage data: ODIN, Oak Ridge National Laboratory (utility-reported)",
attribution_url="https://odin.ornl.gov",
disclaimer=("County-level counts of meters reported out by utilities that participate in ODIN. "
"A utility that does not report to ODIN shows no outages here, even during one. "
"Restoration times are the utility's estimate, when given."),
issue="server-26#184",
),
granularity="county (FIPS)",
implemented=True,
notes=("Public API, verified first-hand 2026-10-05: /odi?format=JSON (counts, start, ETR, utility EIA id), "
"/odi/map?scale=COUNTY (county name, state, county polygon -> bbox and label point) and /odi/status "
"(utility last-received time). Counts are meters, used as customers. Con Edison / O&R are on ODIN's "
"participant list but were NOT reporting on 2026-10-05, so NYC/Westchester show nothing from ODIN "
"until they do. Schema canary: rows upstream but none parseable is an error, never an empty layer."),
homepage="https://odin.ornl.gov",
)
def __init__(self) -> None:
self.reporting_utilities: Optional[int] = None
self.utilities_with_error: Optional[int] = None
async def fetch(self) -> List[OutageArea]:
h = {"User-Agent": USER_AGENT}
odi, county_map, status = await asyncio.gather(
get_json(f"{ODIN_BASE}/odi", params={"format": "JSON"}, headers=h),
get_json(f"{ODIN_BASE}/odi/map", params={"scale": "COUNTY"}, headers=h),
get_json(f"{ODIN_BASE}/odi/status", headers=h),
)
areas, info = parse_odin(odi, county_map, status)
self.reporting_utilities = info["reporting_utilities"]
self.utilities_with_error = info["utilities_with_error"]
return areas
def coverage_note(self) -> Optional[str]:
n = self.reporting_utilities
base = "Only utilities that report to ODIN appear; one outside ODIN shows no outages here even during one."
if n is None:
return base
err = f", {self.utilities_with_error} with a feed error" if self.utilities_with_error else ""
return f"{base} {n} utilities reporting nationwide{err}."
def _utility(names: object) -> Tuple[Optional[str], Optional[str]]:
name = eia = None
for n in names if isinstance(names, list) else []:
if not isinstance(n, dict):
continue
if n.get("nameType") == "UtilityName" and isinstance(n.get("name"), str):
name = n["name"]
elif n.get("nameType") == "UtilityId" and n.get("name") is not None:
eia = str(n["name"])
return name, eia
def parse_odin(odi: object, county_map: object, status: object,
now: Optional[datetime] = None) -> Tuple[List[OutageArea], dict]:
"""The three public ODIN responses -> one normalised record per county.
Events whose estimated restoration is more than ``STALE_AFTER_ETR`` before
``now`` are counted separately (``stale_customers_out``), not as live.
Raises ValueError when a payload has the wrong top-level shape or when rows
came back but none could be read -- an upstream schema change must surface
as a feed error, not as an empty map."""
if not isinstance(odi, dict) or not isinstance(odi.get("outage"), list):
raise ValueError("ODIN /odi: expected {'outage': [...]} -- upstream schema change?")
if not isinstance(county_map, list):
raise ValueError("ODIN /odi/map: expected a list -- upstream schema change?")
if not isinstance(status, list):
raise ValueError("ODIN /odi/status: expected a list -- upstream schema change?")
# Utility freshness, by EIA id.
received: Dict[str, str] = {}
errors = 0
for u in status:
if not isinstance(u, dict):
continue
errors += 1 if u.get("hasError") is True else 0
eia, d = u.get("eiaId"), parse_iso(u.get("receivedDate"))
if eia is not None and d is not None:
received[str(eia)] = iso(d)
require_parsed(len(status), list(received), "ODIN /odi/status")
# County geography.
geo: Dict[str, dict] = {}
for row in county_map:
if not isinstance(row, dict):
continue
fips = str(row.get("id") or "")
if not _FIPS.match(fips):
continue
b = geometry_bbox(row.get("feature"))
geo[fips] = {"county": row.get("countyName") if isinstance(row.get("countyName"), str) else None,
"state": row.get("stateName") if isinstance(row.get("stateName"), str) else None,
"bbox": b}
require_parsed(len(county_map), list(geo), "ODIN /odi/map county")
# Outage events, aggregated per county.
stale_before = (now or datetime.now(timezone.utc)) - STALE_AFTER_ETR
agg: Dict[str, dict] = {}
parsed = stale_events = 0
for o in odi["outage"]:
if not isinstance(o, dict):
continue
fips = str(o.get("communityDescriptor") or "")
meters = finite(o.get("metersAffected"))
if not _FIPS.match(fips) or meters is None or meters < 0:
continue
parsed += 1
area = o.get("outageArea") if isinstance(o.get("outageArea"), dict) else {}
start = parse_iso(o.get("reportedStartTime")) or parse_iso(area.get("earliestReportedTime"))
ert_obj = o.get("estimatedRestorationTime") if isinstance(o.get("estimatedRestorationTime"), dict) else {}
ert = parse_iso(ert_obj.get("ert"))
if ert is not None and ert.year < 2000: # Puget Sound Energy sends 0001-01-01T00:00:00Z for "no ETR"
ert = None
stale = ert is not None and ert < stale_before
stale_events += stale
a = agg.setdefault(fips, {"out": 0, "stale_out": 0, "utilities": {},
"live": {"since": None, "etr": None}, "stale": {"since": None, "etr": None}})
a["stale_out" if stale else "out"] += int(meters)
t = a["stale" if stale else "live"] # since/etr come from live events whenever there are any
if start and (t["since"] is None or start < t["since"]):
t["since"] = start
if ert and (t["etr"] is None or ert > t["etr"]):
t["etr"] = ert
name, eia = _utility(o.get("names"))
key = eia or name or "unknown"
u = a["utilities"].setdefault(key, {"name": name, "eia_id": eia, "customers_out": 0,
"stale_customers_out": 0})
u["stale_customers_out" if stale else "customers_out"] += int(meters)
require_parsed(len(odi["outage"]), [None] * parsed, "ODIN /odi outage")
areas: List[OutageArea] = []
for fips, a in agg.items():
g = geo.get(fips, {})
utilities = sorted(a["utilities"].values(), key=lambda u: (-u["customers_out"], -u["stale_customers_out"]))
stamps = [received[u["eia_id"]] for u in utilities if u["eia_id"] in received]
lat, lon = bbox_centre(g.get("bbox"))
t = a["live"] if a["out"] else a["stale"]
areas.append(make_area(
"odin", fips, county=g.get("county"), state=g.get("state"), customers_out=a["out"],
stale_customers_out=a["stale_out"], customers_served=None, etr=iso(t["etr"]), since=iso(t["since"]),
updated_at=max(stamps) if stamps else None, lat=lat, lon=lon, bbox=g.get("bbox"),
utilities=utilities,
))
areas.sort(key=lambda r: (-r["customers_out"], -r["stale_customers_out"]))
return areas, {"reporting_utilities": len(status), "utilities_with_error": errors, "stale_events": stale_events}
# ── stubs: catalogued, never enable-able ─────────────────────────────────────
_STORM_CENTER_TERMS = ("The utility's public outage map is backed by undocumented JSON (vendor storm-center "
"endpoints) with no published licence for automated reuse; the endpoints can change "
"without notice. Panel ruling (server-26#184): do not ship scraped utility data "
"commercially. A direct data-sharing agreement is the licensed path (also a sales lead).")
def _stub(id: str, name: str, region: str, coverage, status: Licence, label: str, terms: str,
terms_url: Optional[str], granularity: str, notes: str, homepage: Optional[str]) -> StubProvider:
return StubProvider(OutageProviderMeta(
id=id, name=name, region=region, coverage=coverage,
licence=OutageLicenceInfo(status=status, label=label, terms=terms, terms_url=terms_url,
issue="server-26#184"),
granularity=granularity, implemented=False, notes=notes, homepage=homepage,
))
PROVIDERS: List[OutageProvider] = [
OdinProvider(),
_stub("poweroutage_us", "PowerOutage.us (FE Bluefire LLC)", "United States", US_BBOX, Licence.PAID,
"Paid API -- licence is personal/non-commercial (news media excepted); commercial use needs an agreement DRB does not hold",
("API & Content Terms: the licence is for \"personal and noncommercial use (except for News Media "
"Organizations)\"; commercial use needs a separate agreement; pricing by contact. Quoted from a "
"search summary -- the terms page returned 403 to a direct fetch, so re-read it first-hand before "
"any acknowledgement."),
"https://poweroutage.us/legal/apitermsofuse", "county / utility",
"No adapter: needs a commercial contract and its API documentation.", "https://poweroutage.us"),
_stub("coned_oru_stormcenter", "Con Edison / Orange & Rockland outage map", "NYC, Westchester, Orange & Rockland",
NY_BBOX, Licence.AGREEMENT_REQUIRED,
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
_STORM_CENTER_TERMS, "https://www.coned.com/en/services-and-outages/outage-map-faq", "point / polygon + ETR",
("No adapter by design. Con Edison and O&R are on ODIN's participant list but were not reporting to "
"ODIN on 2026-10-05; if they start, the ODIN adapter covers them at county level."),
"https://outagemap.coned.com"),
_stub("nyseg_rge_stormcenter", "NYSEG / RG&E outage map (Avangrid)", "Upstate NY", NY_BBOX,
Licence.AGREEMENT_REQUIRED,
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
("No adapter by design. Avangrid (NYSEG/RG&E) reports to ODIN, so county-level data arrives through "
"the ODIN adapter."),
"https://www.nyseg.com"),
_stub("central_hudson_stormcenter", "Central Hudson outage map", "Hudson Valley, NY", NY_BBOX,
Licence.AGREEMENT_REQUIRED,
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
("No adapter by design. Central Hudson reports to ODIN, so county-level data arrives through the "
"ODIN adapter."),
"https://www.cenhud.com"),
_stub("eagle_i_live", "EAGLE-I live feed (ORNL / DOE)", "United States", US_BBOX, Licence.AGREEMENT_REQUIRED,
"Live feed is a DOE / government-partner system -- DRB has no access agreement",
("EAGLE-I's live 15-minute county outage feed is restricted to DOE and government partners. The public "
"ORNL Constellation dataset is historic only (no terms stated on the dataset) and is no use for a "
"live map."),
"https://doi.ccs.ornl.gov/dataset/d26451af-7ea4-577c-8285-78b72cd8a2dc", "county",
"No adapter: needs a DOE partner agreement.", "https://eagle-i.doe.gov"),
]
@@ -0,0 +1,216 @@
"""Outage provider registry: settings, licence gate, and bbox queries.
Mirrors ``app.internal.cameras.registry`` rule for rule. Settings live in
Firestore ``config/outage_providers``::
{"providers": {"<id>": {"enabled": bool, "updated_at": iso, "updated_by": {...},
"acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}}
Rules (owner decision #247 on server-26#184):
* every provider defaults OFF -- a missing doc/entry means off, and an
unreadable settings doc means everything is off (fail closed);
* a stub (no adapter) or a PROHIBITED source can never be enabled;
* a GATED licence (agreement required / non-commercial / unverified / paid)
can only be enabled with an explicit acknowledgement, recorded with who and
when in the settings doc AND audit_log. The audit write is a precondition
for a gated enable: no record, no enable;
* an acknowledgement is bound to the licence status it was given for. If a
provider's catalogued status changes in code, the old acknowledgement no
longer counts and the provider stops serving until re-acknowledged.
"""
import asyncio
import time
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.internal.outages import providers as sources
from app.internal.outages.base import BBox, Feed, Licence, OutageArea, OutageProvider, area_in_bbox, bbox_intersects
COLLECTION, DOC_ID = "config", "outage_providers"
_TTL_S = 30.0
MAX_AREAS = 3000 # per response; ODIN's whole country is ~100-300 counties on a normal day
PROVIDERS: Dict[str, OutageProvider] = {p.meta.id: p for p in sources.PROVIDERS}
FEEDS: Dict[str, Feed] = {pid: Feed(p) for pid, p in PROVIDERS.items() if p.meta.implemented}
NO_SOURCE_MESSAGE = "No licensed outage source covers this area."
_cache: Dict[str, dict] = {}
_cache_ts = 0.0
class ProviderError(Exception):
"""A refused settings change. ``code`` is the HTTP status the router returns."""
def __init__(self, code: int, message: str, **extra):
super().__init__(message)
self.code, self.message, self.extra = code, message, extra
def invalidate_cache() -> None:
global _cache_ts
_cache_ts = 0.0
async def get_settings() -> Dict[str, dict]:
"""Per-provider settings. Unreadable Firestore means everything is OFF (fail closed)."""
global _cache, _cache_ts
if _cache_ts and time.monotonic() - _cache_ts < _TTL_S:
return _cache
try:
doc = await fstore.doc_get(COLLECTION, DOC_ID) or {}
provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {}
_cache = {k: v for k, v in provs.items() if isinstance(v, dict)}
except Exception as e:
logger.warning(f"outage providers: settings unreadable ({e}); treating every provider as OFF")
_cache = {}
_cache_ts = time.monotonic()
return _cache
def effective(provider: OutageProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]:
"""(serving?, reason-if-not) for a provider given its settings entry."""
meta = provider.meta
if not meta.implemented:
return False, "adapter is a stub"
if meta.licence.status == Licence.PROHIBITED:
return False, "source terms forbid this use"
if not entry or not entry.get("enabled"):
return False, "disabled (default)"
if meta.licence.gated:
ack = entry.get("acknowledgement") or {}
if ack.get("licence_status") != meta.licence.status.value:
return False, "licence acknowledgement missing or out of date"
return True, None
async def is_serving(provider_id: str) -> bool:
p = PROVIDERS.get(provider_id)
if p is None:
return False
return effective(p, (await get_settings()).get(provider_id))[0]
async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str],
acknowledge_licence: bool = False) -> dict:
p = PROVIDERS.get(provider_id)
if p is None:
raise ProviderError(404, f"unknown outage provider '{provider_id}'")
meta, lic = p.meta, p.meta.licence
uid, email = actor
now = datetime.now(timezone.utc).isoformat()
entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}}
if enabled:
if not meta.implemented:
raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}")
if lic.status == Licence.PROHIBITED:
raise ProviderError(409, lic.warning() or "prohibited")
if lic.gated:
if not acknowledge_licence:
raise ProviderError(409, f"{meta.name}: licence acknowledgement required",
requires_acknowledgement=True, warning=lic.warning(),
licence=lic.as_dict())
entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now,
"licence_status": lic.status.value, "warning": lic.warning()}
# Precondition, not a side effect: a gated enable with no audit
# record must not happen. A failure here aborts before the write.
try:
await _audit(actor, "outage_provider.enable_acknowledged", provider_id,
{"licence_status": lic.status.value, "licence_label": lic.label,
"warning": lic.warning(), "acknowledged_at": now})
except Exception as e:
logger.error(f"outage providers: audit write failed, refusing gated enable of {provider_id} ({e})")
raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled "
"without an audit record; try again")
else:
entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement
before = (await get_settings()).get(provider_id) or {}
await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True)
invalidate_cache()
if not (enabled and lic.gated):
try:
await _audit(actor, "outage_provider.enable" if enabled else "outage_provider.disable", provider_id,
{"from": bool(before.get("enabled")), "to": bool(enabled),
"licence_status": lic.status.value})
except Exception as e: # the toggle already happened; never 500 a completed write
logger.error(f"outage providers: audit write failed for {provider_id} ({e})")
logger.info(f"outage provider {provider_id} -> enabled={enabled} by {email or uid}")
return await describe(provider_id)
async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None:
from app.internal import audit
await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action,
details={"provider_id": provider_id, **details})
async def describe(provider_id: str) -> dict:
p = PROVIDERS[provider_id]
entry = (await get_settings()).get(provider_id) or {}
serving, reason = effective(p, entry)
m = p.meta
feed = FEEDS.get(provider_id)
return {
"id": m.id, "name": m.name, "region": m.region, "granularity": m.granularity,
"implemented": m.implemented, "notes": m.notes, "homepage": m.homepage,
"licence": m.licence.as_dict(),
"enabled": bool(entry.get("enabled")),
"serving": serving, "not_serving_reason": reason,
"acknowledgement": entry.get("acknowledgement"),
"updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"),
"coverage_note": p.coverage_note(),
"health": feed.status() if feed else None,
}
async def list_providers() -> List[dict]:
await get_settings()
return [await describe(pid) for pid in PROVIDERS]
def credit(provider_id: str) -> dict:
lic = PROVIDERS[provider_id].meta.licence
return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer}
async def outages_in_bbox(bbox: BBox) -> dict:
"""Outage areas from every SERVING provider whose coverage meets ``bbox``.
Providers that are off are never fetched.
``status`` says why the list is what it is, so an empty layer is never
ambiguous: ``none_enabled`` (no provider is on), ``no_source`` (none of the
serving providers covers this area), ``error`` (every covering provider
failed and has nothing cached), else ``ok`` -- which may legitimately
carry zero areas when nothing is out."""
conf = await get_settings()
serving = [pid for pid in FEEDS if effective(PROVIDERS[pid], conf.get(pid))[0]]
live = [pid for pid in serving if bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
await asyncio.gather(*(FEEDS[pid].get() for pid in live))
areas: List[OutageArea] = []
providers = []
for pid in serving:
feed = FEEDS[pid]
hits = [a for a in feed.items if area_in_bbox(a, bbox)] if pid in live else []
room = max(0, MAX_AREAS - len(areas))
areas.extend(hits[:room])
providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid),
"granularity": PROVIDERS[pid].meta.granularity,
"coverage_note": PROVIDERS[pid].coverage_note(),
"covers_bbox": pid in live,
"fetched_at": feed.fetched_at, "error": feed.error,
"total_in_bbox": len(hits), "truncated": len(hits) > room})
if not serving:
status, message = "none_enabled", "No outage source is enabled. An admin can enable one under Admin -> Outage providers."
elif not live:
status, message = "no_source", NO_SOURCE_MESSAGE
elif all(FEEDS[pid].error and FEEDS[pid].fetched_at is None for pid in live):
status, message = "error", "Every outage source covering this area failed to load; see each provider's error."
else:
status, message = "ok", None
return {"areas": areas, "providers": providers, "serving_count": len(serving),
"status": status, "message": message}
+52 -11
View File
@@ -100,24 +100,65 @@ 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]: # ── Ranged reads for /media playback (server-26#233) ────────────────────────
"""Fetch an object back out of GCS. Server-side read — no signing involved.""" # A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
# memory (the old download_audio(), removed once nothing called it — #225 S8),
# 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
# 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
# credentials every time.
_gcs_client_cache = None
def _gcs_client():
global _gcs_client_cache
if _gcs_client_cache is None:
from google.cloud import storage
if settings.gcp_credentials_path:
_gcs_client_cache = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
_gcs_client_cache = storage.Client()
return _gcs_client_cache
async def audio_object_size(gcs_uri: str) -> Optional[int]:
"""Size in bytes of a stored audio object, or None if it is missing/unreadable."""
bucket_name, blob_path = split_gcs_uri(gcs_uri) bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name: if not bucket_name:
return None return None
def _download() -> bytes: def _size() -> Optional[int]:
from google.cloud import storage blob = _gcs_client().bucket(bucket_name).blob(blob_path)
if settings.gcp_credentials_path: blob.reload()
client = storage.Client.from_service_account_json(settings.gcp_credentials_path) return blob.size
else:
client = storage.Client()
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
try: try:
return await asyncio.to_thread(_download) size = await asyncio.to_thread(_size)
except Exception as e: except Exception as e:
logger.warning(f"GCS download failed for {gcs_uri}: {e}") logger.warning(f"GCS metadata read failed for {gcs_uri}: {e}")
return None
return int(size) if size is not None else None
async def download_audio_range(gcs_uri: str, start: int, end: int) -> Optional[bytes]:
"""Bytes ``start``..``end`` INCLUSIVE of a stored object (GCS's own convention).
Reads only that span from GCS — never the whole object.
"""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _read() -> bytes:
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
return blob.download_as_bytes(start=start, end=end)
try:
return await asyncio.to_thread(_read)
except Exception as e:
logger.warning(f"GCS ranged read failed for {gcs_uri} [{start}-{end}]: {e}")
return None return None
+408
View File
@@ -0,0 +1,408 @@
"""Weather context for the map: NWS active alerts and nearest-METAR wind (server-26#237).
Sources -- both US Government works, public domain, no licence gate:
* NWS alerts: https://api.weather.gov/alerts/active?area=<codes>
(docs: https://www.weather.gov/documentation/services-web-api). NWS requires
a User-Agent naming the app and a contact, and rate-limits, so the feed is
fetched server-side, cached (``settings.weather_alerts_ttl_s``) and shared by
every viewer. Credit: "National Weather Service".
* METAR wind: https://aviationweather.gov/api/data/metar?bbox=minLat,minLon,maxLat,maxLon&format=json
(docs: https://aviationweather.gov/data/api/). Verified 2026-10-05: JSON list,
one latest report per station, HTTP 204 with an empty body when the bbox holds
no station, ``wdir`` is an int or the string "VRB".
Shape and failure rules are the 511NY ones (app/internal/ny511.py): a failed
refresh keeps serving the last good data and reports the error and its age; a
payload whose records ALL fail to parse is a schema change and an error, never
an empty layer.
Many NWS alerts carry ``geometry: null`` -- they are zone-based (forecast/county
zones listed in ``affectedZones``). Each zone's polygon is fetched once and kept
24 h. An alert whose zones cannot be resolved is returned in ``zone_only`` with
its event and expiry, so nothing is silently dropped.
"""
import asyncio
import math
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
import httpx
from app.config import settings
from app.internal.cameras.base import safe_error
from app.internal.logger import logger
NWS_ALERTS_URL = "https://api.weather.gov/alerts/active"
NWS_ZONE_PREFIX = "https://api.weather.gov/zones/" # only zone URLs under this are ever fetched
METAR_URL = "https://aviationweather.gov/api/data/metar"
CREDIT = "National Weather Service"
WIND_CREDIT = "NOAA Aviation Weather Center (METAR)"
DEFAULT_USER_AGENT = "DRB scanner map (contact: set NWS_USER_AGENT)"
RETRY_AFTER_FAILURE_S = 30
ZONE_TTL_S = 24 * 3600
ZONE_FAILURE_RETRY_S = 15 * 60
ZONE_CONCURRENCY = 6
# A state-wide winter event can list 100+ zones. Fetches past this cap wait for
# the next refresh (the zone cache fills across refreshes); meanwhile the alert
# shows in zone_only or with the zones resolved so far.
MAX_ZONE_FETCHES_PER_REFRESH = 80
# Wall-clock cap on one refresh's zone fetches. The refresh runs under the feed
# lock, so without it a slow zone endpoint (20 s timeout x 80 fetches / 6 wide)
# could hold every /weather/alerts request for minutes. Unfinished zones are
# simply not cached and are retried next refresh.
ZONE_BUDGET_S = 15.0
WIND_TTL_S = 5 * 60
WIND_CELL_DEG = 0.5 # wind cache cell; one upstream call per cell per TTL
WIND_MAX_KM = 75.0 # a station farther than this says nothing about the incident's wind
WIND_STALE_S = 2 * 3600 # older reports are returned but flagged stale
_WIND_CACHE_MAX = 500
_warned_ua = False
def _headers(accept: str) -> Dict[str, str]:
global _warned_ua
ua = settings.nws_user_agent or DEFAULT_USER_AGENT
if (ua == DEFAULT_USER_AGENT or "example.com" in ua) and not _warned_ua:
_warned_ua = True
logger.warning("NWS_USER_AGENT is the placeholder -- set it to 'app (contact email)'; NWS may block us")
return {"User-Agent": ua, "Accept": accept}
# ── geometry helpers ─────────────────────────────────────────────────────────
def _polygons(geom: Any) -> List[Any]:
"""Polygon coordinate arrays out of a Polygon / MultiPolygon / GeometryCollection."""
if not isinstance(geom, dict):
return []
t = geom.get("type")
if t == "Polygon" and isinstance(geom.get("coordinates"), list):
return [geom["coordinates"]]
if t == "MultiPolygon" and isinstance(geom.get("coordinates"), list):
return list(geom["coordinates"])
if t == "GeometryCollection":
return [p for g in geom.get("geometries") or [] for p in _polygons(g)]
return []
def _merge(polys: List[Any]) -> Optional[Dict[str, Any]]:
if not polys:
return None
if len(polys) == 1:
return {"type": "Polygon", "coordinates": polys[0]}
return {"type": "MultiPolygon", "coordinates": polys}
def _bbox(geom: Optional[Dict[str, Any]]) -> Optional[List[float]]:
"""[south, west, north, east] of a (Multi)Polygon, or None."""
lats: List[float] = []
lons: List[float] = []
for poly in _polygons(geom):
for ring in poly:
for pt in ring:
try:
lons.append(float(pt[0]))
lats.append(float(pt[1]))
except (TypeError, ValueError, IndexError):
continue
if not lats:
return None
return [min(lats), min(lons), max(lats), max(lons)]
def intersects(b: Optional[List[float]], south: float, west: float, north: float, east: float) -> bool:
return b is not None and b[0] <= north and b[2] >= south and b[1] <= east and b[3] >= west
# ── NWS alerts ───────────────────────────────────────────────────────────────
def normalize_alert(feature: Any) -> Optional[Dict[str, Any]]:
"""One GeoJSON feature -> our record (geometry may still be None). None if unparseable."""
if not isinstance(feature, dict):
return None
p = feature.get("properties")
if not isinstance(p, dict):
return None
aid = p.get("id") or feature.get("id")
event = p.get("event")
if not aid or not event:
return None
geometry = _merge(_polygons(feature.get("geometry")))
zones = [z for z in (p.get("affectedZones") or []) if isinstance(z, str)]
return {
"id": aid,
"event": event,
"severity": p.get("severity"),
"urgency": p.get("urgency"),
"headline": p.get("headline"),
"onset": p.get("onset"),
"expires": p.get("expires"),
"ends": p.get("ends"),
"sender_name": p.get("senderName"), # an NWS office ("NWS Upton NY"), not a person
"area_desc": p.get("areaDesc"),
"geometry": geometry,
"geometry_source": "alert" if geometry else None,
"zones": zones,
}
class _ZoneCache:
def __init__(self):
self._entries: Dict[str, Tuple[float, Optional[List[Any]]]] = {} # url -> (expires_at, polygons|None)
def get(self, url: str) -> Tuple[bool, Optional[List[Any]]]:
e = self._entries.get(url)
if e is None or e[0] < time.time():
return False, None
return True, e[1]
def put(self, url: str, polys: Optional[List[Any]]):
ttl = ZONE_TTL_S if polys else ZONE_FAILURE_RETRY_S
self._entries[url] = (time.time() + ttl, polys)
def purge(self):
now = time.time()
for k in [k for k, e in self._entries.items() if e[0] < now]:
del self._entries[k]
def clear(self):
self._entries.clear()
zones = _ZoneCache()
async def _fetch_zone(client: "httpx.AsyncClient", url: str, sem: asyncio.Semaphore) -> None:
async with sem:
try:
r = await client.get(url, headers=_headers("application/geo+json"))
r.raise_for_status()
polys = _polygons(r.json().get("geometry"))
except Exception as e:
logger.warning(f"NWS zone {url} unresolved: {safe_error(e)}")
polys = []
zones.put(url, polys or None)
async def _resolve_zones(client: "httpx.AsyncClient", alerts: List[Dict[str, Any]]) -> None:
"""Fill geometry for zone-based alerts in place, from the 24 h zone cache."""
zones.purge()
wanted: List[str] = []
for a in alerts:
if a["geometry"] is None:
for z in a["zones"]:
if z.startswith(NWS_ZONE_PREFIX) and not zones.get(z)[0] and z not in wanted:
wanted.append(z)
if wanted:
sem = asyncio.Semaphore(ZONE_CONCURRENCY)
try:
await asyncio.wait_for(
asyncio.gather(*(_fetch_zone(client, z, sem) for z in wanted[:MAX_ZONE_FETCHES_PER_REFRESH])),
timeout=ZONE_BUDGET_S)
except asyncio.TimeoutError:
logger.warning(f"NWS zone fetches over {ZONE_BUDGET_S:.0f}s budget; the rest resolve next refresh")
for a in alerts:
if a["geometry"] is not None:
continue
polys: List[Any] = []
missing = 0
for z in a["zones"]:
hit, p = zones.get(z)
if hit and p:
polys.extend(p)
else:
missing += 1
a["geometry"] = _merge(polys)
if a["geometry"] is not None:
a["geometry_source"] = "zones"
a["zones_unresolved"] = missing # >0: drawn area is smaller than the real one
async def _fetch_alerts() -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
area = ",".join(s.strip().upper() for s in (settings.nws_area or "").split(",") if s.strip())
if not area:
raise ValueError("NWS_AREA is empty -- no state/marine area to ask NWS for")
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(NWS_ALERTS_URL, params={"area": area}, headers=_headers("application/geo+json"))
r.raise_for_status()
raw = r.json()
features = raw.get("features") if isinstance(raw, dict) else None
if not isinstance(features, list):
raise ValueError("NWS alerts payload has no 'features' list -- schema change?")
parsed = [a for a in (normalize_alert(f) for f in features) if a is not None]
if features and not parsed:
raise ValueError(f"0 of {len(features)} NWS alerts parsed -- schema change?")
await _resolve_zones(client, parsed)
mapped, zone_only = [], []
for a in parsed:
a["bbox"] = _bbox(a["geometry"])
if a["geometry"] is None:
zone_only.append({k: v for k, v in a.items() if k not in ("geometry", "geometry_source", "bbox")})
else:
mapped.append(a)
return mapped, zone_only
class _AlertsFeed:
def __init__(self):
self.alerts: List[Dict[str, Any]] = []
self.zone_only: List[Dict[str, Any]] = []
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
self.error: Optional[str] = None
self._next_attempt = 0.0
self._lock = asyncio.Lock()
async def get(self) -> "_AlertsFeed":
if time.time() < self._next_attempt:
return self
async with self._lock: # single flight: waiters see the refreshed state
if time.time() < self._next_attempt:
return self
ttl = max(30, int(settings.weather_alerts_ttl_s))
try:
self.alerts, self.zone_only = await _fetch_alerts()
self.fetched_at = time.time()
self.error = None
self._next_attempt = time.time() + ttl
except Exception as e:
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
self.error = safe_error(e)
logger.warning(f"NWS alerts refresh failed, serving {len(self.alerts)} cached: {self.error}")
return self
alerts = _AlertsFeed()
# ── METAR wind ───────────────────────────────────────────────────────────────
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = p2 - p1, math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 6371.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
def normalize_metar(x: Any) -> Optional[Dict[str, Any]]:
if not isinstance(x, dict) or not x.get("icaoId"):
return None
try:
lat, lon = float(x["lat"]), float(x["lon"])
except (KeyError, TypeError, ValueError):
return None
wspd = x.get("wspd")
if not isinstance(wspd, (int, float)):
return None # no wind group in this report
wdir = x.get("wdir")
variable = isinstance(wdir, str) and wdir.upper() == "VRB"
obs = x.get("obsTime")
obs_iso = (datetime.fromtimestamp(obs, tz=timezone.utc).isoformat().replace("+00:00", "Z")
if isinstance(obs, (int, float)) else x.get("reportTime"))
gust = x.get("wgst")
return {
"station": x["icaoId"],
"station_name": x.get("name") or "",
"lat": lat, "lon": lon,
"obs_epoch": obs if isinstance(obs, (int, float)) else None,
"obs_time": obs_iso,
"wind_dir_deg": wdir if isinstance(wdir, (int, float)) else None,
"variable": variable,
"wind_kt": wspd,
"gust_kt": gust if isinstance(gust, (int, float)) else None,
"raw": x.get("rawOb"),
}
class _WindCell:
def __init__(self):
self.stations: List[Dict[str, Any]] = []
self.fetched_at: Optional[float] = None
self.error: Optional[str] = None
self.next_attempt = 0.0
self.lock = asyncio.Lock()
_wind_cells: Dict[Tuple[int, int], _WindCell] = {}
def _cell_key(lat: float, lon: float) -> Tuple[int, int]:
return (math.floor(lat / WIND_CELL_DEG), math.floor(lon / WIND_CELL_DEG))
async def _fetch_metars(key: Tuple[int, int]) -> List[Dict[str, Any]]:
# Search box = the cell padded by WIND_MAX_KM, so every point in the cell sees
# every station within WIND_MAX_KM of it.
s, w = key[0] * WIND_CELL_DEG, key[1] * WIND_CELL_DEG
n, e = s + WIND_CELL_DEG, w + WIND_CELL_DEG
pad_lat = WIND_MAX_KM / 111.0
pad_lon = WIND_MAX_KM / (111.0 * max(0.2, math.cos(math.radians(max(abs(s), abs(n))))))
bbox = f"{s - pad_lat:.3f},{w - pad_lon:.3f},{n + pad_lat:.3f},{e + pad_lon:.3f}"
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(METAR_URL, params={"bbox": bbox, "format": "json"}, headers=_headers("application/json"))
r.raise_for_status()
if r.status_code == 204 or not r.content.strip():
return [] # no station in the box
raw = r.json()
if not isinstance(raw, list):
raise ValueError(f"METAR API: expected a JSON list, got {type(raw).__name__}")
latest: Dict[str, Dict[str, Any]] = {}
for m in (normalize_metar(x) for x in raw):
if m is None:
continue
cur = latest.get(m["station"])
if cur is None or (m["obs_epoch"] or 0) > (cur["obs_epoch"] or 0):
latest[m["station"]] = m
# Schema canary: reports without a wind group are normal, but if NO record even
# has a station id and position, the field names changed under us.
if raw and not any(isinstance(x, dict) and x.get("icaoId") and "lat" in x for x in raw):
raise ValueError(f"0 of {len(raw)} METAR records parsed -- schema change?")
return list(latest.values())
async def _cell(lat: float, lon: float) -> _WindCell:
key = _cell_key(lat, lon)
cell = _wind_cells.get(key)
if cell is None:
if len(_wind_cells) >= _WIND_CACHE_MAX: # bound memory: drop expired cells, then the oldest
for k in [k for k, c in _wind_cells.items() if c.next_attempt < time.time() and not c.lock.locked()]:
del _wind_cells[k]
if len(_wind_cells) >= _WIND_CACHE_MAX:
del _wind_cells[min(_wind_cells, key=lambda k: _wind_cells[k].next_attempt)]
cell = _wind_cells[key] = _WindCell()
if time.time() < cell.next_attempt:
return cell
async with cell.lock:
if time.time() < cell.next_attempt:
return cell
try:
cell.stations = await _fetch_metars(key)
cell.fetched_at = time.time()
cell.error = None
cell.next_attempt = time.time() + WIND_TTL_S
except Exception as e:
cell.next_attempt = time.time() + RETRY_AFTER_FAILURE_S
cell.error = safe_error(e)
logger.warning(f"METAR refresh for cell {key} failed, serving {len(cell.stations)} cached: {cell.error}")
return cell
async def nearest_wind(lat: float, lon: float) -> Dict[str, Any]:
"""Wind from the nearest METAR station within WIND_MAX_KM, or a clear reason why not."""
cell = await _cell(lat, lon)
base = {"fetched_at": cell.fetched_at, "error": cell.error, "credit": WIND_CREDIT}
ranked = sorted(((haversine_km(lat, lon, s["lat"], s["lon"]), s) for s in cell.stations), key=lambda t: t[0])
if not ranked or ranked[0][0] > WIND_MAX_KM:
if cell.fetched_at is None:
reason = f"METAR feed unavailable: {cell.error}" if cell.error else "METAR feed not fetched yet"
else:
reason = f"no METAR station reporting wind within {WIND_MAX_KM:.0f} km"
return {**base, "available": False, "reason": reason, "wind": None}
dist, s = ranked[0]
stale = s["obs_epoch"] is not None and time.time() - s["obs_epoch"] > WIND_STALE_S
wind = {k: v for k, v in s.items() if k not in ("obs_epoch",)}
wind["distance_km"] = round(dist, 1)
wind["stale"] = stale
return {**base, "available": True, "reason": None, "wind": wind}
+6
View File
@@ -19,12 +19,14 @@ from app.internal.auth import (
) )
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
from app.routers import outages, weather
from app.routers import discord as discord_router from app.routers import discord as discord_router
from app.routers import discord_link as discord_link_router from app.routers import discord_link as discord_link_router
from app.internal import dynsec from app.internal import dynsec
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.routers import discord_web from app.routers import discord_web
from app.routers import discord_checks from app.routers import discord_checks
from app.routers import discord_oauth
async def _clear_legacy_token_flags(): async def _clear_legacy_token_flags():
@@ -147,6 +149,9 @@ app.include_router(places.router, dependencies=[Depends(require_service_or_fi
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit) app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
app.include_router(outages.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(outages.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
app.include_router(weather.router, dependencies=[Depends(require_service_or_firebase_token)]) # auth: any signed-in user/service, like traffic
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run) app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
@@ -166,6 +171,7 @@ app.include_router(discord_web.router)
app.include_router(discord_router.router) app.include_router(discord_router.router)
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213 app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225 app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signed-in starter); 404 while OAuth is off — #225 S6
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP # NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project # backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing # is archived (no CVE patches) and was rejected for an internet-facing
+31 -19
View File
@@ -17,7 +17,6 @@ Auth:
from __future__ import annotations from __future__ import annotations
import secrets import secrets
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Security from fastapi import APIRouter, Depends, HTTPException, Path, Security
@@ -26,6 +25,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import settings from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_rest from app.internal import discord_rest
from app.internal import discord_streams as streams from app.internal import discord_streams as streams
@@ -138,6 +138,7 @@ async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name, mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
created_by=body.created_by, preferred_token_id=body.preferred_token_id, created_by=body.created_by, preferred_token_id=body.preferred_token_id,
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id, max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
interactive=True, # /join, the admin page: re-read a stale "denied" (server-26#251)
) )
except streams.StreamError as e: except streams.StreamError as e:
return _error(e) return _error(e)
@@ -181,23 +182,18 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
org = await fstore.doc_get("organizations", body.org_id) org = await fstore.doc_get("organizations", body.org_id)
if not org: if not org:
raise HTTPException(404, "Organization not found.") raise HTTPException(404, "Organization not found.")
previous_org = await streams.resolve_guild_org(guild_id) # Support override: may move a server between orgs. Re-linking a server
doc = { # that was unlinked or removed brings it back. Live channels placed on the
"guild_id": guild_id, "org_id": body.org_id, # previous org's nodes are stopped (glink._settle) — left running, the
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(), # reconciler would keep re-placing them there.
# Re-linking a server that was unlinked or removed brings it back. out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin",
"unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None, "admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON)
} uid, email = describe_actor(decoded)
if body.guild_name and body.guild_name.strip(): await write_audit(uid, email, "discord.guild.link", details={
doc["guild_name"] = body.guild_name.strip()[:100] "guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"],
await fstore.doc_set(streams.GUILDS, guild_id, doc) "already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin",
# Live channels in this server were placed on the previous org's nodes; })
# left running, the reconciler would keep re-placing them there, so one return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]}
# org's equipment would keep serving a server that now belongs to another.
stopped: list[str] = []
if previous_org and previous_org != body.org_id:
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
@router.delete("/streams/{stream_id}") @router.delete("/streams/{stream_id}")
@@ -273,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")}
@@ -288,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"))}
@@ -295,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 {}
@@ -309,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 "",
@@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
return "needs_fix" # a start is refused with missing_permissions return "needs_fix" # a start is refused with missing_permissions
if any(r["ok"] for r in rows): if any(r["ok"] for r in rows):
return "busy" return "busy"
if any(r["status"] == discord_rest.INVALID for r in rows):
return "invalid" # the bot IS here, but Discord rejected its token (#257)
return "no_bot" return "no_bot"
+5 -7
View File
@@ -101,23 +101,21 @@ async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = De
redeem_guild_limiter.check(f"guild:{guild_id}") redeem_guild_limiter.check(f"guild:{guild_id}")
redeem_user_limiter.check(f"user:{body.discord_user_id}") redeem_user_limiter.check(f"user:{body.discord_user_id}")
actor = f"discord:{body.discord_user_id}" actor = f"discord:{body.discord_user_id}"
before = await streams.resolve_guild_org(guild_id)
try: try:
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id) # Links, then stops anything the server had playing for the org it
# acted for before (the DISCORD_UNLINKED_GUILD_ORG_ID fallback).
result = await glink.redeem_and_settle(body.code, guild_id, body.guild_name, body.discord_user_id)
except glink.LinkError as e: except glink.LinkError as e:
await write_audit(actor, "", "discord.guild.link_refused", await write_audit(actor, "", "discord.guild.link_refused",
details={"guild_id": guild_id, "reason": e.code}) details={"guild_id": guild_id, "reason": e.code})
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code}) return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
org_id = result["org_id"] org_id = result["org_id"]
stopped: list = [] stopped = result["streams_stopped"]
if before and before != org_id:
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
# had playing belongs to that org, not the one it just linked to.
stopped = await streams.stop_guild(guild_id, "relinked")
await write_audit(actor, "", "discord.guild.link", details={ await write_audit(actor, "", "discord.guild.link", details={
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"], "guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped), "code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
"via": "code",
}) })
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}") logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
org = await fstore.doc_get("organizations", org_id) org = await fstore.doc_get("organizations", org_id)
+310
View File
@@ -0,0 +1,310 @@
"""
"Connect a Discord server" through a radio bot's own OAuth2 invite
(server-26#225 S6, owner decision Q1 = per-bot OAuth).
GET /discord/oauth/options org owner / platform admin — which bot Connect
would invite (no state minted). 404 = off.
POST /discord/oauth/start org owner / platform admin — mint a state,
return Discord's authorize URL
POST /discord/oauth/complete signed-in user who started it — exchange the
code, link the server, check permissions
Platform admins may pass ?org_id= to act for another org (resolve_org_scope).
Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
radio bot preferred for ``system_id`` (the org's own system or a shared one;
strict: a preferred bot that can't connect is a 404, not a quiet swap); else
the radio bot preferred for the node's system (``node_id``); else the org's own
bring-your-own bot; else the pool bot with the fewest servers that is under
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
secret set count. A bot the caller's org can't use is a 404, never a 403.
The org a server links to is the one the STATE names, re-checked against a
fresh read of the caller's membership; the server is the one Discord's code
exchange names. The redirect's ``guild_id`` query parameter is ignored.
This reverses ``discord_web``'s old "C2 never calls Discord on the browser's
behalf" on purpose (plan finding 5): the call is made with the bot
application's credentials, after the org-scope checks and rate limits here.
"""
from __future__ import annotations
import asyncio
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_leases as leases
from app.internal import discord_oauth as oauth
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import (
_RateLimiter, describe_actor, get_role, require_firebase_token, require_org_owner_token, resolve_org_scope,
)
from app.internal.logger import logger
from app.routers.discord_checks import voice_check
router = APIRouter(prefix="/discord/oauth", tags=["discord"])
start_limiter = _RateLimiter(max_calls=5, window_seconds=600)
complete_limiter = _RateLimiter(max_calls=10, window_seconds=600)
_ID = r"^[A-Za-z0-9_-]{1,64}$"
_OFF = "Connecting a Discord server through DRB isn't available."
def _require_enabled() -> None:
if not oauth.enabled():
raise HTTPException(404, _OFF)
def _err(status: int, code: str, message: str, **extra) -> JSONResponse:
return JSONResponse(status_code=status, content={"detail": message, "code": code, **extra})
def _server_count(tok: dict) -> Optional[int]:
members = tok.get("guild_ids")
return len(members) if isinstance(members, list) else None
def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
return (tok.get("token_id") in oauth_ids
and tok.get("valid") is not False
and bool(tok.get("application_id"))
and tok.get("org_id") in (None, org_id)
and leases.invitable(tok))
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
token_id: Optional[str] = None, system_id: Optional[str] = None) -> tuple[dict, str]:
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
``pool``. Raises HTTPException 404 when nothing fits."""
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
oauth_ids = await oauth.enabled_token_ids()
if token_id:
tok = tokens.get(token_id)
if not tok or tok.get("org_id") not in (None, org_id):
raise HTTPException(404, "Radio bot not found.")
if not _usable(tok, org_id, oauth_ids):
raise HTTPException(404, f"{tok.get('name') or 'That radio bot'} can't be used to connect a server "
"right now.")
return tok, "chosen"
if system_id:
# Same visibility as a node's system: the org's own or a shared one
# (no org_id). Another org's system is a 404, never a 403.
system = await fstore.doc_get("systems", system_id)
if not system or system.get("org_id") not in (None, org_id):
raise HTTPException(404, "System not found.")
pref_id = system.get("preferred_token_id") or ""
if pref_id:
# The caller asked for THIS system's bot by name: inviting a
# different one instead would be the silent swap this exists to stop.
pref = tokens.get(pref_id)
if not pref or not _usable(pref, org_id, oauth_ids):
raise HTTPException(404, "That system's radio bot can't be used to connect a server right now.")
return pref, "preferred"
if node_id:
node = await fstore.doc_get("nodes", node_id)
if not node or node.get("org_id") != org_id:
raise HTTPException(404, "Node not found.")
system_id = streams._effective_system(node)
system = await fstore.doc_get("systems", system_id) if system_id else None
pref = tokens.get((system or {}).get("preferred_token_id") or "")
if pref and _usable(pref, org_id, oauth_ids):
return pref, "preferred"
own = sorted((t for t in tokens.values() if t.get("org_id") == org_id and _usable(t, org_id, oauth_ids)),
key=lambda t: (str(t.get("name") or "").lower(), t["token_id"]))
if own:
return own[0], "org_bot"
cap = settings.discord_pool_bot_guild_cap
pool = sorted((t for t in tokens.values() if t.get("org_id") is None and _usable(t, org_id, oauth_ids)),
key=lambda t: (_server_count(t) if _server_count(t) is not None else cap,
str(t.get("name") or "").lower(), t["token_id"]))
if pool:
return pool[0], "pool"
raise HTTPException(404, _OFF)
def _bot_summary(tok: dict, why: str) -> dict:
return {"name": tok.get("name") or "DRB radio bot", "reason": why,
"own_bot": tok.get("org_id") is not None}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/options")
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
token_id: Optional[str] = Query(None, pattern=_ID),
org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token),
system_id: Annotated[Optional[str], Query(pattern=_ID)] = None):
"""Whether Connect is offered here, and which bot it would invite."""
_require_enabled()
scope = resolve_org_scope(decoded, org_id)
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id, system_id=system_id)
return {"available": True, "bot": _bot_summary(tok, why)}
class StartBody(BaseModel):
node_id: Optional[str] = Field(None, pattern=_ID)
token_id: Optional[str] = Field(None, pattern=_ID)
system_id: Optional[str] = Field(None, pattern=_ID)
return_to: Optional[str] = None
@router.post("/start")
async def oauth_start(body: StartBody, org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)):
_require_enabled()
scope = resolve_org_scope(decoded, org_id)
uid, email = describe_actor(decoded)
start_limiter.check(f"{uid}:discord_oauth_start")
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id,
system_id=body.system_id)
return_to = oauth.safe_return_to(body.return_to)
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
node_id=body.node_id, return_to=return_to)
await write_audit(uid, email, "discord.oauth.start", details={
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id,
"system_id": body.system_id})
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
class CompleteBody(BaseModel):
# Lengths checked in the handler: a pydantic 422 would echo the code back.
code: str
state: str
async def _still_owner(decoded: dict, uid: str, org_id: str) -> bool:
"""Fresh read: the claims in an ID token can be up to an hour old."""
if get_role(decoded) == "admin":
return True
member = await fstore.doc_get("org_members", uid)
return bool(member and member.get("org_id") == org_id and member.get("org_role") == "owner")
async def _channel_summary(guild_id: str, org_id: str, node_id: Optional[str]) -> Optional[dict]:
"""Permissions right after the link, within discord_oauth_link_check_seconds.
None = not answered in time (the picker's badges will say)."""
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
try:
out = await asyncio.wait_for(
voice_check(guild_id=guild_id, org_id=org_id, node_id=node_id,
channels=link.get("voice_channels") or [], channel_id=None, refresh=True,
with_token_ids=False),
timeout=settings.discord_oauth_link_check_seconds)
except Exception as e: # timeout or anything else: the link already stands
logger.info(f"Discord OAuth link {guild_id}: permission check skipped ({type(e).__name__})")
return None
channels = [{"channel_id": c["channel_id"], "name": c.get("name"), "state": c["state"],
"fix": next((b.get("fix") for b in c.get("bots") or [] if b.get("fix")), None)}
for c in out.get("channels") or []]
return {
"enabled": out.get("enabled"),
"channels_ready": sum(1 for c in channels if c["state"] == "ready"),
"channels_blocked": sum(1 for c in channels if c["state"] == "needs_fix"),
"channels": channels,
}
@router.post("/complete")
async def oauth_complete(body: CompleteBody, decoded: dict = Depends(require_firebase_token)):
_require_enabled()
uid, email = describe_actor(decoded)
complete_limiter.check(f"{uid}:discord_oauth_complete")
if not (0 < len(body.code) <= 200):
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
# 1. Consume the state (single use; must be this caller's).
try:
state = await oauth.consume_state(body.state, uid)
except oauth.StateError as e:
await write_audit(uid, email, "discord.oauth.refused", details={"reason": e.code})
return _err(e.status, e.code, e.message)
org_id, token_id = state["org_id"], state["token_id"]
return_to = state.get("return_to")
# 2. Still the owner of that org (fresh read, not the token's claims).
if not await _still_owner(decoded, uid, org_id):
await write_audit(uid, email, "discord.oauth.refused", details={"org_id": org_id, "reason": "not_owner"})
return _err(403, "not_owner", "Only the organization owner can connect a Discord server.")
# 3. Exchange the code with that bot application's own secret.
tok = await fstore.doc_get(leases.TOKENS, token_id)
secret = await oauth.load_secret(token_id)
if (not tok or not tok.get("application_id") or not secret
or tok.get("org_id") not in (None, org_id) or tok.get("valid") is False):
return _err(409, "bot_unavailable", "That radio bot can no longer connect servers. Start again from DRB.")
client_id = str(tok["application_id"])
r = await oauth.exchange_code(client_id, secret, body.code)
if not r.ok or not isinstance(r.data, dict):
await write_audit(uid, email, "discord.oauth.refused", details={
"org_id": org_id, "token_id": token_id, "reason": "exchange_failed", "status": r.status})
if r.status is not None and 400 <= r.status < 500 and r.status != 429:
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
return _err(502, "discord_unavailable", "Couldn't reach Discord to finish connecting. Start again from DRB.")
await oauth.revoke(client_id, secret, r.data.get("access_token"))
# 4. The server is the one Discord's exchange names — never the query string.
guild = r.data.get("guild") if isinstance(r.data.get("guild"), dict) else None
guild_id = str((guild or {}).get("id") or "")
if not guild_id.isdigit():
await write_audit(uid, email, "discord.oauth.refused", details={
"org_id": org_id, "token_id": token_id, "reason": "no_guild"})
return _err(400, "no_guild", "Discord didn't say which server the bot was added to. Start again from DRB.")
guild_name = guild.get("name") if isinstance(guild.get("name"), str) else None
# 5. Link + settle (a server another org linked is never moved: 409).
try:
result = await glink.link_and_settle(org_id, guild_id, guild_name, uid, "oauth",
extra={"linked_with_token_id": token_id})
except glink.LinkError as e:
await write_audit(uid, email, "discord.guild.link_refused", details={
"guild_id": guild_id, "org_id": org_id, "reason": e.code, "via": "oauth", "token_id": token_id})
return _err(e.status, e.code, e.message, guild_name=guild_name, bot_name=tok.get("name"))
# 6. The bot is in the server now: record it, drop stale cached answers, audit.
members = tok.get("guild_ids")
if isinstance(members, list) and guild_id not in members:
await fstore.doc_update(leases.TOKENS, token_id, {"guild_ids": [*members, guild_id]})
discord_rest.invalidate(guild_id=guild_id)
await write_audit(uid, email, "discord.guild.link", details={
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
"previous_org_id": result["previous_org_id"], "streams_stopped": len(result["streams_stopped"]),
"via": "oauth", "token_id": token_id,
})
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} via OAuth (bot {token_id}) by uid={uid}")
# 7. Which voice channels are ready, in the same request (capped).
check = await _channel_summary(guild_id, org_id, state.get("node_id"))
return {
"ok": True,
"guild_id": guild_id,
"guild_name": guild_name,
"org_id": org_id,
"bot_name": tok.get("name"),
"already_linked": result["already_linked"],
"streams_stopped": len(result["streams_stopped"]),
"node_id": state.get("node_id"),
"return_to": return_to,
"checked": check is not None,
"channels_ready": check["channels_ready"] if check else None,
"channels_blocked": check["channels_blocked"] if check else None,
"channels": check["channels"] if check else [],
}
+218 -17
View File
@@ -6,15 +6,49 @@ The node page used to take raw guild/channel IDs. It now picks from:
* the Discord servers the caller's org owns — ``discord_guilds`` docs that * the Discord servers the caller's org owns — ``discord_guilds`` docs that
resolve to the node's org (an explicit link, or the unlinked-server resolve to the node's org (an explicit link, or the unlinked-server
fallback org, same rule the bot uses: streams.resolve_guild_org), and fallback org, same rule the bot uses: streams.resolve_guild_org), and
* their voice channels, from a directory the DRB server bot pushes * their voice channels, from a directory kept on ``discord_guilds/{id}``.
(``PUT /discord/guilds/{id}/directory``). The bot already holds its
gateway cache of every server it is in; C2 never calls Discord with a Where the directory comes from (server-26#225 S5 — the main bot is optional):
token on the browser's behalf and no token string ever reaches the browser.
* the DRB server bot, when it is in the server: it pushes its gateway view
(``PUT /discord/guilds/{id}/directory``) on start, on changes and every
15 minutes (``directory_source: "main_bot"``);
* otherwise a radio bot that is in the server: C2 reads the channel list
over Discord REST with that bot's token (discord_rest.guild_directory —
the same cached snapshot the permission check reads, so no extra
request) and stores it (``directory_source: "radio_bot"``,
``radio_bot_present``). That happens when the picker is opened and the
stored list is missing (inline, inside the permission-check time budget)
or older than ``discord_directory_refresh_seconds`` (in the background).
This reverses the old rule "C2 never calls Discord on the browser's behalf"
on purpose (server-26#225 plan finding 5): C2 does call Discord when a
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``):
* linked to the org: shown unless the server bot said it left AND no
radio bot the org may use is in it (``bot_tokens.guild_ids``);
* acting for the org only through the unlinked-server fallback: shown only
while the server bot is there, exactly as before. A radio bot's
membership never discovers a server, and REST never creates a
``discord_guilds`` doc.
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node** A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with (streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
the checks that path skipped: the server must belong to the node's org, the the checks that path skipped: the server must belong to the node's org, the
channel must be a voice channel the bot reported in that server, and the channel must be a voice channel in that server's directory, and the
per-server and per-node stream caps apply. per-server and per-node stream caps apply.
Auth: Auth:
@@ -29,6 +63,7 @@ Auth:
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -38,6 +73,7 @@ from pydantic import BaseModel, Field
from app.config import settings from app.config import settings
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import discord_streams as streams from app.internal import discord_streams as streams
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.audit import write_audit from app.internal.audit import write_audit
@@ -54,6 +90,14 @@ router = APIRouter(prefix="/discord", tags=["discord"])
MAX_CHANNELS = 200 MAX_CHANNELS = 200
_NAME_MAX = 100 _NAME_MAX = 100
MAIN_BOT = "main_bot"
RADIO_BOT = "radio_bot"
# The server bot re-pushes every 15 min. A list it pushed more than three
# pushes ago means it is down (it never said it left): a radio bot's view
# takes over until it is back.
MAIN_BOT_STALE_SECONDS = 45 * 60
# How many member radio bots are asked, one after another, for the list.
DIRECTORY_BOTS = 3
def _now_iso() -> str: def _now_iso() -> str:
@@ -80,16 +124,20 @@ class GuildDirectory(BaseModel):
@router.put("/guilds/{guild_id}/directory") @router.put("/guilds/{guild_id}/directory")
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)): async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
"""The server bot's view of one Discord server: its name and voice channels. """The server bot's view of one Discord server: its name and voice channels.
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).""" Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).
Cached Discord snapshots of the server are dropped (a channel was added,
removed or renamed), so the next permission check reads it fresh."""
if not guild_id.isdigit(): if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord snowflake.") raise HTTPException(400, "guild_id must be a Discord snowflake.")
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()} data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
if body.present: if body.present:
data["voice_channels"] = [c.model_dump() for c in body.voice_channels] data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
data["directory_source"] = MAIN_BOT
if body.name: if body.name:
# Same field the /drb link flow writes (feat/213-guild-link, PR #218). # Same field the /drb link flow writes (feat/213-guild-link, PR #218).
data["guild_name"] = body.name data["guild_name"] = body.name
await fstore.doc_set(streams.GUILDS, guild_id, data) await fstore.doc_set(streams.GUILDS, guild_id, data)
discord_rest.invalidate(guild_id=guild_id)
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)} return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
@@ -130,24 +178,156 @@ def _require_control(can: bool) -> None:
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.") raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
async def _org_guilds(org_id: str) -> list[dict]: async def _load_tokens() -> dict[str, dict]:
"""``discord_guilds`` docs this org may stream into, bot still present. return {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
def _member_radio_bots(tokens: dict[str, dict], guild_id: str, org_id: Optional[str]) -> list[str]:
"""Radio bots this org may use (pool, or the org's own) that Discord last
said are in the server, best first. Unknown membership (``guild_ids``
None) does not count: presence has to be positive."""
out = [tid for tid, t in tokens.items()
if t.get("token") and t.get("valid") is not False and t.get("org_id") in (None, org_id)
and isinstance(t.get("guild_ids"), list) and guild_id in t["guild_ids"]]
out.sort(key=lambda tid: leases._candidate_order(tid, tokens[tid], "", org_id, None))
return out
async def _org_guilds(org_id: str, tokens: Optional[dict] = None) -> list[dict]:
"""``discord_guilds`` docs this org may stream into, with a bot there.
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
fallback org — except a server an org explicitly unlinked (``unlinked_at``, fallback org — except a server an org explicitly unlinked (``unlinked_at``,
PR #218), which gets no fallback.""" PR #218), which gets no fallback.
A linked server is shown while the server bot is there or a radio bot the
org may use is (server-26#225 S5: the main bot is optional). A server
that acts for the org only through the fallback still needs the server
bot: a radio bot's membership never discovers one."""
fallback = settings.discord_unlinked_guild_org_id or None fallback = settings.discord_unlinked_guild_org_id or None
if tokens is None:
tokens = await _load_tokens()
out = [] out = []
for g in await fstore.collection_list(streams.GUILDS): for g in await fstore.collection_list(streams.GUILDS):
gid = str(g.get("guild_id") or "") gid = str(g.get("guild_id") or "")
if not gid or g.get("bot_present") is False: if not gid:
continue continue
owner = g.get("org_id") or (None if g.get("unlinked_at") else fallback) if g.get("org_id"):
if owner and owner == org_id: if g["org_id"] != org_id:
out.append(g) continue
if g.get("bot_present") is False and not _member_radio_bots(tokens, gid, org_id):
continue
else:
if g.get("unlinked_at") or not fallback or fallback != org_id or g.get("bot_present") is False:
continue
out.append(g)
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"])) out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
return out return out
# ---------------------------------------------------------------------------
# Directory from a radio bot (server-26#225 S5)
# ---------------------------------------------------------------------------
def _age_seconds(ts) -> Optional[float]:
when = streams._parse_ts(ts)
return (datetime.now(timezone.utc) - when).total_seconds() if when else None
def _directory_due(g: dict) -> Optional[str]:
"""None when the stored list is fine; "missing" when there is none;
"stale" when it is old. Only for a server linked to an org — a fallback
server's list is the server bot's alone."""
if not g.get("org_id"):
return None
if g.get("voice_channels") is None:
return "missing"
from_main = g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
limit = MAIN_BOT_STALE_SECONDS if from_main else settings.discord_directory_refresh_seconds
age = _age_seconds(g.get("directory_at"))
return "stale" if age is None or age > limit else None
def _main_bot_list_fresh(g: dict) -> bool:
"""The server bot is there and its own list is current: it wins."""
return (g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
and _directory_due(g) is None)
async def _refresh_directory(g: dict, org_id: str, tokens: dict[str, dict]) -> Optional[dict]:
"""Read one linked server's voice channels from a member radio bot and
store them. Returns the fields written (also merged into ``g``), or None
when nothing was learned. Writes only onto the existing doc, and only
while it is still linked to ``org_id``: never creates a doc, never
touches ``org_id`` or the server bot's ``bot_present``."""
gid = g["guild_id"]
members = _member_radio_bots(tokens, gid, org_id)
if not members:
return None
docs = [{**tokens[t], "token_id": t} for t in members]
res = await discord_rest.guild_directory(docs, gid, max_bots=DIRECTORY_BOTS)
now = _now_iso()
if res.status == discord_rest.OK:
updates: dict = {"voice_channels": res.voice_channels, "directory_at": now,
"directory_source": RADIO_BOT, "radio_bot_present": True, "radio_bot_checked_at": now}
if res.name:
updates["guild_name"] = res.name
elif res.status == discord_rest.NOT_MEMBER:
updates = {"radio_bot_present": False, "radio_bot_checked_at": now}
else:
return None
cur = await fstore.doc_get(streams.GUILDS, gid)
if not cur or cur.get("org_id") != org_id:
return None # unlinked / moved / removed meanwhile
if _main_bot_list_fresh(cur):
return None # the server bot pushed a fresh list meanwhile; it wins
await fstore.doc_update(streams.GUILDS, gid, updates)
g.update(updates)
logger.info(f"Discord directory for {gid} from radio bot {res.token_id or '-'}: "
f"{res.status}, {len(res.voice_channels or [])} voice channel(s)")
return updates
_directory_tasks: dict[str, asyncio.Task] = {}
async def _refresh_quietly(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
try:
await _refresh_directory(g, org_id, tokens)
except Exception as e: # a background refresh must never surface anywhere
logger.warning(f"Discord directory refresh for {g.get('guild_id')} failed: {type(e).__name__}")
def _refresh_in_background(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
gid = g["guild_id"]
running = _directory_tasks.get(gid)
if running is not None and not running.done():
return
task = asyncio.create_task(_refresh_quietly(dict(g), org_id, tokens))
_directory_tasks[gid] = task
task.add_done_callback(lambda t, gid=gid: _directory_tasks.pop(gid, None)
if _directory_tasks.get(gid) is t else None)
async def _fill_directories(guilds: list[dict], org_id: str, tokens: dict[str, dict]) -> None:
"""Missing lists are read now (all servers at once, inside the
permission-check budget — an answer that comes later is cached and
stored by the next look); stale ones are refreshed in the background and
the stored list is shown meanwhile."""
now: list[dict] = []
for g in guilds:
due = _directory_due(g)
if not due or not _member_radio_bots(tokens, g["guild_id"], org_id):
continue
if due == "missing":
now.append(g)
else:
_refresh_in_background(g, org_id, tokens)
if not now:
return
tasks = [asyncio.create_task(_refresh_quietly(g, org_id, tokens)) for g in now]
await asyncio.wait(tasks, timeout=settings.discord_perm_check_budget_seconds)
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict: def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id) res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
if res.ok: if res.ok:
@@ -181,7 +361,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
system_id = streams._effective_system(node) system_id = streams._effective_system(node)
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
guilds = await _org_guilds(org_id) if org_id else [] tokens = await _load_tokens()
guilds = await _org_guilds(org_id, tokens) if org_id else []
if org_id:
await _fill_directories(guilds, org_id, tokens)
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on") live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
if s.get("org_id") == org_id] if s.get("org_id") == org_id]
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id) nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
@@ -190,7 +373,6 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
by_channel: dict[tuple, list] = {} by_channel: dict[tuple, list] = {}
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")): for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s) by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()} token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
out_guilds = [] out_guilds = []
@@ -216,6 +398,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
"linked": bool(g.get("org_id")), "linked": bool(g.get("org_id")),
"synced": g.get("voice_channels") is not None, "synced": g.get("voice_channels") is not None,
"synced_at": g.get("directory_at"), "synced_at": g.get("directory_at"),
# Who listed the channels: "main_bot" (DRB server bot) or "radio_bot" (S5).
"directory_source": (g.get("directory_source") or (MAIN_BOT if g.get("voice_channels") is not None
else None)),
"main_bot_present": g.get("bot_present") is True,
"voice_channels": channels, "voice_channels": channels,
"streams_on": sum(1 for s in live if s.get("guild_id") == gid), "streams_on": sum(1 for s in live if s.get("guild_id") == gid),
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id), "bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
@@ -261,10 +447,24 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
raise HTTPException(409, "This node is not listening to a system yet.") raise HTTPException(409, "This node is not listening to a system yet.")
guild_id, channel_id = str(body.guild_id), str(body.channel_id) guild_id, channel_id = str(body.guild_id), str(body.channel_id)
guild = next((g for g in await _org_guilds(org_id) if g["guild_id"] == guild_id), None) tokens = await _load_tokens()
guild = next((g for g in await _org_guilds(org_id, tokens) if g["guild_id"] == guild_id), None)
if not guild: if not guild:
raise HTTPException(404, "That Discord server is not linked to this organization.") raise HTTPException(404, "That Discord server is not linked to this organization.")
channel = next((c for c in guild.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
def _find(g: dict) -> Optional[dict]:
return next((c for c in g.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
channel = _find(guild)
if not channel and guild.get("org_id") and not _main_bot_list_fresh(guild):
# No list yet, or a radio bot's list that may predate this channel:
# read it once more (cached snapshot) before saying no.
try:
await asyncio.wait_for(_refresh_directory(guild, org_id, tokens),
timeout=settings.discord_perm_check_budget_seconds)
except asyncio.TimeoutError:
pass
channel = _find(guild)
if not channel: if not channel:
raise HTTPException(400, "That is not a voice channel DRB can see in this server. " raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
"Refresh the list and pick again.") "Refresh the list and pick again.")
@@ -287,6 +487,7 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
channel_name=channel.get("name"), guild_name=_guild_name(guild), channel_name=channel.get("name"), guild_name=_guild_name(guild),
created_by=f"web:{decoded.get('uid') or 'unknown'}", created_by=f"web:{decoded.get('uid') or 'unknown'}",
max_streams=settings.discord_guild_max_streams, max_streams=settings.discord_guild_max_streams,
interactive=True,
) )
except streams.StreamError as e: except streams.StreamError as e:
return JSONResponse(status_code=e.status, return JSONResponse(status_code=e.status,
+125 -22
View File
@@ -10,19 +10,100 @@ happens inline below, in the same spirit as routers/enrollment.py.
The bucket stays fully private and c2-core reads the object server-side with The bucket stays fully private and c2-core reads the object server-side with
Application Default Credentials, so no GCS signed URL — and therefore no Application Default Credentials, so no GCS signed URL — and therefore no
service-account private key on the VM — is involved anywhere in this path. service-account private key on the VM — is involved anywhere in this path.
HTTP Range (server-26#233): the body is streamed from GCS in fixed-size span
reads instead of downloaded whole first. A 600 s FLAC call is ~13 MB, and the
old full-body response (``Accept-Ranges: none``) made the browser wait for all
of it before playback started and made seeking re-download it. Now playback
starts after the first span and a seek is a ``206`` for just the bytes needed.
Only a single range is honoured; a multi-range request gets the full body,
which RFC 9110 §14.2 permits.
""" """
from fastapi import APIRouter, HTTPException, Query, Response import re
from typing import AsyncIterator, Optional, Tuple
from fastapi import APIRouter, Header, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for from app.internal.logger import logger
from app.internal.storage import (
audio_object_size,
content_type_for,
download_audio_range,
gcs_uri_for_call,
verify_audio_link,
)
router = APIRouter(prefix="/media", tags=["media"]) router = APIRouter(prefix="/media", tags=["media"])
# Bytes per GCS span read. ~25 s of FLAC at ~1.3 MB/min, so the first span —
# the only one the browser waits on — is one small GCS round-trip.
STREAM_CHUNK_BYTES = 512 * 1024
# Immutable content, but the URL expires — cache privately only.
_CACHE_CONTROL = "private, max-age=3600"
_RANGE_RE = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
class _Unsatisfiable(Exception):
pass
def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
"""Resolve a ``Range`` header against an object of ``size`` bytes.
Returns an inclusive ``(start, end)``, or None when the header is absent or
should be ignored (not ``bytes``, multi-range, malformed — RFC 9110 lets a
server answer those with the full 200). Raises _Unsatisfiable for a
well-formed range that selects nothing (-> 416).
"""
if not header:
return None
m = _RANGE_RE.match(header)
if not m:
return None # other unit, multi-range ("a-b,c-d") or garbage
first, last = m.group(1), m.group(2)
if first == "" and last == "":
return None
if first == "":
# Suffix range: the final N bytes.
n = int(last)
if n == 0 or size == 0:
raise _Unsatisfiable()
return max(0, size - n), size - 1
start = int(first)
if last != "" and int(last) < start:
return None # invalid spec -> ignore the header
if start >= size:
raise _Unsatisfiable()
end = size - 1 if last == "" else min(int(last), size - 1)
return start, end
async def _stream(gcs_uri: str, first_chunk: bytes, start: int, end: int) -> AsyncIterator[bytes]:
yield first_chunk
pos = start + len(first_chunk)
while pos <= end:
chunk_end = min(pos + STREAM_CHUNK_BYTES - 1, end)
data = await download_audio_range(gcs_uri, pos, chunk_end)
if not data:
# Headers are already sent, so the status can't change; ending the
# body short makes the client see a truncated response rather than
# silently wrong audio.
logger.warning(f"/media stream cut short at byte {pos} of {gcs_uri}")
return
yield data
pos += len(data)
@router.get("/calls/{call_id}/audio") @router.get("/calls/{call_id}/audio")
async def get_call_audio( async def get_call_audio(
call_id: str, call_id: str,
exp: int = Query(..., description="Link expiry, unix seconds."), exp: int = Query(..., description="Link expiry, unix seconds."),
sig: str = Query(..., description="HMAC over call_id and expiry."), sig: str = Query(..., description="HMAC over call_id and expiry."),
range_header: Optional[str] = Header(None, alias="Range"),
): ):
# Verify before touching Firestore so an invalid link costs nothing. # Verify before touching Firestore so an invalid link costs nothing.
if not verify_audio_link(call_id, exp, sig): if not verify_audio_link(call_id, exp, sig):
@@ -36,26 +117,48 @@ async def get_call_audio(
if not gcs_uri: if not gcs_uri:
raise HTTPException(404, "No audio for this call.") raise HTTPException(404, "No audio for this call.")
data = await download_audio(gcs_uri) size = await audio_object_size(gcs_uri)
if not data: if size is None:
raise HTTPException(404, "Audio object missing from storage.") raise HTTPException(404, "Audio object missing from storage.")
return Response( # Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser will
content=data, # not play a FLAC body labelled audio/mpeg. Derived from the stored
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser # object's extension so old .mp3 recordings keep working unchanged.
# will not play a FLAC body labelled audio/mpeg. Derived from the stored media_type = content_type_for(gcs_uri)
# object's extension so old .mp3 recordings keep working unchanged.
media_type=content_type_for(gcs_uri), try:
headers={ span = parse_range(range_header, size)
"Content-Length": str(len(data)), except _Unsatisfiable:
# Whole body at once, no Range support. This was comfortable at return Response(
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a status_code=416,
# long call is now tens of MB and the browser must download all of headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
# it before playback starts. Acceptable for typical few-second )
# transmissions, but this is the change that makes Range support
# actually matter — see DEFERRED.md. if size == 0:
"Accept-Ranges": "none", return Response(
# Immutable content, but the URL expires — cache privately only. content=b"",
"Cache-Control": "private, max-age=3600", media_type=media_type,
}, headers={"Accept-Ranges": "bytes", "Cache-Control": _CACHE_CONTROL},
)
start, end = span if span else (0, size - 1)
# Fetched before the response starts so a read failure is still a clean
# 404 rather than a stream that dies after a 200/206 has gone out.
first = await download_audio_range(gcs_uri, start, min(start + STREAM_CHUNK_BYTES - 1, end))
if not first:
raise HTTPException(404, "Audio object missing from storage.")
headers = {
"Content-Length": str(end - start + 1),
"Accept-Ranges": "bytes",
"Cache-Control": _CACHE_CONTROL,
}
if span:
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
return StreamingResponse(
_stream(gcs_uri, first, start, end),
status_code=206 if span else 200,
media_type=media_type,
headers=headers,
) )
+1 -1
View File
@@ -210,7 +210,7 @@ async def send_command(
doc = await discord_streams.start_stream( doc = await discord_streams.start_stream(
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id, org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id, system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
mode="adhoc", mode="adhoc", interactive=True,
) )
except discord_streams.StreamError as e: except discord_streams.StreamError as e:
raise HTTPException(e.status, e.message) raise HTTPException(e.status, e.message)
+52
View File
@@ -0,0 +1,52 @@
"""Power-outage endpoints (map layer, server-26#184).
``GET /outages`` -- any signed-in user (auth applied at include time in main.py):
outage areas inside a bbox from every provider an admin has enabled, each with
the provider's attribution, disclaimer, coverage note and feed freshness, plus
a ``status`` that says why an empty list is empty.
``/admin/outage-providers`` -- platform admin only: list every catalogued
provider with its licence status and health, and toggle it. Gated licences
need ``acknowledge_licence: true``; that acknowledgement is stored with who and
when and written to audit_log (see app/internal/outages/registry.py).
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.internal.auth import describe_actor, require_admin_token
from app.internal.outages import registry
router = APIRouter(prefix="/outages", tags=["outages"])
admin_router = APIRouter(prefix="/admin/outage-providers", tags=["admin"])
@router.get("")
async def get_outages(
south: float = Query(..., ge=-90, le=90),
west: float = Query(..., ge=-180, le=180),
north: float = Query(..., ge=-90, le=90),
east: float = Query(..., ge=-180, le=180),
):
if south > north or west > east:
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
return await registry.outages_in_bbox((south, west, north, east))
class ProviderToggle(BaseModel):
enabled: bool
acknowledge_licence: bool = False
@admin_router.get("")
async def list_outage_providers(_=Depends(require_admin_token)):
return {"providers": await registry.list_providers()}
@admin_router.put("/{provider_id}")
async def set_outage_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)):
try:
return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal),
acknowledge_licence=body.acknowledge_licence)
except registry.ProviderError as e:
return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra})
+79 -4
View File
@@ -2,8 +2,10 @@ import uuid
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.config import settings
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_oauth
from app.internal import discord_rest from app.internal import discord_rest
from app.internal.audit import write_audit from app.internal.audit import write_audit
from app.internal.auth import ( from app.internal.auth import (
@@ -27,9 +29,27 @@ class TokenCreate(BaseModel):
org_id: Optional[str] = None org_id: Optional[str] = None
def _masked(t: dict) -> dict: # What GET /tokens may return of a bot_tokens doc (server-26#225 S6: an
out = {**t, "token": "•••" + (t.get("token") or "")[-4:]} # allowlist, so a field added to the doc later — a secret, say — never
# leaks by default). The token string itself is masked; OAuth client secrets
# live in bot_oauth/{id}, never on this doc, and are never returned.
_PUBLIC_TOKEN_FIELDS = (
"token_id", "name", "org_id", "valid", "guild_ids", "check_error", "validated_at",
"application_id", "bot_user_id", "bot_name", "lease", "in_use", "assigned_node_id", "assigned_at",
"preferred_for_system_id",
)
def _masked(t: dict, oauth_ids: Optional[set] = None) -> dict:
out = {k: t[k] for k in _PUBLIC_TOKEN_FIELDS if k in t}
out["token"] = "•••" + (t.get("token") or "")[-4:]
out["invite_url"] = leases.invite_url(t.get("application_id")) out["invite_url"] = leases.invite_url(t.get("application_id"))
members = t.get("guild_ids")
out["server_count"] = len(members) if isinstance(members, list) else None
# Pool bots stop being offered at the cap (owner decision Q3); BYO bots have none.
out["server_cap"] = settings.discord_pool_bot_guild_cap if t.get("org_id") is None else None
if oauth_ids is not None:
out["oauth_enabled"] = t.get("token_id") in oauth_ids
return out return out
@@ -47,7 +67,8 @@ async def _republish_after_release(lease: Optional[dict]) -> None:
@router.get("") @router.get("")
async def list_tokens(_: dict = Depends(require_admin_token)): async def list_tokens(_: dict = Depends(require_admin_token)):
"""List all tokens. The actual token string is masked for safety.""" """List all tokens. The actual token string is masked for safety."""
return [_masked(t) for t in await fstore.collection_list("bot_tokens")] oauth_ids = await discord_oauth.enabled_token_ids()
return [_masked(t, oauth_ids) for t in await fstore.collection_list("bot_tokens")]
@router.post("", status_code=201) @router.post("", status_code=201)
@@ -83,7 +104,7 @@ async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
if not await fstore.doc_get("bot_tokens", token_id): if not await fstore.doc_get("bot_tokens", token_id):
raise HTTPException(404, "Token not found.") raise HTTPException(404, "Token not found.")
await discord_rest.refresh_membership([token_id]) await discord_rest.refresh_membership([token_id])
return _masked(await fstore.doc_get("bot_tokens", token_id) or {}) return _masked(await fstore.doc_get("bot_tokens", token_id) or {}, await discord_oauth.enabled_token_ids())
@router.post("/flush", status_code=200) @router.post("/flush", status_code=200)
@@ -265,3 +286,57 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
if existing.get("lease"): if existing.get("lease"):
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.") raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
await fstore.doc_delete("bot_tokens", token_id) await fstore.doc_delete("bot_tokens", token_id)
await discord_oauth.clear_secret(token_id)
class OAuthSecret(BaseModel):
# Length is checked in the handler, not here: a pydantic 422 echoes the
# rejected input back in its body.
client_secret: str
@router.put("/{token_id}/oauth", status_code=200)
async def set_oauth_secret(token_id: str, body: OAuthSecret, decoded: dict = Depends(require_admin_token)):
"""Set (or replace) this radio bot application's OAuth2 client secret, so
"Connect a Discord server" can invite it (server-26#225 S6). Checked with
Discord first (client-credentials grant) — a wrong secret is refused and
nothing is stored. The secret goes to bot_oauth/{id}, server-side only:
it is never echoed, never returned by any route and never logged."""
if "/" in token_id:
raise HTTPException(400, "Bad id.")
tok = await fstore.doc_get("bot_tokens", token_id)
if not tok:
raise HTTPException(404, "Token not found.")
if not tok.get("application_id"):
raise HTTPException(409, "DRB doesn't know this bot's application id yet. Re-check the bot, then try again.")
secret = (body.client_secret or "").strip()
if not (discord_oauth.SECRET_MIN <= len(secret) <= discord_oauth.SECRET_MAX):
raise HTTPException(400, "That doesn't look like a Discord client secret. Copy it from the Discord "
"Developer Portal → OAuth2.")
r = await discord_oauth.check_secret(str(tok["application_id"]), secret)
uid, email = describe_actor(decoded)
if not r.ok:
await write_audit(uid, email, "discord.token.oauth_refused", details={
"token_id": token_id, "status": r.status})
if r.status in (400, 401):
raise HTTPException(400, "Discord didn't accept that client secret for this bot's application. "
"Copy it again from the Discord Developer Portal → OAuth2.")
raise HTTPException(503, "Couldn't check the secret with Discord right now. Nothing was saved; "
"try again in a minute.")
await discord_oauth.store_secret(token_id, secret, uid)
await write_audit(uid, email, "discord.token.oauth_set", details={"token_id": token_id})
logger.info(f"Radio bot {token_id}: OAuth secret set by uid={uid}")
return {"ok": True, "token_id": token_id, "oauth_enabled": True,
"redirect_uri_configured": discord_oauth.enabled()}
@router.delete("/{token_id}/oauth", status_code=200)
async def clear_oauth_secret(token_id: str, decoded: dict = Depends(require_admin_token)):
"""Forget this bot's OAuth secret: Connect stops offering it."""
if "/" in token_id:
raise HTTPException(400, "Bad id.")
removed = await discord_oauth.clear_secret(token_id)
uid, email = describe_actor(decoded)
if removed:
await write_audit(uid, email, "discord.token.oauth_clear", details={"token_id": token_id})
return {"ok": True, "token_id": token_id, "oauth_enabled": False}
+51
View File
@@ -0,0 +1,51 @@
"""Weather context for the map (server-26#237): NWS alerts + nearest-METAR wind.
Public-domain US Government data, cached server-side in app/internal/weather.py;
no licence gate (unlike 511NY / outage providers).
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.internal import weather
router = APIRouter(prefix="/weather", tags=["weather"])
@router.get("/alerts")
async def get_alerts(
south: Optional[float] = Query(None, ge=-90, le=90),
west: Optional[float] = Query(None, ge=-180, le=180),
north: Optional[float] = Query(None, ge=-90, le=90),
east: Optional[float] = Query(None, ge=-180, le=180),
):
"""Active NWS alerts with polygons, optionally filtered to those intersecting a bbox.
``zone_only`` holds alerts whose zones could not be resolved to a polygon; it
is never bbox-filtered (there is no shape to test) and never dropped.
"""
box = (south, west, north, east)
if any(v is not None for v in box) and any(v is None for v in box):
raise HTTPException(400, "give all of south, west, north, east or none")
if south is not None and (south > north or west > east):
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
feed = await weather.alerts.get()
items = feed.alerts
if south is not None:
items = [a for a in items if weather.intersects(a.get("bbox"), south, west, north, east)]
return {
"alerts": items,
"zone_only": feed.zone_only,
"fetched_at": feed.fetched_at,
"error": feed.error,
"credit": weather.CREDIT,
}
@router.get("/wind")
async def get_wind(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Wind from the nearest METAR station, or ``available: false`` with a reason."""
return await weather.nearest_wind(lat, lon)
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
"""
Channel list and presence without the main bot (server-26#225 S5), plus the
interactive re-check of a cached "denied" answer (server-26#251).
* discord_rest.directory_from_snapshot / guild_directory: a radio bot's REST
snapshot becomes the same directory the DRB server bot pushes.
* _org_guilds: a linked server stays visible with only a radio bot in it; a
fallback-org server still needs the server bot; REST never discovers a
server or creates a ``discord_guilds`` doc.
* join_options / start_node_stream: a missing list is read inline from a
member radio bot, a stale one in the background; the server bot's fresh
list is never overwritten; nothing is written onto a server that was
unlinked meanwhile.
* start_stream(interactive=True) re-reads a "denied" snapshot older than
RECHECK_DENIED_AFTER_SECONDS once; the reconciler never does.
"""
import asyncio
import time
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.routers import discord as discord_router
from app.models import CommandPayload
from app.routers import discord_web as web
from app.routers import nodes as nodes_router
from tests.test_discord_perm_check import ( # noqa: F401 (fs = fixture)
ADMIN, GUILD, ORG, OTHER, OWNER, SYS, _guild, _snap, _token, fs, run,
)
LINKED = "300" # linked to ORG, never seen by the server bot
def _ago(seconds: float) -> str:
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
def _rest_channels():
return [
{"id": "9", "name": "Radio", "type": 4, "position": 1},
{"id": "8", "name": "Admin", "type": 4, "position": 0},
{"id": "3001", "name": "dispatch", "type": 2, "position": 1, "parent_id": "9", "permission_overwrites": []},
{"id": "3002", "name": "fireground", "type": 2, "position": 0, "parent_id": "9", "permission_overwrites": []},
{"id": "3003", "name": "staff", "type": 2, "position": 0, "parent_id": "8", "permission_overwrites": []},
{"id": "3004", "name": "lobby", "type": 2, "position": 5, "permission_overwrites": []},
{"id": "3005", "name": "general", "type": 0, "position": 0, "permission_overwrites": []},
{"id": "3006", "name": "town hall", "type": 13, "position": 0, "permission_overwrites": []},
]
def _rest_snap(tok_id, guild_id=LINKED, status=discord_rest.OK):
if status != discord_rest.OK:
return discord_rest.GuildSnapshot(token_id=tok_id, guild_id=guild_id, status=status, error="x")
return discord_rest.GuildSnapshot(
token_id=tok_id, guild_id=guild_id, status=discord_rest.OK, bot_user_id=f"u-{tok_id}",
guild={**_guild(), "id": guild_id, "name": "Station 9"}, member={"roles": []},
channels=_rest_channels())
@pytest.fixture
def dfs(fs, monkeypatch):
"""The perm-check fixture plus a linked server only radio bots know."""
fs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": ORG, "linked_at": _ago(60)})
fs.dir_mode = {} # token_id -> "ok" | "not_member" | "unknown"
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fs.snap_calls.append((tid, guild_id, refresh))
if guild_id != LINKED:
mode = fs.snap_mode.get(tid, "ok")
return _snap(tid, muted=(mode == "muted"))
mode = fs.dir_mode.get(tid, "ok")
status = {"ok": discord_rest.OK, "not_member": discord_rest.NOT_MEMBER}.get(mode, discord_rest.UNKNOWN)
return _rest_snap(tid, status=status)
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
web._directory_tasks.clear()
return fs
async def _drain():
while web._directory_tasks:
await asyncio.gather(*list(web._directory_tasks.values()), return_exceptions=True)
def _ids(opts):
return [g["guild_id"] for g in opts["guilds"]]
# ---------------------------------------------------------------------------
# discord_rest — directory from a snapshot
# ---------------------------------------------------------------------------
def test_directory_from_snapshot_matches_the_server_bots_shape_and_order():
d = discord_rest.directory_from_snapshot(_rest_snap("t1"))
assert d["name"] == "Station 9"
assert [(c["channel_id"], c["category"], c["position"]) for c in d["voice_channels"]] == [
("3004", None, 0), # no category first
("3003", "Admin", 1), # categories by position
("3002", "Radio", 2), # channels by position inside one
("3001", "Radio", 3),
] # text and stage channels are not listed
assert set(d["voice_channels"][0]) == {"channel_id", "name", "category", "position"}
def test_directory_from_snapshot_is_none_unless_ok():
assert discord_rest.directory_from_snapshot(None) is None
for status in (discord_rest.NOT_MEMBER, discord_rest.UNKNOWN, discord_rest.INVALID):
assert discord_rest.directory_from_snapshot(_rest_snap("t1", status=status)) is None
def test_directory_caps_channels_and_name_length():
snap = _rest_snap("t1")
snap.channels = [{"id": str(5000 + i), "name": "x" * 300, "type": 2, "position": i} for i in range(250)]
d = discord_rest.directory_from_snapshot(snap)
assert len(d["voice_channels"]) == discord_rest.DIRECTORY_MAX_CHANNELS
assert len(d["voice_channels"][0]["name"]) == 100
async def test_guild_directory_tries_the_next_bot_and_reports_not_member(dfs):
_token(dfs, "a", guilds=(LINKED,))
_token(dfs, "b", guilds=(LINKED,))
docs = [{**dfs.get("bot_tokens", t)} for t in ("a", "b")]
dfs.dir_mode["a"] = "not_member"
got = await discord_rest.guild_directory(docs, LINKED)
assert got.status == discord_rest.OK and got.token_id == "b" and len(got.voice_channels) == 4
dfs.dir_mode["b"] = "not_member"
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.NOT_MEMBER
dfs.dir_mode["b"] = "unknown"
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.UNKNOWN
assert (await discord_rest.guild_directory([], LINKED)).status == discord_rest.UNKNOWN
# ---------------------------------------------------------------------------
# Which servers an org sees
# ---------------------------------------------------------------------------
def test_linked_server_with_only_a_radio_bot_is_listed_with_its_channels(dfs):
"""Owner requirement 1: invite only the radio bot, no main bot."""
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["synced"] is True and g["directory_source"] == "radio_bot" and g["main_bot_present"] is False
assert [c["name"] for c in g["voice_channels"]] == ["lobby", "staff", "fireground", "dispatch"]
assert g["name"] == "Station 9" and g["linked"] is True and g["bot"]["ok"] is True
doc = dfs.get("discord_guilds", LINKED)
assert doc["org_id"] == ORG and "bot_present" not in doc # never the server bot's field
assert doc["radio_bot_present"] is True and doc["directory_source"] == "radio_bot"
assert doc["guild_name"] == "Station 9" and len(doc["voice_channels"]) == 4
assert "secret" not in repr(opts)
def test_linked_server_without_any_radio_bot_keeps_todays_behaviour(dfs):
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["synced"] is False and g["bot"]["ok"] is False # invite links, as before
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
@pytest.mark.parametrize("tok", [
{"org": OTHER}, # another org's branded bot
{"guilds": None}, # membership never checked
{"valid": False},
])
def test_only_a_usable_member_bot_keeps_a_server_without_the_main_bot(dfs, tok):
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": False})
kw = {"guilds": (LINKED,), **tok}
_token(dfs, "x", **kw)
assert LINKED not in _ids(run(web.join_options("n1", OWNER)))
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
def test_server_bot_leaving_keeps_server_while_a_radio_bot_is_in_it(dfs):
_token(dfs, "t1", guilds=(GUILD,))
run(web.put_guild_directory(GUILD, web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER))
assert GUILD in _ids(opts)
g = next(g for g in opts["guilds"] if g["guild_id"] == GUILD)
assert g["main_bot_present"] is False
def test_fallback_server_is_never_discovered_by_a_radio_bot(dfs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": False, "voice_channels": []})
_token(dfs, "t1", guilds=("400", "401", LINKED)) # also in a server DRB has no doc for
ids = _ids(run(web.join_options("n1", OWNER)))
assert "400" not in ids and "401" not in ids
assert dfs.get("discord_guilds", "401") is None # REST never creates a doc
assert not [c for c in dfs.snap_calls if c[1] in ("400", "401")]
def test_fallback_server_list_is_never_read_over_rest(dfs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": True, "voice_channels": None,
"directory_at": _ago(99999)})
_token(dfs, "t1", guilds=("400",))
assert "400" in _ids(run(web.join_options("n1", OWNER)))
assert not [c for c in dfs.snap_calls if c[1] == "400"]
# ---------------------------------------------------------------------------
# When the list is read, and who wins
# ---------------------------------------------------------------------------
def test_fresh_main_bot_list_is_never_read_over_rest(dfs):
_token(dfs, "t1", guilds=(LINKED,))
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
"directory_source": "main_bot", "directory_at": _ago(600),
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["directory_source"] == "main_bot" and len(g["voice_channels"]) == 1
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
def test_stale_list_is_refreshed_in_the_background(dfs):
_token(dfs, "t1", guilds=(LINKED,))
old = [{"channel_id": "3001", "name": "old name"}]
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": old,
"directory_source": "radio_bot",
"directory_at": _ago(settings.discord_directory_refresh_seconds + 5)})
async def go():
opts = await web.join_options("n1", OWNER)
await _drain()
return opts
opts = run(go())
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert [c["name"] for c in g["voice_channels"]] == ["old name"] # stored list shown meanwhile
assert len(dfs.get("discord_guilds", LINKED)["voice_channels"]) == 4 # and replaced right after
def test_dead_main_bot_list_is_taken_over_by_a_radio_bot(dfs):
_token(dfs, "t1", guilds=(LINKED,))
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
"directory_at": _ago(web.MAIN_BOT_STALE_SECONDS + 60),
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
async def go():
await web.join_options("n1", OWNER)
await _drain()
run(go())
doc = dfs.get("discord_guilds", LINKED)
assert doc["directory_source"] == "radio_bot" and len(doc["voice_channels"]) == 4
assert doc["bot_present"] is True # not ours to change
def test_not_member_marks_presence_and_keeps_the_list(dfs):
_token(dfs, "t1", guilds=(LINKED,))
keep = [{"channel_id": "3001", "name": "dispatch"}]
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": keep,
"directory_source": "radio_bot", "directory_at": _ago(99999)})
dfs.dir_mode["t1"] = "not_member"
async def go():
await web.join_options("n1", OWNER)
await _drain()
run(go())
doc = dfs.get("discord_guilds", LINKED)
assert doc["radio_bot_present"] is False and doc["voice_channels"] == keep
def test_refresh_never_writes_onto_a_server_unlinked_meanwhile(dfs, monkeypatch):
_token(dfs, "t1", guilds=(LINKED,))
real = discord_rest.guild_directory
async def _unlink_then_answer(docs, guild_id, **kw):
dfs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": None, "unlinked_at": _ago(0)})
return await real(docs, guild_id, **kw)
monkeypatch.setattr(discord_rest, "guild_directory", _unlink_then_answer)
g = dict(dfs.get("discord_guilds", LINKED), org_id=ORG)
assert run(web._refresh_directory(g, ORG, {"t1": dfs.get("bot_tokens", "t1")})) is None
assert "voice_channels" not in dfs.get("discord_guilds", LINKED)
def test_slow_discord_never_holds_the_picker_past_the_budget(dfs, monkeypatch):
_token(dfs, "t1", guilds=(LINKED,))
monkeypatch.setattr(settings, "discord_perm_check_budget_seconds", 0.05)
async def _slow(docs, guild_id, **kw):
await asyncio.sleep(5)
monkeypatch.setattr(discord_rest, "guild_directory", _slow)
async def go():
t0 = time.monotonic()
opts = await web.join_options("n1", OWNER)
return opts, time.monotonic() - t0
opts, took = run(go())
assert took < 1.0
assert next(g for g in opts["guilds"] if g["guild_id"] == LINKED)["synced"] is False
def test_directory_push_marks_main_bot_and_drops_cached_snapshots(dfs, monkeypatch):
dropped = []
monkeypatch.setattr(discord_rest, "invalidate", lambda token_id=None, guild_id=None: dropped.append(
(token_id, guild_id)) or 0)
run(web.put_guild_directory(LINKED, web.GuildDirectory(name="S9", voice_channels=[
web.VoiceChannelIn(channel_id="3001", name="dispatch")]), {"service": True}))
doc = dfs.get("discord_guilds", LINKED)
assert doc["directory_source"] == "main_bot" and doc["bot_present"] is True and doc["org_id"] == ORG
assert dropped == [(None, LINKED)]
# ---------------------------------------------------------------------------
# Starting from the picker without the main bot
# ---------------------------------------------------------------------------
def test_start_checks_the_channel_against_the_radio_bots_list(dfs):
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id="3001"), OWNER))
assert doc["ok"] is True and doc["token_id"] == "byo"
s = dfs.get("discord_streams", doc["stream_id"])
assert s["channel_name"] == "dispatch" and s["guild_name"] == "Station 9" and s["node_id"] == "n1"
def test_start_refuses_a_channel_the_radio_bot_does_not_list(dfs):
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
for bad in ("3005", "3999"): # a text channel; no such channel
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id=bad), OWNER))
assert e.value.status_code == 400
# ---------------------------------------------------------------------------
# server-26#251 — an interactive start re-reads a stale "denied"
# ---------------------------------------------------------------------------
@pytest.fixture
def aging(fs, monkeypatch):
"""Snapshots that are ``fs.age`` seconds old unless refreshed; ``muted``
until ``fs.fixed`` (the owner fixed the permission in Discord)."""
fs.age, fs.fixed = 60.0, False
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fs.snap_calls.append((tid, guild_id, refresh))
s = _snap(tid, muted=not (refresh and fs.fixed))
s.fetched_at = time.time() - (0 if refresh else fs.age)
return s
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
_token(fs, "t1", name="DRB Radio 2")
return fs
def _join():
return discord_router.start_stream(discord_router.StreamStart(guild_id=GUILD, channel_id="1001",
system_id=SYS), {"service": True})
def test_join_after_fixing_the_permission_succeeds_at_once(aging):
aging.fixed = True
r = run(_join())
assert r["token_id"] == "t1"
assert ("t1", GUILD, True) in aging.snap_calls
def test_still_denied_after_the_recheck_is_refused(aging):
r = run(_join())
assert r.status_code == 409 and b"can't Speak in #dispatch" in r.body
assert [c for c in aging.snap_calls if c[2]] == [("t1", GUILD, True)] # once
def test_a_fresh_denied_answer_is_not_asked_again(aging):
aging.age = 1.0
aging.fixed = True
r = run(_join())
assert r.status_code == 409
assert not [c for c in aging.snap_calls if c[2]]
async def test_reconciler_never_rechecks(aging):
aging.fixed = True
with pytest.raises(streams.StreamError) as e:
await streams.start_stream(org_id=ORG, guild_id=GUILD, channel_id="1001", system_id=SYS)
assert e.value.code == "missing_permissions"
assert not [c for c in aging.snap_calls if c[2]]
def test_web_picker_start_is_interactive(aging):
aging.fixed = True
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=GUILD, channel_id="1001"), OWNER))
assert doc["ok"] is True and doc["token_id"] == "t1"
def test_node_discord_join_command_is_interactive(aging):
aging.fixed = True
out = run(nodes_router.send_command("n1", CommandPayload(action="discord_join", guild_id=GUILD,
channel_id="1001"), ADMIN))
assert out["ok"] is True and out["token_id"] == "t1"
+464
View File
@@ -0,0 +1,464 @@
"""
Per-bot OAuth "Connect a Discord server" (server-26#225 S6).
Discord is an httpx.MockTransport; Firestore is tests/fake_firestore.py.
"""
import asyncio
import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_oauth as oauth
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.routers import discord_oauth as router
from app.routers import tokens as tokens_router
from tests.fake_firestore import FakeFirestore
REDIRECT = "https://drb.example.test/discord/connected"
SECRET_A = "s3cr3t-app-a-0123456789abcdefXYZ"
SECRET_POOL = "s3cr3t-pool-0123456789abcdefXYZ"
GUILD = "100000000000000001"
OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
class Discord:
"""Scripted OAuth endpoints. ``token_reply`` answers POST /oauth2/token."""
def __init__(self):
self.calls: list[httpx.Request] = []
self.token_reply = httpx.Response(200, json={
"access_token": "user-access-token", "token_type": "Bearer", "scope": "bot",
"guild": {"id": GUILD, "name": "Station 7"}})
def __call__(self, request: httpx.Request) -> httpx.Response:
self.calls.append(request)
path = request.url.path.removeprefix("/api/v10")
if path == "/oauth2/token":
r = self.token_reply
return r(request) if callable(r) else r
if path == "/oauth2/token/revoke":
return httpx.Response(200, json={})
return httpx.Response(404, json={"code": 0})
def form(self, i: int) -> dict:
return {k: v[0] for k, v in parse_qs(self.calls[i].content.decode()).items()}
def basic(self, i: int) -> str:
raw = self.calls[i].headers["authorization"].split(" ", 1)[1]
return base64.b64decode(raw).decode()
def paths(self):
return [r.url.path.removeprefix("/api/v10") for r in self.calls]
@pytest.fixture
def discord(monkeypatch):
d = Discord()
discord_rest.reset()
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(d))
return d
@pytest.fixture
def fs(monkeypatch, discord):
fake = FakeFirestore.install(monkeypatch)
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_pool_bot_guild_cap", 90)
for org in ("org-a", "org-b", "founding"):
fake.put("organizations", org, {"org_id": org, "name": org})
fake.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "owner"})
fake.put("org_members", "owner-b", {"uid": "owner-b", "org_id": "org-b", "org_role": "owner"})
# org-a's own bot, two pool bots (one full), org-b's own bot.
fake.put("bot_tokens", "own-a", {"token_id": "own-a", "name": "Riverton Radio", "token": "tok-own-a",
"org_id": "org-a", "application_id": "111", "guild_ids": []})
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "tok-pool-1",
"org_id": None, "application_id": "201", "guild_ids": ["9"] * 40})
fake.put("bot_tokens", "pool-2", {"token_id": "pool-2", "name": "DRB Radio 2", "token": "tok-pool-2",
"org_id": None, "application_id": "202", "guild_ids": ["9"] * 90})
fake.put("bot_tokens", "own-b", {"token_id": "own-b", "name": "Harbor Radio", "token": "tok-own-b",
"org_id": "org-b", "application_id": "333", "guild_ids": []})
for tid, secret in (("own-a", SECRET_A), ("pool-1", SECRET_POOL), ("pool-2", SECRET_POOL),
("own-b", SECRET_A)):
fake.put(oauth.OAUTH, tid, {"token_id": tid, "client_secret": secret, "enabled": True})
for lim in (router.start_limiter, router.complete_limiter):
lim._log.clear()
stopped: list = []
async def _stop_guild(guild_id, reason, channel_id=None, stream_id=None):
stopped.append((guild_id, reason))
return [f"{guild_id}:1"]
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
fake.stopped = stopped
async def _voice_check(**kw):
return {"enabled": True, "channels": [
{"channel_id": "1", "name": "dispatch", "state": "ready", "bots": [{"fix": None}]},
{"channel_id": "2", "name": "fireground", "state": "needs_fix",
"bots": [{"fix": "In Discord: Edit Channel → Permissions → add Riverton Radio → allow Speak, "
"then try again."}]},
{"channel_id": "3", "name": "ops", "state": "unknown", "bots": []},
]}
monkeypatch.setattr(router, "voice_check", _voice_check)
return fake
def _audit(fake, action):
return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
def _status(resp):
return getattr(resp, "status_code", 200)
def _body(resp):
return json.loads(resp.body) if hasattr(resp, "body") else resp
async def _start(decoded=OWNER_A, **kw):
out = await router.oauth_start(router.StartBody(**kw), org_id=None, decoded=decoded)
q = parse_qs(urlparse(out["authorize_url"]).query)
return out, {k: v[0] for k, v in q.items()}
async def _complete(state, decoded=OWNER_A, code="the-code"):
return await router.oauth_complete(router.CompleteBody(code=code, state=state), decoded=decoded)
# ---------------------------------------------------------------------------
# Off switch
# ---------------------------------------------------------------------------
async def test_every_route_is_404_while_redirect_uri_is_empty(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", "")
for call in (router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A),
router.oauth_start(router.StartBody(), org_id=None, decoded=OWNER_A),
_complete("anything")):
with pytest.raises(HTTPException) as e:
await call
assert e.value.status_code == 404
async def test_404_when_no_usable_bot_has_a_secret(fs):
for tid in ("own-a", "pool-1", "pool-2"):
fs.data[oauth.OAUTH].pop(tid)
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
# ---------------------------------------------------------------------------
# Which bot
# ---------------------------------------------------------------------------
async def test_org_bot_first_then_least_full_pool_bot_under_cap(fs):
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert out["bot"] == {"name": "Riverton Radio", "reason": "org_bot", "own_bot": True}
fs.data[oauth.OAUTH].pop("own-a")
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "pool"
# pool-2 is at the cap (90): never offered, even when it's the only one left
fs.data[oauth.OAUTH].pop("pool-1")
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
async def test_preferred_bot_for_the_nodes_system_wins(fs):
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s1"})
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A)
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "preferred"
# another org's node is a 404
fs.put("nodes", "nb", {"node_id": "nb", "org_id": "org-b", "assigned_system_id": "s1"})
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id="nb", token_id=None, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
async def test_system_id_invites_that_systems_preferred_bot(fs):
"""The S7 picker's "bot for a system" choice: a platform-pinned pool bot
is invited by system_id, never quietly swapped for Automatic."""
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s1")
assert out["bot"] == {"name": "DRB Radio 1", "reason": "preferred", "own_bot": False}
out, q = await _start(system_id="s1", return_to="/settings")
assert q["client_id"] == "201" and out["bot"]["reason"] == "preferred"
assert _audit(fs, "discord.oauth.start")[-1]["details"]["system_id"] == "s1"
# a shared system (no org_id) serves every org, as on the node path
fs.put("systems", "shared", {"system_id": "shared", "org_id": None, "preferred_token_id": "pool-1"})
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_B,
system_id="shared")
assert out["bot"]["name"] == "DRB Radio 1"
# system_id outranks node_id
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s2"})
fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-a"})
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A, system_id="s1")
assert out["bot"]["name"] == "DRB Radio 1"
async def test_system_id_of_another_org_or_unknown_is_404(fs):
fs.put("systems", "sb", {"system_id": "sb", "org_id": "org-b", "preferred_token_id": "pool-1"})
for sid in ("sb", "nope"):
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid)
assert e.value.status_code == 404 and e.value.detail == "System not found."
with pytest.raises(HTTPException) as e:
await _start(system_id=sid)
assert e.value.status_code == 404
assert not _audit(fs, "discord.oauth.start")
# a platform admin acting for org-b may use it
out = await router.oauth_options(node_id=None, token_id=None, org_id="org-b", decoded=ADMIN, system_id="sb")
assert out["bot"]["name"] == "DRB Radio 1"
async def test_system_id_whose_bot_cannot_connect_is_404_not_a_swap(fs):
# pool-2 is at the cap; another org's bot pinned (bad data); no secret
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-2"})
fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-b"})
fs.put("systems", "s3", {"system_id": "s3", "org_id": "org-a", "preferred_token_id": "pool-1"})
fs.data[oauth.OAUTH].pop("pool-1")
for sid in ("s1", "s2", "s3"):
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid)
assert e.value.status_code == 404 and "Harbor" not in str(e.value.detail)
# no preferred bot at all: the usual order (org's own bot first)
fs.put("systems", "s4", {"system_id": "s4", "org_id": "org-a"})
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s4")
assert out["bot"]["reason"] == "org_bot"
async def test_explicit_bot_must_be_usable_by_the_org(fs):
out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
assert out["bot"]["reason"] == "chosen"
for tid in ("own-b", "pool-2", "nope"): # other org's bot, full pool bot, unknown
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=tid, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
# ---------------------------------------------------------------------------
# Start
# ---------------------------------------------------------------------------
async def test_start_returns_the_bot_invite_and_stores_only_a_hash_of_the_state(fs):
out, q = await _start(return_to="/nodes/n1")
assert urlparse(out["authorize_url"]).netloc == "discord.com"
assert q["client_id"] == "111" and q["scope"] == "bot" and q["response_type"] == "code"
assert q["permissions"] == str((1 << 10) | (1 << 20) | (1 << 21))
assert q["redirect_uri"] == REDIRECT
nonce = q["state"]
assert len(nonce) >= 40
assert fs.get(oauth.STATES, nonce) is None # the nonce itself is never stored
st = fs.get(oauth.STATES, oauth.state_key(nonce))
assert st["org_id"] == "org-a" and st["uid"] == "owner-a" and st["token_id"] == "own-a"
assert st["return_to"] == "/nodes/n1" and st["used_at"] is None
assert len(_audit(fs, "discord.oauth.start")) == 1
@pytest.mark.parametrize("bad", ["https://evil.test/x", "//evil.test/x", "/\\evil.test", "nodes", "/a\nb"])
async def test_return_to_is_a_same_site_path_only(fs, bad):
_, q = await _start(return_to=bad)
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["return_to"] is None
async def test_start_rate_limited_per_user(fs):
for _ in range(5):
await _start()
with pytest.raises(HTTPException) as e:
await _start()
assert e.value.status_code == 429
# ---------------------------------------------------------------------------
# Complete
# ---------------------------------------------------------------------------
async def test_complete_links_the_server_from_the_exchange_and_reports_channels(fs, discord):
_, q = await _start(return_to="/settings/discord")
out = await _complete(q["state"])
assert out["ok"] and out["guild_id"] == GUILD and out["guild_name"] == "Station 7"
assert out["org_id"] == "org-a" and out["bot_name"] == "Riverton Radio"
assert (out["channels_ready"], out["channels_blocked"]) == (1, 1)
assert out["channels"][1]["fix"].startswith("In Discord")
assert out["return_to"] == "/settings/discord"
g = fs.get(streams.GUILDS, GUILD)
assert g["org_id"] == "org-a" and g["linked_via"] == "oauth" and g["linked_by"] == "owner-a"
assert g["linked_with_token_id"] == "own-a"
assert fs.get("bot_tokens", "own-a")["guild_ids"] == [GUILD]
# the exchange used that bot application's own credentials and the exact redirect URI
assert discord.paths() == ["/oauth2/token", "/oauth2/token/revoke"]
assert discord.basic(0) == f"111:{SECRET_A}"
assert discord.form(0) == {"grant_type": "authorization_code", "code": "the-code", "redirect_uri": REDIRECT}
assert discord.form(1)["token"] == "user-access-token" # revoked straight away
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["used_at"]
[entry] = _audit(fs, "discord.guild.link")
assert entry["details"]["via"] == "oauth" and entry["details"]["guild_id"] == GUILD
async def test_state_is_single_use(fs):
_, q = await _start()
assert _status(await _complete(q["state"])) == 200
again = await _complete(q["state"])
assert _status(again) == 409 and _body(again)["code"] == "state_used"
async def test_another_users_state_is_refused_and_burned(fs):
_, q = await _start(OWNER_A)
resp = await _complete(q["state"], decoded=OWNER_B) # login-CSRF shape
assert _status(resp) == 403 and _body(resp)["code"] == "wrong_user"
assert fs.get(streams.GUILDS, GUILD) is None
assert _status(await _complete(q["state"], decoded=OWNER_A)) == 409 # consumed
async def test_expired_and_unknown_states(fs):
_, q = await _start()
key = oauth.state_key(q["state"])
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
fs.put(oauth.STATES, key, {**fs.get(oauth.STATES, key), "expires_at": past})
resp = await _complete(q["state"])
assert _status(resp) == 410 and fs.get(streams.GUILDS, GUILD) is None
assert _status(await _complete("not-a-state")) == 400
async def test_caller_who_is_no_longer_owner_is_refused(fs, discord):
_, q = await _start()
fs.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "member"})
resp = await _complete(q["state"]) # token claims still say owner
assert _status(resp) == 403 and _body(resp)["code"] == "not_owner"
assert discord.calls == [] and fs.get(streams.GUILDS, GUILD) is None
async def test_exchange_without_a_guild_links_nothing(fs, discord):
discord.token_reply = httpx.Response(200, json={"access_token": "x", "scope": "bot"})
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 400 and _body(resp)["code"] == "no_guild"
assert fs.get(streams.GUILDS, GUILD) is None
async def test_rejected_code_and_unreachable_discord(fs, discord):
discord.token_reply = httpx.Response(400, json={"error": "invalid_grant"})
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 400 and _body(resp)["code"] == "code_rejected"
def _down(request):
raise httpx.ConnectError("down", request=request)
discord.token_reply = _down
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 502 and fs.get(streams.GUILDS, GUILD) is None
async def test_server_linked_to_another_org_is_never_moved(fs):
fs.put(streams.GUILDS, GUILD, {"guild_id": GUILD, "org_id": "org-b", "guild_name": "Harbor"})
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 409 and _body(resp)["code"] == "linked_elsewhere"
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-b"
assert fs.stopped == []
assert [e["details"]["via"] for e in _audit(fs, "discord.guild.link_refused")] == ["oauth"]
async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
_, q = await _start()
out = await _complete(q["state"])
assert out["streams_stopped"] == 1 and fs.stopped == [(GUILD, glink.RELINK_STOP_REASON)]
async def test_permission_check_timeout_leaves_the_link_standing(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_oauth_link_check_seconds", 0.05)
async def _slow(**kw):
await asyncio.sleep(1)
monkeypatch.setattr(router, "voice_check", _slow)
_, q = await _start()
out = await _complete(q["state"])
assert out["ok"] and out["checked"] is False and out["channels_ready"] is None
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-a"
async def test_bot_handed_to_another_org_after_start_is_refused(fs):
_, q = await _start()
fs.put("bot_tokens", "own-a", {**fs.get("bot_tokens", "own-a"), "org_id": "org-b"})
resp = await _complete(q["state"])
assert _status(resp) == 409 and _body(resp)["code"] == "bot_unavailable"
# ---------------------------------------------------------------------------
# Secrets: PUT /tokens/{id}/oauth, never returned, never logged
# ---------------------------------------------------------------------------
async def test_set_secret_checks_it_with_discord_and_never_echoes_it(fs, discord, caplog):
caplog.set_level(logging.DEBUG)
fs.data[oauth.OAUTH].clear()
discord.token_reply = httpx.Response(401, json={"error": "invalid_client"})
with pytest.raises(HTTPException) as e:
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
decoded=ADMIN)
assert e.value.status_code == 400 and fs.get(oauth.OAUTH, "pool-1") is None
discord.token_reply = httpx.Response(200, json={"access_token": "cc-token", "scope": "identify"})
out = await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
decoded=ADMIN)
assert out["oauth_enabled"] is True
assert discord.form(1) == {"grant_type": "client_credentials", "scope": "identify"}
assert discord.basic(1) == f"201:{SECRET_POOL}"
assert discord.form(2)["token"] == "cc-token" # the check's token is revoked
assert fs.get(oauth.OAUTH, "pool-1")["client_secret"] == SECRET_POOL
assert "client_secret" not in fs.get("bot_tokens", "pool-1")
listed = await tokens_router.list_tokens(_=ADMIN)
everything = json.dumps([listed, out, fs.data.get("audit_log")]) + caplog.text
assert SECRET_POOL not in everything and "tok-pool-1" not in everything
row = next(t for t in listed if t["token_id"] == "pool-1")
assert row["oauth_enabled"] is True and row["server_count"] == 40 and row["server_cap"] == 90
async def test_unreachable_discord_saves_nothing(fs, discord):
fs.data[oauth.OAUTH].clear()
def _down(request):
raise httpx.ConnectError("down", request=request)
discord.token_reply = _down
with pytest.raises(HTTPException) as e:
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
decoded=ADMIN)
assert e.value.status_code == 503 and fs.get(oauth.OAUTH, "pool-1") is None
async def test_token_list_is_an_allowlist(fs):
fs.put("bot_tokens", "pool-1", {**fs.get("bot_tokens", "pool-1"), "client_secret": "LEAK", "future": "x"})
row = next(t for t in await tokens_router.list_tokens(_=ADMIN) if t["token_id"] == "pool-1")
assert "client_secret" not in row and "future" not in row
assert row["token"] == "•••ol-1" and row["name"] == "DRB Radio 1"
async def test_clear_and_delete_remove_the_secret(fs):
await tokens_router.clear_oauth_secret("pool-1", decoded=ADMIN)
assert fs.get(oauth.OAUTH, "pool-1") is None
await tokens_router.delete_token("own-a", _=ADMIN)
assert fs.get(oauth.OAUTH, "own-a") is None
async def test_malformed_secret_is_refused_without_echo(fs, discord):
fs.data[oauth.OAUTH].clear()
with pytest.raises(HTTPException) as e:
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret="short-secret"),
decoded=ADMIN)
assert e.value.status_code == 400 and "short-secret" not in str(e.value.detail)
assert discord.calls == [] and fs.get(oauth.OAUTH, "pool-1") is None
@@ -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
+20 -1
View File
@@ -84,7 +84,7 @@ def fs(monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "") monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_perm_check_enabled", True) monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
# Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" # Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" | "invalid"
fake.snap_mode = {} fake.snap_mode = {}
fake.snap_calls = [] fake.snap_calls = []
@@ -99,6 +99,8 @@ def fs(monkeypatch):
return _snap(tid, status=discord_rest.UNKNOWN) return _snap(tid, status=discord_rest.UNKNOWN)
if mode == "not_member": if mode == "not_member":
return _snap(tid, status=discord_rest.NOT_MEMBER) return _snap(tid, status=discord_rest.NOT_MEMBER)
if mode == "invalid":
return _snap(tid, status=discord_rest.INVALID)
return _snap(tid, muted=(mode == "muted")) return _snap(tid, muted=(mode == "muted"))
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot) monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
@@ -413,6 +415,23 @@ def test_voice_check_needs_fix_when_every_free_bot_is_denied(fs):
assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed
def test_voice_check_invalid_token_is_not_no_bot(fs):
# server-26#257: the bot is in the server but Discord rejected its token.
# "No radio bot" (and an invite to a bot already there) would be wrong.
_token(fs, "t1")
fs.snap_mode["t1"] = "invalid"
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
assert out["channels"][0]["state"] == "invalid"
assert out["channels"][0]["bots"][0]["status"] == "invalid"
# a working bot elsewhere in the list still wins
_token(fs, "t2")
discord_rest.invalidate()
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
assert out["channels"][0]["state"] == "ready"
# no bot at all is still no_bot
assert checks._channel_state([], True) == "no_bot"
def test_voice_check_is_org_scoped(fs): def test_voice_check_is_org_scoped(fs):
_token(fs, "t1") _token(fs, "t1")
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)): for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)):
+4 -1
View File
@@ -80,7 +80,10 @@ def test_directory_push_keeps_org_link(fs):
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"] assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
def test_directory_bot_removed_hides_guild(fs): def test_directory_bot_removed_hides_guild_without_a_radio_bot(fs):
# server-26#225 S5: the server bot leaving hides a linked server only when
# no radio bot the org may use is in it either.
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True})) run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER)) opts = run(web.join_options("n1", OWNER))
assert opts["guilds"] == [] assert opts["guilds"] == []
@@ -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
+220
View File
@@ -0,0 +1,220 @@
"""
HTTP Range on /media/calls/{id}/audio (server-26#233).
The GCS reads are replaced by an in-memory object: ``audio_object_size`` and
``download_audio_range`` are patched where routers/media.py looks them up, and
every span read is recorded so the tests can assert the route never asks GCS
for more than the requested range (plus at most one STREAM_CHUNK_BYTES span).
"""
import time
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal import storage
from app.routers import media
app = FastAPI()
app.include_router(media.router)
client = TestClient(app)
CALL_ID = "call-abc"
GCS_URI = "gs://bucket/calls/call-abc.flac"
class FakeObject:
"""A stored object plus a log of every (start, end) span read from it."""
def __init__(self, data: bytes):
self.data = data
self.reads: list[tuple[int, int]] = []
self.size_calls = 0
async def size(self, gcs_uri):
self.size_calls += 1
return len(self.data)
async def read(self, gcs_uri, start, end):
self.reads.append((start, end))
return self.data[start:end + 1]
@pytest.fixture
def signed(monkeypatch):
monkeypatch.setattr(storage.settings, "service_key", "test-key", raising=False)
exp = int(time.time()) + 600
return {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)}
def _run(obj: FakeObject, params, headers=None, doc=None):
doc_get = AsyncMock(return_value=doc if doc is not None else {"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})
with patch.object(media.fstore, "doc_get", doc_get), \
patch.object(media, "audio_object_size", obj.size), \
patch.object(media, "download_audio_range", obj.read):
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=params, headers=headers or {})
return resp, doc_get
def test_bounded_range_returns_206_with_exact_bytes(signed):
obj = FakeObject(bytes(range(256)) * 40) # 10240 bytes
resp, _ = _run(obj, signed, {"Range": "bytes=100-199"})
assert resp.status_code == 206
assert resp.content == obj.data[100:200]
assert resp.headers["content-range"] == f"bytes 100-199/{len(obj.data)}"
assert resp.headers["content-length"] == "100"
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-type"] == "audio/flac"
assert resp.headers["cache-control"] == "private, max-age=3600"
# Only the requested span was read from GCS — not the whole object.
assert obj.reads == [(100, 199)]
def test_open_ended_range(signed):
obj = FakeObject(b"x" * 5000 + b"tail")
resp, _ = _run(obj, signed, {"Range": "bytes=5000-"})
assert resp.status_code == 206
assert resp.content == b"tail"
assert resp.headers["content-range"] == "bytes 5000-5003/5004"
def test_suffix_range(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=-3"})
assert resp.status_code == 206
assert resp.content == b"789"
assert resp.headers["content-range"] == "bytes 7-9/10"
def test_suffix_longer_than_object_returns_whole_object(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=-500"})
assert resp.status_code == 206
assert resp.content == obj.data
assert resp.headers["content-range"] == "bytes 0-9/10"
def test_end_past_object_is_clamped(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=8-999"})
assert resp.status_code == 206
assert resp.content == b"89"
assert resp.headers["content-range"] == "bytes 8-9/10"
@pytest.mark.parametrize("rng", ["bytes=10-", "bytes=50-60", "bytes=-0"])
def test_unsatisfiable_range_is_416(signed, rng):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": rng})
assert resp.status_code == 416
assert resp.headers["content-range"] == "bytes */10"
assert obj.reads == []
def test_no_range_is_200_full_body_and_advertises_ranges(signed):
obj = FakeObject(b"abcdef" * 100)
resp, _ = _run(obj, signed)
assert resp.status_code == 200
assert resp.content == obj.data
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-length"] == str(len(obj.data))
assert "content-range" not in resp.headers
@pytest.mark.parametrize("rng", ["bytes=0-1,4-5", "items=0-5", "bytes=5-2", "garbage"])
def test_ignored_range_headers_fall_back_to_200(signed, rng):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": rng})
assert resp.status_code == 200
assert resp.content == obj.data
def test_long_object_is_streamed_in_bounded_spans(signed):
# A ~13 MB "600 s FLAC": a seek near the end reads only from there on, in
# spans no larger than STREAM_CHUNK_BYTES, and the first span is all the
# route waits on before the response starts.
size = 13 * 1024 * 1024
obj = FakeObject(bytes(size))
start = size - 1_500_000
resp, _ = _run(obj, signed, {"Range": f"bytes={start}-"})
assert resp.status_code == 206
assert len(resp.content) == 1_500_000
assert obj.reads[0][0] == start
assert all(e - s + 1 <= media.STREAM_CHUNK_BYTES for s, e in obj.reads)
assert sum(e - s + 1 for s, e in obj.reads) == 1_500_000
def test_bad_signature_is_403_before_any_lookup(signed):
obj = FakeObject(b"0123456789")
resp, doc_get = _run(obj, {"exp": signed["exp"], "sig": "0" * 64}, {"Range": "bytes=0-3"})
assert resp.status_code == 403
doc_get.assert_not_called()
assert obj.size_calls == 0
assert obj.reads == []
def test_expired_link_is_403(signed, monkeypatch):
exp = int(time.time()) - 5
obj = FakeObject(b"0123456789")
resp, doc_get = _run(obj, {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)})
assert resp.status_code == 403
doc_get.assert_not_called()
def test_missing_object_is_404(signed):
obj = FakeObject(b"")
async def no_size(gcs_uri):
return None
with patch.object(media.fstore, "doc_get", AsyncMock(return_value={"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})), \
patch.object(media, "audio_object_size", no_size), \
patch.object(media, "download_audio_range", obj.read):
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=signed)
assert resp.status_code == 404
def test_legacy_mp3_keeps_its_content_type(signed):
obj = FakeObject(b"ID3" + b"\0" * 50)
resp, _ = _run(obj, signed, {"Range": "bytes=0-2"}, doc={"call_id": CALL_ID, "audio_gcs_uri": "gs://b/calls/x.mp3"})
assert resp.status_code == 206
assert resp.headers["content-type"] == "audio/mpeg"
assert resp.content == b"ID3"
# ── storage.py: the ranged reads never pull the whole object ────────────────
def _fake_client(monkeypatch, size=1234, payload=b"span"):
from unittest.mock import MagicMock
client = MagicMock()
blob = client.bucket.return_value.blob.return_value
blob.size = size
blob.download_as_bytes.return_value = payload
monkeypatch.setattr(storage, "_gcs_client_cache", client)
return client, blob
def test_storage_size_reloads_metadata_only(monkeypatch):
import asyncio
client, blob = _fake_client(monkeypatch, size=13_000_000)
assert asyncio.run(storage.audio_object_size(GCS_URI)) == 13_000_000
client.bucket.assert_called_with("bucket")
client.bucket.return_value.blob.assert_called_with("calls/call-abc.flac")
blob.reload.assert_called_once()
blob.download_as_bytes.assert_not_called()
def test_storage_range_read_passes_inclusive_bounds(monkeypatch):
import asyncio
_, blob = _fake_client(monkeypatch)
assert asyncio.run(storage.download_audio_range(GCS_URI, 100, 199)) == b"span"
blob.download_as_bytes.assert_called_once_with(start=100, end=199)
def test_storage_range_read_failure_returns_none(monkeypatch):
import asyncio
_, blob = _fake_client(monkeypatch)
blob.download_as_bytes.side_effect = RuntimeError("gcs down")
assert asyncio.run(storage.download_audio_range(GCS_URI, 0, 10)) is None
blob.reload.side_effect = RuntimeError("404")
assert asyncio.run(storage.audio_object_size(GCS_URI)) is None
+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 = {
+329
View File
@@ -0,0 +1,329 @@
"""Power-outage providers (server-26#184): registry, licence gate, admin routes, ODIN adapter.
What is held here (mirrors tests/test_camera_providers.py):
* every provider defaults OFF, and an off provider is never fetched;
* stubs can never be enabled (409);
* a gated licence needs ``acknowledge_licence``, which is stored with who/when
and audited -- and an audit failure refuses the enable (fail closed);
* an acknowledgement given for a different licence status no longer counts;
* the admin routes are admin-only;
* the ODIN parser reads the recorded public payloads, and an all-unparseable
payload is an error, not an empty layer;
* an area no serving provider covers says so instead of returning a bare [].
"""
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.internal.auth import require_admin_token, require_service_or_firebase_token
from app.internal.outages import base, providers, registry
from app.internal.outages.base import Licence
from app.main import app
client = TestClient(app)
ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"}
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "odin_public_2026-10-05.json").read_text())
STUBS = ("poweroutage_us", "coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live")
NY_BBOX = (42.5, -78.5, 43.5, -75.5) # Rochester .. Syracuse
RECORDED_AT = datetime(2026, 10, 5, 6, 45, tzinfo=timezone.utc) # when the fixture was captured
class FakeStore:
def __init__(self):
self.docs = {}
self.audit = []
async def doc_get(self, col, doc):
return self.docs.get((col, doc))
async def doc_set(self, col, doc, data, merge=True):
cur = self.docs.setdefault((col, doc), {})
if col == "audit_log":
self.audit.append(data)
return
for k, v in data.items(): # one-level deep merge is all registry needs
if merge and isinstance(v, dict) and isinstance(cur.get(k), dict):
cur[k] = {**cur[k], **v}
else:
cur[k] = v
@pytest.fixture(autouse=True)
def store():
s = FakeStore()
registry.invalidate_cache()
for feed in registry.FEEDS.values():
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \
patch("app.internal.audit.fstore.doc_set", s.doc_set):
yield s
registry.invalidate_cache()
app.dependency_overrides.clear()
def run(coro):
return asyncio.run(coro)
def _odin_areas():
return providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
now=RECORDED_AT)[0]
# ── registry / gate ──────────────────────────────────────────────────────────
def test_every_catalogued_source_is_registered_and_defaults_off():
listed = run(registry.list_providers())
assert {p["id"] for p in listed} == {"odin", *STUBS}
assert all(p["enabled"] is False and p["serving"] is False for p in listed)
assert all(p["licence"]["label"] for p in listed)
odin = next(p for p in listed if p["id"] == "odin")
assert odin["licence"]["status"] == "unverified" and odin["licence"]["gated"] is True
assert odin["licence"]["terms_url"] == "https://odin.ornl.gov/v3/api-docs"
assert odin["not_serving_reason"] == "disabled (default)"
def test_catalogued_licence_statuses_match_the_source_terms():
lic = {pid: p.meta.licence.status for pid, p in registry.PROVIDERS.items()}
assert lic["poweroutage_us"] == Licence.PAID
for pid in ("coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live"):
assert lic[pid] == Licence.AGREEMENT_REQUIRED, pid
assert all(not registry.PROVIDERS[pid].meta.implemented for pid in STUBS)
def test_warning_is_worded_for_outage_data():
w = registry.PROVIDERS["odin"].meta.licence.warning()
assert "outage data" in w and "imagery" not in w
def test_off_provider_is_never_fetched():
fetch = AsyncMock(return_value=[])
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
out = run(registry.outages_in_bbox(NY_BBOX))
assert out["areas"] == [] and out["providers"] == [] and fetch.await_count == 0
assert out["serving_count"] == 0 and out["status"] == "none_enabled"
def test_stubs_cannot_be_enabled():
for pid in STUBS:
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True))
assert e.value.code == 409, pid
def test_gated_licence_requires_acknowledgement(store):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("odin", True, ("u1", "a@x")))
assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True
assert "Licence unverified" in e.value.extra["warning"]
assert not store.audit and not run(registry.is_serving("odin"))
out = run(registry.set_enabled("odin", True, ("u1", "a@x"), acknowledge_licence=True))
assert out["serving"] is True
ack = out["acknowledgement"]
assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"]
assert ack["licence_status"] == Licence.UNVERIFIED.value
assert store.audit[-1]["action"] == "outage_provider.enable_acknowledged"
assert store.audit[-1]["details"]["provider_id"] == "odin"
assert store.docs[("config", "outage_providers")]["providers"]["odin"]["enabled"] is True
def test_gated_enable_refused_when_audit_cannot_be_written(store):
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
assert e.value.code == 503
assert not run(registry.is_serving("odin"))
assert ("config", "outage_providers") not in store.docs
def test_disable_clears_acknowledgement_and_is_audited(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
out = run(registry.set_enabled("odin", False, ("u", "e")))
assert out["serving"] is False and out["acknowledgement"] is None
assert store.audit[-1]["action"] == "outage_provider.disable"
def test_stale_acknowledgement_stops_serving():
p = registry.PROVIDERS["odin"]
entry = {"enabled": True, "acknowledgement": {"licence_status": "paid"}}
assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date")
entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value
assert registry.effective(p, entry) == (True, None)
def test_unreadable_settings_fail_closed():
with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))):
registry.invalidate_cache()
assert run(registry.get_settings()) == {}
assert not run(registry.is_serving("odin"))
# ── bbox query ───────────────────────────────────────────────────────────────
def test_query_returns_areas_in_bbox_with_credit(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(return_value=_odin_areas())):
out = run(registry.outages_in_bbox(NY_BBOX))
assert out["status"] == "ok" and out["serving_count"] == 1
assert {a["fips"] for a in out["areas"]} == {"36055", "36067"} # not Albany, not King County WA
prov = out["providers"][0]
assert prov["attribution"].startswith("Outage data: ODIN") and prov["disclaimer"]
assert prov["total_in_bbox"] == 2 and prov["fetched_at"] and prov["error"] is None
assert "Only utilities that report to ODIN appear" in prov["coverage_note"]
def test_area_no_serving_provider_covers_says_so(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
fetch = AsyncMock(return_value=_odin_areas())
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
out = run(registry.outages_in_bbox((51.4, -0.2, 51.6, 0.1))) # London
assert fetch.await_count == 0 # outside ODIN coverage -> never fetched
assert out["areas"] == [] and out["status"] == "no_source"
assert out["message"] == "No licensed outage source covers this area."
def test_dead_feed_is_an_error_not_an_empty_layer(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(side_effect=ValueError("schema change?"))):
out = run(registry.outages_in_bbox(NY_BBOX))
assert out["status"] == "error" and "schema change" in out["providers"][0]["error"]
def test_failed_refresh_keeps_last_good_feed():
feed = registry.FEEDS["odin"]
with patch.object(feed.provider, "fetch", AsyncMock(return_value=_odin_areas())):
run(feed.get())
feed._next_attempt = 0.0
with patch.object(feed.provider, "fetch", AsyncMock(side_effect=RuntimeError("down"))):
run(feed.get())
assert len(feed.items) == 4 and "down" in feed.error and feed.fetched_at
# ── admin routes ─────────────────────────────────────────────────────────────
def test_admin_routes_require_admin():
def deny():
raise HTTPException(403, "Admin access required")
app.dependency_overrides[require_admin_token] = deny
assert client.get("/admin/outage-providers").status_code == 403
assert client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True}).status_code == 403
def test_admin_route_toggle_and_gate(store):
app.dependency_overrides[require_admin_token] = lambda: ADMIN
r = client.put("/admin/outage-providers/odin", json={"enabled": True})
assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True
assert r.json()["licence"]["status"] == "unverified"
r = client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True})
assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com"
assert client.put("/admin/outage-providers/poweroutage_us",
json={"enabled": True, "acknowledge_licence": True}).status_code == 409
assert client.put("/admin/outage-providers/nope", json={"enabled": True}).status_code == 404
listed = client.get("/admin/outage-providers").json()["providers"]
assert next(p for p in listed if p["id"] == "odin")["serving"] is True
def test_outages_route_rejects_inverted_bbox_and_reports_status():
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"}
assert client.get("/outages", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400
r = client.get("/outages", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
assert r.status_code == 200 and r.json()["areas"] == [] and r.json()["status"] == "none_enabled"
def test_outages_route_requires_sign_in():
def deny():
raise HTTPException(401, "Not signed in")
app.dependency_overrides[require_service_or_firebase_token] = deny
assert client.get("/outages", params={"south": 40, "west": -74, "north": 41, "east": -73}).status_code == 401
# ── ODIN adapter (recorded public payloads, 2026-10-05) ──────────────────────
def test_odin_parser_reads_recorded_payload():
areas, info = providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
now=RECORDED_AT)
by = {a["fips"]: a for a in areas}
assert set(by) == {"36001", "36067", "36055", "53033"}
onondaga = by["36067"]
assert onondaga["id"] == "odin:36067" and onondaga["provider"] == "odin"
assert onondaga["county"] == "Onondaga" and onondaga["state"] == "New York"
assert onondaga["customers_out"] == 11 and onondaga["customers_served"] is None
assert onondaga["etr"] == "2026-10-05T08:00:00Z" and onondaga["since"] == "2026-10-04T18:35:40Z"
assert onondaga["updated_at"].startswith("2026-10-05T06:20:47")
assert onondaga["utilities"] == [{"name": "NATIONAL GRID", "eia_id": "13573", "customers_out": 11,
"stale_customers_out": 0}]
assert onondaga["stale"] is False and onondaga["stale_customers_out"] == 0
s, w, n, e = onondaga["bbox"]
assert s < onondaga["lat"] < n and w < onondaga["lon"] < e
assert 42.7 < onondaga["lat"] < 43.3 and -76.6 < onondaga["lon"] < -75.8
# King County WA: several outage events from two utilities, summed per county
king = by["53033"]
assert king["customers_out"] == sum(r["metersOut"] for r in FIXTURE["odi_map_scale_county"] if r["id"] == "53033")
assert len(king["utilities"]) == 2
# Puget Sound Energy's "0001-01-01T00:00:00Z" means no ETR -- not a restoration time, not stale.
assert king["etr"] is None and king["stale_customers_out"] == 0
assert info["reporting_utilities"] == len(FIXTURE["odi_status"]) and info["utilities_with_error"] == 1
def test_odin_events_long_past_their_etr_are_stale_not_live():
# ODIN still listed Avangrid's Monroe County row from April 2026 (ETR
# 2026-04-10) on 2026-10-05. It must not read as 18 customers out right now.
by = {a["fips"]: a for a in _odin_areas()}
monroe = by["36055"]
assert monroe["customers_out"] == 0 and monroe["stale_customers_out"] == 18 and monroe["stale"] is True
assert monroe["since"].startswith("2026-04-10") # still shown, so the stale row is explainable
# Same payload, captured the day the outage was live: not stale.
live = {a["fips"]: a for a in providers.parse_odin(
FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
now=datetime(2026, 4, 10, 20, 0, tzinfo=timezone.utc))[0]}
assert live["36055"]["customers_out"] == 18 and live["36055"]["stale"] is False
# Live counties sort ahead of stale ones.
assert [a["stale"] for a in _odin_areas()] == sorted(a["stale"] for a in _odin_areas())
def test_odin_fetch_uses_the_three_public_endpoints():
calls = []
async def fake_get_json(url, params=None, headers=None):
calls.append((url, params))
return {"https://odin.ornl.gov/odi": FIXTURE["odi_format_json"],
"https://odin.ornl.gov/odi/map": FIXTURE["odi_map_scale_county"],
"https://odin.ornl.gov/odi/status": FIXTURE["odi_status"]}[url]
p = providers.OdinProvider()
with patch.object(providers, "get_json", fake_get_json):
areas = run(p.fetch())
assert len(areas) == 4
assert ("https://odin.ornl.gov/odi", {"format": "JSON"}) in calls
assert ("https://odin.ornl.gov/odi/map", {"scale": "COUNTY"}) in calls
assert p.reporting_utilities == len(FIXTURE["odi_status"])
assert "utilities reporting nationwide" in p.coverage_note()
def test_odin_schema_canary_trips_on_unparseable_payload():
odi, cmap, status = FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"]
renamed_odi = {"outage": [{"county": "36067", "count": 11}]} # fields renamed upstream
with pytest.raises(ValueError, match="schema"):
providers.parse_odin(renamed_odi, cmap, status)
with pytest.raises(ValueError, match="schema"):
providers.parse_odin(odi, [{"fips": "36067"}], status)
with pytest.raises(ValueError, match="schema"):
providers.parse_odin(odi, cmap, [{"utility": "x"}])
with pytest.raises(ValueError, match="schema"):
providers.parse_odin([], cmap, status) # wrapper changed
# A genuinely quiet day is NOT an error: nothing upstream, nothing parsed.
assert providers.parse_odin({"outage": []}, [], status)[0] == []
def test_area_in_bbox_uses_county_extent():
a = base.make_area("odin", "36067", county="Onondaga", state="New York", customers_out=1,
lat=43.0, lon=-76.2, bbox=(42.77, -76.5, 43.27, -75.9))
assert base.area_in_bbox(a, (43.2, -76.0, 43.5, -75.5)) # overlaps the county's corner only
assert not base.area_in_bbox(a, (44.0, -76.0, 44.5, -75.5))
+239
View File
@@ -0,0 +1,239 @@
"""
server-26#237 — weather backend: NWS active alerts + nearest-METAR wind.
Pins the failure shapes that would otherwise look like "no weather right now":
an upstream error keeps last-good data AND reports the error, a schema change is
an error (not an empty layer), and zone-based alerts (geometry null) are never
dropped. Also pins the NWS contract: User-Agent sent, one upstream call per TTL.
"""
import asyncio
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal import weather
from app.internal.auth import require_service_or_firebase_token
client = TestClient(app)
SQUARE = {"type": "Polygon", "coordinates": [[[-74.0, 41.0], [-73.5, 41.0], [-73.5, 41.5], [-74.0, 41.5], [-74.0, 41.0]]]}
ZONE_POLY = {"type": "Polygon", "coordinates": [[[-75.0, 42.0], [-74.5, 42.0], [-74.5, 42.5], [-75.0, 42.0]]]}
ZONE_A = "https://api.weather.gov/zones/forecast/NYZ067"
ZONE_B = "https://api.weather.gov/zones/forecast/NYZ999"
def _alert(aid, event, geometry=None, zones=()):
return {"id": f"https://api.weather.gov/alerts/{aid}", "type": "Feature", "geometry": geometry,
"properties": {"id": aid, "event": event, "severity": "Severe", "urgency": "Immediate",
"headline": f"{event} issued by NWS Upton NY", "onset": "2026-10-05T10:00:00-04:00",
"expires": "2026-10-05T12:00:00-04:00", "ends": None, "senderName": "NWS Upton NY",
"areaDesc": "Westchester", "affectedZones": list(zones)}}
class Resp:
def __init__(self, body=None, status=200):
self._body, self.status_code = body, status
self.content = b"" if body is None else b"{}"
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def json(self):
return self._body
class FakeHttp:
"""Routes GETs by URL; records every call (url, params, headers)."""
def __init__(self, routes):
self.routes, self.calls = routes, []
def __call__(self, **kw):
return self
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, url, params=None, headers=None):
self.calls.append((url, params, headers))
r = self.routes.get(url)
if r is None:
return Resp(status=404)
return r() if callable(r) else r
def setup_function():
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
weather.alerts = weather._AlertsFeed()
weather.zones.clear()
weather._wind_cells.clear()
def teardown_function():
app.dependency_overrides.pop(require_service_or_firebase_token, None)
def _nws(features, extra=None):
routes = {weather.NWS_ALERTS_URL: Resp({"type": "FeatureCollection", "features": features})}
routes.update(extra or {})
return FakeHttp(routes)
def test_happy_path_normalises_and_sends_user_agent():
http = _nws([_alert("a1", "Severe Thunderstorm Warning", SQUARE)])
with patch.object(weather.httpx, "AsyncClient", http), \
patch.object(weather.settings, "nws_user_agent", "DRB (ops@example.com)"), \
patch.object(weather.settings, "nws_area", "ny, ct"):
feed = asyncio.run(weather.alerts.get())
url, params, headers = http.calls[0]
assert params == {"area": "NY,CT"}
assert headers["User-Agent"] == "DRB (ops@example.com)" and headers["Accept"] == "application/geo+json"
a = feed.alerts[0]
assert {k: a[k] for k in ("id", "event", "severity", "urgency", "sender_name", "expires")} == {
"id": "a1", "event": "Severe Thunderstorm Warning", "severity": "Severe", "urgency": "Immediate",
"sender_name": "NWS Upton NY", "expires": "2026-10-05T12:00:00-04:00"}
assert a["geometry"] == SQUARE and a["geometry_source"] == "alert"
assert a["bbox"] == [41.0, -74.0, 41.5, -73.5]
assert feed.error is None and feed.fetched_at is not None and feed.zone_only == []
def test_null_geometry_resolves_zones_or_lands_in_zone_only():
http = _nws(
[_alert("z1", "Flood Watch", None, [ZONE_A]), # zone resolves -> polygon
_alert("z2", "Heat Advisory", None, [ZONE_B]), # zone 404s -> zone_only, not dropped
_alert("z3", "Wind Advisory", None, ["https://evil.example/zones/x"])], # never fetched
{ZONE_A: Resp({"geometry": ZONE_POLY, "properties": {}})})
with patch.object(weather.httpx, "AsyncClient", http):
feed = asyncio.run(weather.alerts.get())
assert [a["id"] for a in feed.alerts] == ["z1"]
assert feed.alerts[0]["geometry"] == ZONE_POLY and feed.alerts[0]["geometry_source"] == "zones"
assert feed.alerts[0]["zones_unresolved"] == 0
assert sorted(z["id"] for z in feed.zone_only) == ["z2", "z3"]
assert feed.zone_only[0]["expires"] and feed.zone_only[0]["event"]
assert not any("evil.example" in c[0] for c in http.calls)
# second refresh: zone cache (24 h) means ZONE_A is not fetched again
weather.alerts._next_attempt = 0.0
with patch.object(weather.httpx, "AsyncClient", http):
asyncio.run(weather.alerts.get())
assert sum(1 for c in http.calls if c[0] == ZONE_A) == 1
def test_upstream_500_keeps_last_good_and_reports_error():
ok = _nws([_alert("a1", "Tornado Warning", SQUARE)])
with patch.object(weather.httpx, "AsyncClient", ok):
asyncio.run(weather.alerts.get())
good_at = weather.alerts.fetched_at
weather.alerts._next_attempt = 0.0
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.NWS_ALERTS_URL: Resp(status=500)})):
feed = asyncio.run(weather.alerts.get())
assert [a["id"] for a in feed.alerts] == ["a1"] and feed.fetched_at == good_at
assert "500" in feed.error
assert feed._next_attempt - __import__("time").time() <= weather.RETRY_AFTER_FAILURE_S + 1
r = client.get("/weather/alerts")
body = r.json()
assert r.status_code == 200 and body["error"] and len(body["alerts"]) == 1
assert body["fetched_at"] == good_at and body["credit"] == "National Weather Service"
def test_schema_canary_trips_when_no_feature_parses():
http = FakeHttp({weather.NWS_ALERTS_URL: Resp({"features": [{"props": {}}, {"properties": {"name": "x"}}]})})
with patch.object(weather.httpx, "AsyncClient", http):
feed = asyncio.run(weather.alerts.get())
assert feed.alerts == [] and "schema" in feed.error
def test_empty_feature_list_is_not_an_error():
with patch.object(weather.httpx, "AsyncClient", _nws([])):
feed = asyncio.run(weather.alerts.get())
assert feed.error is None and feed.fetched_at is not None and feed.alerts == []
def test_cache_one_upstream_call_per_ttl_even_when_concurrent():
http = _nws([_alert("a1", "Tornado Warning", SQUARE)])
async def many():
await asyncio.gather(*(weather.alerts.get() for _ in range(8)))
await weather.alerts.get()
with patch.object(weather.httpx, "AsyncClient", http):
asyncio.run(many())
assert sum(1 for c in http.calls if c[0] == weather.NWS_ALERTS_URL) == 1
def test_endpoint_bbox_filter_and_validation():
far = {"type": "Polygon", "coordinates": [[[-79.0, 42.8], [-78.5, 42.8], [-78.5, 43.0], [-79.0, 42.8]]]} # Buffalo
http = _nws([_alert("near", "Flood Warning", SQUARE), _alert("far", "Lake Effect Snow Warning", far),
_alert("zo", "Heat Advisory", None, [ZONE_B])])
with patch.object(weather.httpx, "AsyncClient", http):
r = client.get("/weather/alerts", params={"south": 40.9, "west": -74.2, "north": 41.2, "east": -73.4})
body = r.json()
assert r.status_code == 200 and [a["id"] for a in body["alerts"]] == ["near"]
assert [z["id"] for z in body["zone_only"]] == ["zo"] # never bbox-filtered away
assert len(client.get("/weather/alerts").json()["alerts"]) == 2
assert client.get("/weather/alerts", params={"south": 41.4, "west": -74, "north": 40.9, "east": -73}).status_code == 400
assert client.get("/weather/alerts", params={"south": 40.9}).status_code == 400
def _metar(icao, lat, lon, wdir, wspd, wgst=None, obs=None):
return {"icaoId": icao, "name": f"{icao} airport", "lat": lat, "lon": lon,
"obsTime": obs or int(__import__("time").time()) - 600, "reportTime": "2026-10-05T06:48:00.000Z",
"wdir": wdir, "wspd": wspd, "wgst": wgst, "rawOb": f"METAR {icao} ..."}
def test_wind_picks_nearest_station_and_caches_per_cell():
rows = [_metar("KLGA", 40.7794, -73.8803, 310, 6),
_metar("KHPN", 41.067, -73.7076, "VRB", 4, 18),
_metar("KHPN", 41.067, -73.7076, 200, 3, obs=1)] # older duplicate report
http = FakeHttp({weather.METAR_URL: Resp(rows)})
with patch.object(weather.httpx, "AsyncClient", http):
r = client.get("/weather/wind", params={"lat": 41.03, "lon": -73.76}) # White Plains
body = r.json()
assert r.status_code == 200 and body["available"] is True
w = body["wind"]
assert w["station"] == "KHPN" and w["distance_km"] < 10
assert w["wind_dir_deg"] is None and w["variable"] is True and w["wind_kt"] == 4 and w["gust_kt"] == 18
assert w["stale"] is False and body["credit"]
client.get("/weather/wind", params={"lat": 41.04, "lon": -73.77}) # same cell -> cached
assert len(http.calls) == 1
params, headers = http.calls[0][1], http.calls[0][2]
s, w_, n, e = (float(v) for v in params["bbox"].split(","))
assert s < 41.03 < n and w_ < -73.76 < e and params["format"] == "json"
assert "User-Agent" in headers
def test_wind_unavailable_reasons():
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.METAR_URL: Resp(None, status=204)})):
body = client.get("/weather/wind", params={"lat": 30.0, "lon": -50.0}).json()
assert body["available"] is False and "no METAR station" in body["reason"] and body["error"] is None
weather._wind_cells.clear()
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.METAR_URL: Resp(status=503)})):
body = client.get("/weather/wind", params={"lat": 41.03, "lon": -73.76}).json()
assert body["available"] is False and "unavailable" in body["reason"] and "503" in body["error"]
def test_slow_zone_endpoint_cannot_hold_the_feed_lock():
"""Zone fetches run under the feed lock; a hung zone endpoint must not stall
every /weather/alerts request -- the alert lands in zone_only instead."""
import time
class SlowZones(FakeHttp):
async def get(self, url, params=None, headers=None):
if url.startswith(weather.NWS_ZONE_PREFIX):
await asyncio.sleep(5)
return await super().get(url, params, headers)
http = SlowZones({weather.NWS_ALERTS_URL: Resp({"features": [_alert("z1", "Flood Watch", None, [ZONE_A])]}),
ZONE_A: Resp({"geometry": ZONE_POLY})})
with patch.object(weather, "ZONE_BUDGET_S", 0.2), patch.object(weather.httpx, "AsyncClient", http):
t0 = time.time()
feed = asyncio.run(weather.alerts.get())
assert time.time() - t0 < 2
assert feed.error is None and [z["id"] for z in feed.zone_only] == ["z1"]
assert weather.zones.get(ZONE_A) == (False, None) # not cached as a failure; retried next refresh
+124 -114
View File
@@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { ReplayTab } from "@/components/admin/ReplayTab"; import { ReplayTab } from "@/components/admin/ReplayTab";
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab"; import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab";
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types"; import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
@@ -84,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 }) {
@@ -135,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}
@@ -237,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
@@ -280,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>
@@ -294,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>
)) ))
@@ -319,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>
))} ))}
@@ -335,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>
@@ -454,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 && (
@@ -471,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>
@@ -495,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>
@@ -516,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
@@ -537,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>
@@ -572,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>
@@ -582,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
@@ -601,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>
@@ -666,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>
@@ -696,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>
@@ -735,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>
@@ -759,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>
@@ -819,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"
@@ -835,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>
@@ -856,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>
@@ -951,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>
@@ -969,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)}`)
@@ -1028,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>
@@ -1067,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>
)} )}
@@ -1103,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 && (
@@ -1168,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 || "") : "");
@@ -1196,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.
@@ -1211,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>
@@ -1221,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>
@@ -1247,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>
@@ -1268,11 +1271,12 @@ function SttEvalTab() {
// Main admin page // Main admin page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval"; type AdminTab = "features" | "cameras" | "outages" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
const TAB_LABELS: { key: AdminTab; label: string }[] = [ const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" }, { key: "features", label: "AI Features" },
{ key: "cameras", label: "Camera providers" }, { key: "cameras", label: "Camera providers" },
{ key: "outages", label: "Outage providers" },
{ key: "correlation", label: "Correlation Debug" }, { key: "correlation", label: "Correlation Debug" },
{ key: "replay", label: "Replay" }, { key: "replay", label: "Replay" },
{ key: "calls", label: "Calls" }, { key: "calls", label: "Calls" },
@@ -1285,7 +1289,7 @@ export default function AdminPage() {
const { user, isAdmin, loading: authLoading } = useAuth(); const { user, isAdmin, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features"); const [tab, setTab] = useState<AdminTab>("features");
// Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state. // Deep link to a tab (/admin#cameras, /admin#outages), e.g. from a map layer's empty state.
useEffect(() => { useEffect(() => {
const h = window.location.hash.slice(1); const h = window.location.hash.slice(1);
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab); if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
@@ -1304,19 +1308,22 @@ export default function AdminPage() {
if (!isAdmin) return null; if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow // Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras"; 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}
@@ -1324,14 +1331,17 @@ 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 === "correlation" && <CorrelationDebugTab />} {tab === "correlation" && <CorrelationDebugTab />}
{tab === "replay" && <ReplayTab />} {tab === "replay" && <ReplayTab />}
{tab === "calls" && <StaleCallsTab />} {tab === "calls" && <StaleCallsTab />}
{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>
+4 -94
View File
@@ -8,105 +8,15 @@
* memorable thing per §2.4: "where was he when he said that" is answered * memorable thing per §2.4: "where was he when he said that" is answered
* by looking, not cross-referencing timestamps. * by looking, not cross-referencing timestamps.
*/ */
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api"; import { InlinePlayer } from "@/components/InlinePlayer";
import { hasAudio as callHasAudio } from "@/lib/audioQueue";
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity"; import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} }
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${r.toString().padStart(2, "0")}`;
}
function InlinePlayer({ callId }: { callId: string }) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
async function ensureUrl() {
if (url || loading) return;
setLoading(true);
try {
const full = await c2api.getCall(callId);
setUrl(full.audio_url ?? null);
if (!full.audio_url) setError(true);
} catch {
setError(true);
} finally {
setLoading(false);
}
}
async function toggle() {
if (!url) {
await ensureUrl();
return;
}
const el = audioRef.current;
if (!el) return;
if (playing) el.pause();
else el.play();
}
// Once the URL lands, autoplay (the click that fetched it was the play intent).
useEffect(() => {
if (url && audioRef.current) audioRef.current.play().catch(() => {});
}, [url]);
if (error) {
return <span className="text-xs text-sev-major">Audio unavailable</span>;
}
return (
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
<button
onClick={toggle}
disabled={loading}
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
title={playing ? "Pause" : "Play"}
>
{loading ? "…" : playing ? "❚❚" : "▶"}
</button>
<input
type="range"
min={0}
max={duration || 0}
value={current}
onChange={(e) => {
const t = Number(e.target.value);
if (audioRef.current) audioRef.current.currentTime = t;
setCurrent(t);
}}
className="flex-1 h-1 accent-accent"
disabled={!url}
/>
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
{fmtClock(current)}
</span>
{url && (
<audio
ref={audioRef}
src={url}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
className="hidden"
/>
)}
</div>
);
}
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) { function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
if (!stopNumber) { if (!stopNumber) {
@@ -132,7 +42,7 @@ export function CallSpineEntry({
isAdmin?: boolean; isAdmin?: boolean;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasAudio = !!(call.audio_gcs_uri || call.audio_url); const hasAudio = callHasAudio(call);
const transcript = call.transcript_corrected || call.transcript; const transcript = call.transcript_corrected || call.transcript;
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)"; const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords; const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
+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}
+7 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
import { Nav } from "@/components/Nav"; import { Nav } from "@/components/Nav";
import { MarketingHeader } from "@/components/marketing/MarketingHeader"; import { MarketingHeader } from "@/components/marketing/MarketingHeader";
import { MarketingFooter } from "@/components/marketing/MarketingFooter"; import { MarketingFooter } from "@/components/marketing/MarketingFooter";
import { useKioskFlag } from "@/lib/useKiosk";
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x // Public marketing surface — exact paths, not prefixes, so e.g. /features/x
// (if it ever exists) doesn't accidentally get pulled into marketing chrome. // (if it ever exists) doesn't accidentally get pulled into marketing chrome.
@@ -36,6 +37,7 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { user, loading, orgId } = useAuth(); const { user, loading, orgId } = useAuth();
const router = useRouter(); const router = useRouter();
const kiosk = useKioskFlag();
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -62,7 +64,11 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
} }
// Live ("/") is full-bleed under its own top bar, not the padded // Live ("/") is full-bleed under its own top bar, not the padded
// max-width container the rest of the app uses. // max-width container the rest of the app uses. In kiosk mode (?kiosk=1 or
// #kiosk, server-26#234) the wall gets the whole screen: no nav at all.
if (pathname === "/" && user && kiosk) {
return <main className="h-screen">{children}</main>;
}
if (pathname === "/" && user) { if (pathname === "/" && user) {
return ( return (
<> <>
+9 -13
View File
@@ -28,17 +28,12 @@ const SELECT =
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of // Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system. // the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
function startRequest(choice: string, data: OrgPreferredBots | null): OAuthStartRequest { function startRequest(choice: string): OAuthStartRequest {
const req: OAuthStartRequest = { return_to: "/settings/organization" }; const req: OAuthStartRequest = { return_to: "/settings/organization" };
if (choice.startsWith("tok:")) req.token_id = choice.slice(4); if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
else if (choice.startsWith("sys:")) { // c2-core resolves the system's preferred bot itself, including a pool bot
const id = choice.slice(4); // a platform admin pinned (whose token_id the org never sees).
// The org's own bot preferred for this system: name it by token_id, which else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
// S6 already accepts. Only a platform-pinned pool bot needs system_id.
const pref = data?.systems.find((s) => s.system_id === id)?.preferred_token_id;
if (pref) req.token_id = pref;
else req.system_id = id;
}
return req; return req;
} }
@@ -132,7 +127,7 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
setActionError(null); setActionError(null);
setJustLinked(null); setJustLinked(null);
try { try {
const { url } = await startDiscordOAuth(startRequest(choice, choices)); const { url } = await startDiscordOAuth(startRequest(choice));
window.location.assign(url); window.location.assign(url);
// Leave the button in its busy state: the page is navigating to Discord. // Leave the button in its busy state: the page is navigating to Discord.
} catch (err) { } catch (err) {
@@ -334,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>
); );
+89
View File
@@ -0,0 +1,89 @@
"use client";
/**
* Click-to-listen control for one call — server-26#233. Moved out of
* CallSpineEntry.tsx so the live wall's spine, the map's path-stop popups and
* rail cards share it. It no longer owns an <audio> element: playback goes
* through lib/audioQueue.ts's single element, so starting a call anywhere
* stops whatever else was playing, and the map can ring the stop that's
* speaking.
*
* Every hook runs before any early return (the server-26#109 item 2 bug was a
* return between hooks). Nothing plays until the button is clicked.
*/
import { useAuth } from "@/components/AuthProvider";
import { canPlayAudio, seek, toggle, useAudio } from "@/lib/audioQueue";
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${r.toString().padStart(2, "0")}`;
}
/** Just the round play/pause button — for map popups, rail cards, spine rows. */
export function PlayButton({ callId, label, className = "" }: { callId: string; label?: string; className?: string }) {
const auth = useAuth();
const active = useAudio((s) => s.playingCallId === callId);
const status = useAudio((s) => (s.playingCallId === callId ? s.status : "idle"));
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
if (!canPlayAudio(auth)) return null;
const playing = active && status === "playing";
const loading = active && status === "loading";
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(callId); }}
disabled={loading}
title={failed ? "Audio unavailable — click to retry" : playing ? "Pause" : label ?? "Play this call"}
aria-label={playing ? "Pause call audio" : label ?? "Play call audio"}
aria-pressed={playing}
className={`w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[10px] disabled:opacity-50 ${failed ? "bg-raised text-sev-major border border-sev-major" : "bg-accent text-white"} ${className}`}
>
{loading ? "…" : playing ? "❚❚" : failed ? "!" : "▶"}
</button>
);
}
/**
* Button + seek bar + clock. `compact` shows only the button until this call
* is the one playing, then expands — for dense lists like the wall's spine.
*/
export function InlinePlayer({ callId, compact = false }: { callId: string; compact?: boolean }) {
const auth = useAuth();
const active = useAudio((s) => s.playingCallId === callId);
const current = useAudio((s) => (s.playingCallId === callId ? s.current : 0));
const duration = useAudio((s) => (s.playingCallId === callId ? s.duration : 0));
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
if (!canPlayAudio(auth)) return null;
if (failed && !compact) {
return (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<PlayButton callId={callId} />
<span className="text-xs text-sev-major">Audio unavailable</span>
</div>
);
}
if (compact && !active) return <PlayButton callId={callId} />;
return (
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
<PlayButton callId={callId} />
<input
type="range"
min={0}
max={duration || 0}
step="any"
value={Math.min(current, duration || 0)}
onChange={(e) => seek(Number(e.target.value))}
className="flex-1 h-1 accent-accent"
disabled={!active || !duration}
aria-label="Seek"
/>
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
{fmtClock(current)}{duration ? ` / ${fmtClock(duration)}` : ""}
</span>
</div>
);
}
+390 -63
View File
@@ -7,34 +7,95 @@
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep * KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents, * links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD. * paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
*
* Saved views (server-26#234): overlays, basemap and viewport live in the
* URL hash too (lib/liveWall.ts wallHash), so a reload or a copied link comes
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
* wall-display mode (lib/useKiosk.ts).
*
* Filter bar (server-26#235): the `f.*` hash keys (lib/liveFilter.ts) are
* applied ONCE here and the filtered lists feed the map (pins, paths, rail),
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
* 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, 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 { useActiveCalls, useCalls } from "@/lib/useCalls"; import { useSystems } from "@/lib/useSystems";
import { useActiveIncidents } from "@/lib/useIncidents"; import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
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 {
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds, DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
} from "@/lib/liveWall"; } from "@/lib/liveWall";
import { PRESETS, applyFilter, parseFilter, serialiseFilter, type PresetKey, type WallFilter } from "@/lib/liveFilter";
import { FilterBar, type FilterOption } from "@/components/live/FilterBar";
import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
import type { MapViewState } from "@/components/MapView";
import { TimeScrubber } from "@/components/TimeScrubber"; import { TimeScrubber } from "@/components/TimeScrubber";
import { TranscriptTicker } from "@/components/live/TranscriptTicker"; import { TranscriptTicker } from "@/components/live/TranscriptTicker";
import { CameraDrill } from "@/components/live/CameraDrill"; 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 { 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
// 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.
const KIOSK_TYPE = [
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
"[&[data-kiosk]_.w-56]:w-80",
].join(" ");
const PIN_KEY = "drb.wall.pinnedCameras"; const PIN_KEY = "drb.wall.pinnedCameras";
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`;
@@ -87,18 +148,19 @@ 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 } = useCalls(500); const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
const { orgId } = useAuth();
const { systems } = useSystems();
const { aircraft } = useAircraft(); const { aircraft } = useAircraft();
const { vessels } = useVessels(); const { vessels } = useVessels();
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [clock, setClock] = useState<string>(""); const [clock, setClock] = useState<string>("");
useEffect(() => { // No heartbeat-driven "last updated" any more (server-26#232): a node
if (!nodesLoading) setLastUpdated(new Date()); // check-in kept '● Live' fresh while STT/correlation were dead (#196). The
}, [nodes, activeCalls, activeIncidents, nodesLoading]); // wall's liveness now comes from usePipelineHealth, keyed to pipeline output.
useEffect(() => { useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false })); const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
tick(); tick();
@@ -107,18 +169,93 @@ export function LiveView() {
}, []); }, []);
// ── Wall state, mirrored in the URL hash ────────────────────────────────── // ── Wall state, mirrored in the URL hash ──────────────────────────────────
// `link` = where the wall is (incident/camera/tour/kiosk/filters);
// `mapState` = what the map looks like (layers/base/viewport). Both are
// written back to the hash by one effect, so the address bar is always a
// working "this exact view" link. null until the hash has been read, so
// MapView never mounts with the wrong initial view.
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false }); const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
const [mapState, setMapState] = useState<MapViewState | null>(null);
const mapStateRef = useRef<MapViewState | null>(null);
mapStateRef.current = mapState;
/** Bumped when a hash edit changes layers/base/view: MapView remounts with them. */
const [mapGen, setMapGen] = useState(0);
const [kioskQuery, setKioskQuery] = useState(false);
useEffect(() => { useEffect(() => {
const read = () => setLinkState(parseWallHash(window.location.hash)); const read = (first: boolean) => {
read(); const parsed = parseWallHash(window.location.hash);
window.addEventListener("hashchange", read); const { layers, base, view, ...nav } = parsed;
return () => window.removeEventListener("hashchange", read); setLinkState(nav);
const cur = mapStateRef.current;
const next = resolveWallView({ layers, base, view }, first || !cur ? loadWallPrefs() : cur);
if (first || !cur) setMapState(next);
else if (!sameView(cur, next)) { setMapState(next); setMapGen((g) => g + 1); }
};
setKioskQuery(kioskInQuery());
read(true);
const onHash = () => read(false);
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []); }, []);
const setLink = useCallback((next: WallLink) => { useEffect(() => {
setLinkState(next); if (!mapState) return;
const h = wallHash(next); const h = wallHash({ ...link, ...mapState });
window.history.replaceState(null, "", h || window.location.pathname + window.location.search); if (h !== window.location.hash) {
window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search + h);
}
announceKioskChange();
}, [link, mapState]);
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
// ── Filter (server-26#235) ────────────────────────────────────────────────
const filterKey = JSON.stringify(link.filter ?? {});
// eslint-disable-next-line react-hooks/exhaustive-deps
const filter = useMemo(() => parseFilter(link.filter), [filterKey]);
const setFilter = useCallback((f: WallFilter) => patchLink({ filter: serialiseFilter(f) }), [patchLink]);
/** A preset switches its map layers on through the saved-view layer state (no MapView edit). */
const onPreset = useCallback((p: PresetKey) => {
const cur = mapStateRef.current;
if (!cur) return;
const have = cur.layers ?? DEFAULT_LAYERS;
if (PRESETS[p].layers.every((l) => have.includes(l))) return;
const next = { ...cur, layers: normaliseLayers([...have, ...PRESETS[p].layers]) };
saveWallPrefs(next);
setMapState(next);
setMapGen((g) => g + 1);
}, []); }, []);
const onViewStateChange = useCallback((s: MapViewState) => {
setMapState((cur) => {
// Keep a known viewport if the map hasn't reported one yet.
const next = { ...s, view: s.view ?? cur?.view };
saveWallPrefs(next);
return next;
});
}, []);
const [copied, setCopied] = useState<"ok" | "fail" | null>(null);
const copyViewLink = useCallback(() => {
const done = (r: "ok" | "fail") => { setCopied(r); setTimeout(() => setCopied(null), 2000); };
try {
navigator.clipboard.writeText(window.location.href).then(() => done("ok"), () => done("fail"));
} catch {
done("fail");
}
}, []);
// ── Kiosk ─────────────────────────────────────────────────────────────────
const kiosk = kioskQuery || !!link.kiosk;
const kioskState = useKiosk(kiosk, callsError);
const toggleKiosk = useCallback(() => {
if (kiosk) {
stripKioskQuery();
setKioskQuery(false);
patchLink({ kiosk: false });
} else {
patchLink({ kiosk: true });
// This click is the user gesture fullscreen needs.
if (!kioskState.fullscreen) kioskState.toggleFullscreen();
}
}, [kiosk, kioskState, patchLink]);
const tourMs = link.tourMs ?? TOUR_MS;
const [pinned, setPinned] = useState<Camera[]>([]); const [pinned, setPinned] = useState<Camera[]>([]);
useEffect(() => { setPinned(loadPinned()); }, []); useEffect(() => { setPinned(loadPinned()); }, []);
@@ -133,16 +270,63 @@ export function LiveView() {
// ── Geography ───────────────────────────────────────────────────────────── // ── Geography ─────────────────────────────────────────────────────────────
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]); const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]); const filtered = useMemo(
() => applyFilter(activeIncidents, recentCalls, filter, callsByIncident),
[activeIncidents, recentCalls, filter, callsByIncident],
);
const sorted = useMemo(() => worstFirst(filtered.incidents), [filtered.incidents]);
const pointsById = useMemo(() => { const pointsById = useMemo(() => {
const m = new Map<string, LatLon[]>(); const m = new Map<string, LatLon[]>();
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
@@ -156,11 +340,56 @@ export function LiveView() {
}, [pointsById, cams.cameras]); }, [pointsById, cams.cameras]);
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]); const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
const events = useRegionEvents(regionBox); const events = useRegionEvents(regionBox);
const health = usePipelineHealth({
calls: recentCalls,
incidents: activeIncidents,
nodes,
eventsStatus: events.events_status,
cameraProviders: cams.providers,
});
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); const 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
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; // or it has resolved (a shared #inc= link must still land); it is not added
const near = selected ? nearById.get(selected.incident_id) ?? [] : []; // to the tour. Its full call list rides along so the path is complete.
const keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
const mapIncidents = useMemo(
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
[keepSelected, selected, filtered.incidents],
);
const mapCalls = useMemo(() => {
if (!selectedCalls.length) return filtered.calls;
const have = new Set(filtered.calls.map((c) => c.call_id));
const extra = selectedCalls.filter((c) => !have.has(c.call_id));
return extra.length ? [...filtered.calls, ...extra] : filtered.calls;
}, [selectedCalls, filtered.calls]);
// Filter choices come from what the wall has loaded (plus system names).
const filterOptions = useMemo(() => {
const sysNames = new Map(systems.map((s) => [s.system_id, s.name]));
const sysIds = new Set<string>();
const tgNames = new Map<string, string>();
for (const c of recentCalls) {
if (c.system_id) sysIds.add(c.system_id);
if (c.talkgroup_id != null) {
const id = String(c.talkgroup_id);
if (c.talkgroup_name || !tgNames.has(id)) tgNames.set(id, c.talkgroup_name || id);
}
}
const tagCount = new Map<string, number>();
for (const i of activeIncidents) {
for (const s of i.system_ids ?? []) sysIds.add(String(s));
for (const t of i.talkgroup_ids ?? []) if (!tgNames.has(String(t))) tgNames.set(String(t), String(t));
for (const t of i.tags ?? []) tagCount.set(t.toLowerCase(), (tagCount.get(t.toLowerCase()) ?? 0) + 1);
}
const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label);
return {
systems: Array.from(sysIds).map((id) => ({ id, label: sysNames.get(id) ?? id })).sort(byLabel),
talkgroups: Array.from(tgNames.entries()).map(([id, name]) => ({ id, label: name === id ? id : `${name} (${id})` })).sort(byLabel),
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]);
const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
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;
@@ -177,13 +406,39 @@ export function LiveView() {
}, [servingPinned, sorted, nearById]); }, [servingPinned, sorted, nearById]);
// ── Navigation ──────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]); const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
// ── 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 stepUp = useCallback(() => { const stepUp = useCallback(() => {
if (link.cam) setLink({ ...link, cam: null, tour: false }); if (link.cam) patchLink({ cam: null, tour: false });
else if (link.inc) setLink({ inc: null, cam: null, tour: false }); else if (link.inc) patchLink({ inc: null, cam: null, tour: false });
else if (link.tour) setLink({ ...link, tour: false }); else if (link.tour) patchLink({ tour: false });
}, [link, setLink]); }, [link, patchLink]);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
@@ -203,30 +458,37 @@ export function LiveView() {
const next = () => { const next = () => {
setLinkState((cur) => { setLinkState((cur) => {
const i = cur.inc ? tourList.indexOf(cur.inc) : -1; const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true }; return { ...cur, inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
window.history.replaceState(null, "", wallHash(nxt));
return nxt;
}); });
setTourStart(Date.now()); setTourStart(Date.now());
}; };
if (!link.inc || !tourList.includes(link.inc)) next(); if (!link.inc || !tourList.includes(link.inc)) next();
else setTourStart(Date.now()); else setTourStart(Date.now());
const id = setInterval(next, TOUR_MS); const id = setInterval(next, tourMs);
const prog = setInterval(() => setTourNow(Date.now()), 500); const prog = setInterval(() => setTourNow(Date.now()), 500);
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(",")]); }, [link.tour, tourList.join(","), tourMs]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0; // 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;
// ── KPIs ────────────────────────────────────────────────────────────────── // ── KPIs ──────────────────────────────────────────────────────────────────
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length; const count = (sev: string) => filtered.incidents.filter((i) => i.severity === sev).length;
const nodesUp = nodes.filter((n) => n.status !== "offline").length; // "Nodes hearing": a call in the last N min, not merely not-offline — a node
// that checks in but hears nothing is not contributing to the picture.
const nodesHearing = health.nodesHearing;
const eventsOff = !!events.events_status?.disabled; const eventsOff = !!events.events_status?.disabled;
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0; // "Listening" only when every stage is healthy; otherwise the amber bar says why.
const mostRecentSeen = configuredButQuiet const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1) && activeIncidents.length === 0 && !health.degraded;
: null;
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => ( const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined} <button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
@@ -236,29 +498,31 @@ export function LiveView() {
); );
return ( return (
<div className="flex flex-col h-full"> <div data-kiosk={kiosk ? "" : undefined} className={`flex flex-col h-full ${KIOSK_TYPE}`}>
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */} {/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs"> <div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<nav aria-label="Drill level" className="flex items-center gap-0.5"> <nav aria-label="Drill level" className="flex items-center gap-0.5">
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))} {crumb("Region", !link.inc, true, () => patchLink({ inc: null, cam: null, tour: false }))}
<span className="text-ink-muted">›</span> <span className="text-ink-muted">›</span>
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc, {crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
() => setLink({ ...link, cam: null, tour: false }))} () => patchLink({ cam: null, tour: false }))}
<span className="text-ink-muted">›</span> <span className="text-ink-muted">›</span>
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)} {crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
</nav> </nav>
<div className="hidden md:flex items-center gap-3 flex-wrap"> <div className="hidden md:flex items-center gap-3 flex-wrap">
<Kpi value={count("major")} label="major" /> <Kpi value={count("major")} label="major" />
<Kpi value={count("moderate")} label="moderate" /> <Kpi value={count("moderate")} label="moderate" />
<Kpi value={activeIncidents.length} label="active" /> <Kpi value={filtered.active ? `${filtered.incidents.length} of ${activeIncidents.length}` : activeIncidents.length}
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" /> label={filtered.active ? "active · filtered" : "active"} />
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
<Kpi value={cams.cameras.length} label="cams" /> <Kpi value={cams.cameras.length} label="cams" />
<Kpi value={aircraft.length} label="aircraft" /> <Kpi value={aircraft.length} label="aircraft" />
<Kpi value={vessels.length} label="vessels" /> <Kpi value={vessels.length} label="vessels" />
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" /> <Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
</div> </div>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })} <TruthStrip health={health} placement="chips" />
<button type="button" aria-pressed={link.tour} onClick={() => patchLink({ cam: null, tour: !link.tour })}
title="Wall mode: cycle through active incidents, worst first" title="Wall mode: cycle through active incidents, worst first"
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}> className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Auto-tour Auto-tour
@@ -268,47 +532,108 @@ export function LiveView() {
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} /> <span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
</span> </span>
)} )}
<button type="button" onClick={copyViewLink} title="Copy a link to this exact view: layers, map position, selection"
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"}
</button>
<AttentionControls prefs={attnPrefs} onChange={changeAttnPrefs} armed={cues.armed} />
<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>
</div> </div>
<FilterBar
filter={filter}
onChange={setFilter}
onPreset={onPreset}
systems={filterOptions.systems}
talkgroups={filterOptions.talkgroups}
tags={filterOptions.tags}
shown={filtered.incidents.length}
total={activeIncidents.length}
readOnly={kiosk}
/>
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
<TruthStrip health={health} placement="bar" />
<div className="flex-1 min-h-0 flex flex-col lg:flex-row"> <div className="flex-1 min-h-0 flex flex-col lg:flex-row">
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0"> <div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
{nodesLoading || callsLoading ? ( {/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{/* 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
nodes={nodes} nodes={nodes}
activeCalls={activeCalls} activeCalls={activeCalls}
incidents={activeIncidents} incidents={mapIncidents}
calls={recentCalls} calls={mapCalls}
lastUpdated={lastUpdated}
selectedIncidentId={selected?.incident_id ?? null} selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident} onSelectIncident={selectIncident}
nearCameras={near} nearCameras={near}
onOpenCamera={openCamera} onOpenCamera={openCamera}
onAir={onAir} onAir={onAir}
camCountByIncident={camCountByIncident} camCountByIncident={camCountByIncident}
pulseIncidentIds={attention.pulseIds}
filteredFrom={filtered.active ? activeIncidents.length : undefined}
key={mapGen}
initialLayers={mapState.layers}
initialBase={mapState.base}
initialView={mapState.view}
onViewStateChange={onViewStateChange}
/> />
)} )}
</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 && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none"> <div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap"> <span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""} ● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
</span> </span>
</div> </div>
)} )}
</div> </div>
<aside className="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}
@@ -320,10 +645,11 @@ 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={activeIncidents} incidents={filtered.incidents}
calls={recentCalls} calls={filtered.calls}
onAirCount={onAir.size} onAirCount={onAir.size}
nodes={nodes} nodes={nodes}
wallCameras={wallCameras} wallCameras={wallCameras}
@@ -340,8 +666,9 @@ export function LiveView() {
</aside> </aside>
</div> </div>
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} /> <TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
<TimeScrubber calls={recentCalls} incidents={activeIncidents} 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
@@ -351,7 +678,7 @@ export function LiveView() {
incidentTitle={selected?.title} incidentTitle={selected?.title}
pinned={pinnedIds.has(drillCam.id)} pinned={pinnedIds.has(drillCam.id)}
onTogglePin={() => togglePin(drillCam)} onTogglePin={() => togglePin(drillCam)}
onClose={() => setLink({ ...link, cam: null })} onClose={() => patchLink({ cam: null })}
/> />
)} )}
</div> </div>
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>
+37 -14
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
@@ -116,8 +118,8 @@ function DiscordPicker({
const channelOnHere = channel?.stream?.node_id === nodeId; const channelOnHere = channel?.stream?.node_id === nodeId;
const check = guildId ? checks[guildId] : undefined; const check = guildId ? checks[guildId] : undefined;
const checkOn = check?.data?.enabled !== false; const checkOn = check?.data?.enabled !== false;
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => { const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => {
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined; const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined;
const v = channelVerdict(row); const v = channelVerdict(row);
// This system already plays here: c2-core counts its bot as busy (it holds // This system already plays here: c2-core counts its bot as busy (it holds
// the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW // the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
@@ -128,19 +130,26 @@ function DiscordPicker({
}; };
const chosenVerdict = channel ? verdictOf(channel) : null; const chosenVerdict = channel ? verdictOf(channel) : null;
const definiteDeny = blocksStart(chosenVerdict); const definiteDeny = blocksStart(chosenVerdict);
// "Not a voice channel" can't change, so Start stays off. "Needs fix" can:
// Start re-checks with Discord first (server-26#256), so fixing it in
// Discord and pressing Start again just works — no hunting for Re-check.
const startOff = chosenVerdict?.kind === "not_voice";
// Not for "invalid": that bot is already in the server; inviting won't help (#257).
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy"; const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
const runCheck = useCallback( const runCheck = useCallback(
async (gid: string, refresh: boolean) => { async (gid: string, refresh: boolean): Promise<VoiceCheck | null> => {
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } })); setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
try { try {
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh)); const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } })); setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
return data;
} catch (err) { } catch (err) {
const e = parseC2Error(err); const e = parseC2Error(err);
// 404 = route not deployed yet or server not visible: no badges (fail-open). // 404 = route not deployed yet or server not visible: no badges (fail-open).
const msg = e.status === 404 ? null : e.message; const msg = e.status === 404 ? null : e.message;
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } })); setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
return null;
} }
}, },
[nodeId], [nodeId],
@@ -166,6 +175,12 @@ function DiscordPicker({
setSending(true); setSending(true);
setError(null); setError(null);
try { try {
if (definiteDeny) {
// Fresh answer from Discord; still refused = stay here, the row shows why.
// A failed check fails open, like everywhere else in the picker.
const fresh = await runCheck(guild.guild_id, true);
if (fresh && blocksStart(verdictOf(channel, fresh))) return;
}
await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode }); await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
onStarted(); onStarted();
onClose(); onClose();
@@ -198,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">
@@ -246,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">
@@ -382,11 +399,17 @@ function DiscordPicker({
<button <button
type="button" type="button"
onClick={start} onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny} disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined} title={
startOff
? "That is not a voice channel. Pick a voice channel."
: definiteDeny
? "Fixed the permission in Discord? Start checks again first."
: undefined
}
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold" className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
> >
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"} {sending ? (definiteDeny ? "Checking…" : "Starting…") : channelOnHere ? "Update" : "Start radio"}
</button> </button>
<button <button
type="button" type="button"
+5 -5
View File
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`; : `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Conventional Channels</h2>
{encrypted.length > 0 && ( {encrypted.length > 0 && (
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono"> <p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
{subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "} {subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "}
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
)} )}
<ul className="space-y-1 font-mono text-xs"> <ul className="space-y-1 font-mono text-xs">
{channels.map((c, i) => ( {channels.map((c, i) => (
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300"> <li key={i} className="flex flex-wrap gap-x-3 text-ink-2">
<span className="text-white">{c.name || mhz(c.freq)}</span> <span className="text-white">{c.name || mhz(c.freq)}</span>
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>} {c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>} {c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
{c.encrypted ? ( {c.encrypted ? (
<span className="text-red-400"> <span className="text-red-400">
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""} ✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""} {c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
</span> </span>
) : ( ) : (
<span className="text-gray-400"> <span className="text-ink-2">
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"} {c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
</span> </span>
)} )}
+15 -15
View File
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
} }
const selectClass = const selectClass =
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]"; "bg-raised border border-line-strong rounded px-2 py-1 text-ink text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
function DeviceSelect({ function DeviceSelect({
value, devices, devicesKnown, autoLabel, label, disabled, onChange, value, devices, devicesKnown, autoLabel, label, disabled, onChange,
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
<p className="text-xs text-gray-500 font-mono mb-3"> <p className="text-xs text-ink-muted font-mono mb-3">
{devicesKnown {devicesKnown
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.` ? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
: "This node hasn't reported its SDRs yet."} : "This node hasn't reported its SDRs yet."}
</p> </p>
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-gray-200">OP25 SDR</div> <div className="text-ink">OP25 SDR</div>
<div className="text-xs text-gray-500"> <div className="text-xs text-ink-muted">
{op25Moving {op25Moving
? "Saving restarts OP25 on the selected SDR." ? "Saving restarts OP25 on the selected SDR."
: node.op25_sdr_serial : node.op25_sdr_serial
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
: "Waiting for SDR"; : "Waiting for SDR";
return ( return (
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span> <span className="w-4 text-right text-ink-muted text-xs">{row.enabled ? ++rank : ""}</span>
<input <input
type="checkbox" type="checkbox"
checked={row.enabled} checked={row.enabled}
disabled={!canEdit} disabled={!canEdit}
aria-label={`Enable ${meta.name}`} aria-label={`Enable ${meta.name}`}
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))} onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/> />
<div className="flex-1 min-w-[7rem]"> <div className="flex-1 min-w-[7rem]">
<div className="text-gray-200">{meta.name}</div> <div className="text-ink">{meta.name}</div>
<div className="text-xs text-gray-500">{meta.hint}</div> <div className="text-xs text-ink-muted">{meta.hint}</div>
</div> </div>
<DeviceSelect <DeviceSelect
value={row.pin} value={row.pin}
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
disabled={i === 0} disabled={i === 0}
aria-label={`Move ${meta.name} up`} aria-label={`Move ${meta.name} up`}
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30" className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
> >
▲ ▲
</button> </button>
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
disabled={i === rows.length - 1} disabled={i === rows.length - 1}
aria-label={`Move ${meta.name} down`} aria-label={`Move ${meta.name} down`}
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30" className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
> >
▼ ▼
</button> </button>
</div> </div>
)} )}
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}> <span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-ink-muted"}`}>
{state} {state}
</span> </span>
</div> </div>
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
})} })}
</div> </div>
<p className="text-xs text-gray-500 font-mono mt-2"> <p className="text-xs text-ink-muted font-mono mt-2">
Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its
antenna, or leave it on &quot;Any spare SDR&quot;. antenna, or leave it on &quot;Any spare SDR&quot;.
</p> </p>
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
> >
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</button> </button>
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>} {message && <span className="text-xs text-ink-muted font-mono">{message}</span>}
</div> </div>
)} )}
</section> </section>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
const styles: Record<NodeStatus, string> = { const styles: Record<NodeStatus, string> = {
online: "bg-green-950 text-green-400 border border-green-800", online: "bg-green-950 text-green-400 border border-green-800",
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse", recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
offline: "bg-gray-900 text-gray-500 border border-gray-700", offline: "bg-surface text-ink-muted border border-line-strong",
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800", unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
}; };
+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>
@@ -0,0 +1,156 @@
"use client";
/**
* Admin → Outage providers (server-26#184). Same licence gate as Camera
* providers: every source is listed with its licence status shown first, all
* default OFF. A licence-gated source (unverified / agreement required / paid)
* shows a blocking warning and needs an explicit confirmation, which c2-core
* records with who/when in config/outage_providers and audit_log. Stubs —
* utility storm-center maps, PowerOutage.us, EAGLE-I live — cannot be switched
* on at all: DRB holds no right to that data.
*/
import { useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
};
function fmtEpoch(s: number | null | undefined): string {
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
}
export function OutageProvidersTab() {
const [providers, setProviders] = useState<OutageProviderAdmin[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [confirming, setConfirming] = useState<OutageProviderAdmin | null>(null);
const [ackChecked, setAckChecked] = useState(false);
const load = () =>
c2api.listOutageProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
useEffect(() => { load(); }, []);
async function toggle(p: OutageProviderAdmin, enabled: boolean, acknowledge = false) {
if (enabled && p.licence.gated && !acknowledge) {
setError(null);
setAckChecked(false);
setConfirming(p);
return;
}
setBusy(p.id);
setError(null);
try {
const updated = await c2api.setOutageProvider(p.id, enabled, acknowledge);
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
setConfirming(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
return (
<section className="space-y-3">
<p className="text-ink-2 text-xs leading-snug">
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
agreement exists.
</p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? (
<p className="text-ink-muted text-sm font-mono">Loading…</p>
) : (
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
{providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
return (
<div key={p.id} className="px-5 py-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
</div>
<div className="shrink-0 text-right space-y-1">
<button
type="button"
role="switch"
aria-checked={p.enabled}
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
disabled={busy === p.id || (!!lockReason && !p.enabled)}
title={lockReason ?? undefined}
onClick={() => toggle(p, !p.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p>
</div>
</div>
{p.implemented && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-ink-2">
<span>counties out: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
</div>
)}
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
{p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with the data: “{p.licence.attribution}”</p>}
{p.acknowledgement && (
<p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p>
)}
<details className="text-[11px] text-ink-muted">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p>
<p className="mt-1 space-x-2">
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
</p>
</details>
</div>
);
})}
</div>
)}
{confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
<div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
<label className="flex items-start gap-2 text-sm text-ink">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div>
</div>
</div>
)}
</section>
);
}
+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.
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
} catch (err) { } catch (err) {
const e = parseC2Error(err); const e = parseC2Error(err);
if (e.status === 403) setHidden(true); if (e.status === 403) setHidden(true);
else setLoadError(e.message); // A server error's raw body ("C2 API error 500: ...") means nothing to an owner.
else setLoadError(e.status !== null && e.status >= 500 ? "DRB couldn't load them just now." : e.message);
} }
}, []); }, []);
@@ -67,15 +68,20 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used." subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
/> />
{loadError ? ( {loadError ? (
<p role="alert" className="text-sm text-sev-major">Couldn&apos;t load radio bots: {loadError}</p> <p role="alert" className="text-sm text-sev-major">
Couldn&apos;t load radio bots: {loadError}{" "}
<button type="button" onClick={() => load()} className="text-accent hover:underline">
Try again
</button>
</p>
) : !data ? ( ) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p> <p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? ( ) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p> <p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? ( ) : bots.length === 0 ? (
<p className="text-sm text-ink-muted"> <p className="text-sm text-ink-muted">
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. Adding
your own bot for a system, ask DRB support to add it to your organization. your own bot isn&apos;t self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
</p> </p>
) : ( ) : (
<div className="divide-y divide-line"> <div className="divide-y divide-line">
@@ -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>,
@@ -20,6 +20,7 @@ import {
type ParsedC2Error, type ParsedC2Error,
type VoiceCheck, type VoiceCheck,
type VoiceCheckBot, type VoiceCheckBot,
type VoiceCheckChannel,
} from "@/lib/discordPicker"; } from "@/lib/discordPicker";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
@@ -70,17 +71,19 @@ function AddChannelForm({
const cid = channelId.trim(); const cid = channelId.trim();
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : ""; const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
const runCheck = useCallback(async (key: string, refresh: boolean) => { const runCheck = useCallback(async (key: string, refresh: boolean): Promise<VoiceCheck | null> => {
const [gid, ch] = key.split(":"); const [gid, ch] = key.split(":");
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false })); setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
try { try {
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh)); const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c)); setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
return data;
} catch (err) { } catch (err) {
const e = parseStartError(err); const e = parseStartError(err);
setCheck((c) => setCheck((c) =>
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c, c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
); );
return null;
} }
}, []); }, []);
// Check the channel once its ID looks complete (debounced while typing). // Check the channel once its ID looks complete (debounced while typing).
@@ -98,8 +101,8 @@ function AddChannelForm({
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null; const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
const shownName = channelName.trim() || checked?.name || null; const shownName = channelName.trim() || checked?.name || null;
// Per-bot result, by token id (the admin route sends ids) or else by name. // Per-bot result, by token id (the admin route sends ids) or else by name.
const botCheck = (b: RadioBot): VoiceCheckBot | undefined => const botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name)); row?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null; const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org); const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
@@ -124,6 +127,13 @@ function AddChannelForm({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
if (checkKey && (verdict?.kind === "fix" || chosenBotDenied)) {
// "Needs fix" can change: ask Discord again before refusing (server-26#256).
// Still refused = stay here, the row shows why; a failed check fails open.
const fresh = await runCheck(checkKey, true);
const row = fresh && fresh.enabled !== false ? fresh.channels[cid] : undefined;
if (row && (blocksStart(channelVerdict(row)) || (chosenBot && botCheck(chosenBot, row)?.ok === false))) return;
}
const res = await c2api.startDiscordStream({ const res = await c2api.startDiscordStream({
guild_id: guildId, guild_id: guildId,
channel_id: channelId.trim(), channel_id: channelId.trim(),
@@ -221,7 +231,7 @@ function AddChannelForm({
</div> </div>
{verdict && <FixText verdict={verdict} channelName={shownName} />} {verdict && <FixText verdict={verdict} channelName={shownName} />}
{chosenBotDenied && ( {chosenBotDenied && (
<div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and Re-check.</div> <div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and press Start again.</div>
)} )}
{checkOn && <BotDetails channel={checked} channelName={shownName} />} {checkOn && <BotDetails channel={checked} channelName={shownName} />}
<CheckFooter <CheckFooter
@@ -252,10 +262,16 @@ function AddChannelForm({
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied} disabled={saving || !systemId || verdict?.kind === "not_voice"}
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined} title={
verdict?.kind === "not_voice"
? "That is not a voice channel. Pick a voice channel."
: blocksStart(verdict) || chosenBotDenied
? "Fixed the permission in Discord? Start checks again first."
: undefined
}
> >
{saving ? "Starting…" : "Start radio channel"} {saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
</Button> </Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button> <Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div> </div>
@@ -332,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", ""]}>
@@ -29,6 +29,11 @@ const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.", title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
}, },
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." }, no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
invalid: {
label: "Radio bot needs replacing",
cls: "border-sev-major/50 text-sev-major",
title: "A radio bot is in this server, but Discord rejected its token. Inviting it again won't help — DRB support has to replace it.",
},
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." }, unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
}; };
@@ -59,11 +64,19 @@ export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | n
); );
} }
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */ /** The sentence under a "Needs fix" / "Not a voice channel" / "Radio bot needs replacing" channel. */
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) { export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
if (verdict.kind === "not_voice") { if (verdict.kind === "not_voice") {
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>; return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
} }
if (verdict.kind === "invalid") {
return (
<span className="block text-xs text-sev-major mt-1">
Discord rejected the radio bot in this server, so it can&apos;t play radio. Inviting it again won&apos;t help —
DRB support has to replace it.
</span>
);
}
if (verdict.kind !== "fix") return null; if (verdict.kind !== "fix") return null;
return ( return (
<span className="block text-xs text-sev-major mt-1"> <span className="block text-xs text-sev-major mt-1">
@@ -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>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
/**
* Live wall filter bar (server-26#235): one row under the wall bar — presets,
* free-text search, type chips, severity floor, system/talkgroup, age window,
* tag chips, clear. Presentation only; the filter lives in the URL hash
* (lib/liveFilter.ts) and LiveView applies it once to everything it plots.
* Wraps on narrow screens; below md the less-used controls fold behind a
* "Filters" toggle so the map keeps its height.
*/
import { useEffect, useRef, useState } from "react";
import { SEVERITY_LABEL, type Severity } from "@/lib/severity";
import {
AGE_WINDOWS_H, EMPTY_FILTER, INCIDENT_TYPES, PRESETS, PRESET_KEYS, describeFilter, isFilterActive,
type IncidentType, type PresetKey, type WallFilter,
} from "@/lib/liveFilter";
export interface FilterOption { id: string; label: string }
const TYPE_LABEL: Record<IncidentType, string> = {
fire: "Fire", ems: "EMS", police: "Police", accident: "Accident", other: "Other",
};
const SEV_FLOORS: Severity[] = ["minor", "moderate", "major"];
const Q_DEBOUNCE_MS = 250;
const chip = (on: boolean) =>
`px-2 py-0.5 rounded-full border whitespace-nowrap ${on ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`;
const select = "px-1.5 py-0.5 rounded border border-line bg-surface text-ink-2 max-w-[11rem]";
const toggle = <T,>(arr: T[], v: T): T[] => (arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
export function FilterBar({
filter, onChange, onPreset, systems, talkgroups, tags, shown, total, readOnly = false,
}: {
filter: WallFilter;
onChange: (f: WallFilter) => void;
/** A preset was switched on — the caller also turns its map layers on. */
onPreset: (p: PresetKey) => void;
systems: FilterOption[];
talkgroups: FilterOption[];
/** Most common tags in the loaded incidents (selected ones are always shown). */
tags: string[];
shown: number;
total: number;
/** Kiosk: no controls, just the summary when a filter is on. */
readOnly?: boolean;
}) {
const active = isFilterActive(filter);
const [more, setMore] = useState(false);
// Search is debounced locally so each keystroke doesn't rewrite the hash
// and re-filter every transcript; an outside change (hash edit, Clear) wins.
const [q, setQ] = useState(filter.q);
const committed = useRef(filter.q);
useEffect(() => {
// Compare trimmed: the hash stores q trimmed, so a just-typed trailing
// space ("main ") must not read as an outside change and get eaten.
if (filter.q !== committed.current.trim()) { committed.current = filter.q; setQ(filter.q); }
}, [filter.q]);
const filterRef = useRef(filter);
filterRef.current = filter;
useEffect(() => {
if (q === committed.current) return;
const id = setTimeout(() => { committed.current = q; onChange({ ...filterRef.current, q }); }, Q_DEBOUNCE_MS);
return () => clearTimeout(id);
}, [q, onChange]);
const sysName = (id: string) => systems.find((s) => s.id === id)?.label ?? id;
const tgName = (id: string) => talkgroups.find((t) => t.id === id)?.label ?? id;
const summary = (
<span className={`whitespace-nowrap ${active ? "text-ink font-semibold" : "text-ink-muted"}`} aria-live="polite">
{active ? `Filtered: ${shown} of ${total}` : `${total} active`}
</span>
);
if (readOnly) {
if (!active) return null;
return (
<div className="shrink-0 flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 border-b border-line bg-surface/95 text-xs">
{summary}
<span className="text-ink-2 min-w-0 break-words">{describeFilter(filter, { sys: sysName, tg: tgName })}</span>
</div>
);
}
const set = (p: Partial<WallFilter>) => onChange({ ...filter, ...p });
const tagList = Array.from(new Set([...filter.tags, ...tags]));
const tgValue = filter.talkgroups.length === 1 ? filter.talkgroups[0] : "";
const sysValue = filter.systems.length === 1 ? filter.systems[0] : "";
return (
<div role="search" aria-label="Filter the live map"
className="shrink-0 flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<div className="flex items-center gap-1" role="group" aria-label="Presets">
{PRESET_KEYS.map((k) => (
<button key={k} type="button" aria-pressed={filter.preset === k} title={PRESETS[k].title} className={chip(filter.preset === k)}
onClick={() => {
if (filter.preset === k) { set({ preset: null }); return; }
set({ preset: k });
onPreset(k);
}}>
{PRESETS[k].label}
</button>
))}
</div>
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, location, transcript"
aria-label="Search incidents and transcripts"
className="min-w-0 flex-1 basis-40 max-w-xs px-2 py-0.5 rounded border border-line bg-surface text-ink placeholder:text-ink-muted" />
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={`md:hidden ${chip(false)}`}>
{more ? "Fewer filters" : "Filters"}
</button>
<div className={`${more ? "flex" : "hidden"} md:flex flex-wrap items-center gap-x-2 gap-y-1 w-full md:w-auto`}>
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Incident type">
{INCIDENT_TYPES.map((t) => (
<button key={t} type="button" aria-pressed={filter.types.includes(t)} className={chip(filter.types.includes(t))}
onClick={() => set({ types: toggle(filter.types, t) })}>
{TYPE_LABEL[t]}
</button>
))}
</div>
<select aria-label="Severity floor" className={select} value={filter.sevFloor ?? ""}
onChange={(e) => set({ sevFloor: (e.target.value || null) as Severity | null })}>
<option value="">Any severity</option>
{SEV_FLOORS.map((s) => <option key={s} value={s}>{SEVERITY_LABEL[s]}{s === "major" ? "" : "+"}</option>)}
</select>
<select aria-label="Age window" className={select} value={filter.ageH ?? ""}
onChange={(e) => set({ ageH: e.target.value ? Number(e.target.value) : null })}>
<option value="">Any age</option>
{AGE_WINDOWS_H.map((h) => <option key={h} value={h}>Last {h}h</option>)}
{filter.ageH && !(AGE_WINDOWS_H as readonly number[]).includes(filter.ageH) && (
<option value={filter.ageH}>Last {filter.ageH}h</option>
)}
</select>
{systems.length > 1 || sysValue ? (
<select aria-label="Radio system" className={select} value={sysValue}
onChange={(e) => set({ systems: e.target.value ? [e.target.value] : [] })}>
<option value="">All systems</option>
{filter.systems.length > 1 && <option value="" disabled>{filter.systems.length} systems</option>}
{systems.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
) : null}
{talkgroups.length > 0 || tgValue ? (
<select aria-label="Talkgroup" className={select} value={tgValue}
onChange={(e) => set({ talkgroups: e.target.value ? [e.target.value] : [] })}>
<option value="">All talkgroups</option>
{filter.talkgroups.length > 1 && <option value="" disabled>{filter.talkgroups.length} talkgroups</option>}
{talkgroups.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
) : null}
{tagList.length > 0 && (
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Tags">
{tagList.map((t) => (
<button key={t} type="button" aria-pressed={filter.tags.includes(t)} className={chip(filter.tags.includes(t))}
onClick={() => set({ tags: toggle(filter.tags, t) })}>
#{t}
</button>
))}
</div>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{summary}
{active && (
<button type="button" onClick={() => { committed.current = ""; setQ(""); onChange(EMPTY_FILTER); }} className={chip(false)}>
Clear
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
/**
* Live wall kiosk controls + reconnect banner (server-26#234). State lives in
* lib/useKiosk.ts; this file is presentation only.
*/
import { useEffect, useState } from "react";
import type { KioskBanner as Banner, KioskState } from "@/lib/useKiosk";
const btn = "px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap";
export function KioskControls({ kiosk, state, onToggleKiosk }: {
kiosk: boolean;
state: KioskState;
onToggleKiosk: () => void;
}) {
return (
<>
{state.canFullscreen && (
<button type="button" onClick={state.toggleFullscreen} aria-pressed={state.fullscreen} className={btn}
title={state.fullscreen ? "Leave full screen" : "Full screen"}>
{state.fullscreen ? "Exit full screen" : "Full screen"}
</button>
)}
{kiosk && state.wake === "on" && (
<span className="text-ink-muted whitespace-nowrap" title="Screen Wake Lock held — this display will not sleep">Awake</span>
)}
<button type="button" onClick={onToggleKiosk} aria-pressed={kiosk} className={btn}
title={kiosk ? "Back to the normal app layout" : "Wall display: no nav bar, large type, screen kept awake"}>
{kiosk ? "Exit kiosk" : "Kiosk"}
</button>
</>
);
}
export function KioskBanner({ banner, onRetry }: { banner: Banner | null; onRetry: () => void }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!banner?.retryAt) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [banner?.retryAt]);
if (!banner) return null;
const secs = banner.retryAt ? Math.max(0, Math.ceil((banner.retryAt - now) / 1000)) : null;
return (
<div role="alert" className="shrink-0 flex items-center gap-3 px-3 py-1.5 border-b border-line bg-raised text-xs text-ink">
<span className="font-semibold">{banner.tone === "offline" ? "Offline" : "Reconnecting"}</span>
<span className="text-ink-2">{banner.message}{secs != null ? ` Retrying in ${secs}s.` : ""}</span>
{banner.tone === "error" && (
<button type="button" onClick={onRetry} className={`${btn} ml-auto`}>Retry now</button>
)}
</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>
</>
);
}
@@ -0,0 +1,53 @@
"use client";
/**
* Truth strip (server-26#232) — replaces the heartbeat-driven '● Live' pill.
*
* Healthy: compact per-stage chips (Radio · Transcripts · AI · Feeds), each
* with its last-output age. Any core stage dry past the threshold, or any
* /health/ai tier degraded: a full-width amber bar that says what broke and
* since when — "DEGRADED — radio heard, not transcribed since 12:15".
* Theme tokens only (sev-moderate is the amber), so both themes follow.
*/
import type { PipelineHealth, PipelineStage } from "@/lib/usePipelineHealth";
function dotClass(s: PipelineStage["state"]): string {
if (s === "degraded") return "bg-sev-moderate";
if (s === "unknown") return "bg-ink-muted";
return "bg-ink-2";
}
export function TruthChips({ health }: { health: PipelineHealth }) {
return (
<div className="flex items-center gap-1.5 flex-wrap" role="status" aria-label="Pipeline health">
{health.stages.map((s) => (
<span key={s.key} title={s.detail}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] whitespace-nowrap ${
s.state === "degraded" ? "border-sev-moderate text-ink bg-sev-moderate/10" : "border-line text-ink-2"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${dotClass(s.state)}`} aria-hidden="true" />
{s.label}
<span className="sr-only">: {s.state === "ok" ? "healthy" : s.state}, {s.detail}</span>
</span>
))}
</div>
);
}
export function TruthBar({ health }: { health: PipelineHealth }) {
if (!health.degraded || !health.message) return null;
return (
<div role="alert"
className="shrink-0 w-full px-3 py-1.5 border-b border-sev-moderate bg-sev-moderate/15 text-xs text-ink flex flex-wrap items-baseline gap-x-2">
<b className="font-bold tracking-wide">DEGRADED</b>
<span>— {health.message}</span>
<span className="ml-auto text-ink-muted whitespace-nowrap">
stale after {health.staleMin} min · map shows last-known state
</span>
</div>
);
}
/** Chips for the wall bar, plus the bar itself directly below it when degraded. */
export function TruthStrip({ health, placement }: { health: PipelineHealth; placement: "chips" | "bar" }) {
return placement === "chips" ? <TruthChips health={health} /> : <TruthBar health={health} />;
}
+349 -23
View File
@@ -14,7 +14,17 @@ import { useAuth } from "@/components/AuthProvider";
import { CameraFeed } from "@/components/CameraFeed"; import { CameraFeed } from "@/components/CameraFeed";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall"; import { InlinePlayer } from "@/components/InlinePlayer";
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
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)";
@@ -22,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 {
@@ -31,13 +43,15 @@ function hhmm(iso: string): string {
} }
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5"; const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
// Spine rows rendered before "show all loaded" (server-26#239).
const SPINE_CAP = 30;
const SEC = "px-4 py-3 border-b border-line"; const SEC = "px-4 py-3 border-b border-line";
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) { export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
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>
); );
@@ -57,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,
@@ -101,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>
@@ -119,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}>
@@ -173,16 +424,37 @@ 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.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
const auth = useAuth();
const audioOk = canPlayAudio(auth);
const playingCallId = useAudio((s) => s.playingCallId);
const queueLen = useAudio((s) => s.queue.length);
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
// Truncation is said out loud, not silent (server-26#239): the spine shows
// what this page has loaded, which can be less than the incident holds.
const [showAll, setShowAll] = useState(false);
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? []; const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
const cleared = inc.units_cleared ?? []; const cleared = inc.units_cleared ?? [];
@@ -192,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>
@@ -211,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 }) => (
@@ -229,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>
@@ -250,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>
@@ -260,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>
@@ -270,23 +558,61 @@ export function IncidentPanel({
</div> </div>
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Call spine · newest first</h3> <div className="flex items-center gap-2 mb-1.5">
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
{audioOk && playable.length > 0 && (
playingHere ? (
<button type="button" onClick={stopAudio}
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
</button>
) : (
<button type="button" onClick={() => playIncident(playable)}
title="Play this incident's loaded calls back to back, oldest first"
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
▶ Play incident · {playable.length}
</button>
)
)}
</div>
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
<p className="text-[10px] text-ink-muted mb-1.5">
{showAll || spine.length <= SPINE_CAP
? `Showing all ${spine.length} loaded of ${totalCalls}`
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
{spine.length > SPINE_CAP && (
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
{showAll ? "show latest 30" : "show all loaded"}
</button>
)}
</p>
) : null}
{spine.length ? ( {spine.length ? (
<ol className="space-y-2"> <ol className="space-y-2">
{spine.slice(0, 30).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;
return ( return (
<li key={c.call_id} className="flex gap-2 text-xs"> <li key={c.call_id}
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
aria-current={isPlaying ? "true" : undefined}>
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]" <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}
<div className="min-w-0"> 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="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>
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>} {c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
</div> </div>
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p> <p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
</div> </div>
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
</li> </li>
); );
})} })}
+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 };
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
/**
* Call-audio playback store — server-26#233.
*
* ONE module-level <audio> element for the whole app, so exactly one call
* plays at a time no matter how many players are on screen (spine rows, map
* path-stop popups, rail cards, the incident page). Components read it through
* useSyncExternalStore, which is what lets the wall panel and the map share
* "now playing" without LiveView.tsx threading state between them.
*
* Nothing here runs until play()/playIncident() is called from a click, so
* nothing autoplays on page load. The playback URL is minted per play by
* GET /calls/{id} (a short-lived HMAC link, see c2-core storage.py) rather
* than read off the Firestore doc, which only holds the gs:// location.
* c2-core serves it with HTTP Range, so a long call starts after the first
* span and seeking is a 206 for just the bytes needed.
*/
import { useSyncExternalStore } from "react";
import { c2api } from "@/lib/c2api";
import type { CallRecord } from "@/lib/types";
export type AudioStatus = "idle" | "loading" | "playing" | "paused";
export interface AudioState {
/** Calls still to play after the current one, oldest first. */
queue: string[];
playingCallId: string | null;
status: AudioStatus;
/** The last call whose audio could not be fetched or decoded. */
errorCallId: string | null;
current: number;
duration: number;
}
const IDLE: AudioState = {
queue: [], playingCallId: null, status: "idle", errorCallId: null, current: 0, duration: 0,
};
let state: AudioState = IDLE;
const listeners = new Set<() => void>();
let el: HTMLAudioElement | null = null;
// Bumped on every play/stop so a getCall() that resolves after the user has
// already moved on is dropped instead of hijacking the player.
let token = 0;
function set(patch: Partial<AudioState>) {
state = { ...state, ...patch };
listeners.forEach((l) => l());
}
function audio(): HTMLAudioElement {
if (el) return el;
el = new Audio();
el.preload = "auto";
el.addEventListener("play", () => set({ status: "playing" }));
el.addEventListener("pause", () => { if (state.status === "playing") set({ status: "paused" }); });
el.addEventListener("timeupdate", () => set({ current: el!.currentTime }));
el.addEventListener("loadedmetadata", () => set({ duration: Number.isFinite(el!.duration) ? el!.duration : 0 }));
el.addEventListener("ended", advance);
el.addEventListener("error", () => {
if (!state.playingCallId || !el!.getAttribute("src")) return;
set({ errorCallId: state.playingCallId });
advance();
});
return el;
}
function advance() {
const [next, ...rest] = state.queue;
if (next) void start(next, rest);
else stop();
}
async function start(callId: string, queue: string[]) {
const mine = ++token;
const a = audio();
a.pause();
set({ queue, playingCallId: callId, status: "loading", current: 0, duration: 0,
errorCallId: state.errorCallId === callId ? null : state.errorCallId });
let url: string | null = null;
try {
url = (await c2api.getCall(callId)).audio_url ?? null;
} catch {
url = null;
}
if (mine !== token) return;
if (!url) {
set({ errorCallId: callId });
advance();
return;
}
a.src = url;
// The click that got us here is the play intent; a rejection (autoplay
// policy after a slow fetch) leaves it paused for one more click.
a.play().catch(() => { if (mine === token) set({ status: "paused" }); });
}
/** Play one call now, replacing whatever is playing or queued. */
export function play(callId: string) {
void start(callId, []);
}
/** Play an incident's calls back to back. Pass them oldest first. */
export function playIncident(callIdsOldestFirst: string[]) {
const [first, ...rest] = callIdsOldestFirst;
if (first) void start(first, rest);
}
export function stop() {
token++;
if (el) {
el.pause();
el.removeAttribute("src");
el.load();
}
set({ ...IDLE, errorCallId: state.errorCallId });
}
export function pause() {
el?.pause();
}
export function resume() {
el?.play().catch(() => {});
}
export function seek(t: number) {
if (el && Number.isFinite(t)) {
el.currentTime = t;
set({ current: t });
}
}
/** Play/pause toggle for one call's button. */
export function toggle(callId: string) {
if (state.playingCallId !== callId) play(callId);
else if (state.status === "playing") pause();
else if (state.status === "paused") resume();
}
function subscribe(l: () => void): () => void {
listeners.add(l);
return () => {
listeners.delete(l);
// Last player unmounted (navigated away from every page that shows one):
// stop rather than keep playing audio nothing on screen can pause.
// Deferred so a same-tick remount (StrictMode, route swap) doesn't stop it.
if (!listeners.size) setTimeout(() => { if (!listeners.size && state.playingCallId) stop(); }, 0);
};
}
/**
* Subscribe to a slice of the store. Return a primitive from `select` (an id,
* a status, a number) so a component re-renders only when that slice changes
* — the map must not re-render on every timeupdate.
*/
export function useAudio<T>(select: (s: AudioState) => T): T {
return useSyncExternalStore(subscribe, () => select(state), () => select(IDLE));
}
/** True when a call has a stored recording c2-core can serve. */
export function hasAudio(c: Pick<CallRecord, "audio_gcs_uri" | "audio_url">): boolean {
return !!(c.audio_gcs_uri || c.audio_url);
}
/**
* The ONE gate for call-audio playback in the UI. BUSINESS_MODEL.md §3 puts
* audio playback in Scanner and up, with the Public tier getting none; no tier
* concept exists in the frontend yet (and Gate A keeps tier names off public
* surfaces), so today this is true for any signed-in org member. When tiers
* land, switch the Public tier off HERE rather than at each player.
*/
export function canPlayAudio(auth: { user: unknown; orgId: string | null; isAdmin?: boolean }): boolean {
return !!auth.user && (!!auth.orgId || !!auth.isAdmin);
}
+32 -2
View File
@@ -1,5 +1,9 @@
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types"; import { C2ApiError } from "@/lib/c2error";
import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
WeatherAlertsResponse, WindResponse,
} from "@/lib/types";
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -16,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();
} }
@@ -514,6 +518,32 @@ export const c2api = {
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}), }),
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
// `status` explains an empty list: none_enabled / no_source / error / ok.
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
request<OutagesResponse>(`/outages?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
})}`),
// Admin → Outage providers. Same licence gate as cameras: a gated provider
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
method: "PUT",
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);
}

Some files were not shown because too many files have changed in this diff Show More