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>
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>
- 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>
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>
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>
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>
- 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>
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>
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>
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>
Re-adding the same system to a voice channel returns the running stream
untouched (already_on). The form closed as if a stream had been added and
the new label/bot was silently dropped; it now stays open and explains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
- 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>
- POST /discord/nodes/{id}/streams/stop: stream_id must be guild:channel
snowflakes, so a malformed id is a 422 instead of a Firestore path error.
- Offline footer said channels resume on reconnect; ad-hoc ones stop once
the node is gone past the lease grace (reconcile_once). Say so.
- Picker: say why Start is disabled when the node has no system.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #218 (server-26#213 item 1) unlinks a server by clearing org_id and
stamping unlinked_at, and resolve_guild_org then refuses the fallback org.
The overview showed such a server as "Unlinked · fallback org" and offered it
in Add radio channel, where start_stream would 403. Mirror the rule here
(forward-compatible: unlinked_at does not exist on main yet). Also key each
stream row by the resolved guild id, and drop panel copy that pointed at a
"Needed" figure the page doesn't show / misstated the invite permissions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card only loads for owners/admins (the list endpoint is owner-only), so a
member saw "Loading…" forever. Show an owner-only note instead. Also assert the
one-server/two-org race really retried, as the PR body already claimed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A server an org explicitly unlinked (unlinked_at) must not fall back to the
default org in the picker either, or the founding org could stream into it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- 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>
GET /tokens and PUT /tokens/{id}/prefer became platform-admin only in #216
(server-26#204 item 6). Three frontend surfaces still offered them to
operators:
- systems page "Preferred Bot Token" panel: now rendered for admins only.
No non-admin listing endpoint was added: the write behind the picker is
admin-only too, so a list would only feed a control that 403s on save.
The panel also no longer turns a fetch error into "No tokens in pool."
- /tokens page: admin-only (operators are sent to /network).
- /network hub: the Bot Tokens card is shown to admins only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
reject_node only set approval_status, so an approved-then-rejected node kept
its dynsec login, its /upload key and /systems read access.
- reject_node: status -> rejected first (fail-closed), then
dynsec.delete_node_client, then delete node_keys. dynsec failure -> 502,
key kept, retry is idempotent. Re-approve mints a fresh key.
- upload.py: valid key alone is not enough; node must be approved (403),
uncached read so revocation bites immediately.
- dynsec.reconcile_all: deletes rejected nodes' logins on startup, healing
nodes rejected before this change or whose revoke failed.
- Reject confirm text says it revokes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LiveView: localStorage pins carry image URLs, so they rendered (without
credit) after an admin disabled the provider. Pins now show only while
their provider is serving (server-26#198 licence gating).
- MapView: pinned/docked map players drop out once their provider leaves
the serving list; credits are kept from the last answer so docked players
keep attribution while the overlay is hidden.
- WallPanels: the "no camera provider enabled" link to /admin was a dead CTA
for non-admins (bounced to /). Link shown to admins only, deep-linked to
/admin#cameras; the admin page now honours a tab hash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- system_config.normalize(): a P25 system with config.mode "conventional"
carries config.channels [{freq MHz, name, nac?}] (1-2, within 1.5 MHz) and
can no longer carry control channels (422). A missing mode is trunked and
passes through untouched, so there is no migration. An analog channel is
mirrored into control_channels for older nodes.
- talkgroups.name_from_system resolves the edge node's synthetic per-channel
id (freq in 100 Hz units) to the channel name.
- The checkin persists radio_channels (conventional channels + encryption).
- Systems editor: Type = P25 trunked / P25 conventional / Analog (NBFM) /
DMR, plus a channel-row editor for conventional and a one-frequency form for
analog. The node page shows conventional channels and an encryption notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
Any fatal hls.js error set videoFailed permanently, so a brief 511NY feed
drop left the tile on a 60s snapshot until it was reopened. Now fatal
media/network errors get up to 3 in-place recoveries, and a tile that did
fall back retries video every 30s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- DOT cameras can be pinned at the camera or docked in a strip over the
map; up to 6 open at once, switchable between modes.
- Below zoom 14, 511 cameras/events bucket into a 40px world-pixel grid,
one icon per cell with a count badge; click zooms in.
- Weather radar refreshed by remounting via `key`, and a remounted child of
an unchecked LayersControl.Overlay isn't re-added to the map — radar went
off every 5 min. Now refreshes in place with setUrl + cache-buster.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- Token colors wrapped in color-mix so opacity modifiers (bg-surface/90 etc.)
generate rules; map overlay panels were rendering see-through.
- Light mode: keep white text on saturated fills (primary/danger buttons were
remapped to navy); map text-gray-200, bg-gray-800/60, border-gray-600.
- Dark mode: lift text-gray-600/700 to gray-500 for contrast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a camera's snapshot pins a 192x108 tile anchored at the camera --
the live HLS stream when the camera has one (hls.js; Safari native), else the
snapshot refreshed every 60s, falling back to the snapshot if the stream dies.
Up to 6 pins, oldest dropped. Players exist only while the DOT Cameras overlay
is on, so hiding it stops every stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
QA (drb-qa-review) blockers: sdr_count defaulted to 1 for nodes that
never sent it, so the panel claimed 'reports 1 SDR (0 spare)'. It is now
None until reported, and the count is only quoted alongside a real
secondary_sdr_running report. Rows read 'Not reported' instead of
'Waiting for SDR' when the node hasn't said what's running (closes#187).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
QA follow-ups on the ADS-B/AIS overlays:
- Legend gains an aircraft altitude key (tar1090 ramp) while the Aircraft
overlay is on (closes#185).
- AIS aids to navigation (MMSI 99xxxxxxx) render as small yellow buoy
diamonds labelled 'Aid to navigation', no speed; vessels get a 22px
outlined hull, and heading 511/360 ('not available') no longer
rotates the icon (closes#186).
- Aircraft details panel becomes a bottom sheet above the incident drawer
below md, instead of fighting the layers control at the top right.
Verified: tsc --noEmit clean (node:20 on radio-box).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The popup sat on top of the plane and hid the trail it had just drawn.
Details now render in a panel docked top-right (portaled into the
Leaflet container, click/scroll propagation disabled), so the map can
be panned to follow the path. Clicking empty map or the same plane
deselects; hover tooltip unchanged.
Verified: tsc --noEmit clean (node:20 on radio-box).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>