Commit graph

225 commits

Author SHA1 Message Date
Matt Johnson
80460c83a8 WZDx: poll-time state allowlist with Idaho-region default (v0.9.17)
Narrow which WZDx registry feeds are fetched at poll time, so out-of-state
feeds are never requested (no bandwidth/quota cost), rather than fetching
nationally and dropping out-of-bounds events at the archive INSERT (v0.9.12).

- states is now list[StateCode] (Literal of 56 canonical 2-letter codes),
  default ["ID","WA","OR","NV","UT","WY","MT"]. Semantic flip: empty/unset/null
  now means the Idaho-region default, NOT "every eligible feed". Poll nationally
  by selecting all states in the GUI.
- list[Literal] renders as the existing checkboxes multi-select widget (firms.py
  precedent); pydantic rejects malformed codes at save time.
- Add informational quota_estimate (cap=None: never warns/blocks) so the edit
  page shows the fetch-volume reduction.
- adapters_edit.html: standalone quota panel for flat-config adapters, guarded
  by not has_model_list so model_list adapters don't double-render; reuses the
  identical quota.* keys + CSS so the v0.9.15 client recompute works for them too.
- 7 new tests (default/null fallback, _discover filtering, malformed rejection,
  quota math, offline Jinja panel render).

Event data shape is unchanged (poll-behavior-only), so no published_ids reset
is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 05:57:57 +00:00
4fa7e031e3
Merge pull request #77 from zvx-echo6/v0_9_16_ruff_cleanup v0.9.16
v0.9.16: ruff baseline cleanup (routes.py, test_wfigs.py)
2026-05-26 22:57:18 -06:00
Matt Johnson
c379e3688e v0.9.16: ruff baseline cleanup (routes.py, test_wfigs.py)
Mechanical lint fixes only — ruff 31 -> 0 across the two files.

- E402 module-import-not-at-top: 15 -> 0 (relocated module-level
  `logger = logging.getLogger(...)` from mid-import to below the import
  block in routes.py; imports now contiguous at top of file)
- F811 redefined-while-unused: 10 -> 0 (removed 10 redundant function-local
  `from central.gui.csrf import reuse_or_generate_pre_auth_csrf` re-imports;
  the module-level import at line 26 is now load-bearing)
- F401 unused-import: 4 -> 0 (routes.py: fastapi.Depends, and
  central.gui.csrf.reuse_or_generate_pre_auth_csrf resolved by the F811 fix;
  test_wfigs.py: sqlite3, central.config_models.RegionConfig)
- E702 multiple-statements-on-one-line-semicolon: 2 -> 0 (split the two
  semicolon-joined statements in _fused_bbox, indentation preserved)

Deliberate function-local wizard imports (circular-import workaround) left
untouched. pytest: 890 passed / 1 skipped, unchanged across 3 runs.

no behavior change; ruff mechanical fixes only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 04:51:58 +00:00
eb97ffb24c
Merge pull request #76 from zvx-echo6/v0_9_15_live_quota v0.9.15
v0.9.15: live JS quota recompute on multi-bbox editor
2026-05-26 22:30:07 -06:00
Matt Johnson
f52e545ddf v0.9.15: live JS quota recompute on multi-bbox editor
Mirror TomTomIncidentsAdapter.quota_estimate in client-side JS so the
quota panel updates live as the operator edits a per-row cadence or the
adapter cadence, and on Add/Delete bbox -- instead of only on Save.

- tomtom_incidents.py: expose seconds_per_month + default_cadence_s in
  the quota dict (no JS magic numbers).
- model_list.html: data-quota-* attrs + .quota-detail/.quota-msg spans;
  self-contained DOMContentLoaded-guarded IIFE applying the same
  max(adapter_cadence, bbox_cadence||default) floor and 80%/100%
  warn/block thresholds. No-op when no quota panel present.
- tests: structural asserts for data-attrs + span hooks (JS exec needs
  a browser; live behaviour eyeballed manually).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 04:29:32 +00:00
91f8478f80
Merge pull request #75 from zvx-echo6/v0_9_14_fused_fire v0.9.14
v0.9.14: fused FIRMS+WFIGS fire view
2026-05-26 21:50:11 -06:00
Matt Johnson
d5367ff55e v0.9.14: fused FIRMS+WFIGS fire view
Pairs each live WFIGS perimeter with its nearby/contemporaneous FIRMS hotspots
into a single "fire" on the /events map. FIRMS hotspots carry no IrwinID, so the
link is spatial+temporal: a hotspot is confirmed (part of a known fire) when it
lies within 1km of a perimeter AND within 72h of it; hotspots matching no
perimeter render amber as "unconfirmed" -- a possible new fire detected by
satellite before an official perimeter exists (early-warning signal).

- routes.py: read-only /events/fire-fused.json (PostGIS ST_DWithin geography join)
- events_list.html: "Fuse fire layers" toggle (default on); centroid fire glyph
  that expands to polygon + hotspot dots on click; amber unconfirmed hotspots
- central.css: --fire-confirmed / --fire-unconfirmed vars (retune without code)
- 11 tests (shaping, bbox parse, R/T + bbox param wiring); spatial correctness
  verified on prod (Summit Creek: perimeter + 90 hotspots; 191 unconfirmed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:49:30 +00:00
05b89df3a6
Merge pull request #74 from zvx-echo6/v0_9_13_events_retention v0.9.13
v0.9.13: per-stream archived-events retention sweep
2026-05-26 20:36:44 -06:00
Matt Johnson
9f67b4c1f2 v0.9.13: per-stream archived-events retention sweep
Hourly supervisor sweep deletes archived events older than their stream's
config.streams.max_age_s (the /streams 1/7/14/30/365d buttons -- the per-stream
source-of-truth). Explicit STREAM_CATEGORY_DOMAINS map (category domains do not
equal stream subject domains for the traffic family); fail-safe skip on
missing/<=0 max_age_s; unmapped-domain warning so a new adapter cannot silently
dodge retention.

TimescaleDB hypertable gotcha: events.ctid is chunk-local, NOT globally unique --
DELETE batching keys on the composite PK (id, time). See the inline NOTE in
config_store.delete_events_older_than and the PR body for the incident write-up.

- config_store: delete_events_older_than (batched on (id,time)) + unmapped_event_domains
- supervisor: STREAM_CATEGORY_DOMAINS, _sweep_events_retention, _events_retention_loop
- streams_list.html: max-age also governs archived-events retention
- 9 tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:31:11 +00:00
3ff6f1ebc0
Merge pull request #73 from zvx-echo6/v0_9_12_archive_bbox_filter v0.9.12
v0.9.12: archive-level monitoring-area bbox filter
2026-05-26 17:40:59 -06:00
Matt Johnson
384d6118a6 v0.9.12: archive-level monitoring-area bbox filter
Add a single archive-level geographic filter at the events INSERT path: events
whose geometry falls entirely outside a system-configured monitoring area are
dropped before archival. Null-geom events (SWPC trio, .removed tombstones) are
always kept. Uses a pure shapely intersects() predicate so border-straddlers
are kept (matches ST_Intersects), and is fail-open on unparseable geometry.

- config.system gains monitor_{north,south,east,west} (migration 030, Idaho default)
- archive refreshes the bbox every 60s (no restart needed to change it); adds a
  per-adapter dropped-count counter, debug log per drop, cumulative INFO rollup
- new GUI editor at /monitoring-area (Leaflet draggable rectangle, N/S/E/W inputs)
- no adapter code changes; well-behaved adapters keep upstream API filtering
- 12 tests covering all five verdicts, drop-and-count, border/point-on-edge, fail-open

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:40:17 +00:00
c772d117d0
Merge pull request #72 from zvx-echo6/v0.9.11-hide-tombstones v0.9.11
Hide tombstones from default events view + show-removed toggle (v0.9.11)
2026-05-26 16:15:05 -06:00
Matt Johnson
85d0e8f1cc Hide tombstones from default events view + show-removed toggle (v0.9.11)
The /events feed was dominated by *.removed tombstone events (audit records
for features dropped from upstream feeds), burying geometry-bearing events
like fire perimeters (wfigs_perimeters: 54 real perimeters vs 1015 tombstones).
The GUI now default-hides any event whose category ends in .removed, with a
"Show removed" checkbox to restore them; URL state is preserved (HX-Push-Url)
so a shared link shows what the sharer saw. events.json is unchanged (still
returns tombstones) so API consumers are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:14:38 +00:00
92a1b3f2c6
Merge pull request #71 from zvx-echo6/v0.9.10-bbox-map v0.9.10
Read-only Leaflet map preview for multi-bbox editor (v0.9.10)
2026-05-26 12:43:54 -06:00
Matt Johnson
c5ed52db4b Read-only Leaflet map preview for multi-bbox editor (v0.9.10)
Adds a passive Leaflet map to the generic model_list editor that renders
every bbox row as a labeled translucent rectangle, auto-detected via the
min/max lon+lat sub-fields (TomTom incidents). Read-only: no drag/draw,
precise tuning stays in the per-row coord inputs. Rectangles redraw live
on input/add/remove; viewport fits only on initial render so typing never
jumps the map. Non-bbox model_lists (StateConfig, TileCoord) are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:43:08 +00:00
9ff8a33415
Merge PR #70: generic model_list adapter editor + TomTom bbox validation & quota (v0.9.9) v0.9.9
fix(gui): generic model_list editor — un-breaks 4 list-of-model adapter Edit pages + TomTom bbox validation & quota (v0.9.9)
2026-05-26 00:14:29 -06:00
Matt Johnson
7a5092c77f fix(gui): generic model_list editor for list-of-model adapters + TomTom bbox validation & quota (v0.9.9)
Fixes a shared form_descriptors 500 (NotImplementedError: unsupported list
type) that broke the Edit page for ALL FOUR adapters whose settings carry a
list[<BaseModel>] field: tomtom_incidents, tomtom_flow, state_511_atis,
state_511_atis_cameras.

- form_descriptors: list[BaseModel] -> generic "model_list" widget with
  recursive per-column sub_field descriptors.
- New _partials/model_list.html: vanilla-JS repeatable-row editor
  (add/remove/renumber), driven entirely by sub_fields (no adapter-name
  branching). Single-region edit pages render byte-identically.
- TomTom: BBox/Settings Pydantic validators (10,000 km^2 cap, coord ranges,
  min<max, cadence_s>=60, unique names) as the single source of truth
  (enforced at supervisor load AND GUI POST). Duck-typed quota_estimate hook
  + read-only quota panel; POST hard-blocks estimates over the 2,500/mo free
  tier (422). TOMTOM_FREE_TIER_CALLS_PER_MONTH is a tunable for paid tiers.
- routes: model_list form parse, row-aware ValidationError messages, 422 for
  model_list failures (single-region region errors still re-render at 200).
- tests: 11 new (real-Jinja render across 3 adapters + byte-identical nws
  no-regression guard, POST persist + oversized/degenerate/duplicate/cadence/
  quota 422 matrix, quota estimate). Full suite 848 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 05:57:34 +00:00
00a450b22f
Merge PR #69: wfigs_perimeters true polygons via Geo.geometry (v0.9.8) v0.9.8
feat(wfigs_perimeters): emit true polygons via Geo.geometry (v0.9.8)
2026-05-25 20:59:56 -06:00
Matt Johnson
382f744c12 feat(wfigs_perimeters): emit true polygons via Geo.geometry (v0.9.8)
Plumb the upstream GeoJSON Polygon/MultiPolygon into Geo.geometry so the
archive renders the real fire-perimeter shape (ST_GeomFromGeoJSON) instead
of the bbox 4-corner fallback. Adapter-local change; the v0.9.3 Geo.geometry
framework already carries it end-to-end. Graceful null + dict-or-string
coercion via _as_geometry; fall-off removal events stay NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:58:54 +00:00
8c78df7834
Merge PR #68: paginate state_511_atis /List/GetData (v0.9.7) v0.9.7
fix(state_511_atis): paginate /List/GetData to fix 100-row truncation (v0.9.7)
2026-05-25 20:46:20 -06:00
Matt Johnson
52b9ae0bbd fix(state_511_atis): paginate /List/GetData to fix 100-row truncation (v0.9.7)
Castle Rock caps each DataTables page at 100 rows regardless of `length`, so the
single-request fetcher silently dropped rows on any layer over 100 (confirmed
live: Construction recordsFiltered=114, returned 100 -> 14 rows invisible).
Backports the v0.9.6 cameras pagination loop into _fetch_details.

- _LIST_PAGE_LENGTH 1000 -> 100; new _list_body(start) builder; new @retry
  _fetch_page(base_url, layer, start).
- _fetch_details loops start+=100 until recordsFiltered collected or an empty
  page, with a _MAX_PAGES=50 ceiling that warns+breaks. Mid-pagination failure
  returns rows-so-far (retried next poll).
- Incidents (1) / Closures (29) are under 100 today but pagination applies
  uniformly; future-proof.

central-supervisor restart only (no stream, migration, template, or dep change).

Full suite: 833 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:40:58 +00:00
cc8847bec7
Merge pull request #67 from zvx-echo6/feat/state-511-cameras v0.9.6
feat(state_511_atis_cameras): Castle Rock 511 traffic cameras telemetry (v0.9.6)
2026-05-25 19:34:35 -06:00
Matt Johnson
02bc692bda feat(state_511_atis_cameras): Castle Rock 511 traffic cameras telemetry (v0.9.6)
New CENTRAL_TRAFFIC_CAMERAS stream + state_511_atis_cameras adapter. Telemetry
half of Castle Rock (events shipped in v0.9.2). Each Idaho camera -> one
telemetry event on /telemetry; detail drawer renders <img> direct from the
source (no blob storage / proxy in Central -- URL only).

supervisor + gui + ARCHIVE restart (NEW event-bearing stream
central.traffic_cameras.>). Ships disabled; public-unauth (no api key). Idaho only.

- Full camera list via POST /List/GetData/Cameras (DataTables), PAGINATED at
  100/page (Idaho ~455 = 5 pages). GetUserCameras was a red herring (4 default
  cams). The 100-row page cap also means v0.9.2 state_511_atis silently
  truncates its 114-row Construction layer -> separate v0.9.7 fix.
- Subject central.traffic_cameras.{state}.{camera_id}; category
  camera.state_511_atis_cameras -> GUI event_type "camera". data_class=telemetry.
- Per-UTC-day dedup {state}:cam:{id}:{YYYY-MM-DD}: one event per camera per day
  -- always shows today's cameras, no per-poll flooding, no retention
  coordination. Inherits the v0.9.1 dedup mixin.
- All sources included (Idaho511/ITDNET/RWIS/UDOT/ODOT/WYDOT/MTD border cameras);
  source surfaced in data + the drawer for provenance. WKT POINT (lon lat) -> geo.
- No upstream image-capture timestamp (lastUpdated is config-edit time); drawer
  shows no false "Captured" line. Cadence 600s. Severity 1 (telemetry).

Full suite: 829 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:33:21 +00:00
d241bfea26
Merge pull request #66 from zvx-echo6/feat/tomtom-incidents-per-bbox-cadence v0.9.5.1
feat(tomtom_incidents): per-bbox cadence (v0.9.5.1)
2026-05-25 19:03:12 -06:00
Matt Johnson
7fdf47f2f0 feat(tomtom_incidents): per-bbox cadence (v0.9.5.1)
Lets each incident bbox poll at its own interval so busy metros refresh more
often than quiet corridors. Backward-compatible, code-only patch.

- Optional BBox.cadence_s (int | None = None) -> per-bbox poll interval; None
  falls back to the adapter's default_cadence_s. Existing settings without the
  field keep their current behavior.
- In-memory _last_polled {bbox_name: datetime}, per process. _bbox_due() gates
  fetches; poll() fetches only due bboxes. First poll after (re)start fetches all
  (one-shot catch-up; storage dedup on <state>:tomtom:<id> collapses overlap).
- _last_polled is recorded ONLY after a successful fetch -- a failed bbox stays
  due and retries next cycle (regression-guarded).
- Supervisor wakes the adapter at the adapter-level cadence; set that to the GCD
  of the per-bbox cadences for exact intervals (extra wakeups cost zero API calls).

central-supervisor restart only. No gui/archive restart, no migration, no new dep.

Full suite: 815 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:01:55 +00:00
0cb8f5a96b
Merge pull request #65 from zvx-echo6/feat/tomtom-incidents v0.9.5
feat(tomtom_incidents): TomTom real-time traffic incidents adapter (v0.9.5)
2026-05-25 18:26:45 -06:00
Matt Johnson
42d5faa80c feat(tomtom_incidents): TomTom real-time traffic incidents adapter (v0.9.5)
Fourth CENTRAL_TRAFFIC event adapter. Complements wzdx (federal work zones) and
state_511_atis (state-DOT reports) with TomTom commercial vehicle-telematics
coverage. Polls the Orbis incidentDetails endpoint per metro bbox, emits one
event per incident to central.traffic.incident.<state>. Ships disabled.

central-supervisor + central-gui restart only -- adapter row on the EXISTING
CENTRAL_TRAFFIC stream, so NO archive restart and no new stream/dependency.
Reuses the existing "tomtom" api key.

- Bbox limit refutation: incidentDetails rejects bbox > 10,000 km^2, so coverage
  is per-metro bboxes (Treasure Valley / Boise, 8,601 km^2), NOT statewide. One
  bbox @ 1800s = 1,440 calls/mo = 58% of the 2,500/mo free-tier cap. Expansion
  rows must respect N*(43200/cadence_min) <= 2500.
- category="incident.tomtom_incidents" -> GUI event_type "incident" (shared with
  state_511_atis; cross-source overlap is by design = additive coverage, distinct
  dedup ids + categories, no Central-side cross-source dedup).
- Severity from magnitudeOfDelay (0->1,1->1,2->2,3->3,4->4; 4=closure). Never None.
- geo.geometry carries TomTom's Point/LineString directly (already lon/lat GeoJSON;
  the v0.9.3 framework renders the affected road as a polyline). No decode needed.
- Dedup id <state_code>:tomtom:<tomtom_id> (upstream id stable across polls,
  verified 154/154 over 60s). Inherits the v0.9.1 dedup mixin.
- aiohttp params= URL-encodes the fields{} GraphQL braces (no curl-glob issue);
  key redacted from logs; poll skips cleanly without a key.

Full suite: 809 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:25:27 +00:00
a70b53572e
Merge pull request #64 from zvx-echo6/feat/tomtom-flow-passthrough v0.9.4
feat(tomtom_flow): Navi passthrough endpoint /api/traffic/flow (v0.9.4)
2026-05-25 18:05:22 -06:00
Matt Johnson
cf1d96e159 feat(tomtom_flow): Navi passthrough endpoint /api/traffic/flow (v0.9.4)
Phased PR2 of v0.9.3. Adds the on-demand tile passthrough so Navi can call
Central instead of navi-traffic, and so tiles outside the polled coverage set
still get persisted. central-gui restart only (new route); no supervisor, no
archive, no migration, no new stream/dep.

GET /api/traffic/flow/<z>/<x>/<y>.{png,pbf} -- exactly matches navi-traffic's
route for a drop-in frontend flip (s/navi-traffic/central:8000/). No auth
(tailnet-trusted, matches navi-traffic's existing posture; exempted in
middleware). Cache-Control max-age=60 (TomTom's advertised tile TTL).

- .png -> passthrough + cache only (raster unparseable for storage).
- .pbf -> passthrough + decode via the shared tomtom_flow_parse.decode_flow_tile
  and publish segments to CENTRAL_TRAFFIC_FLOW via the GUI's NATS connection
  (wrap_event, supervisor's exact envelope shape). Same minute-bucketed dedup id
  as the polling adapter, so adapter+passthrough fetches of one tile in the same
  minute collapse at the archive's (id, time) upsert -- intentional collision.
- Publish is best-effort: wrapped in try/except + guards get_js() None, so NATS
  state never blocks serving the tile (HTTP > storage).
- API key via ConfigStore(get_pool()).get_api_key("tomtom"); 503 if unset;
  redacted from any logged upstream error; 502 on upstream failure (no publish).

Single-flight (shared in-flight upstream request per tile) queued for v0.9.5.
navi-frontend flip + navi-traffic deprecation live in the navi repo (Matt's call).

Full suite: 787 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:04:02 +00:00
da466248bd
Merge pull request #63 from zvx-echo6/feat/tomtom-flow v0.9.3
feat(tomtom_flow): TomTom Orbis vector flow-tile telemetry adapter + CENTRAL_TRAFFIC_FLOW (v0.9.3)
2026-05-25 17:27:04 -06:00
Matt Johnson
b8033444ec feat(tomtom_flow): TomTom Orbis vector flow-tile telemetry adapter + CENTRAL_TRAFFIC_FLOW (v0.9.3)
Third CENTRAL_TRAFFIC-family member, first telemetry traffic source. Polls a
configured tile coverage set (Idaho metros, z=10), fetches Orbis vector flow
tiles, decodes per-segment relative_speed + road geometry, emits one telemetry
Event per road segment per poll to the new CENTRAL_TRAFFIC_FLOW stream. Renders
as colored polylines (green free-flow -> red jam) on the /telemetry map.

Production code; supervisor + gui + ARCHIVE restart (NEW event-bearing stream
central.traffic_flow.> -> archive must resubscribe). Ships disabled; needs a
"tomtom" api key in config.api_keys before enable.

- Subject central.traffic_flow.{z}.{x}.{y} (token traffic_flow, non-overlapping
  with central.traffic.>). category="flow.tomtom_flow" -> GUI event_type "flow".
- Severity from relative_speed: >=0.75=1, 0.5-0.75=2, 0.25-0.5=3, <0.25=4.
- Cadence 300s; 7-day retention (high-volume telemetry). Dedup minute-bucketed,
  inherited from the v0.9.1 SourceAdapter mixin.
- Shared tomtom_flow_parse module (decode + slippy-tile georeference) reused by
  the v0.9.4 on-demand passthrough endpoint.
- Generic framework change (Option A, ~3 lines, inert for the other 14
  adapters): Geo.geometry optional field + archive _build_geom_sql prefers it,
  so segments persist their real LineString to the PostGIS geom column.
- Idaho-only (Orbis tier confirmed live). Cameras + Navi passthrough are follow-ups.
- deps: mapbox-vector-tile (vector PBF decode); itsdangerous promoted to an
  explicit dependency (gui/csrf.py + gui/wizard.py imported it as an undeclared
  transitive that uv re-lock would otherwise prune).

Full suite: 780 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:25:44 +00:00
5b2f613e6b
Merge pull request #62 from zvx-echo6/feat/state-511-atis v0.9.2
feat(state_511_atis): Castle Rock 511 adapter — Idaho (v0.9.2)
2026-05-25 16:02:32 -06:00
Matt Johnson
30e25bf475 feat(state_511_atis): Castle Rock 511 adapter — Idaho incidents/closures/road work (v0.9.2)
Second CENTRAL_TRAFFIC adapter. Production code; central-supervisor + central-gui
restart (new adapter class + ADAPTER_GROUPS). No new stream -> no archive restart;
migration 026 adds the adapter row only. Ships disabled.

Two-endpoint join per layer: GET /map/mapIcons/<Layer> (markers: itemId + coords)
joined on id with POST /List/GetData/<Layer> (DataTables detail: roadwayName,
description, county, severity). The marker feed has coords but no text; the List
feed has text but no coords.

Layers -> event_types (wzdx category/subject precedent): Incidents->incident,
Closures->closure, Construction (type "Roadwork")->work_zone. category is
"<event_type>.state_511_atis"; subject central.traffic.<event_type>.<state>.
Severity 3 if isFullClosure else 1. Cadence 300s. Dedup inherited from the
v0.9.1 SourceAdapter mixin. enrichment_locations canonical (latitude,longitude)
from the marker join; county/state come upstream.

Templatized per state via settings {"states":[{code,base_url}]} but ships
Idaho-only: cross-state spot-checks refuted the shared-URL hypothesis (Oregon
TripCheck is HTML, Wyoming wyoroad 404 -- neither is Castle Rock). Add states as
settings rows once each host is verified.

Also fixes a latent test bug: test_consumer_doc per-adapter heading regex was
[a-z_]+ (no digits); state_511_atis is the first adapter name with digits, so
widened to [a-z0-9_]+.

Full suite: 759 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:01:11 +00:00
efb2a5799d
Merge pull request #61 from zvx-echo6/feat/wzdx-direction-and-dedup-mixin v0.9.1
fix(wzdx): drop 'unknown' direction from subject + extract dedup mixin (v0.9.1)
2026-05-25 15:19:47 -06:00
Matt Johnson
9cd2183cc3 fix(wzdx): drop 'unknown' direction from subject + extract dedup mixin (v0.9.1)
Two v0.9.0 fast-follows. Production code; central-supervisor + central-gui
restart (adapter base change + template change). No migration, no new stream.

(a) Work-zone subject + detail no longer leak vehicle direction "unknown"
(common in AZ mcdot etc.) -- gated on direction not in (None, "unknown") in both
wzdx partials. Was "Work zone on MORELAND ST unknown".

(b) is_published/mark_published/sweep_old_ids extracted from per-adapter inline
copies onto the SourceAdapter base (beside bump_last_seen); a dedup_sweep_days
class attr parameterizes the retention window (NWIS=30, default=14). Inline
copies deleted from inciweb/nwis/wzdx; the other 10 adapters keep theirs as a
future cleanup. Net dedup code down ~52 lines.

Full suite: 744 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:18:21 +00:00
c6c5367ccf
Merge pull request #60 from zvx-echo6/feat/wzdx-traffic-bootstrap v0.9.0
feat(wzdx): WZDx adapter + CENTRAL_TRAFFIC family bootstrap (v0.9.0)
2026-05-25 14:42:42 -06:00
Matt Johnson
7eab5fc1b1 feat(wzdx): WZDx adapter + CENTRAL_TRAFFIC family bootstrap (v0.9.0)
Opens Phase 4 transportation aggregation (Design B, Central-direct). New
registry-driven wzdx adapter polls the FHWA WZDx Feed Registry, fetches each
eligible v4.x GeoJSON feed concurrently, and emits work_zone events into the new
CENTRAL_TRAFFIC stream. Production code; central-supervisor AND central-gui
restart (new adapter class + stream + ADAPTER_GROUPS). Ships disabled.

First adapter to use the category/subject split: category="work_zone.wzdx" (GUI
event_type "work_zone" via split_part) while the NATS subject is
central.traffic.work_zone.{state}. Subject state from the registry row, geocoder
state as fallback. Severity from vehicle_impact (all-lanes-closed=3,
some-lanes-closed=2, all-lanes-open=1, unknown/missing=1). Feed filter
geojson + active + needapikey=false + version 4.x (21 of 39 feeds). 600s cadence.
Dedup composite <data_source_id>:<feature_id> in the shared cursors.db; stateless
discovery (no conftest isolation entry). enrichment_locations uses the canonical
("latitude","longitude") paths.

Full suite: 739 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:35:08 +00:00
de464a08e1
Merge pull request #59 from zvx-echo6/feat/nwis-site-stats-enricher v0.8.0
feat(nwis): site + stats enrichment — named location + WaterWatch normalcy band (v0.8.0)
2026-05-25 09:35:43 -06:00
Matt Johnson
8612f0b75d feat(nwis): site + stats enrichment — named location + WaterWatch normalcy band (v0.8.0)
Opens the v0.8.x data-quality cleanup arc. Production code; central-gui AND
central-supervisor restart (adapter contract + enrichment behavior change).

NWIS events rendered as a bare "Water reading: 111 ft3/s" with an empty Location
column -- an operator couldn't tell where the gauge is or whether 111 ft3/s is
drought-low, normal, or near-flood. Coordinates were present but the reverse
geocoder returns null city/state/county for rural gauge points, and USGS site +
percentile data was never fetched. v0.8.0 fetches both.

Approach B (adapter-owned, per the proposal decision): the NWIS adapter -- which
already owns the USGS APIs -- fetches site metadata and daily stats itself and
writes two provenance bundles under event.data["_enriched"]:
- usgs_site {name, lat, lon, state, county} from the OGC monitoring-locations
  item-by-id (the API family the adapter already speaks; JSON, no RDB parser).
- usgs_stats {value, percentile, class_label, severity_band, p10..p90, record_max,
  count, period} from the legacy RDB daily-statistics service (the OGC API has no
  stats endpoint). USGS percentiles are % of days at-or-below, so higher = higher
  flow; classified to the WaterWatch bands -> severity 0-4 (record=4, much
  above/below=3, above/below=2, normal=1; None reserved for "no stats", distinct
  from a normal-flow gauge). Severity is set on the event, so it drives the v0.7.1
  severity chip-picker filter + v0.7.2 map-marker opacity.
- new nwis_enrich.py: pure parse/classify/percentile/band helpers + a sqlite
  SiteStatsCache (site TTL 365d, stats TTL 90d -- one fetch per site+param serves
  every reading for the window, so a warm cache makes zero USGS calls). USGS down
  -> cached-if-present else all-null bundle; the event still publishes.

Framework: the single agreed generic change -- supervisor apply_enrichment now
MERGES into _enriched instead of overwriting, so the still-global geocoder phase
doesn't clobber the adapter's bundles. No other adapter writes _enriched, so this
is inert for them.

GUI: _event_summaries/nwis.html -> "<site> -- <value> <units> (<band>, <Nth>
percentile)", with graceful fallback to "<site> -- <value>" then the bare
"Water reading:". _event_rows/nwis.html detail gains site/normalcy/typical/location
rows. _events_rows.html Location column falls back generically to any
_enriched.<source> carrying state/county when the geocoder is null (works for
future enrichers). events.json contract unchanged (additions under _enriched only).

conftest isolate_enrichment_cache also redirects NWIS_CACHE_DB_PATH off the prod
path (unprivileged-user test isolation). Adds tests/test_nwis_enrichment.py (28
tests: parse, band edges incl P0/P9/P10/P75/P90/record, percentile interpolation,
cache hit/miss/expire, adapter enrich + graceful-null + cache-hit-no-refetch,
summary rendering per band).

Full suite: 710 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:30:19 +00:00
6546db0144
Merge pull request #58 from zvx-echo6/feat/visual-rewrite-v075 v0.7.5
feat(visual-rewrite): drop CSS framework, adopt gui_preview design system across every page (v0.7.5)
2026-05-25 02:37:27 -06:00
Matt Johnson
e20a8398c6 feat(visual-rewrite): drop the CSS framework, adopt the gui_preview design system across every page (v0.7.5)
PR #6 of the v0.7.x GUI rework arc, and its close-out. Production code;
central-gui restart only (templates + a static stylesheet; supervisor untouched).

The v0.7.0-v0.7.4 arc shipped the FEATURES the gui_preview.html mock shows
(chip-pickers, active pills, markercluster + shape/opacity markers, collapsed
grouped legend, real paginator, telemetry tab) but on top of the old CSS
framework. v0.7.5 closes the visual gap: the framework is removed entirely and
replaced by a hand-authored design system that IS the preview (neutral greys, a
single blue accent, 6px radii, 14px type, bordered cards/tables).

- New static/css/central.css: design tokens + element base (forms, buttons,
  tables, cards, nav) + every events-feed component, authored so the existing
  template class names + element IDs the page JS/HTMX depends on (.chip-picker*,
  .events-table, .event-row, .legend-chip, .evt-marker, #fit-to-results,
  #map-filter-toggle, ...) render in the preview's language unchanged. Mounted at
  /static (already wired in __init__.py); linked from base.html + base_wizard.html.
- base.html / base_wizard.html: framework <link> removed; nav restructured to the
  preview shell (.nav / .brand / .nav-links / .logout-btn); flash messages moved
  off framework color vars to .flash-* classes.
- events_list.html: 190-line inline <style> deleted (moved to central.css); map
  wrapped in .map-container with a floating .map-toolbar (Fit / map-filter toggle),
  legend below. HTMX wiring (hx-get/hx-target/hx-push-url), the v0.7.1 chip-picker
  JS, the v0.7.2 marker shapes/opacity, and the v0.7.4 base_path split are
  untouched.
- Every other page (dashboard, adapters, api-keys, enrichment, streams, login,
  change-password, and the full setup wizard) de-framework'd: framework class
  names (outline/secondary/contrast/grid/container) renamed to neutral
  .btn-*/.cols; no framework default rendering; same tokens/typography/forms/tables.
- /events.json + the v0.7.1 registry-index adapter palette + v0.7.2 severity
  opacity + v0.7.4 stable cross-tab chip colors are all unchanged -- this is a
  visual shell swap, not a behavior or data change.

grep -rn pico over templates + static returns zero. All GUI routes return 200
(authed) / 302 (unauthed); no 5xx. Full suite: 682 passed, 1 skipped (central and
unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:28:06 +00:00
83c5ad6e6e
Merge pull request #57 from zvx-echo6/feat/telemetry-separation v0.7.4
feat(telemetry-separation): dedicated /telemetry tab split from /events by adapter data_class (v0.7.4)
2026-05-25 01:38:43 -06:00
Matt Johnson
8d193d3266 feat(telemetry-separation): dedicated /telemetry tab split from /events by adapter data_class (v0.7.4)
PR #5 of the v0.7.x GUI rework arc. Production code; central-gui restart only
(supervisor untouched -- data_class is read only by central-gui per request).

- SourceAdapter gains a `data_class` class attr (Literal["event","telemetry"],
  default "event"). NWIS opts in as "telemetry" (continuous high-volume water
  gauges); every other adapter stays "event". The /events vs /telemetry split is
  thus registry-derived from class attrs -- no hardcoded adapter-name lists.
- routes.py refactor: `_class_adapter_names(data_class)` and a `data_class` arg
  on `_adapter_filter_options` scope the flat + domain-grouped chip/legend lists
  to a class (colors stay keyed to the FULL registry, so an adapter keeps one
  color across tabs). `_fetch_events` accepts `class_adapters` and adds an
  `adapter = ANY(...)` condition. Shared `_events_query`, `_events_page(data_class,
  base_path)` and `_events_rows_fragment(...)` back both tabs; `/events`,
  `/events/rows`, `/telemetry`, `/telemetry/rows` are thin wrappers.
- Templates parameterized with a `base_path` context var (form action, hx-get,
  hx-push-url header, clear-all redirect, JS BASE_PATH const); the `_events_rows`
  paginator macro takes `base`. Same templates serve both tabs; nav gains a
  Telemetry link.
- /events.json UNCHANGED -- the cursor path sets no `class_adapters`, so the
  subject + pagination contract is intact (TestEventsJsonSubject still passes).

Adds TestTelemetrySeparation (data_class defaults, registry split 11 event / 1
telemetry, class-scoped filter options, color stability, and the `adapter =
ANY(...)` SQL shape incl. the no-class events.json path). Updates the events
frontend tests for the base_path-parameterized templates.

Full suite: 682 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:34:08 +00:00
76d519bcd5
Merge pull request #56 from zvx-echo6/feat/layout-pagination v0.7.3
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3)
2026-05-24 20:05:03 -06:00
Matt Johnson
f8d89d53d4 feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3)
PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only.

- Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands
  to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform
  ellipsis-truncated names + full-name title tooltips. Clicking a legend chip
  toggles that adapter's filter (reuses the chip-picker's hidden CSV via
  syncField), so the legend doubles as a filter affordance.
- Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the
  cell; full ISO in the cell tooltip + a new Time row in the expanded detail).
  Adapter cell is a chip (color swatch + short name; display_name is the
  tooltip). table-layout:fixed + per-column widths + fixed 37px row height with
  nowrap/ellipsis cells -> no per-row wrap variation.
- Real paginator: _fetch_events offset-mode returns the exact page slice plus the
  grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page
  numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250
  per-page selector. URL state persists offset + limit. events.json keeps cursor
  pagination (back-compat): offset param presence selects offset-mode, its
  absence keeps the cursor path -- cleanly separable by endpoint.

Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250,
offset-vs-cursor query shape, _build_pagination windowing). Updates the time
format + adapter-cell + pagination-mode assertions in the existing frontend
tests to the new contract.

Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER()
is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
8802f2e45b
Merge pull request #55 from zvx-echo6/feat/map-rework v0.7.2
feat(map-rework): fit-to-results, marker clustering, map-filter toggle, shape/opacity (v0.7.2)
2026-05-24 19:20:42 -06:00
Matt Johnson
ed9b6b53be feat(map-rework): fit-to-results, marker clustering, map-filter toggle, shape/opacity encoding (v0.7.2)
PR #3 of the v0.7.x GUI rework arc. Makes the /events Leaflet map readable and
intentional. Production code; central-gui restart only (no adapter change).

- Fit-to-results default: the map now fits the actual event distribution on
  load (previously disabled -> fixed global zoom-4). Empty result set falls
  back to the CONUS setView (no crash). Re-fits after each HTMX swap, but only
  when the map-filter toggle is OFF (when ON the viewport drives the bbox, so
  re-fitting would fight/loop the filter).
- leaflet.markercluster (1.5.3, via CDN): point markers cluster into numbered
  badges (disableClusteringAtZoom=9, showCoverageOnHover=false,
  spiderfyOnMaxZoom=true). markercluster supports point markers only, so
  polygons/lines render in a separate un-clustered featureGroup; fit unions both.
- Map-filter toggle ("Filter table by map view"), default OFF. When off the
  table shows all filter-matching events regardless of map zoom; the backend
  ignores region_* unless map_filter is set (guards bookmarked URLs too). URL
  carries map_filter=1 only when on (hidden input disabled otherwise).
- Per-event_type marker shape (derived event_type = first category segment):
  circle = quake/hydro/space (points), square = fire (areas),
  triangle = wx (NWS alerts/warnings), star = disaster (GDACS/EONET).
  Rendered as divIcon + CSS clip-path; point markers switched from circleMarker
  to L.marker(divIcon) (also required for markercluster compatibility).
- Per-severity opacity: critical(4)=1.0, high(3)=0.85, moderate(2)=0.7,
  low(1)=0.5, unknown(0/NULL)=0.4. Needed adding severity to the _fetch_events
  SELECT + event dict (row.get for mock-tolerance) + a data-severity row attr.

Adds 4 tests (map_filter gating on/off, bbox reaches query only when on,
severity in SELECT); updates test_events_bbox_guard for the new toggle contract.

Full suite: 662 passed, 1 skipped (central and unprivileged zvx). Vanilla JS +
HTMX + Leaflet/markercluster; CSS functional-only (polish deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:20:04 +00:00
b3b61d8f44
Merge pull request #54 from zvx-echo6/feat/events-filtering v0.7.1
feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1)
2026-05-24 18:59:22 -06:00
Matt Johnson
380cde31f8 feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1)
Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events
filter row with a multi-select, URL-addressable filtering surface.

- Search: full-width box, debounced 300ms, server-side ILIKE over the inner
  adapter payload (covers the derived subject + location); parameterized with
  LIKE wildcards escaped (ESCAPE '\'). Injection-safe.
- Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared
  _chip_picker.html macro). Adapter is grouped by domain with color swatches and
  an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated.
- Event Type is derived as split_part(category,'.',1) (no event_type column yet;
  a stand-in until the v0.8 canonical schema). Severity maps labels to the
  numeric scale (4=critical..1=low, 0/NULL=unknown).
- Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range,
  encoded in a single `time` token. GUI defaults to last_24h; events.json keeps
  its single-value adapter/since/until contract (no default).
- Active pills: server-rendered from parsed state, updated out-of-band on each
  HTMX swap; each x clears that filter and re-submits.
- URL state persistence: every filter in the query string; /events/rows sets
  HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work.

Filter options are rendered server-side at page load (DISTINCT category +
split_part, registry adapters, severity enum) -- no new AJAX endpoints.

Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish
is deferred to a later pass per the rework plan.

Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety,
time-preset resolution with injected clock, severity/NULL handling, active-pill
descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to
the new filter_state/adapters contract.

Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter
base class change -> central-gui restart only (no supervisor restart).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:58:38 +00:00
6af419c345
Merge pull request #53 from zvx-echo6/feat/gui-bugs v0.7.0
feat(gui-bugs): fix eonet dashboard exception + out-of-range map bbox (v0.7.0)
2026-05-24 16:38:54 -06:00