50 Commits
Author SHA1 Message Date
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 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 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 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 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 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
Logan CusanoandClaude Opus 5.5 7fa50f24d2 approve_node never rotates an existing key; node update-check endpoint; real Update Node button (node-26#14, #15)
- approve_node: reuses node_keys/{id}.api_key when one exists (still
  re-syncs dynsec with it, still never overwrites node_keys). Re-clicking
  Approve can no longer lock a live node out. Reissue stays explicit.
- POST /nodes/{id}/request-update (admin): sets update_requested_at on the
  node doc AND sends MQTT node_update best-effort, so offline/locked-out
  nodes still get it.
- POST /nodes/{id}/update-check (node, inline auth: Bearer api_key for THAT
  node or X-Pickup-Secret): host updater's HTTPS check-in. Returns only
  update_requested_at; records updater_last_check_at / sha / applied result.
  Unknown node -> 401 (no node_id oracle). C2 cannot name a ref, image or
  command.
- Frontend: "Update Node Software" (admin only) calls request-update, honest
  confirm text; node page shows updater check-in and last update state.

Tests: tests/test_node_provisioning.py (12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:09:52 -04:00
Logan CusanoandClaude Opus 5.5 2b42e5ee9a Admin Users: show each user's org and move a user into yours
A viewer whose first login ran self-serve signup got an empty org of
their own (org_role owner), so they saw no incidents or calls, and the
earlier fix deliberately never moved a user who already had an org.
PATCH /admin/users/{uid} now moves a user when org_id is passed
explicitly (claims + org_members, audited with left_org_id; the old org
is not deleted). The user list returns org_id/org_role, and the admin
user panel shows the org and a 'Move to my organization' button when it
isn't yours.

Verified: c2-core pytest 496 passed; frontend tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:52:55 -04:00
Logan CusanoandClaude Opus 5.5 fb2d737d6f Node SDRs: pin OP25 and each service to a dongle by serial (node-26#11)
Pairs with node-26 feat/sdr-pins. NodeRecord gains sdr_pins
(service -> serial), sdr_devices and op25_sdr_serial, mirrored from the
node's checkin. PATCH /nodes/{id} validates pins (known services, one
dongle per service) and sends priority/pins as a 'set_sdr_config'
command, never a config re-push. The node restarts OP25 only when OP25's
own dongle changes. The node page's section becomes 'SDRs' with an OP25
SDR dropdown ('Automatic (first SDR)' + detected dongles) and a
per-service dongle dropdown ('Any spare SDR'), plus duplicate-serial and
double-pin warnings.

Verified: c2-core pytest 490 passed; frontend tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:36:49 -04:00
Logan CusanoandClaude Opus 5.5 3347ced863 511NY DOT cameras + traffic events map layers (#183)
GET /traffic/511 serves a bbox slice of the statewide 511NY camera and event
feeds from an in-memory cache (cameras 1h, events 2m TTL, fetched lazily) --
public data, so no Firestore writes. A failed refresh keeps the last good data
and reports the error; a schema change (nothing parses) is an error, not an
empty layer. Frontend: opt-in "DOT Cameras" and "Traffic Events" overlays that
fetch only while shown, with an on-map notice when the feed is down or stale.
NY511_API_KEY is optional in config (the API answers without one today; the
terms require a registered key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:26:38 -04:00
Logan CusanoandClaude Opus 5.5 3cbb0828ae Secondary SDR priority: C2 field, node command, dashboard section
Pairs with node-26 feat/secondary-sdr-priority. NodeRecord gains
secondary_sdr_priority (ordered; SDRs beyond OP25's run it top-down) and
secondary_sdr_running, both mirrored from the node's checkin.
PATCH /nodes/{id} accepts the priority, validates it, and sends it as a
'set_secondary_priority' MQTT command. A priority-only change never
re-pushes system config, because that restarts OP25. The node detail page
gets a 'Secondary SDRs' section (admin-editable) with enable, reorder,
save, and live Running / Waiting-for-SDR state from the checkin.

Verified: c2-core pytest 482 passed; frontend tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:10:11 -04:00
Logan CusanoandClaude Opus 5.5 aff3f16d32 Admin Replay: re-run the pipeline over past calls in a sandbox (#170)
Correlation has only ever been measured through live AI windows: days of
wall time per change, and the 09-20→22 window was invalidated outright by
unfunded AI accounts (#169). Recordings are kept regardless of AI, so the
traffic to measure against already exists.

- internal/replay.py: runs a time range of real calls through the live
  pipeline code in original order, clock pinned per call, into
  replay_runs/{run_id}/calls|incidents. Modes: audio (re-transcribe),
  transcripts (re-extract), reuse (correlation only from a prior run's
  scenes). Simulates the idle-resolve and orphan-recorrelation sweeps on
  virtual time. No alerts, summaries, vocab, AI-health alerts or pending
  terms. One run at a time, <=5000 calls, <=7 days.
- firestore.py: ContextVar sandbox redirect for calls/incidents.
- clock.py: ContextVar-pinnable now(), used on the correlation path.
- feature_flags.py: ContextVar flag override so replay runs with live AI off.
- upload.py: scene loop extracted to _extract_and_correlate, shared by the
  live pipeline and replay so replay measures the code that runs live.
- resolved_via on every incident resolve, so a real clear can be told
  from the idle timeout — live and in replay.
- routers/replay.py + /admin Replay tab: estimate, start, compare runs,
  drill into incidents with audio.

Reviewed by drb-correlation-review; its leak and fidelity findings are
fixed and covered by tests. c2-core: 456 pass. Frontend typecheck not run
(no Node on the authoring box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:23:57 -04:00
Logan CusanoandClaude Opus 5.5 6479174022 frontend: date range picker on Incidents and Archive; fix Archive paging
Incidents and Archive get a from/to date range (native date inputs,
local-day bounds). Incidents filters in the Firestore query; Archive
passes date_from/date_to to GET /calls/search, which applies them as a
started_at range — both ride the existing org_id/started_at index.

Also fixes /calls/search and /calls/eval-queue paging: the cursor went to
Firestore as a raw ISO string against a timestamp field, which compares
by type rather than time, so "Load more" re-read the first page. Cursor
and range bounds are now parsed to datetimes (400 on garbage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:03:40 -04:00
Logan CusanoandClaude Sonnet 5 5f85a878fa admin: STT eval harness — record human-verified transcripts, measure real WER (#163)
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m56s
Build & Deploy / Report a failed deploy (push) Successful in 1s
Backend: three new routes on the calls router, deliberately separate from
PATCH /{call_id}/transcript (a production correction with real side effects
-- re-extraction, incident unlinking, vocabulary learning). This is pure
measurement and must never share that code path.

  GET  /calls/eval-queue        -- calls with a transcript but no
                                    eval_transcript yet, paged (same bounded-
                                    window-plus-cursor shape as /search)
  PUT  /{call_id}/eval-transcript -- records eval_transcript/_by/_at only;
                                      never touches transcript/transcript_corrected
  GET  /calls/eval-stats        -- eval_count + average word error rate of
                                    the raw and corrected machine transcripts
                                    against the human-verified ones

internal/wer.py: standard word-level Levenshtein WER. Returns None (not 0.0)
when the reference is empty -- a call nobody transcribed must not score as a
perfect match.

Frontend: a new "STT Eval" tab on /admin -- one call at a time, audio player,
a textarea pre-filled with the machine transcript to correct into ground
truth, Save & next / Skip, running WER stats at the top. Built for working a
handful of calls at a time over however many sittings it takes, not a
one-shot form: the queue auto-refills from where the last save left off.

Verified: 438 pass, 0 fail (12 new backend tests). Frontend is UNVERIFIED --
this box has no Node.js/npm (confirmed absent), so neither typecheck nor the
dev server could be run. Matches existing code patterns and the CallRecord/
c2api types by manual review only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:04:30 -04:00
Logan CusanoandClaude Opus 5 964343c819 area_context v2 + Maps place verification (server-26#36, #37)
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:

  area_context: { municipality?, county?, state?,
                  center?, radius_km?, resolved_from?, resolved_at?,
                  local_knowledge?: [{term, meaning}] }

`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.

Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.

The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.

#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.

No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.

Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.

The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.

38 new tests; 240 pass. Frontend typechecks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:43:59 -04:00
Logan CusanoandClaude Opus 5 58efdbd6eb Correct the transcript before anything reads it
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Successful in 2m28s
Build & Deploy / Report a failed deploy (push) Skipped
Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.

internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.

Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
municipality, and that municipality's streets must not be buried under a
county-wide list. A single-municipality system is the degenerate case: populate
the system level and every talkgroup inherits it. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.

Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.

Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.

Two things found on the way:

PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.

Closes server-26#36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:23:41 -04:00
Logan CusanoandClaude Opus 5 457e6d7e0f Make Archive a real page instead of a redirect
Build & Deploy / Build & push images (push) Successful in 4m7s
Build & Deploy / Deploy to VM (push) Successful in 3m44s
Build & Deploy / Report a failed deploy (push) Skipped
/calls was a ten-line stub that redirected to /incidents, so there was nowhere
in the app to look at a call. The nav's "Archive" link led to the incident
list, and a call that never correlated was invisible entirely -- which is
backwards when correlation quality is the thing under development, because the
orphans are the evidence. Its stated blocker (Gitea #17/#18) closed weeks ago.

The page browses the org's calls newest-first over the new /calls/search route,
filtered by link state (all / orphans / linked), transcript presence, and
system, with a transcript substring search and cursor paging. A row expands to
the full transcript, a playback link minted on demand, and the correlation path
that decided it. The counts line -- how many of the loaded calls are orphaned,
how many have no transcript at all -- is the number worth watching during an
AI window.

Attribution is the point of it: attach an orphan to the incident it belongs to,
or detach one the correlator got wrong. Both go through the routes fixed in the
previous commit, so a manual attachment now actually shows up on the incident.

Admin-only. It exposes every call in the org regardless of node ownership and
carries controls that rewrite incident membership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:36:38 -04:00
Logan CusanoandClaude Opus 5 2a1d52b7af Add a real signup path instead of the accidental one
SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was
Google sign-in on /login, which auto-provisions a Firebase account with no
role or org claim at all - previously that meant "viewer role, full read
access" the moment the AuthProvider cookie logic (previous commit) let it
through. That's closed now regardless; this commit is the other side of it
- giving people an actual way in.

app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or
Google, same visual language as /login. It only creates the Firebase
account - org naming is deliberately not on this page, so every path that
produces an account with no org (this one, and Google-via-/login) converges
on the same next screen.

app/onboarding/page.tsx: that screen. Shown to any signed-in user with no
orgId (ChromeSwitcher's redirect, previous commit), collects an org name,
calls the new c2api.signup() -> POST /auth/signup (routers/links.py,
already shipped), then refreshClaims() to force-refetch the ID token so
orgId picks up immediately and the same redirect effect sends them on to
/dashboard - no manual reload needed.

lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token
mint/list/revoke calls (routers/org.py, already shipped on the backend)
and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead
of the settings/legal commits that use them so this stays one add per
concept rather than scattering client additions across later commits.

/login gained a "Don't have an account? Sign up" link to /signup. This is
signup plumbing, not marketing copy - pricing/plan copy (app/pricing,
lib/billing.ts) is untouched in this pass, that's a separate, still-open
decision (SAAS_PLAN.md section 6).

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:34:04 -04:00
Logan Cusano c42bd1902c feat: Add local system override with 24h timeout support 2026-07-12 23:05:53 -04:00
Logan 1f17b6c0d2 feat: add role-based user management, audit log, and session tracking
Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.

Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
  legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
  one-time invite link), get (with sessions), patch role/nodes/name,
  disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
  user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router

Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
  trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
  with role/nodes editor, disable/enable/delete, login history) and Audit
  Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession

Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
2026-06-22 00:02:09 -04:00
Logan 961cc6f36e add button to clear stale 'active' calls 2026-06-21 23:45:28 -04:00
Logan 6ae4d398f8 add trips permissions 2026-06-21 20:00:48 -04:00
Logan 981f03ac06 allow overlap (note) tags 2026-06-21 15:52:15 -04:00
Logan 4dd3343026 add event editing 2026-06-21 15:35:57 -04:00
Logan 3fb3bca034 add tags
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
2026-06-21 15:00:37 -04:00
Logan 7b9aefbcc5 Add UI to trips 2026-06-21 10:12:33 -04:00
Logan 8edb717dd2 Add trips to UI
lib/types.ts — TripRecord and TripEvent types

lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent

lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date

app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link

app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event.

components/Nav.tsx — Trips link added to the nav
2026-06-20 23:34:45 -04:00
Logan e55412d8c7 UI Updates
app/map/page.tsx

Removed IncidentCard component and the incidents grid below the map — the on-map sidebar inside MapView is the single display
Moved kiosk exit button from top-3 left-3 (overlapping zoom controls) to bottom-[5.5rem] left-3
components/MapView.tsx

Fixed popup "View incident →" link — adds stopPropagation() + window.location.href to prevent Leaflet intercepting the click
Added "View details →" link on each sidebar incident card so you can navigate from the map panel without opening a popup
Added "News Alerts" overlay layer (placeholder, ready for RSS/feed integration)
lib/types.ts

Added preferred_token_id?: string | null to SystemRecord
lib/c2api.ts

Added setPreferredToken(tokenId, systemId) calling PUT /tokens/{tokenId}/prefer/{systemId} (backend already existed)
app/systems/page.tsx

Added PreferredTokenPanel component — loads the token pool lazily on expand, shows radio buttons to set/clear the preferred token, displayed on each system card above the AI flags panel
2026-06-03 01:08:21 -04:00
Logan 913fe0cbee Add source call audio playback to vocabulary suggestions
When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.

- vocabulary_learner: track sampled call docs, attach source_call_ids
  to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
  first expand, shows audio controls + transcript snippet
2026-06-01 01:45:03 -04:00
Logan 5eed4e08ce Implement delete node function 2026-05-25 20:20:50 -04:00
Logan 35ce8e911e audio fixes attempt 2026-05-23 14:59:51 -04:00
Logan bcc3d3406d add debug in admin 2026-05-17 18:42:42 -04:00
Logan 640667c9f9 Implement per-system AI flags 2026-04-27 00:50:01 -04:00
Logan c959437059 Implement Admin UI to disable AI components 2026-04-27 00:37:51 -04:00
Logan 317f9d2a9d Updates to intel and correlation 2026-04-23 01:26:41 -04:00
Logan 88103c8011 UI Fix 2026-04-21 22:26:33 -04:00
Logan 338b946ba3 Start to learn vocab from talkgroups to improve accuracy of STT 2026-04-21 22:17:30 -04:00
Logan 303c5b13cf big ui and intel updates 2026-04-19 16:48:55 -04:00
Logan 0df53df92e UI Updates 2026-04-19 15:22:29 -04:00
Logan 3b3a136d04 Massive update 2026-04-11 13:44:08 -04:00
Logan 2f0597c81b Initial commit — DRB server stack
Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands),
frontend (Next.js admin UI), and mosquitto config.
2026-04-05 19:01:39 -04:00