The Live map was held behind useCalls(500), measured at ~9.2s to first map
paint on prod; it now mounts as soon as the saved view is known (nodes are
awaited only when there is no view). The legend painted over the expanded
Leaflet layers list (z-1001 vs the map's z-0 stacking context): it is now a
<details> that hides while the list is open and starts folded on short
viewports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens in globals.css, Tailwind gray/indigo scales remapped so the ~350
legacy raw-class uses follow the palette, light-mode override sheet moved
off slate, map accent hexes updated. Severity hues retuned for AA.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
Token text-ink-muted is #8C98AE in dark mode, ~2.9:1 on the always-white
popups (node, incident, path-stop, AIS, 511 camera). Scoped override to
#5B6577 (~6:1). Found in tonight's regression review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UI gate only — no route auth changes. Node page: override Ack/Force
Revert, Configure/Reassign and Restart OP25 are admin-only; non-admins on
an unconfigured node get a "an admin needs to assign it" note. /nodes
"Needs Configuration" cards open the config modal only for admins, and
link to the node page otherwise. /systems: New/Edit/Duplicate/Delete and
the AI-flags, area-context, talkgroup-pending and vocabulary panels are
admin-only; operators get a read-only list. (Delete's missing catch was
fixed in #290.)
Widening any of these routes to org owners/operators is a separate,
security-reviewed backend change.
Frontend unverified: no Node on the authoring box (#207).
Closes#222Closes#283
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#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>
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used
raw Tailwind greys, kept readable in light mode only by the !important
override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the
light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both.
Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2,
500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised,
border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder.
Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700,
text-white and marketing pages left alone. text-[9px] -> text-[10px].
Frontend unverified: no Node on the authoring box (#207).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "Dark" base layer was bright OSM in prod; invert the raster via
.drb-tiles-dark so dark-theme overlays sit on a dark ground.
- Rail and mobile incident cards are opaque, not glass over the map.
- Dark --ink-muted #77839A -> #8C98AE, --sev-major #D03B3B -> #F05252
(both failed AA as small text on surface/raised).
- Card borders used `color + "55"` on CSS var() colours, which is
invalid CSS — no border rendered. Now color-mix.
- 10px rail labels -> 11px.
Frontend unverified: no Node on the authoring box (#207).
Closes#136
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #279: cache incident/fan divIcons by their visual inputs and React.memo
MapView, so LiveView's 1s clock no longer rebuilds icons / calls setIcon
and the #238 pulse ring completes its cycle.
- #275: IncidentPanel shows "resolved", "lasted X" frozen at resolved_at,
and labels live cameras/context as current, not incident-time.
- #268: rail header (desktop + mobile drawer) reads "N of M · filtered"
via a new filteredFrom prop while the wall filter is on.
- #278: while camera tiles are docked bottom-centre, the weather status
chip and radar badge stack under the tiles inside the dock instead of
being covered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
The 1 h loop set MapView state every 600 ms, re-rendering the whole map
component and rebuilding (setIcon) every marker icon on each tick. The
frame now lives in a small external store that only the radar frames and
the valid-time badge subscribe to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- 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>
The /incidents/[id] page kept its own stop numbering (every geocoded call,
1..N) and path length, while its MapView now numbers only movement
incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops
1-3 on the spine and unnumbered dots on the map. Use the shared
pathStops()/stopNumbers(), show 'km path' only for movement incidents,
and surface the 'may be merged' flag in the header.
Legend: a plain pin is also what an older (pre-#236) pin with no recorded
precision looks like; say so instead of claiming 'Street address'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>
- 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>
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>
- Cap one refresh's NWS zone fetches at 15 s wall clock. They run under the
feed lock, so a hung zone endpoint could otherwise stall every
/weather/alerts request for minutes; unfinished zones retry next refresh.
- Prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the
contact-less placeholder. Default it to the site URL (overridable via
vault_nws_user_agent); warn on the .env.example example.com placeholder too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- 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>
GET /weather/alerts — api.weather.gov/alerts/active for settings.nws_area,
sent with the NWS-required User-Agent, cached weather_alerts_ttl_s (90 s)
with single-flight refresh. Zone-based alerts (geometry null) get their
polygons from affectedZones (24 h zone cache, api.weather.gov/zones/ only);
any still unresolved are returned in zone_only, never dropped. Failed
refresh keeps last-good data + error + fetched_at; a payload where no
feature parses is a schema-change error. Optional bbox filter.
GET /weather/wind — nearest METAR (aviationweather.gov /api/data/metar,
bbox query) within 75 km, cached 5 min per 0.5-degree cell; VRB wind and
stale reports flagged; available:false with a reason otherwise.
Both behind require_service_or_firebase_token like /traffic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>