Commit Graph
109 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 4fab959ddb AI providers: review follow-ups to 0060ccd
Build & Deploy / Build & push images (push) Successful in 4m59s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 2m48s
Build & Deploy / Report a failed deploy (push) Skipped
Replay usage key gemini_usage -> llm_usage (backend + ReplayTab), re-price the
correction/correlation estimates for gpt-4o-mini, no temperature for o-series
or gpt-5 models, and refresh comments still naming Gemini.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:52:02 -04:00
Logan CusanoandClaude Opus 5.5 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 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 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 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 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 eaa0be07c5 S7 review: align OAuth UI with S6's actual contract (server-26#225)
- complete: S6 returns streams_stopped, not stopped — the "N radio
  channels were stopped" note never showed.
- start: bot name is nested as bot.name.
- "The bot for a system" with an org-owned preferred bot now sends
  token_id (S6 accepts it today); system_id only for a platform-pinned
  pool bot, which still needs S6 to accept system_id.
- A 404 for one chosen bot no longer hides Connect and the picker
  (the message told the user to try Automatic, but the button was gone).
- Server count uses GET /tokens' per-bot server_cap when present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:56:05 -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 dd3e5c9655 Discord: "Connect a Discord server" OAuth UI (server-26#225 S7)
- /discord/connected: Discord's OAuth redirect target. Stashes code/state
  in sessionStorage, strips them from the URL (replaceState), no-referrer
  (metadata + response header), sends a signed-out user to
  /login?next=/discord/connected and resumes, access_denied = "Nothing
  changed", result card "Linked X: N ready, M need a fix".
- middleware.ts: the callback path joins NO_SESSION_COOKIE_GATE (plan
  finding 2). Also needed for signed-in users: drb_session is
  SameSite=Strict, so it is not sent on the cross-site redirect back from
  discord.com.
- /login honours ?next= for an allowlist of one path (no open redirect).
- DiscordServersCard: Connect is the primary action with a radio-bot
  picker (automatic / the bot for a system / the org's own bots); the
  /drb link code moves to a collapsed secondary option and opens itself
  when /discord/oauth/start answers 404.
- RadioBotsPanel: write-only client-secret field per bot (PUT/DELETE
  /tokens/{id}/oauth), "Connect on/off", "N of 100 servers" with the
  pool cap (90) warning.
- lib/discordOAuth.ts: the S6 client, kept out of c2api.ts; only ever
  navigates to a discord.com /oauth2/authorize URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:51:39 -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
Logan CusanoandClaude Opus 5.5 1ee1560383 Discord picker: per-bot permission status, owner's preferred bot (server-26#225 S4)
Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
  Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
  plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
  server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
  unknown fails open. A channel this system already plays in is judged by
  the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
  bad_channel) are shown verbatim, multi-line.

Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).

The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.

Stacked on feat/225-s3-perm-check: must merge after S3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:28:36 -04:00
logan c4e1f9aa61 Merge pull request 'Discord: several radio streams per voice channel; adding never overwrites (#213)' (#227) from fix/213-multi-stream-per-channel 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 1m49s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 01:44:35 -04:00
Logan CusanoandClaude Opus 5.5 8af410aede Discord: several radio streams per voice channel; adding never overwrites (#213)
Owner-reported on live: adding a radio channel for a server + voice channel
that already had one (different label, system, even bot) overwrote the
existing stream and made its bot leave, because stream_id was guild:channel.

- New streams get guild:channel:<12 hex> ids (leases.new_stream_id); old
  guild:channel docs keep working for read/stop/remove/node reports. Route
  validators accept both (leases.STREAM_ID_PATTERN).
- start_stream: no stream_id = ADD. Same system already on in that channel
  is idempotent (already_on, label/mode untouched); a stopped one of that
  system is restarted; anything else is a new doc. stream_id = explicit
  restart/edit (admin Start button, reconciler), incl. moving to another bot.
- preferred_token_id is strict: bot already in this server -> 409
  preferred_busy naming the stream; on another node -> preferred_other_node;
  unusable -> preferred_unavailable. No silent swap. pool_exhausted now says
  one-bot-per-server and to add + invite another bot.
- One-token-per-guild was already enforced inside the lease transaction;
  now covered by race tests. Per-guild asyncio lock serialises the
  already-on / server-cap check with the create (guild half of #223).
- Stop by stream_id (POST /discord/guilds/{id}/stop {stream_id}); admin UI
  Stop/Start/Remove act on the row's stream; node picker lists every stream
  in a channel and adds beside other systems.
- Bot: /leave stream: (autocomplete) stops one; /leave channel: with several
  streams asks which unless all:True; bare /leave still stops the server.
  /status and /join name each stream's bot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:30:40 -04:00
Logan CusanoandClaude Opus 5.5 f18f7a49f6 Nodes: Update All Nodes — fleet-wide update request for every approved node
POST /nodes/request-update-all (admin) does what Update Node does for each
approved node: flags update_requested_at for the HTTPS updater and nudges it
over MQTT. Pending and rejected nodes are skipped. Admin-only button on the
Nodes page reports which nodes weren't reachable over MQTT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:29:30 -04:00
Logan CusanoandClaude Opus 5.5 c92bdfe841 Discord: log node-reported stream state; remove a radio channel or a server
- handle_actual logs every node-reported state TRANSITION (not heartbeats)
  with stream/node/guild/channel/token ids and the node's error text: info
  for connected/connecting/stopped, warning for error, and a dedicated
  "radio bot X failed to log in on node N: <reason>" line for login_failed.
  The node's reason code is now stored in actual.code and shown next to the
  error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
  desired state first (node republished without it, token -> releasing per
  #216, freed on the node's ack), then deletes the doc in a transaction that
  refuses if the channel was restarted in between. DELETE
  /discord/streams/{id} (platform admin) and DELETE
  /discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
  only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
  channel in it the same way, then tombstones discord_guilds (org_id None +
  unlinked_at, #218 semantics, plus removed_at) so it never falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
  admin link brings it back. Optional leave_radio_bots makes pool bots and the
  server's org's own BYO bots leave via Discord REST; the DRB server bot stays
  until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.

Refs #213, #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:40:43 -04:00
logan fabdf5839e Merge pull request 'Node page Discord picker: org's servers + voice channels, not raw IDs (#213 item 3)' (#220) from feat/213-node-join-picker into main
Build & Deploy / Build & push images (push) Successful in 4m58s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m37s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #220
2026-10-04 22:05:49 -04:00
logan ebd7e893ce Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #219
2026-10-04 22:05:36 -04:00
Logan CusanoandClaude Opus 5.5 d3277c13e1 Discord admin page: servers, radio channels, radio bots (server-26#213 item 2)
Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
  hidden from org owners because every route behind it is admin-only).
  KPIs + tabs:
  * Radio channels: every discord_streams doc, desired -> actual, the node's
    real error, red after 2 min desired-on without connecting. Stop
    (POST /discord/guilds/{id}/stop), Start for a stopped channel and
    "+ Add radio channel" (POST /discord/streams), with the invite links a
    "no bot in this server" refusal carries.
  * Servers: linked servers plus servers seen running radio while unlinked
    (acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
    (PUT /discord/guilds/{id}/link).
  * Radio bots: valid / check_error, guild membership with a per-server
    invite link for each server the bot is missing from, lease (node,
    since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
    Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
  * How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
  per-channel talkgroup chips, "Generate link code" (item 1).

Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
  and bot names, orgs, systems, limits. No token strings, no Discord user
  ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
  and re-linking a server to a different org stops its live channels
  (the reconciler would otherwise keep re-placing them on the old org's
  nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
  types them in; the bot always sent snowflakes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:05:35 -04:00
Logan CusanoandClaude Opus 5.5 62e6d69bfa Node page Discord picker: org's linked servers + voice channels, not raw IDs (#213 item 3, #109 item 13)
- c2-core routers/discord_web.py:
  PUT  /discord/guilds/{id}/directory (service key): the server bot pushes each
       server's name + voice channels; merged onto discord_guilds, never touches org_id.
  GET  /discord/nodes/{id}/join-options: the node org's servers (same
       resolve rule as the bot), their voice channels with any live stream,
       radio-bot readiness + invite links, and this node's streams. No tokens.
  POST /discord/nodes/{id}/streams: stream pinned to the node; server must be
       the node org's, channel must be in the bot's directory, guild + node caps.
  POST /discord/nodes/{id}/streams/stop: one stream on this node, or all.
  Control = platform admin, org owner, or operator owning the node.
- nodes.py: legacy raw-ID discord_join now refuses a server outside the node's org.
- discord bot: DirectorySync cog pushes on ready (15-min loop), join/leave,
  rename, voice/category channel changes (debounced).
- frontend: NodeDiscordRadio panel + picker replaces DiscordJoinModal and the
  admin-only Join/Leave buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:02:16 -04:00
Logan CusanoandClaude Opus 5.5 455c55f5c2 feat(discord): self-serve server->org linking with /drb link (#213 item 1)
- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
  10-minute code; minting revokes the org's older codes. The bot redeems it
  via POST /discord/guilds/{id}/link/redeem (service key) inside one
  Firestore transaction, so two servers racing for one code cannot both win
  and a server linked to org A refuses org B's code. GET/DELETE
  /discord/linked-guilds lists/unlinks the caller's org's servers; unlink
  stops that server's streams. Rate-limited (mint per user, redeem per
  server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
  the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
  GatedTree answers every other command in an unlinked server with how to
  link (autocomplete returns nothing). With the default fallback org set,
  nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
  generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
  revoked/malformed codes, wrong-org takeover, idempotent relink, rate
  limits, fallback stop, scoped list, unlink without fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:01:25 -04:00
Logan CusanoandClaude Opus 5.5 2f21728bce Cameras: redact API keys from feed errors; gate TfL/Calgary; clamp map bbox
- Feed/catalog error text (shown to every signed-in user via GET /cameras and
  /traffic/511 status fields) carried httpx's full request URL, including
  ?key= for Ontario 511 and the 511NY DAA key. Query strings are now dropped.
- TfL and Calgary licences are per GEV only (TfL's terms page 403'd), the same
  footing as Ontario 511, so they are UNVERIFIED (acknowledgement required)
  instead of ungated attribution_required. Attribution still renders.
- The map camera layer clamps its bbox to +/-180/+/-90; zoomed-out bounds
  produced a 422 ("could not reach server").

Refs server-26#198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:06:47 -04:00
Logan CusanoandClaude Opus 5.5 aa5b71d853 Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
  catalog cache; one adapter per source in design-canvas/camera-sources.md.
  Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
  endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
  TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
  511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
  TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
  (agreement required / unverified / non-commercial / paid) need an explicit
  acknowledgement stored with who/when and written to audit_log; the audit
  write is a precondition. Stubs and prohibited sources cannot be enabled.
  Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
  + NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
  ("may not alter the substance of a report").

Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
  worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
  bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
  (ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
  nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
  last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
  provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:06 -04:00