feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
"""Tests for events feed frontend routes."""
|
|
|
|
|
|
2026-05-21 19:07:19 +00:00
|
|
|
import html
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-05-21 19:07:19 +00:00
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui import templates as _gui_templates
|
|
|
|
|
from central.gui.routes import events_list, events_rows, events_json, _derive_subject
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventsFeedFrontendAuthenticated:
|
|
|
|
|
"""Test events feed frontend with authentication."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_no_filters_returns_html(self):
|
|
|
|
|
"""GET /events authenticated, no filters returns HTML with events."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": f"event_{i}",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Weather Alert",
|
|
|
|
|
"subject": f"Test Alert {i}",
|
|
|
|
|
"geometry": '{"type": "Point", "coordinates": [-122.4, 37.8]}' if i % 2 == 0 else None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
for i in range(5)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
|
|
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
|
|
|
assert "events" in context
|
|
|
|
|
assert context["filter_error"] is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_adapter_filter(self):
|
|
|
|
|
"""GET /events?adapter=nws returns only nws events."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {"adapter": "nws"}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "nws_event_1",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "NWS Alert",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
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
|
|
|
# v0.7.1: adapter is now a multi-select; single value parses to a 1-list.
|
|
|
|
|
assert context["filter_state"]["adapters"] == ["nws"]
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_since_until_filter(self):
|
|
|
|
|
"""GET /events?since=...&until=... filters by time window."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {
|
|
|
|
|
"since": "2026-05-17T00:00:00",
|
|
|
|
|
"until": "2026-05-17T12:00:00",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "in_range",
|
|
|
|
|
"time": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "In Range",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
# Verify filter was actually parsed and passed to template
|
|
|
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
|
|
|
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
|
|
|
|
context = call_kwargs.get("context", call_kwargs)
|
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
|
|
|
# v0.7.1: the GUI uses time presets, but legacy since/until are still
|
|
|
|
|
# honored (JSON API + bookmarks); they parse without error and apply.
|
|
|
|
|
assert context["filter_error"] is None
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_region_filter(self):
|
|
|
|
|
"""GET /events with full region bbox filters by location."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {
|
|
|
|
|
"region_north": "49.5",
|
|
|
|
|
"region_south": "31",
|
|
|
|
|
"region_east": "-102",
|
|
|
|
|
"region_west": "-124.5",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "in_bbox",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "In BBox",
|
|
|
|
|
"geometry": '{"type": "Point", "coordinates": [-120, 40]}',
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
# Verify region filter was actually parsed and passed to template
|
|
|
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
|
|
|
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
|
|
|
|
context = call_kwargs.get("context", call_kwargs)
|
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
|
|
|
assert context["filter_state"]["region_north"] == "49.5"
|
|
|
|
|
assert context["filter_state"]["region_south"] == "31"
|
|
|
|
|
assert context["filter_state"]["region_east"] == "-102"
|
|
|
|
|
assert context["filter_state"]["region_west"] == "-124.5"
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_partial_region_shows_error_banner(self):
|
|
|
|
|
"""GET /events with partial region shows error banner, not 400."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {"region_north": "49"}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
# Should be 200, not 400
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert context["filter_error"] is not None
|
|
|
|
|
assert "region" in context["filter_error"].lower()
|
|
|
|
|
# Events should be empty due to validation error
|
|
|
|
|
assert context["events"] == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-05-25 02:04:23 +00:00
|
|
|
async def test_events_with_limit_builds_pagination(self):
|
|
|
|
|
"""GET /events?limit=5 (offset-mode): pagination reflects total/next page."""
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {"limit": "5"}
|
|
|
|
|
|
2026-05-25 02:04:23 +00:00
|
|
|
# 5 rows for page 1; total_count=12 (the window count) => 3 pages.
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": f"event_{i}",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": f"Event {i}",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
2026-05-25 02:04:23 +00:00
|
|
|
"total_count": 12,
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
}
|
2026-05-25 02:04:23 +00:00
|
|
|
for i in range(5)
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
2026-05-25 02:04:23 +00:00
|
|
|
pg = context["pagination"]
|
|
|
|
|
assert pg["total"] == 12 and pg["total_pages"] == 3 and pg["page"] == 1
|
|
|
|
|
assert pg["next_offset"] == 5 and pg["prev_offset"] is None
|
|
|
|
|
assert context["next_cursor"] is None # offset-mode (GUI) doesn't use cursor
|
|
|
|
|
assert len(context["events"]) == 5
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventsRowsFragment:
|
|
|
|
|
"""Test /events/rows HTMX fragment."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_rows_returns_fragment(self):
|
|
|
|
|
"""GET /events/rows returns table fragment, not full page."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {"limit": "5"}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "event_1",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "Event 1",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_rows(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
# Verify it uses the fragment template
|
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
|
|
|
assert call_args.kwargs.get("name") == "_events_rows.html"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGeometrySummary:
|
|
|
|
|
"""Test geometry summary function."""
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_polygon(self):
|
|
|
|
|
"""Polygon geometry shows point count."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
geom = {
|
|
|
|
|
"type": "Polygon",
|
|
|
|
|
"coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]
|
|
|
|
|
}
|
|
|
|
|
summary = _geometry_summary(geom)
|
|
|
|
|
assert "Polygon" in summary
|
|
|
|
|
assert "5 pts" in summary
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_point(self):
|
|
|
|
|
"""Point geometry shows 'Point'."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
geom = {"type": "Point", "coordinates": [-122.4, 37.8]}
|
|
|
|
|
summary = _geometry_summary(geom)
|
|
|
|
|
assert summary == "Point"
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_linestring(self):
|
|
|
|
|
"""LineString geometry shows point count."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
geom = {
|
|
|
|
|
"type": "LineString",
|
|
|
|
|
"coordinates": [[-122, 37], [-121, 38], [-120, 39]]
|
|
|
|
|
}
|
|
|
|
|
summary = _geometry_summary(geom)
|
|
|
|
|
assert "Line" in summary
|
|
|
|
|
assert "3 pts" in summary
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_none(self):
|
|
|
|
|
"""None geometry shows 'None'."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
summary = _geometry_summary(None)
|
|
|
|
|
assert summary == "None"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDataGeometryAttribute:
|
|
|
|
|
"""Test that rows have valid geometry data attributes."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_event_with_geometry_has_valid_json(self):
|
|
|
|
|
"""Events with geometry have parseable JSON in data-geometry."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "geom_event",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "With Geometry",
|
|
|
|
|
"geometry": '{"type": "Polygon", "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]}',
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_rows(mock_request)
|
|
|
|
|
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
event = context["events"][0]
|
|
|
|
|
# Geometry should be parsed dict, not string
|
|
|
|
|
assert isinstance(event["geometry"], dict)
|
|
|
|
|
assert event["geometry"]["type"] == "Polygon"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_event_without_geometry_has_none(self):
|
|
|
|
|
"""Events without geometry have None for geometry field."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "no_geom_event",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "No Geometry",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_rows(mock_request)
|
|
|
|
|
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
event = context["events"][0]
|
|
|
|
|
assert event["geometry"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCrossEndpointParity:
|
|
|
|
|
"""Test that /events.json and /events return the same filtered results."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_category_filter_both_endpoints(self):
|
|
|
|
|
"""Category filter works on both /events.json and /events."""
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "weather_event",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Weather Alert",
|
|
|
|
|
"subject": "Weather Event",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
query_params = {"category": "Weather Alert"}
|
|
|
|
|
|
|
|
|
|
# Test /events.json
|
|
|
|
|
json_request = MagicMock()
|
|
|
|
|
json_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
json_request.query_params = query_params
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
json_response = await events_json(json_request)
|
|
|
|
|
|
|
|
|
|
json_data = json.loads(json_response.body)
|
|
|
|
|
assert len(json_data["events"]) == 1
|
|
|
|
|
assert json_data["events"][0]["category"] == "Weather Alert"
|
|
|
|
|
|
|
|
|
|
# Test /events
|
|
|
|
|
html_request = MagicMock()
|
|
|
|
|
html_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
html_request.state.csrf_token = "test_csrf"
|
|
|
|
|
html_request.query_params = query_params
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
await events_list(html_request)
|
|
|
|
|
|
|
|
|
|
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert len(html_context["events"]) == 1
|
|
|
|
|
assert html_context["events"][0]["category"] == "Weather Alert"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-05-25 02:04:23 +00:00
|
|
|
async def test_pagination_modes_split_cleanly_by_endpoint(self):
|
|
|
|
|
"""v0.7.3: events.json keeps CURSOR pagination (next_cursor set); the
|
|
|
|
|
GUI events page uses OFFSET pagination (next_cursor None + pagination)."""
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
first_page = [
|
|
|
|
|
{
|
|
|
|
|
"id": f"event_{i}",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": f"Event {i}",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
for i in range(3)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = first_page
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
json_request = MagicMock()
|
|
|
|
|
json_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
json_request.query_params = {"limit": "2"}
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
json_response = await events_json(json_request)
|
|
|
|
|
|
|
|
|
|
json_data = json.loads(json_response.body)
|
|
|
|
|
json_cursor = json_data["next_cursor"]
|
|
|
|
|
assert json_cursor is not None
|
|
|
|
|
|
|
|
|
|
html_request = MagicMock()
|
|
|
|
|
html_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
html_request.state.csrf_token = "test_csrf"
|
|
|
|
|
html_request.query_params = {"limit": "2"}
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
mock_conn.fetch.return_value = first_page
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
await events_list(html_request)
|
|
|
|
|
|
|
|
|
|
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
2026-05-25 02:04:23 +00:00
|
|
|
# events.json paginates by cursor; the GUI by offset (no cursor, has paginator).
|
|
|
|
|
assert json_cursor is not None
|
|
|
|
|
assert html_context["next_cursor"] is None
|
|
|
|
|
assert "pagination" in html_context
|
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestErrorSemantics:
|
|
|
|
|
"""Test error handling differences between JSON and HTML endpoints."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_json_endpoint_returns_400_on_invalid_limit(self):
|
|
|
|
|
"""/events.json?limit=0 returns 400 JSON error."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {"limit": "0"}
|
|
|
|
|
|
|
|
|
|
response = await events_json(mock_request)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = json.loads(response.body)
|
|
|
|
|
assert "error" in data
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_html_endpoint_returns_200_with_error_banner(self):
|
|
|
|
|
"""/events?limit=0 returns 200 HTML with error banner."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf"
|
|
|
|
|
mock_request.query_params = {"limit": "0"}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert context["filter_error"] is not None
|
|
|
|
|
assert "limit" in context["filter_error"].lower()
|
|
|
|
|
assert context["events"] == []
|
1b-9c: Events feed UX iteration — colors, popups, viewport filter, expandable rows (#28)
* feat: events feed UX iteration - colors, popups, viewport filter
A. Color-code polygons by adapter (NWS amber, FIRMS red, USGS violet)
B. Click popup on polygons showing time + adapter + category + subject
C. Map viewport drives spatial filter - pan/zoom updates table via HTMX
D. Add legend showing adapter color mapping
E. Remove draw-bbox control, region inputs now hidden (auto-managed)
Template changes:
- _events_rows.html: add data-adapter, data-category, data-time, data-subject
- events_list.html: ADAPTER_COLORS mapping, bindPopup, moveend handler
Test: verify template renders adapter/category/subject for JS consumption
* fix: remove isoformat() call on already-formatted time string
* feat: full events feed UX iteration
A. Color-code polygons by adapter with legend
B. Click popup on polygons with "View details" link
C. Viewport-driven spatial filter - pan/zoom updates table via HTMX
Map never auto-fits after initial load (user controls viewport)
D. Expandable row details showing full event data payload
Changes:
- _events_rows.html: add data-event-id, expand button, detail row
- events_list.html: eventLayerGroup pattern, buildPopup, rebindEventLayers
Fit to results button, expand/collapse handlers, CSS.escape for IDs
* fix: add programmaticMove flag to prevent viewport refresh loop
Suppress moveend handler during fitBounds/setView calls to prevent
feedback loop: fitBounds -> moveend -> applyViewportFilter -> HTMX
swap -> repeat.
* fix: map never auto-fits - user controls viewport
- Disable initial fitToAllLayers on page load
- Remove fitBounds/setView from row click handler
- Map only moves when user pans/zooms
- Table filters based on visible viewport
* fix: map shows all events always, only table filters
Map polygons are drawn once on load and never cleared/redrawn.
HTMX swap only updates the table, not the map layers.
User viewport is fully preserved.
* fix: use htmx.trigger instead of dispatchEvent for HTMX swap
dispatchEvent(submit) was triggering native form submission (full page
reload). htmx.trigger() properly triggers HTMX swap.
Also re-enable initial rebindEventLayers so polygons load on first render.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-05-18 14:19:27 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventRowDataAttributes:
|
|
|
|
|
"""Test that _events_rows.html renders required data attributes."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_row_renders_data_adapter_attribute(self):
|
|
|
|
|
"""Event rows include data-adapter attribute for color coding."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf"
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "test1",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "usgs_quake",
|
|
|
|
|
"category": "quake.event",
|
|
|
|
|
"subject": "M4.2 Earthquake",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
# The template receives events with adapter field for data-adapter attribute
|
|
|
|
|
assert len(context["events"]) == 1
|
|
|
|
|
assert context["events"][0]["adapter"] == "usgs_quake"
|
|
|
|
|
assert context["events"][0]["category"] == "quake.event"
|
2026-05-21 19:07:19 +00:00
|
|
|
# `subject` is now derived from the inner payload (rendered partial),
|
|
|
|
|
# not a DB pass-through, so the mock's input value is no longer echoed;
|
|
|
|
|
# just confirm the field is present. See TestEventsJsonSubject for the
|
|
|
|
|
# derivation contract.
|
|
|
|
|
assert "subject" in context["events"][0]
|
2026-05-21 05:45:15 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- PR L-b: operator /events tab polish ---------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _events_context(events):
|
|
|
|
|
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
|
2026-05-25 02:04:23 +00:00
|
|
|
n = len(events)
|
2026-05-21 05:45:15 +00:00
|
|
|
return {
|
|
|
|
|
"events": events,
|
|
|
|
|
"next_cursor": None,
|
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
|
|
|
"base_path": "/events",
|
2026-05-25 02:04:23 +00:00
|
|
|
"query_string": "",
|
|
|
|
|
"pagination": {
|
|
|
|
|
"total": n, "offset": 0, "limit": 50, "page": 1, "total_pages": 1,
|
|
|
|
|
"start": 1 if n else 0, "end": n, "prev_offset": None, "next_offset": None,
|
|
|
|
|
"pages": [{"page": 1, "offset": 0, "current": True}] if n else [],
|
|
|
|
|
"per_page_options": [25, 50, 100, 250],
|
2026-05-21 05:45:15 +00:00
|
|
|
},
|
2026-05-25 02:04:23 +00:00
|
|
|
"filter_error": None,
|
2026-05-21 05:45:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _event(adapter, inner=None, geometry=None):
|
|
|
|
|
"""Build an event dict matching _fetch_events output shape.
|
|
|
|
|
|
|
|
|
|
`inner` populates payload->data->data (the adapter-specific payload) at
|
|
|
|
|
event["data"]["data"]["data"], which the per-adapter partials read.
|
|
|
|
|
"""
|
|
|
|
|
return {
|
|
|
|
|
"id": "evt-" + adapter,
|
|
|
|
|
"time": "2026-05-17T12:00:00+00:00",
|
|
|
|
|
"received": "2026-05-17T12:00:00+00:00",
|
|
|
|
|
"adapter": adapter,
|
|
|
|
|
"category": adapter + ".test",
|
|
|
|
|
"subject": "subject",
|
|
|
|
|
"geometry": geometry,
|
|
|
|
|
"geometry_summary": "",
|
|
|
|
|
"data": {"data": {"data": inner or {}}},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_rows(events):
|
|
|
|
|
"""Render _events_rows.html through the real Jinja environment."""
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
return gui_templates.env.get_template("_events_rows.html").render(
|
|
|
|
|
**_events_context(events)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRegistryDrivenAdapterFilter:
|
|
|
|
|
"""(A) Adapter filter <select> is driven by discover_adapters(), no hardcoded list."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_filter_options_cover_every_discovered_adapter(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
registry = discover_adapters()
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf"
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = []
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
|
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
|
|
|
# v0.7.4: /events shows event-class adapters only (telemetry-class, e.g.
|
|
|
|
|
# nwis, moved to /telemetry). Registry-derived, sorted, no extras.
|
|
|
|
|
event_names = sorted(n for n, c in registry.items()
|
|
|
|
|
if getattr(c, "data_class", "event") == "event")
|
|
|
|
|
assert [a["name"] for a in context["adapters"]] == event_names
|
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
|
|
|
# Each entry carries name + display_name (v0.7.1 adds a positional color).
|
|
|
|
|
by_name = {a["name"]: a for a in context["adapters"]}
|
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
|
|
|
for name in event_names:
|
|
|
|
|
cls = registry[name]
|
|
|
|
|
assert by_name[name]["display_name"] == cls.display_name
|
|
|
|
|
assert by_name[name]["color"].startswith("#")
|
2026-05-21 05:45:15 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPerAdapterRowPartials:
|
|
|
|
|
"""(C) Per-adapter row partials with registry-derived dispatch + _default fallback."""
|
|
|
|
|
|
|
|
|
|
def test_every_discovered_adapter_has_a_partial(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
# get_template raises TemplateNotFound if a per-adapter file is missing.
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
gui_templates.env.get_template("_event_rows/%s.html" % name)
|
|
|
|
|
|
|
|
|
|
def test_default_fallback_partial_exists(self):
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
gui_templates.env.get_template("_event_rows/_default.html")
|
|
|
|
|
|
|
|
|
|
def test_every_discovered_adapter_renders_without_error(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
html = _render_rows([_event(name)])
|
|
|
|
|
assert 'data-adapter="%s"' % name in html
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_falls_back_to_default(self):
|
|
|
|
|
# No bespoke partial -> dispatch resolves to _default.html (no crash),
|
|
|
|
|
# and the raw payload block is still rendered.
|
|
|
|
|
html = _render_rows([_event("not_a_real_adapter", inner={"foo": "bar"})])
|
|
|
|
|
assert 'data-adapter="not_a_real_adapter"' in html
|
|
|
|
|
assert "event-data-pre" in html
|
|
|
|
|
|
|
|
|
|
def test_usgs_quake_partial_surfaces_curated_fields(self):
|
|
|
|
|
html = _render_rows([_event(
|
|
|
|
|
"usgs_quake",
|
|
|
|
|
inner={"magnitude": 4.2, "magType": "mb", "place": "10km N of Town", "depth": 5.0},
|
|
|
|
|
)])
|
|
|
|
|
assert "Magnitude" in html
|
|
|
|
|
assert "4.2" in html
|
|
|
|
|
assert "10km N of Town" in html
|
|
|
|
|
|
|
|
|
|
def test_nws_partial_surfaces_curated_fields(self):
|
|
|
|
|
html = _render_rows([_event(
|
|
|
|
|
"nws",
|
|
|
|
|
inner={"event": "Tornado Warning", "headline": "TORNADO", "severity": "Extreme"},
|
|
|
|
|
)])
|
|
|
|
|
assert "Tornado Warning" in html
|
|
|
|
|
assert "Extreme" in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMapAllAdapterGeometry:
|
|
|
|
|
"""(B) Every non-null geometry reaches the map; rebind-on-swap is enabled."""
|
|
|
|
|
|
|
|
|
|
def test_polygon_geometry_emitted_as_data_geometry(self):
|
|
|
|
|
poly = {
|
|
|
|
|
"type": "Polygon",
|
|
|
|
|
"coordinates": [[[-104.18, 37.14], [-103.66, 37.14],
|
|
|
|
|
[-103.66, 37.43], [-104.18, 37.43], [-104.18, 37.14]]],
|
|
|
|
|
}
|
|
|
|
|
html = _render_rows([_event("nws", inner={"event": "x"}, geometry=poly)])
|
|
|
|
|
assert "data-geometry=" in html
|
|
|
|
|
assert "Polygon" in html
|
|
|
|
|
|
|
|
|
|
def test_event_without_geometry_omits_data_geometry(self):
|
|
|
|
|
html = _render_rows([_event("swpc_protons", inner={"flux": 1.0})])
|
|
|
|
|
assert "data-geometry=" not in html
|
|
|
|
|
|
|
|
|
|
def test_map_rebinds_on_swap_and_handles_degenerate_geometry(self):
|
|
|
|
|
# Regression guard for the NWIS-only map: rebind must fire on HTMX swap
|
|
|
|
|
# and the degenerate-geometry fallback must exist.
|
|
|
|
|
import pathlib
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
src = pathlib.Path(
|
|
|
|
|
gui_templates.env.loader.searchpath[0], "events_list.html"
|
|
|
|
|
).read_text()
|
|
|
|
|
assert "// rebindEventLayers(); // DISABLED" not in src
|
|
|
|
|
assert "isDegenerate" in src
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- PR L-c: readable Time / Location / Subject / Adapter columns ---------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_row_cells(html):
|
|
|
|
|
"""Visible <td> text of the first event-row (before its detail row).
|
|
|
|
|
|
|
|
|
|
Splits on the detail row's class attribute (not the bare string
|
|
|
|
|
'event-detail', which also appears in the page's <style> block).
|
|
|
|
|
"""
|
|
|
|
|
import re
|
|
|
|
|
body = html.split('class="event-detail"')[0]
|
|
|
|
|
return [
|
|
|
|
|
re.sub(r"<[^>]+>", "", c).strip()
|
|
|
|
|
for c in re.findall(r"<td[^>]*>(.*?)</td>", body, re.S)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventTimeFormat:
|
2026-05-25 02:04:23 +00:00
|
|
|
"""(A) Server-side 'MM-DD HH:MM UTC' formatting (24h, no seconds, no year;
|
|
|
|
|
v0.7.3 dropped the year from the cell for single-line stable rows)."""
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
def test_format_basic_utc(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
2026-05-25 02:04:23 +00:00
|
|
|
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21 06:00 UTC"
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
def test_format_converts_offset_to_utc(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
# 19:30 at -06:00 is 01:30 UTC the next day.
|
2026-05-25 02:04:23 +00:00
|
|
|
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21 01:30 UTC"
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
def test_format_empty_and_none(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
assert _format_event_time("") == ""
|
|
|
|
|
assert _format_event_time(None) == ""
|
|
|
|
|
|
2026-05-25 02:04:23 +00:00
|
|
|
def test_format_no_seconds_no_year_no_offset_suffix(self):
|
2026-05-21 07:05:20 +00:00
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
out = _format_event_time("2026-01-02T03:04:59+00:00")
|
2026-05-25 02:04:23 +00:00
|
|
|
assert out == "01-02 03:04 UTC"
|
|
|
|
|
assert ":59" not in out and "+00" not in out and "2026" not in out
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTableDisplayDecoration:
|
|
|
|
|
"""(A)/(D) _decorate_table_events adds display fields; /events.json unaffected."""
|
|
|
|
|
|
|
|
|
|
def test_adapter_display_matches_registry(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui.routes import _decorate_table_events, _format_event_time
|
|
|
|
|
registry = discover_adapters()
|
|
|
|
|
events = [_event(name) for name in registry]
|
|
|
|
|
_decorate_table_events(events)
|
|
|
|
|
for ev in events:
|
|
|
|
|
cls = registry[ev["adapter"]]
|
|
|
|
|
assert ev["adapter_display"] == cls.display_name
|
|
|
|
|
assert ev["time_human"] == _format_event_time(ev["time"])
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_display_falls_back_to_name(self):
|
|
|
|
|
from central.gui.routes import _decorate_table_events
|
|
|
|
|
events = [_event("not_a_real_adapter")]
|
|
|
|
|
_decorate_table_events(events)
|
|
|
|
|
assert events[0]["adapter_display"] == "not_a_real_adapter"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_json_has_no_table_only_fields(self):
|
|
|
|
|
# No /events.json schema change: display fields must not leak into JSON.
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = [{
|
|
|
|
|
"id": "e1",
|
|
|
|
|
"time": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nwis",
|
|
|
|
|
"category": "hydro",
|
|
|
|
|
"subject": None,
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}]
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
resp = await events_json(mock_request)
|
|
|
|
|
|
|
|
|
|
ev = json.loads(resp.body)["events"][0]
|
|
|
|
|
assert "time_human" not in ev
|
|
|
|
|
assert "adapter_display" not in ev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLocationColumn:
|
|
|
|
|
"""(B) Generic _enriched/top-level location reader — no adapter logic."""
|
|
|
|
|
|
|
|
|
|
def _location(self, inner):
|
|
|
|
|
return _first_row_cells(_render_rows([_event("usgs_quake", inner=inner)]))[2]
|
|
|
|
|
|
|
|
|
|
def test_city_state_country(self):
|
|
|
|
|
loc = self._location({"_enriched": {"geocoder": {
|
|
|
|
|
"city": "Trinidad", "state": "Colorado", "country": "United States"}}})
|
|
|
|
|
assert loc == "Trinidad, Colorado, United States"
|
|
|
|
|
|
|
|
|
|
def test_state_country_when_city_null(self):
|
|
|
|
|
loc = self._location({"_enriched": {"geocoder": {
|
|
|
|
|
"city": None, "state": "Missouri", "country": "United States"}}})
|
|
|
|
|
assert loc == "Missouri, United States"
|
|
|
|
|
|
|
|
|
|
def test_top_level_country_fallback(self):
|
|
|
|
|
# gdacs-style: no geocoder, country at top level.
|
|
|
|
|
assert self._location({"country": "Austria"}) == "Austria"
|
|
|
|
|
|
|
|
|
|
def test_top_level_state_only_fallback(self):
|
|
|
|
|
# wfigs-style: state code at top level, no country.
|
|
|
|
|
assert self._location({"state": "CO"}) == "CO"
|
|
|
|
|
|
|
|
|
|
def test_landclass_fallback(self):
|
|
|
|
|
loc = self._location({"_enriched": {"geocoder": {
|
|
|
|
|
"city": None, "state": None, "country": None,
|
|
|
|
|
"landclass": "Ridgecrest Field Office"}}})
|
|
|
|
|
assert loc == "Ridgecrest Field Office"
|
|
|
|
|
|
|
|
|
|
def test_coordinates_alone_are_not_a_location(self):
|
|
|
|
|
# Bare lat/lon is a position, not a place name -> "—".
|
|
|
|
|
assert self._location({"latitude": 35.3603324, "longitude": -117.7854995}) == "—"
|
|
|
|
|
|
|
|
|
|
def test_none_when_nothing_available(self):
|
|
|
|
|
assert self._location({}) == "—"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSubjectColumn:
|
|
|
|
|
"""(C) Per-adapter one-line summaries, registry-derived dispatch + fallback."""
|
|
|
|
|
|
|
|
|
|
def test_every_adapter_has_a_summary_partial(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
gui_templates.env.get_template("_event_summaries/%s.html" % name)
|
|
|
|
|
|
|
|
|
|
def test_default_summary_partial_exists(self):
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
gui_templates.env.get_template("_event_summaries/_default.html")
|
|
|
|
|
|
|
|
|
|
def test_every_adapter_summary_renders_without_error(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
_render_rows([_event(name)]) # must not raise
|
|
|
|
|
|
|
|
|
|
def test_usgs_quake_summary_is_plain_language(self):
|
|
|
|
|
cells = _first_row_cells(_render_rows([_event(
|
|
|
|
|
"usgs_quake", inner={"magnitude": 1.347, "magType": "ml",
|
|
|
|
|
"place": "14 km W of Johannesburg, CA"})]))
|
|
|
|
|
assert cells[3] == "Magnitude 1.3 — 14 km W of Johannesburg, CA"
|
|
|
|
|
assert "ml" not in cells[3] # scale code dropped
|
|
|
|
|
|
|
|
|
|
def test_nwis_summary_drops_parameter_code(self):
|
|
|
|
|
cells = _first_row_cells(_render_rows([_event(
|
|
|
|
|
"nwis", inner={"parameter_code": "00060", "value": 111.0,
|
|
|
|
|
"unit_of_measure": "ft^3/s"})]))
|
|
|
|
|
assert cells[3] == "Water reading: 111.0 ft^3/s"
|
|
|
|
|
assert "00060" not in cells[3] # opaque pcode dropped
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_summary_is_dash(self):
|
|
|
|
|
cells = _first_row_cells(_render_rows([_event("not_a_real_adapter", inner={"x": 1})]))
|
|
|
|
|
assert cells[3] == "—"
|
|
|
|
|
|
|
|
|
|
def test_summary_populates_data_subject_for_popup(self):
|
|
|
|
|
html = _render_rows([_event("usgs_quake", inner={"magnitude": 1.347,
|
|
|
|
|
"place": "near Town"})])
|
|
|
|
|
assert 'data-subject="Magnitude 1.3 — near Town"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTableRendersThroughHTTP:
|
|
|
|
|
"""End-to-end HTTP render: Time and Adapter cells must be populated in the
|
|
|
|
|
real response, guarding the route->template binding (not the helper alone)."""
|
|
|
|
|
|
|
|
|
|
def _mock_pool(self):
|
|
|
|
|
now = datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
def _fetchrow(query, *args):
|
|
|
|
|
q = " ".join(str(query).split())
|
|
|
|
|
if "config.sessions" in q:
|
|
|
|
|
return {"id": 1, "username": "admin", "created_at": now,
|
|
|
|
|
"password_changed_at": now, "csrf_token": "csrf"}
|
|
|
|
|
if "map_tile_url" in q:
|
|
|
|
|
return {"map_tile_url": "https://t/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OSM"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
rows = [
|
|
|
|
|
{"id": "evt-q", "time": now, "received": now, "adapter": "usgs_quake",
|
|
|
|
|
"category": "quake", "subject": None, "geometry": None,
|
|
|
|
|
"data": {"data": {"data": {"magnitude": 1.3, "place": "near Town"}}},
|
|
|
|
|
"regions": []},
|
|
|
|
|
{"id": "evt-w", "time": now, "received": now, "adapter": "nwis",
|
|
|
|
|
"category": "hydro", "subject": None, "geometry": None,
|
|
|
|
|
"data": {"data": {"data": {"value": 5.0, "unit_of_measure": "ft"}}},
|
|
|
|
|
"regions": []},
|
|
|
|
|
]
|
|
|
|
|
mock_conn = MagicMock()
|
|
|
|
|
mock_conn.fetchrow = AsyncMock(side_effect=_fetchrow)
|
|
|
|
|
mock_conn.fetch = AsyncMock(return_value=rows)
|
|
|
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_conn.__aexit__ = AsyncMock()
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
|
|
|
|
return mock_pool
|
|
|
|
|
|
|
|
|
|
def _client(self):
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from starlette.testclient import TestClient
|
|
|
|
|
from central.gui.middleware import SessionMiddleware
|
|
|
|
|
from central.gui.routes import router
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(router)
|
|
|
|
|
app.add_middleware(SessionMiddleware)
|
|
|
|
|
return TestClient(app, cookies={"central_session": "valid"})
|
|
|
|
|
|
|
|
|
|
def _expected_adapter_display(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
return discover_adapters()["usgs_quake"].display_name
|
|
|
|
|
|
|
|
|
|
def test_events_page_time_and_adapter_cells_populated(self):
|
|
|
|
|
mock_pool = self._mock_pool()
|
|
|
|
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
|
|
|
|
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
resp = self._client().get("/events")
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
cells = _first_row_cells(resp.text)
|
2026-05-25 02:04:23 +00:00
|
|
|
# v0.7.3: single-line MM-DD HH:MM UTC (no year in the cell).
|
|
|
|
|
assert cells[1].endswith("UTC") and "2026" not in cells[1] and len(cells[1].split()) == 3
|
|
|
|
|
# Adapter cell is now a chip showing the short name; display_name is the tooltip.
|
|
|
|
|
assert cells[4] == "usgs_quake"
|
|
|
|
|
assert self._expected_adapter_display() in resp.text # display_name in title=
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
|
|
|
|
|
mock_pool = self._mock_pool()
|
|
|
|
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
|
|
|
|
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
resp = self._client().get("/events/rows")
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
cells = _first_row_cells(resp.text)
|
2026-05-25 02:04:23 +00:00
|
|
|
assert cells[1].endswith("UTC") and "2026" not in cells[1] and len(cells[1].split()) == 3
|
|
|
|
|
assert cells[4] == "usgs_quake"
|
|
|
|
|
assert self._expected_adapter_display() in resp.text
|
2026-05-21 19:07:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- feat(events-json-subject): JSON subject derivation ------------------
|
|
|
|
|
|
|
|
|
|
# Representative inner adapter payloads (payload->'data'->'data'), captured from
|
|
|
|
|
# production -- one per registered adapter. Keyed by adapter name so the
|
|
|
|
|
# coverage test below fails loudly if a new adapter ships without a sample.
|
|
|
|
|
_SAMPLE_INNER = {
|
|
|
|
|
"eonet": {"title": "Kress Wildfire, Swisher, Texas"},
|
|
|
|
|
"firms": {"frp": 0.34, "confidence": "nominal"},
|
|
|
|
|
"gdacs": {"title": "Green flood alert in Austria", "alertlevel": "Green"},
|
|
|
|
|
"inciweb": {"title": "MTHLF Jericho Creek"},
|
|
|
|
|
"nwis": {"value": 93.2, "unit_of_measure": "ft^3/s"},
|
|
|
|
|
"nws": {"event": "Special Weather Statement", "severity": "Moderate"},
|
|
|
|
|
"swpc_alerts": {
|
|
|
|
|
"product_id": "EF3A",
|
|
|
|
|
"message": (
|
|
|
|
|
"Space Weather Message Code: ALTEF3\r\nSerial Number: 3691\r\n"
|
|
|
|
|
"Issue Time: 2026 May 21 0509 UTC\r\n\r\nCONTINUED ALERT: "
|
|
|
|
|
"Electron 2MeV Integral Flux exceeded 1000pfu"
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
"swpc_kindex": {"Kp": 1.0},
|
|
|
|
|
"swpc_protons": {"flux": 15.06399917602539, "energy": ">=1 MeV"},
|
|
|
|
|
"usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"},
|
|
|
|
|
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
|
|
|
|
|
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
|
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
|
|
|
"wzdx": {"road_names": ["I-80"], "direction": "eastbound"},
|
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
|
|
|
"tomtom_flow": {"road_category": "primary", "relative_speed": 0.11},
|
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
|
|
|
"tomtom_incidents": {"description": "Roadworks", "from": "Early Road", "to": "Slade Road"},
|
2026-06-03 22:36:26 -06:00
|
|
|
"itd_511": {"event_type_short": "work_zone", "roadway_name": "I-84"},
|
|
|
|
|
"itd_511_cameras": {"location": "I-84 Mountain Home", "camera_id": 42},
|
v0.10.10: new avalanche_org adapter — backcountry avalanche advisories (#98)
meshai-requested adapter for avalanche.org's per-center map layers (SNFAC
Sawtooth + PAC Payette by default; operator-extensible to any avalanche.org
forecast center). Pure passthrough + severity gate, no cross-source fusion,
fits Central's adapter pattern cleanly.
Adapter surface:
- Polls https://api.avalanche.org/v2/public/products/map-layer/{center_id}
per configured center; default cadence 1800s (30 min).
- Severity gate: only danger_level >= 3 publishes. danger_level 0/1/2
(None/Low/Moderate), -1 ('no rating'), and off_season=true all omitted at
adapter level. Idaho summer = all 4 SNFAC + 2 PAC zones yield 0 events;
that's correct behavior, verified by the negative-case test against the
frozen 2026-06-08 SNFAC fixture.
- Severity mapping (corrected from meshai's inverted spec): danger_level
3 (Considerable) → severity 2, 4 (High) → 3, 5 (Extreme) → 4. Matches
Central's 4-most-severe convention (nws.SEVERITY_MAP).
- Subject: central.avy.advisory.us.{state_lower} — one per state; v0.10.8's
category-discriminated Nats-Msg-Id keeps multiple zones in the same state
from colliding in JetStream dedup.
- Stream: CENTRAL_AVY (central.avy.>); 7-day / 1 GiB retention defaults.
- Event.data fields per meshai spec: center_id, zone_name, danger_level,
danger_name, travel_advice (truncated to 200 chars), state, valid_date,
end_date, off_season=false, latitude/longitude (polygon centroid via
shapely), plus geo.geometry passes through as the upstream Polygon.
Tests (38 in test_avalanche_org.py):
- Pure helpers: _slug (8 cases), _parse_iso (6 cases), _centroid (2 cases).
- Severity gate: 3 publish cases (danger 3/4/5 → severity 2/3/4),
4 omit cases (danger -1/0/1/2), off_season=true omit, missing state omit,
unparseable geom omit, travel_advice truncation, subject derivation.
- Real-fixture negative case: 4-zone SNFAC fixture all omitted off-season.
- Real-fixture positive case: same fixture with synthetic winter overrides
publishes all 4 with valid centroids on actual Idaho polygons.
- End-to-end poll() with mixed severities and the new wiring (streams
registry + supervisor family map).
- Defensive: empty center_ids list yields nothing without crashing.
Wiring + plumbing:
- src/central/streams.py: StreamEntry('CENTRAL_AVY', 'central.avy.>')
- src/central/supervisor.py: STREAM_CATEGORY_DOMAINS['CENTRAL_AVY']=('avy',)
- sql/migrations/035: seed config.streams row (mirror of 019/CENTRAL_SPACE,
idempotent ON CONFLICT DO NOTHING). Note: migrations don't auto-run on
supervisor restart -- see deferred ops list (schema_migrations cleanup
blocks central-migrate from running anything cleanly).
- src/central/gui/templates/_event_rows/avalanche_org.html (8 lines)
- src/central/gui/templates/_event_summaries/avalanche_org.html (2 lines)
Both required by the existing per-adapter template consistency tests.
Doc updates (required by existing doc-vs-registry tests):
- docs/PRODUCER-INTEGRATION.md §6.1: added 'avy' to top-level-domain list.
- docs/PRODUCER-INTEGRATION.md §8: added StreamEntry('CENTRAL_AVY',...) line
to the verbatim snippet.
- docs/CONSUMER-INTEGRATION.md §3 stream layout table: added CENTRAL_AVY row.
- docs/CONSUMER-INTEGRATION.md §6: new '### avalanche_org' subsection with
source, subject convention, dedup key, severity gate, Event.data field
table, and off-season behavior note.
- tests/test_events_feed_frontend.py: added avalanche_org to _SAMPLE_INNER
and _EXPECTED_SUBJECT (the events-JSON subject-derivation coverage tests).
Budget note: this PR is well over the ~400-line target -- the new-adapter
surface picked up downstream consistency tests (doc validators + frontend
sample coverage + template partials) I didn't anticipate at probe time.
Most of the overrun is the SNFAC fixture (1,135 lines pretty-printed JSON,
non-code) and the adapter + tests pair. Stripping the fixture and the
required doc/template edits would leave ~620 lines of code; the fixture
itself is a frozen snapshot, not a maintenance burden.
Full sweep: 1072 passed, 0 failures (+41 from this PR), ruff clean on
all new files. One PRE-EXISTING ruff violation in supervisor.py (unused
poll_start variable at line 388) surfaces when we touch supervisor.py;
confirmed not introduced by this PR via git stash check.
Deploy plan (NEW STREAM — archive restart required per
[[feedback_new_stream_needs_archive_restart]]):
1. Squash-merge -> tag v0.10.10 -> push.
2. On central: pull main -> systemctl restart central-supervisor -> ALSO
systemctl restart central-archive (new event-bearing stream; archive
enumerates consumers at startup and doesn't hot-reload).
3. Migration 035 deferred to morning per the schema_migrations cleanup
task -- the stream creation itself doesn't depend on it (supervisor
creates JetStream streams from the STREAMS registry at startup; the
config.streams row is for operator-tunable retention only).
4. Verify: nats stream info CENTRAL_AVY (created), poll log shows
yielded=0 / omitted=N (off-season), no positive publishes during
summer (correct).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 21:57:56 -06:00
|
|
|
"avalanche_org": {"zone_name": "Banner Summit", "danger_name": "Considerable"},
|
2026-06-09 00:54:19 -06:00
|
|
|
"celestrak_tle": {
|
|
|
|
|
"norad_id": 25544,
|
|
|
|
|
"satellite_name": "ISS (ZARYA)",
|
|
|
|
|
"_enriched": {"orbit": {
|
|
|
|
|
"inclination_deg": 51.6336,
|
|
|
|
|
"mean_motion_rev_per_day": 15.49672912,
|
|
|
|
|
}},
|
|
|
|
|
},
|
2026-06-09 01:16:43 -06:00
|
|
|
"satpass_predict": {
|
|
|
|
|
"satellite_name": "ISS (ZARYA)",
|
|
|
|
|
"norad_id": 25544,
|
|
|
|
|
"peak_time": "2026-06-09T15:39:37+00:00",
|
|
|
|
|
"max_elevation_deg": 40.3,
|
|
|
|
|
},
|
v0.12.0: sat_positions adapter (live global satellite positions) + sat_common refactor
## Architectural framing
The v0.11.1 `satpass_predict` adapter is **observer-anchored**: "when does satellite X pass over fixed observer Y, and what's the elevation/azimuth at that observer's site?" It answers a fixed-QTH question and emits one event per (observer, satellite, AOS) tuple.
The new `sat_positions` adapter is the **global** counterpart: "where is satellite X right now?" No observer. One event per tracked NORAD ID per poll, on subject `central.sat.position.<norad_id>`. Consumers (meshAI, GUI map widgets, anything that wants a live world map) subscribe to `central.sat.position.>` and plot.
They complement each other; neither replaces the other.
Direct quote from Matt's use-case: *"location of the sats... map of where the sats are then we have meshai or whatever other service calling central's data grab it and do whatever work it needed."* This adapter is that.
## sat_common extraction rationale
The four pure SGP4 / coordinate helpers (`EARTH_RADIUS_KM`, `gmst_rad`, `eci_to_ecef`, `subsatellite_point`) were private symbols inside `satpass_predict.py`. `sat_positions` needs the same three helpers. Three options were considered:
1. **Cross-import** from `satpass_predict.py` — creates an adapter-to-adapter dependency, ugly.
2. **Extract to `sat_common.py`** — matches the existing `wfigs_common.py` / `swpc_common.py` precedent. Both adapters become siblings of a shared helper module. ✓ chosen.
3. **Duplicate** — math drift over time.
Symbol names dropped their leading underscore on extraction (public-API convention matching `swpc_common.parse_swpc_timestamp` / `wfigs_common.severity_from_acres`). Existing internal call sites in `satpass_predict.py` were updated via mechanical `replace_all`. Observer-specific helpers (`_observer_ecef`, `_topocentric_az_el`, `_visibility_footprint`, `_severity_from_elev`, `_build_pass_geometry`, `_next_passes`) stay in `satpass_predict.py` per YAGNI — they're not used by `sat_positions` today.
Existing `tests/test_satpass_predict.py` was updated mechanically to import the helpers from `sat_common` via aliases (preserves the underscore-prefixed local names in the tests so the rest of the test body needs no change). All 44 satpass_predict tests pass unchanged.
## CENTRAL_SAT stream cap bump
`config.streams.max_bytes` for `CENTRAL_SAT` goes from **1 GiB → 5 GiB** in migration 039. Sizing math:
- celestrak_tle: ~190 sats × 1 envelope/day = ~190 events/day = ~1.4k events/week. Fit in 1 GiB easily.
- sat_positions: ~190 sats × 1440 ticks/day (60s cadence) = **~273.6k events/day = ~1.9M events/week**. At ~1 KB per envelope including the CloudEvents wrapper, that's **~1.9 GiB/week**.
- Plus existing TLE + pass envelopes already on the stream → ~3 GiB headroom needed.
- 5 GiB = 5368709120 bytes = operator-tunable margin without over-provisioning.
`STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` extends from `("tle", "pass")` to `("tle", "pass", "position")` so the supervisor's retention sweep covers position events too.
## Subject + dedup
| Field | Value |
|---|---|
| Subject | `central.sat.position.<norad_id>` — one subject per satellite, globally |
| Dedup id | `<norad_id>:<position_iso>` where `position_iso` is the propagation timestamp truncated to whole seconds (defensive collapse if cadence is ever tightened) |
| Severity | 1 (informational telemetry, no alerting) |
| data_class | `telemetry` — surfaces on `/telemetry`, not `/events` |
| Cadence | 60s default; operator-tunable via standard `cadence_s` field |
## Settings shape
```json
{"track_only_norad_ids": [], "max_tle_age_days": 14}
```
- Empty `track_only_norad_ids` = track every NORAD ID with a fresh TLE in the events table (derive-from-celestrak_tle, default behavior).
- Non-empty list pins to those NORAD IDs only (operator override — "I only care about the ISS and these 12 Starlink sats").
- `max_tle_age_days` bounds TLE freshness; LEO drag means TLEs go stale in days, GEO is good for months. Parameterized into the SQL query as a timedelta interval so operator-tightened windows (e.g. 3d) apply without code change.
## Event.data fields
`norad_id`, `satellite_name`, `lon_deg`, `lat_deg`, `alt_km`, `velocity_kmps`, `heading_deg`, `tle_epoch`.
- `lon_deg`/`lat_deg`/`alt_km`: sub-satellite point via SGP4 → ECI → ECEF rotation → spherical-earth lon/lat/alt.
- `velocity_kmps`: magnitude of the SGP4 ECI velocity vector. ECI vs ECEF difference is ~6% for LEO (earth rotation 0.46 km/s vs 7.7 km/s orbital speed); fine for consumer "the sat is moving at X km/s" text.
- `heading_deg`: great-circle initial bearing from the sub-sat point at `t` to the sub-sat point at `t+1s` (finite-difference; simpler than rotating velocity through GMST + the earth-rotation cross term).
## Diff size — flag for review
**+894 / -63 = +831 net** across 14 files. Spec budget was ≤700 lines. **Over by ~131 net** (or ~194 gross).
Breakdown:
- `sat_positions.py`: 286 lines (under the ≤350 adapter line cap ✓)
- `sat_common.py`: 65 lines (the extraction)
- Migration 039: 58 lines (heavy on inline comments documenting the size math; could trim ~25 lines if you want)
- satpass_predict.py: net -1 line (refactor; lost 4 helper defs and one constant comment, gained 5-line import block)
- Templates: 14 lines (event_rows + event_summaries partials)
- Wiring: 4 lines (supervisor + ADAPTER_GROUPS)
- Docs (CONSUMER-INTEGRATION.md): 40 lines (required by `tests/test_consumer_doc.py::test_every_adapter_has_a_subsection`)
- **Tests: 426 lines.** This is the bulk of the overage.
The tests are all spec-mandated (sub-sat math, velocity range, heading range, build_event, subject_for, empty-TLE, track_only gate, stale-TLE skip, sat_common helpers, regression-guard on the moved helpers via test_satpass_predict.py preservation). I could shrink `test_sat_positions.py` by consolidating the 11 spec-mandated tests into fewer parameterized cases, but each test pins one behavior the spec called out by name. Flagging for your call: keep as-is, or do you want a tighter parameterized version?
## Test plan
- [x] `pytest tests/test_sat_common.py tests/test_sat_positions.py` — **28 new tests, all pass**.
- [x] `pytest tests/test_satpass_predict.py` — **44/44 pass** (regression guard: existing tests work after the sat_common extraction).
- [x] `pytest tests/test_events_feed_frontend.py` — **119/119 pass** (JSON-feed coverage extended to include sat_positions sample event + expected subject string).
- [x] `pytest tests/test_telemetry_separation.py` — **9/9 pass** (`_TELEMETRY` pin extended to include `sat_positions`).
- [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (CONSUMER-INTEGRATION.md `### sat_positions` subsection added).
- [x] `pytest tests/test_producer_doc.py` — **10/10 pass** (no PRODUCER-INTEGRATION update needed; CENTRAL_SAT stream is pre-existing).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1209 passed, 1 skipped, 0 failures**.
- [x] Ruff: clean on all new code. 3 pre-existing F841 unused-variable warnings (supervisor.py:390 `poll_start`, test_events_feed_frontend.py:425 / :466 `result`) confirmed via `git blame` to be from commits May 2026 — not introduced.
## Deploy plan
1. Squash-merge → tag v0.12.0 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep).
3. **`central-migrate`** to apply migration 039 (seeds `config.adapters` row + bumps `config.streams.max_bytes` for CENTRAL_SAT).
4. `sudo systemctl restart central-supervisor` (picks up STREAM_CATEGORY_DOMAINS extension + new adapter discovery).
5. `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change).
6. **No** `central-archive` restart (CENTRAL_SAT stream already exists; no new stream).
7. Verify: `nats stream info CENTRAL_SAT` shows max_bytes=5368709120; supervisor journal shows sat_positions discovered.
8. Smoke-test: enable celestrak_tle first if not already, wait for one poll, then enable sat_positions via GUI. Within 60s expect one `central.sat.position.<norad_id>` event per tracked sat on the stream.
## Halt acknowledgment
Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 15:23:32 -06:00
|
|
|
"sat_positions": {
|
|
|
|
|
"satellite_name": "ISS (ZARYA)",
|
|
|
|
|
"norad_id": 25544,
|
|
|
|
|
"lat_deg": 43.6,
|
|
|
|
|
"lon_deg": -116.2,
|
|
|
|
|
"alt_km": 408.5,
|
|
|
|
|
"velocity_kmps": 7.66,
|
|
|
|
|
"heading_deg": 87.3,
|
|
|
|
|
},
|
v0.12.1: n2yo_visualpasses adapter (server-side visible-pass alerts)
## Architectural placement — complement, not replacement
| | satpass_predict (v0.11.1) | **n2yo_visualpasses (this PR)** |
|---|---|---|
| Computes from | Raw TLEs via local SGP4 | n2yo's pre-computed visualpasses endpoint |
| Magnitude data? | ✗ (SGP4 alone can't compute illumination) | ✓ (server-side sun-geometry) |
| Sun illumination filter? | ✗ | ✓ (n2yo returns sunlit passes only) |
| Cost per (observer, sat) pair | Local compute, free | One n2yo API transaction |
| Failure mode | TLE drift over time | Quota exhaustion, vendor outage |
Both adapters serve the same operator question ("when is sat X overhead at site Y?") but with different data sources. Matt's stated use case is to have **both** running so a vendor outage or quota burn on n2yo doesn't blind him to passes that satpass_predict can still propagate locally.
## Subject collision is intentional
Both adapters emit on `central.sat.pass.us.<state_lower>.<observer_slug>`. A consumer subscribing to e.g. `central.sat.pass.us.id.boise` receives events from **both** adapters. Disambiguation lives in `data.category`:
- `pass.satpass_predict` → local SGP4
- `pass.n2yo_visualpasses` → n2yo API
The v0.10.8 category-discriminated `Nats-Msg-Id` keeps both adapters' JetStream dedup windows separate even when they emit for the same (observer, satellite, AOS) tuple (which they will, by design, for sunlit passes).
This is documented explicitly in the new `### n2yo_visualpasses` subsection of `docs/CONSUMER-INTEGRATION.md` so future consumer integrators don't get surprised.
## Quota math
Default settings ship a curated **6 observers × 6 sats** configuration:
- **Observers** (ID + UT): Filer (primary), Boise, Idaho Falls, Ogden, Salt Lake City, Provo
- **Satellites** (curated for amateur observation): ISS (25544), NOAA-15 (25338), NOAA-18 (28654), NOAA-19 (33591), SO-50 (27607), AO-91 (43017)
At 1h cadence: **6 × 6 × 24 = 864 transactions/day**, comfortably under n2yo's free-tier **1000/day cap** with ~13% headroom for retries or expansion. Operator can extend either dimension if they upgrade quota.
## API key plumbing (tomtom_flow pattern)
Exact mirror of the v0.9.3 tomtom_flow precedent — confirmed during recon to be the established pattern:
```python
requires_api_key = "n2yo" # class attr, GUI surfaces "requires X" warning
api_key_field = "api_key_alias" # class attr, GUI renders api_key_select dropdown
# Settings field:
api_key_alias: str = "n2yo"
```
Cached `_api_key` populated via `ConfigStore.get_api_key(alias)` in `startup()` and `apply_config()`. Missing-key path: log INFO, return immediately (zero events, no exception). The live key is scrubbed from log strings via a `_redact()` helper before they hit journald.
**`python -m set_api_key` does not exist** — that was a speculative invocation in the spec. The actual flow is GUI-based: Matt adds the `n2yo` alias via the `/api-keys` page, then enables the adapter via `/adapters/n2yo_visualpasses/edit`.
## Diff size — flag for review
**+848 / −1 = +847 net** across 8 files. Spec budget was ≤600 lines. **Over by ~247** (~41%, similar shape to v0.12.0's overage).
| File | Lines | Notes |
|---|---|---|
| `src/central/adapters/n2yo_visualpasses.py` | 330 | **Under** the ≤350 adapter cap ✓ |
| `tests/test_n2yo_visualpasses.py` | 411 | The bulk of the overage |
| `sql/migrations/040_add_n2yo_visualpasses_adapter.sql` | 45 | Heavy comment block; could trim ~15 lines |
| `docs/CONSUMER-INTEGRATION.md` | 40 | Required by `test_consumer_doc` |
| Partials (event_rows + event_summaries) | 13 | |
| `tests/test_events_feed_frontend.py` | 8 | _SAMPLE_INNER + _EXPECTED_SUBJECT |
| `src/central/gui/routes.py` | 1 | ADAPTER_GROUPS extension |
**Test breakdown** (31 tests in 8 classes):
- 9 severity-bucketing tests — spec called out 4 boundaries (-3.1, -2.9, -0.5, 2.5); the extra 5 pin inclusive-vs-exclusive at -3.0, -1.0, 2.0 boundaries + the ranges in between. Useful regression guards but not strictly spec-required.
- 4 settings-default tests — pin the curated 6×6 set + quota math.
- 4 adapter-class-attrs tests — pin requires_api_key/api_key_field/data_class/default_cadence_s wiring.
- 3 subject_for tests — happy path + UT-state lowercasing + unknown fallback.
- 1 _pass_to_event shape test.
- 7 poll-loop tests — missing key, empty observers, empty norad_ids, happy path, empty passes array, fetch-failure-doesn't-kill-poll, multi-obs-multi-sat 6×6 aggregate.
- 1 HTTP-layer test — 401 → None (the one test that goes through the real session.get mock).
- 2 static-isolation tests — acceptance bar #2 (no hardcoded keys) and #4 (no absolute paths).
I can trim the test file to ~250 lines by dropping the non-strictly-spec-mandated tests (settings defaults, class attrs, extra severity boundaries, extra subject_for variants). **Flag for your call:** keep the comprehensive suite, or trim to spec minimum?
## Test plan
- [x] `pytest tests/test_n2yo_visualpasses.py` — **31/31 pass** (all offline, zero n2yo API hits).
- [x] `pytest tests/test_events_feed_frontend.py` — **122/122 pass** (fixture coverage extended).
- [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (new `### n2yo_visualpasses` subsection accepted).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files) — **1243 passed, 1 skipped, 0 failures**.
- [x] Ruff: **clean on new files** (`n2yo_visualpasses.py`, `test_n2yo_visualpasses.py`). The pre-existing F841 warnings in routes.py / test_events_feed_frontend.py / supervisor.py are unchanged from v0.11.3-pre.
- [x] **No hardcoded API key in diff** — `git diff main..HEAD | grep -iE 'apiKey=[A-Z0-9]{6,}|api_key.*=.*"[A-Z0-9]{6,}'` returns empty.
- [x] **No absolute paths in test code** — `TestStaticIsolation` enforces this at runtime.
## Deploy plan
1. Squash-merge PR #N → tag v0.12.1 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (aiohttp already in venv from earlier adapters).
3. **Matt adds the n2yo API key via GUI `/api-keys` page** (Add → alias `n2yo` → paste key). Do this **before** enabling the adapter — missing-key path is graceful but the adapter logs INFO and skips polling until the key lands.
4. Apply migration 040 manually via psql (per option C established pattern):
`sudo -u postgres psql central -f /opt/central/sql/migrations/040_add_n2yo_visualpasses_adapter.sql`
**Do NOT** run `central-migrate` — orphan migrations 032-039 stay deferred for the morning queue.
5. `sudo systemctl restart central-supervisor` (picks up the new adapter via discovery) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change).
6. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the adapter row is new).
7. Verify: `config.adapters` has `n2yo_visualpasses` row with `enabled=false`; `config.api_keys` has alias `n2yo`; supervisor log shows the adapter discovered but not polling (matches `enabled=false`).
8. Matt enables via `/adapters/n2yo_visualpasses/edit` when ready. First poll happens within 1h; events surface at `/events` filtered by adapter=n2yo_visualpasses.
## Halt acknowledgment
Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 16:00:55 -06:00
|
|
|
"n2yo_visualpasses": {
|
|
|
|
|
"satellite_name": "ISS (ZARYA)",
|
|
|
|
|
"norad_id": 25544,
|
|
|
|
|
"peak_time": "2026-06-09T21:14:00+00:00",
|
|
|
|
|
"magnitude": -3.4,
|
|
|
|
|
"max_elevation_deg": 47.0,
|
|
|
|
|
},
|
v0.13.0: sat_orbits adapter (forward-orbit-track per satellite) + antimeridian splitter
## Matt's "each sat's path" framing
After enabling the satellite family in v0.12.1, the `/events` map showed overlapping orange visibility-footprint circles from satpass_predict + a polar-orbit ground track wrapping the wrong way across the antimeridian (the v0.11.2 documented limitation). Matt's ask:
> honestly i just want each sats path.
Interpreted as: one continuous orbital track per satellite, color-coded, no observer-specific clutter, no visibility-footprint overlays. Six tracked sats = six distinguishable lines on the map.
## Family placement — global line counterpart to global points
| Adapter | What it publishes | Geometry | Cadence |
|---|---|---|---|
| satpass_predict (v0.11.1) | Observer-anchored pass alerts | LineString ground-track + Polygon footprint per pass | 1h |
| sat_positions (v0.12.0) | Current sub-sat POINT per sat | Point centroid only | 60s |
| **sat_orbits (this PR)** | Forward-orbit LINE per sat | LineString / MultiLineString, 90min horizon | 5min |
Each answers a different question; they complement.
## Antimeridian splitter — shared sat_common primitive
`split_antimeridian(coords)` lives in `sat_common.py` next to `gmst_rad` / `eci_to_ecef` / `subsatellite_point`. Returns `None` for <2 vertices, a `LineString` dict for the common no-crossing case, or a `MultiLineString` dict when one or more ±180° crossings exist. Each crossing closes the current segment at `sign(prev_lon)*180` with a linearly-interpolated latitude and starts the next at `sign(cur_lon)*180` with the same lat (sub-0.1° error at LEO orbital speeds, well below Leaflet rendering precision).
**Sibling concern fixed:** `satpass_predict._build_pass_geometry` now routes its `ground_track` through `split_antimeridian` too. This was the v0.11.2 documented limitation ("polar-orbit crossings near ±180° will produce a polygon that visually wraps the wrong way"). Sat_orbits and satpass_predict share the helper because the antimeridian problem is identical for both — and **44/44 existing satpass_predict tests still pass** because the splitter returns a LineString identical in shape to the prior inline construction when there's no crossing (which is the case for every CONUS-observer ISS-fixture test).
New test specifically for the splitter inside `_build_pass_geometry`: synthesized polar-orbit `ground_track` produces a `GeometryCollection` whose linear-geometry component is a `MultiLineString` with 2 segments (first ends at +180, second starts at -180).
## GUI per-NORAD-ID color helper
20-line addition to `events_list.html`:
```js
function orbitColorForNoradId(norad) {
var hue = (norad * 137.508) % 360; // golden-angle hue distribution
return "hsl(" + hue.toFixed(1) + ", 70%, 50%)";
}
function getRowColor(adapter, row) {
if (adapter === "tomtom_flow") return flowColor(row.dataset.severity);
if (adapter === "sat_orbits") {
var norad = parseInt((row.dataset.eventId || "").split(":")[0], 10);
if (!isNaN(norad)) return orbitColorForNoradId(norad);
}
return getAdapterColor(adapter);
}
```
`event_id` shape is `<norad_id>:<iso>` (same as sat_positions), so JS reads the first colon-token. **Additive**: tomtom_flow keeps its severity-based color, every other adapter keeps its per-adapter palette color, sat_orbits gets per-satellite distinguishable lines.
## Phase A sanity (per spec)
```
vertices = 91 ✓ (90min @ 60s + 1 endpoint)
first vertex = (170.66°, -17.15°, 417.4km) ✓ matches v0.11.1 ISS pin
last vertex = (140.52°, -8.60°, 415.9km) ✓ geographically distinct
antimeridian crossings in 90min track = 1
geometry type = MultiLineString, 2 segments ✓ splitter integrates
```
## Diff size
**+838 / −9 = +829 net** across 15 files. Spec budget was ≤800 lines. **29 over** — much tighter than v0.12.0 (894) or v0.12.1 (848). Adapter LoC 275 (well under 350 cap). sat_common splitter 51 LoC (~budget).
Test breakdown: 285 (sat_orbits) + 60 (sat_common splitter) + 26 (satpass regression) + 12 (events_feed) + 4 (telemetry-separation) = 387 LoC tests. Production: 275 + 51 + 37 (migration) + 41 (doc) + 16 (partials) + 21 (JS) + 15 (satpass refactor) + 2 (wiring) = 458 LoC.
## Test plan
- [x] `pytest tests/test_sat_orbits.py` — 19 new tests, all pass.
- [x] `pytest tests/test_sat_common.py` — 7 new splitter tests, 16 total pass.
- [x] `pytest tests/test_satpass_predict.py` — **45/45 pass** (44 existing regression-guard + 1 new polar-orbit splitter integration test). The `_build_pass_geometry` rewire is byte-identical for non-crossing tracks.
- [x] `pytest tests/test_events_feed_frontend.py` — 125/125 pass (sat_orbits sample + expected subject extended).
- [x] `pytest tests/test_telemetry_separation.py` — 9/9 pass (`_TELEMETRY` pin extended with `sat_orbits`).
- [x] `pytest tests/test_consumer_doc.py` — 6/6 pass (new `### sat_orbits` subsection accepted).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1274 passed, 1 skipped, 0 failures**.
- [x] Ruff: clean on all new + touched satellite-family code.
## Deploy plan
1. Squash-merge PR #N → tag v0.13.0 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep).
3. Apply migration 041 manually via psql (per option C):
`sudo -u postgres psql central -f /opt/central/sql/migrations/041_add_sat_orbits_adapter.sql`
4. `sudo systemctl restart central-supervisor` (picks up new adapter + STREAM_CATEGORY_DOMAINS extension) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS extension + JS color helper).
5. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the category-domain tuple grew, archive already covers `central.sat.>`).
6. Verify: `config.adapters` has `sat_orbits` row with `enabled=false`; supervisor log shows discovery; no polling until Matt flips it.
7. Matt enables via `/adapters/sat_orbits/edit` when ready. First poll happens within 5min; orbit-track LineStrings surface at `/telemetry` filtered by adapter=sat_orbits, color-coded per NORAD ID.
## Halt acknowledgment
Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 18:50:47 -06:00
|
|
|
"sat_orbits": {
|
|
|
|
|
"satellite_name": "ISS (ZARYA)",
|
|
|
|
|
"norad_id": 25544,
|
|
|
|
|
"propagation_start_iso": "2026-06-09T22:35:00+00:00",
|
|
|
|
|
"forward_minutes": 90,
|
|
|
|
|
"sample_seconds": 60,
|
|
|
|
|
"vertex_count": 91,
|
|
|
|
|
"current_lon_deg": 170.6553,
|
|
|
|
|
"current_lat_deg": -17.1487,
|
|
|
|
|
"current_alt_km": 417.4,
|
|
|
|
|
},
|
2026-05-21 19:07:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
|
|
|
|
# (its message runs through Jinja's truncate(80)) and is checked separately.
|
|
|
|
|
# swpc_protons expects unescaped ">=" -- _derive_subject html.unescapes the
|
|
|
|
|
# autoescaped partial output so JSON consumers get plain text.
|
|
|
|
|
_EXPECTED_SUBJECT = {
|
|
|
|
|
"eonet": "Kress Wildfire, Swisher, Texas",
|
|
|
|
|
"firms": "Fire detected — 0.34 MW radiative power",
|
|
|
|
|
"gdacs": "Green flood alert in Austria — Green alert",
|
|
|
|
|
"inciweb": "MTHLF Jericho Creek",
|
|
|
|
|
"nwis": "Water reading: 93.2 ft^3/s",
|
|
|
|
|
"nws": "Special Weather Statement — Moderate",
|
|
|
|
|
"swpc_kindex": "Geomagnetic activity (Kp index): 1.0",
|
|
|
|
|
"swpc_protons": "Solar proton flux: 15.06 pfu at >=1 MeV",
|
|
|
|
|
"usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA",
|
|
|
|
|
"wfigs_incidents": "Wildfire incident — Montezuma, CO",
|
|
|
|
|
"wfigs_perimeters": "Wildfire perimeter — Carbon, MT",
|
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
|
|
|
"wzdx": "Work zone on I-80 eastbound",
|
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
|
|
|
"tomtom_flow": "Traffic flow (primary) — 11% of free-flow",
|
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
|
|
|
"tomtom_incidents": "Roadworks on Early Road → Slade Road",
|
2026-06-03 22:36:26 -06:00
|
|
|
"itd_511": "Road work on I-84",
|
|
|
|
|
"itd_511_cameras": "Camera: I-84 Mountain Home",
|
v0.10.10: new avalanche_org adapter — backcountry avalanche advisories (#98)
meshai-requested adapter for avalanche.org's per-center map layers (SNFAC
Sawtooth + PAC Payette by default; operator-extensible to any avalanche.org
forecast center). Pure passthrough + severity gate, no cross-source fusion,
fits Central's adapter pattern cleanly.
Adapter surface:
- Polls https://api.avalanche.org/v2/public/products/map-layer/{center_id}
per configured center; default cadence 1800s (30 min).
- Severity gate: only danger_level >= 3 publishes. danger_level 0/1/2
(None/Low/Moderate), -1 ('no rating'), and off_season=true all omitted at
adapter level. Idaho summer = all 4 SNFAC + 2 PAC zones yield 0 events;
that's correct behavior, verified by the negative-case test against the
frozen 2026-06-08 SNFAC fixture.
- Severity mapping (corrected from meshai's inverted spec): danger_level
3 (Considerable) → severity 2, 4 (High) → 3, 5 (Extreme) → 4. Matches
Central's 4-most-severe convention (nws.SEVERITY_MAP).
- Subject: central.avy.advisory.us.{state_lower} — one per state; v0.10.8's
category-discriminated Nats-Msg-Id keeps multiple zones in the same state
from colliding in JetStream dedup.
- Stream: CENTRAL_AVY (central.avy.>); 7-day / 1 GiB retention defaults.
- Event.data fields per meshai spec: center_id, zone_name, danger_level,
danger_name, travel_advice (truncated to 200 chars), state, valid_date,
end_date, off_season=false, latitude/longitude (polygon centroid via
shapely), plus geo.geometry passes through as the upstream Polygon.
Tests (38 in test_avalanche_org.py):
- Pure helpers: _slug (8 cases), _parse_iso (6 cases), _centroid (2 cases).
- Severity gate: 3 publish cases (danger 3/4/5 → severity 2/3/4),
4 omit cases (danger -1/0/1/2), off_season=true omit, missing state omit,
unparseable geom omit, travel_advice truncation, subject derivation.
- Real-fixture negative case: 4-zone SNFAC fixture all omitted off-season.
- Real-fixture positive case: same fixture with synthetic winter overrides
publishes all 4 with valid centroids on actual Idaho polygons.
- End-to-end poll() with mixed severities and the new wiring (streams
registry + supervisor family map).
- Defensive: empty center_ids list yields nothing without crashing.
Wiring + plumbing:
- src/central/streams.py: StreamEntry('CENTRAL_AVY', 'central.avy.>')
- src/central/supervisor.py: STREAM_CATEGORY_DOMAINS['CENTRAL_AVY']=('avy',)
- sql/migrations/035: seed config.streams row (mirror of 019/CENTRAL_SPACE,
idempotent ON CONFLICT DO NOTHING). Note: migrations don't auto-run on
supervisor restart -- see deferred ops list (schema_migrations cleanup
blocks central-migrate from running anything cleanly).
- src/central/gui/templates/_event_rows/avalanche_org.html (8 lines)
- src/central/gui/templates/_event_summaries/avalanche_org.html (2 lines)
Both required by the existing per-adapter template consistency tests.
Doc updates (required by existing doc-vs-registry tests):
- docs/PRODUCER-INTEGRATION.md §6.1: added 'avy' to top-level-domain list.
- docs/PRODUCER-INTEGRATION.md §8: added StreamEntry('CENTRAL_AVY',...) line
to the verbatim snippet.
- docs/CONSUMER-INTEGRATION.md §3 stream layout table: added CENTRAL_AVY row.
- docs/CONSUMER-INTEGRATION.md §6: new '### avalanche_org' subsection with
source, subject convention, dedup key, severity gate, Event.data field
table, and off-season behavior note.
- tests/test_events_feed_frontend.py: added avalanche_org to _SAMPLE_INNER
and _EXPECTED_SUBJECT (the events-JSON subject-derivation coverage tests).
Budget note: this PR is well over the ~400-line target -- the new-adapter
surface picked up downstream consistency tests (doc validators + frontend
sample coverage + template partials) I didn't anticipate at probe time.
Most of the overrun is the SNFAC fixture (1,135 lines pretty-printed JSON,
non-code) and the adapter + tests pair. Stripping the fixture and the
required doc/template edits would leave ~620 lines of code; the fixture
itself is a frozen snapshot, not a maintenance burden.
Full sweep: 1072 passed, 0 failures (+41 from this PR), ruff clean on
all new files. One PRE-EXISTING ruff violation in supervisor.py (unused
poll_start variable at line 388) surfaces when we touch supervisor.py;
confirmed not introduced by this PR via git stash check.
Deploy plan (NEW STREAM — archive restart required per
[[feedback_new_stream_needs_archive_restart]]):
1. Squash-merge -> tag v0.10.10 -> push.
2. On central: pull main -> systemctl restart central-supervisor -> ALSO
systemctl restart central-archive (new event-bearing stream; archive
enumerates consumers at startup and doesn't hot-reload).
3. Migration 035 deferred to morning per the schema_migrations cleanup
task -- the stream creation itself doesn't depend on it (supervisor
creates JetStream streams from the STREAMS registry at startup; the
config.streams row is for operator-tunable retention only).
4. Verify: nats stream info CENTRAL_AVY (created), poll log shows
yielded=0 / omitted=N (off-season), no positive publishes during
summer (correct).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 21:57:56 -06:00
|
|
|
"avalanche_org": "Avalanche advisory — Banner Summit (Considerable)",
|
2026-06-09 00:54:19 -06:00
|
|
|
"celestrak_tle": "TLE update: ISS (ZARYA) (NORAD 25544) — 92.9min orbit at 51.6°",
|
2026-06-09 01:16:43 -06:00
|
|
|
"satpass_predict": "ISS (ZARYA) passes overhead at 15:39 UTC — max elevation 40°",
|
v0.12.0: sat_positions adapter (live global satellite positions) + sat_common refactor
## Architectural framing
The v0.11.1 `satpass_predict` adapter is **observer-anchored**: "when does satellite X pass over fixed observer Y, and what's the elevation/azimuth at that observer's site?" It answers a fixed-QTH question and emits one event per (observer, satellite, AOS) tuple.
The new `sat_positions` adapter is the **global** counterpart: "where is satellite X right now?" No observer. One event per tracked NORAD ID per poll, on subject `central.sat.position.<norad_id>`. Consumers (meshAI, GUI map widgets, anything that wants a live world map) subscribe to `central.sat.position.>` and plot.
They complement each other; neither replaces the other.
Direct quote from Matt's use-case: *"location of the sats... map of where the sats are then we have meshai or whatever other service calling central's data grab it and do whatever work it needed."* This adapter is that.
## sat_common extraction rationale
The four pure SGP4 / coordinate helpers (`EARTH_RADIUS_KM`, `gmst_rad`, `eci_to_ecef`, `subsatellite_point`) were private symbols inside `satpass_predict.py`. `sat_positions` needs the same three helpers. Three options were considered:
1. **Cross-import** from `satpass_predict.py` — creates an adapter-to-adapter dependency, ugly.
2. **Extract to `sat_common.py`** — matches the existing `wfigs_common.py` / `swpc_common.py` precedent. Both adapters become siblings of a shared helper module. ✓ chosen.
3. **Duplicate** — math drift over time.
Symbol names dropped their leading underscore on extraction (public-API convention matching `swpc_common.parse_swpc_timestamp` / `wfigs_common.severity_from_acres`). Existing internal call sites in `satpass_predict.py` were updated via mechanical `replace_all`. Observer-specific helpers (`_observer_ecef`, `_topocentric_az_el`, `_visibility_footprint`, `_severity_from_elev`, `_build_pass_geometry`, `_next_passes`) stay in `satpass_predict.py` per YAGNI — they're not used by `sat_positions` today.
Existing `tests/test_satpass_predict.py` was updated mechanically to import the helpers from `sat_common` via aliases (preserves the underscore-prefixed local names in the tests so the rest of the test body needs no change). All 44 satpass_predict tests pass unchanged.
## CENTRAL_SAT stream cap bump
`config.streams.max_bytes` for `CENTRAL_SAT` goes from **1 GiB → 5 GiB** in migration 039. Sizing math:
- celestrak_tle: ~190 sats × 1 envelope/day = ~190 events/day = ~1.4k events/week. Fit in 1 GiB easily.
- sat_positions: ~190 sats × 1440 ticks/day (60s cadence) = **~273.6k events/day = ~1.9M events/week**. At ~1 KB per envelope including the CloudEvents wrapper, that's **~1.9 GiB/week**.
- Plus existing TLE + pass envelopes already on the stream → ~3 GiB headroom needed.
- 5 GiB = 5368709120 bytes = operator-tunable margin without over-provisioning.
`STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` extends from `("tle", "pass")` to `("tle", "pass", "position")` so the supervisor's retention sweep covers position events too.
## Subject + dedup
| Field | Value |
|---|---|
| Subject | `central.sat.position.<norad_id>` — one subject per satellite, globally |
| Dedup id | `<norad_id>:<position_iso>` where `position_iso` is the propagation timestamp truncated to whole seconds (defensive collapse if cadence is ever tightened) |
| Severity | 1 (informational telemetry, no alerting) |
| data_class | `telemetry` — surfaces on `/telemetry`, not `/events` |
| Cadence | 60s default; operator-tunable via standard `cadence_s` field |
## Settings shape
```json
{"track_only_norad_ids": [], "max_tle_age_days": 14}
```
- Empty `track_only_norad_ids` = track every NORAD ID with a fresh TLE in the events table (derive-from-celestrak_tle, default behavior).
- Non-empty list pins to those NORAD IDs only (operator override — "I only care about the ISS and these 12 Starlink sats").
- `max_tle_age_days` bounds TLE freshness; LEO drag means TLEs go stale in days, GEO is good for months. Parameterized into the SQL query as a timedelta interval so operator-tightened windows (e.g. 3d) apply without code change.
## Event.data fields
`norad_id`, `satellite_name`, `lon_deg`, `lat_deg`, `alt_km`, `velocity_kmps`, `heading_deg`, `tle_epoch`.
- `lon_deg`/`lat_deg`/`alt_km`: sub-satellite point via SGP4 → ECI → ECEF rotation → spherical-earth lon/lat/alt.
- `velocity_kmps`: magnitude of the SGP4 ECI velocity vector. ECI vs ECEF difference is ~6% for LEO (earth rotation 0.46 km/s vs 7.7 km/s orbital speed); fine for consumer "the sat is moving at X km/s" text.
- `heading_deg`: great-circle initial bearing from the sub-sat point at `t` to the sub-sat point at `t+1s` (finite-difference; simpler than rotating velocity through GMST + the earth-rotation cross term).
## Diff size — flag for review
**+894 / -63 = +831 net** across 14 files. Spec budget was ≤700 lines. **Over by ~131 net** (or ~194 gross).
Breakdown:
- `sat_positions.py`: 286 lines (under the ≤350 adapter line cap ✓)
- `sat_common.py`: 65 lines (the extraction)
- Migration 039: 58 lines (heavy on inline comments documenting the size math; could trim ~25 lines if you want)
- satpass_predict.py: net -1 line (refactor; lost 4 helper defs and one constant comment, gained 5-line import block)
- Templates: 14 lines (event_rows + event_summaries partials)
- Wiring: 4 lines (supervisor + ADAPTER_GROUPS)
- Docs (CONSUMER-INTEGRATION.md): 40 lines (required by `tests/test_consumer_doc.py::test_every_adapter_has_a_subsection`)
- **Tests: 426 lines.** This is the bulk of the overage.
The tests are all spec-mandated (sub-sat math, velocity range, heading range, build_event, subject_for, empty-TLE, track_only gate, stale-TLE skip, sat_common helpers, regression-guard on the moved helpers via test_satpass_predict.py preservation). I could shrink `test_sat_positions.py` by consolidating the 11 spec-mandated tests into fewer parameterized cases, but each test pins one behavior the spec called out by name. Flagging for your call: keep as-is, or do you want a tighter parameterized version?
## Test plan
- [x] `pytest tests/test_sat_common.py tests/test_sat_positions.py` — **28 new tests, all pass**.
- [x] `pytest tests/test_satpass_predict.py` — **44/44 pass** (regression guard: existing tests work after the sat_common extraction).
- [x] `pytest tests/test_events_feed_frontend.py` — **119/119 pass** (JSON-feed coverage extended to include sat_positions sample event + expected subject string).
- [x] `pytest tests/test_telemetry_separation.py` — **9/9 pass** (`_TELEMETRY` pin extended to include `sat_positions`).
- [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (CONSUMER-INTEGRATION.md `### sat_positions` subsection added).
- [x] `pytest tests/test_producer_doc.py` — **10/10 pass** (no PRODUCER-INTEGRATION update needed; CENTRAL_SAT stream is pre-existing).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1209 passed, 1 skipped, 0 failures**.
- [x] Ruff: clean on all new code. 3 pre-existing F841 unused-variable warnings (supervisor.py:390 `poll_start`, test_events_feed_frontend.py:425 / :466 `result`) confirmed via `git blame` to be from commits May 2026 — not introduced.
## Deploy plan
1. Squash-merge → tag v0.12.0 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep).
3. **`central-migrate`** to apply migration 039 (seeds `config.adapters` row + bumps `config.streams.max_bytes` for CENTRAL_SAT).
4. `sudo systemctl restart central-supervisor` (picks up STREAM_CATEGORY_DOMAINS extension + new adapter discovery).
5. `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change).
6. **No** `central-archive` restart (CENTRAL_SAT stream already exists; no new stream).
7. Verify: `nats stream info CENTRAL_SAT` shows max_bytes=5368709120; supervisor journal shows sat_positions discovered.
8. Smoke-test: enable celestrak_tle first if not already, wait for one poll, then enable sat_positions via GUI. Within 60s expect one `central.sat.position.<norad_id>` event per tracked sat on the stream.
## Halt acknowledgment
Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 15:23:32 -06:00
|
|
|
"sat_positions": "ISS (ZARYA) at 43.6°N 116.2°W, alt 408km, 7.7km/s",
|
v0.12.1: n2yo_visualpasses adapter (server-side visible-pass alerts)
## Architectural placement — complement, not replacement
| | satpass_predict (v0.11.1) | **n2yo_visualpasses (this PR)** |
|---|---|---|
| Computes from | Raw TLEs via local SGP4 | n2yo's pre-computed visualpasses endpoint |
| Magnitude data? | ✗ (SGP4 alone can't compute illumination) | ✓ (server-side sun-geometry) |
| Sun illumination filter? | ✗ | ✓ (n2yo returns sunlit passes only) |
| Cost per (observer, sat) pair | Local compute, free | One n2yo API transaction |
| Failure mode | TLE drift over time | Quota exhaustion, vendor outage |
Both adapters serve the same operator question ("when is sat X overhead at site Y?") but with different data sources. Matt's stated use case is to have **both** running so a vendor outage or quota burn on n2yo doesn't blind him to passes that satpass_predict can still propagate locally.
## Subject collision is intentional
Both adapters emit on `central.sat.pass.us.<state_lower>.<observer_slug>`. A consumer subscribing to e.g. `central.sat.pass.us.id.boise` receives events from **both** adapters. Disambiguation lives in `data.category`:
- `pass.satpass_predict` → local SGP4
- `pass.n2yo_visualpasses` → n2yo API
The v0.10.8 category-discriminated `Nats-Msg-Id` keeps both adapters' JetStream dedup windows separate even when they emit for the same (observer, satellite, AOS) tuple (which they will, by design, for sunlit passes).
This is documented explicitly in the new `### n2yo_visualpasses` subsection of `docs/CONSUMER-INTEGRATION.md` so future consumer integrators don't get surprised.
## Quota math
Default settings ship a curated **6 observers × 6 sats** configuration:
- **Observers** (ID + UT): Filer (primary), Boise, Idaho Falls, Ogden, Salt Lake City, Provo
- **Satellites** (curated for amateur observation): ISS (25544), NOAA-15 (25338), NOAA-18 (28654), NOAA-19 (33591), SO-50 (27607), AO-91 (43017)
At 1h cadence: **6 × 6 × 24 = 864 transactions/day**, comfortably under n2yo's free-tier **1000/day cap** with ~13% headroom for retries or expansion. Operator can extend either dimension if they upgrade quota.
## API key plumbing (tomtom_flow pattern)
Exact mirror of the v0.9.3 tomtom_flow precedent — confirmed during recon to be the established pattern:
```python
requires_api_key = "n2yo" # class attr, GUI surfaces "requires X" warning
api_key_field = "api_key_alias" # class attr, GUI renders api_key_select dropdown
# Settings field:
api_key_alias: str = "n2yo"
```
Cached `_api_key` populated via `ConfigStore.get_api_key(alias)` in `startup()` and `apply_config()`. Missing-key path: log INFO, return immediately (zero events, no exception). The live key is scrubbed from log strings via a `_redact()` helper before they hit journald.
**`python -m set_api_key` does not exist** — that was a speculative invocation in the spec. The actual flow is GUI-based: Matt adds the `n2yo` alias via the `/api-keys` page, then enables the adapter via `/adapters/n2yo_visualpasses/edit`.
## Diff size — flag for review
**+848 / −1 = +847 net** across 8 files. Spec budget was ≤600 lines. **Over by ~247** (~41%, similar shape to v0.12.0's overage).
| File | Lines | Notes |
|---|---|---|
| `src/central/adapters/n2yo_visualpasses.py` | 330 | **Under** the ≤350 adapter cap ✓ |
| `tests/test_n2yo_visualpasses.py` | 411 | The bulk of the overage |
| `sql/migrations/040_add_n2yo_visualpasses_adapter.sql` | 45 | Heavy comment block; could trim ~15 lines |
| `docs/CONSUMER-INTEGRATION.md` | 40 | Required by `test_consumer_doc` |
| Partials (event_rows + event_summaries) | 13 | |
| `tests/test_events_feed_frontend.py` | 8 | _SAMPLE_INNER + _EXPECTED_SUBJECT |
| `src/central/gui/routes.py` | 1 | ADAPTER_GROUPS extension |
**Test breakdown** (31 tests in 8 classes):
- 9 severity-bucketing tests — spec called out 4 boundaries (-3.1, -2.9, -0.5, 2.5); the extra 5 pin inclusive-vs-exclusive at -3.0, -1.0, 2.0 boundaries + the ranges in between. Useful regression guards but not strictly spec-required.
- 4 settings-default tests — pin the curated 6×6 set + quota math.
- 4 adapter-class-attrs tests — pin requires_api_key/api_key_field/data_class/default_cadence_s wiring.
- 3 subject_for tests — happy path + UT-state lowercasing + unknown fallback.
- 1 _pass_to_event shape test.
- 7 poll-loop tests — missing key, empty observers, empty norad_ids, happy path, empty passes array, fetch-failure-doesn't-kill-poll, multi-obs-multi-sat 6×6 aggregate.
- 1 HTTP-layer test — 401 → None (the one test that goes through the real session.get mock).
- 2 static-isolation tests — acceptance bar #2 (no hardcoded keys) and #4 (no absolute paths).
I can trim the test file to ~250 lines by dropping the non-strictly-spec-mandated tests (settings defaults, class attrs, extra severity boundaries, extra subject_for variants). **Flag for your call:** keep the comprehensive suite, or trim to spec minimum?
## Test plan
- [x] `pytest tests/test_n2yo_visualpasses.py` — **31/31 pass** (all offline, zero n2yo API hits).
- [x] `pytest tests/test_events_feed_frontend.py` — **122/122 pass** (fixture coverage extended).
- [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (new `### n2yo_visualpasses` subsection accepted).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files) — **1243 passed, 1 skipped, 0 failures**.
- [x] Ruff: **clean on new files** (`n2yo_visualpasses.py`, `test_n2yo_visualpasses.py`). The pre-existing F841 warnings in routes.py / test_events_feed_frontend.py / supervisor.py are unchanged from v0.11.3-pre.
- [x] **No hardcoded API key in diff** — `git diff main..HEAD | grep -iE 'apiKey=[A-Z0-9]{6,}|api_key.*=.*"[A-Z0-9]{6,}'` returns empty.
- [x] **No absolute paths in test code** — `TestStaticIsolation` enforces this at runtime.
## Deploy plan
1. Squash-merge PR #N → tag v0.12.1 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (aiohttp already in venv from earlier adapters).
3. **Matt adds the n2yo API key via GUI `/api-keys` page** (Add → alias `n2yo` → paste key). Do this **before** enabling the adapter — missing-key path is graceful but the adapter logs INFO and skips polling until the key lands.
4. Apply migration 040 manually via psql (per option C established pattern):
`sudo -u postgres psql central -f /opt/central/sql/migrations/040_add_n2yo_visualpasses_adapter.sql`
**Do NOT** run `central-migrate` — orphan migrations 032-039 stay deferred for the morning queue.
5. `sudo systemctl restart central-supervisor` (picks up the new adapter via discovery) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change).
6. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the adapter row is new).
7. Verify: `config.adapters` has `n2yo_visualpasses` row with `enabled=false`; `config.api_keys` has alias `n2yo`; supervisor log shows the adapter discovered but not polling (matches `enabled=false`).
8. Matt enables via `/adapters/n2yo_visualpasses/edit` when ready. First poll happens within 1h; events surface at `/events` filtered by adapter=n2yo_visualpasses.
## Halt acknowledgment
Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 16:00:55 -06:00
|
|
|
"n2yo_visualpasses": "ISS (ZARYA) visible pass at 21:14 UTC — mag -3.4, peak 47°",
|
v0.13.0: sat_orbits adapter (forward-orbit-track per satellite) + antimeridian splitter
## Matt's "each sat's path" framing
After enabling the satellite family in v0.12.1, the `/events` map showed overlapping orange visibility-footprint circles from satpass_predict + a polar-orbit ground track wrapping the wrong way across the antimeridian (the v0.11.2 documented limitation). Matt's ask:
> honestly i just want each sats path.
Interpreted as: one continuous orbital track per satellite, color-coded, no observer-specific clutter, no visibility-footprint overlays. Six tracked sats = six distinguishable lines on the map.
## Family placement — global line counterpart to global points
| Adapter | What it publishes | Geometry | Cadence |
|---|---|---|---|
| satpass_predict (v0.11.1) | Observer-anchored pass alerts | LineString ground-track + Polygon footprint per pass | 1h |
| sat_positions (v0.12.0) | Current sub-sat POINT per sat | Point centroid only | 60s |
| **sat_orbits (this PR)** | Forward-orbit LINE per sat | LineString / MultiLineString, 90min horizon | 5min |
Each answers a different question; they complement.
## Antimeridian splitter — shared sat_common primitive
`split_antimeridian(coords)` lives in `sat_common.py` next to `gmst_rad` / `eci_to_ecef` / `subsatellite_point`. Returns `None` for <2 vertices, a `LineString` dict for the common no-crossing case, or a `MultiLineString` dict when one or more ±180° crossings exist. Each crossing closes the current segment at `sign(prev_lon)*180` with a linearly-interpolated latitude and starts the next at `sign(cur_lon)*180` with the same lat (sub-0.1° error at LEO orbital speeds, well below Leaflet rendering precision).
**Sibling concern fixed:** `satpass_predict._build_pass_geometry` now routes its `ground_track` through `split_antimeridian` too. This was the v0.11.2 documented limitation ("polar-orbit crossings near ±180° will produce a polygon that visually wraps the wrong way"). Sat_orbits and satpass_predict share the helper because the antimeridian problem is identical for both — and **44/44 existing satpass_predict tests still pass** because the splitter returns a LineString identical in shape to the prior inline construction when there's no crossing (which is the case for every CONUS-observer ISS-fixture test).
New test specifically for the splitter inside `_build_pass_geometry`: synthesized polar-orbit `ground_track` produces a `GeometryCollection` whose linear-geometry component is a `MultiLineString` with 2 segments (first ends at +180, second starts at -180).
## GUI per-NORAD-ID color helper
20-line addition to `events_list.html`:
```js
function orbitColorForNoradId(norad) {
var hue = (norad * 137.508) % 360; // golden-angle hue distribution
return "hsl(" + hue.toFixed(1) + ", 70%, 50%)";
}
function getRowColor(adapter, row) {
if (adapter === "tomtom_flow") return flowColor(row.dataset.severity);
if (adapter === "sat_orbits") {
var norad = parseInt((row.dataset.eventId || "").split(":")[0], 10);
if (!isNaN(norad)) return orbitColorForNoradId(norad);
}
return getAdapterColor(adapter);
}
```
`event_id` shape is `<norad_id>:<iso>` (same as sat_positions), so JS reads the first colon-token. **Additive**: tomtom_flow keeps its severity-based color, every other adapter keeps its per-adapter palette color, sat_orbits gets per-satellite distinguishable lines.
## Phase A sanity (per spec)
```
vertices = 91 ✓ (90min @ 60s + 1 endpoint)
first vertex = (170.66°, -17.15°, 417.4km) ✓ matches v0.11.1 ISS pin
last vertex = (140.52°, -8.60°, 415.9km) ✓ geographically distinct
antimeridian crossings in 90min track = 1
geometry type = MultiLineString, 2 segments ✓ splitter integrates
```
## Diff size
**+838 / −9 = +829 net** across 15 files. Spec budget was ≤800 lines. **29 over** — much tighter than v0.12.0 (894) or v0.12.1 (848). Adapter LoC 275 (well under 350 cap). sat_common splitter 51 LoC (~budget).
Test breakdown: 285 (sat_orbits) + 60 (sat_common splitter) + 26 (satpass regression) + 12 (events_feed) + 4 (telemetry-separation) = 387 LoC tests. Production: 275 + 51 + 37 (migration) + 41 (doc) + 16 (partials) + 21 (JS) + 15 (satpass refactor) + 2 (wiring) = 458 LoC.
## Test plan
- [x] `pytest tests/test_sat_orbits.py` — 19 new tests, all pass.
- [x] `pytest tests/test_sat_common.py` — 7 new splitter tests, 16 total pass.
- [x] `pytest tests/test_satpass_predict.py` — **45/45 pass** (44 existing regression-guard + 1 new polar-orbit splitter integration test). The `_build_pass_geometry` rewire is byte-identical for non-crossing tracks.
- [x] `pytest tests/test_events_feed_frontend.py` — 125/125 pass (sat_orbits sample + expected subject extended).
- [x] `pytest tests/test_telemetry_separation.py` — 9/9 pass (`_TELEMETRY` pin extended with `sat_orbits`).
- [x] `pytest tests/test_consumer_doc.py` — 6/6 pass (new `### sat_orbits` subsection accepted).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1274 passed, 1 skipped, 0 failures**.
- [x] Ruff: clean on all new + touched satellite-family code.
## Deploy plan
1. Squash-merge PR #N → tag v0.13.0 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep).
3. Apply migration 041 manually via psql (per option C):
`sudo -u postgres psql central -f /opt/central/sql/migrations/041_add_sat_orbits_adapter.sql`
4. `sudo systemctl restart central-supervisor` (picks up new adapter + STREAM_CATEGORY_DOMAINS extension) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS extension + JS color helper).
5. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the category-domain tuple grew, archive already covers `central.sat.>`).
6. Verify: `config.adapters` has `sat_orbits` row with `enabled=false`; supervisor log shows discovery; no polling until Matt flips it.
7. Matt enables via `/adapters/sat_orbits/edit` when ready. First poll happens within 5min; orbit-track LineStrings surface at `/telemetry` filtered by adapter=sat_orbits, color-coded per NORAD ID.
## Halt acknowledgment
Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 18:50:47 -06:00
|
|
|
"sat_orbits": "ISS (ZARYA) orbital track — 90min forward from 22:35 UTC",
|
2026-05-21 19:07:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _subject_event(adapter: str, inner: dict) -> dict:
|
|
|
|
|
"""Build a minimal event dict shaped like _fetch_events output."""
|
|
|
|
|
return {"adapter": adapter, "data": {"data": {"data": inner}}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventsJsonSubject:
|
|
|
|
|
"""/events.json `subject` is derived from the inner payload and carries the
|
|
|
|
|
same human text as the GUI's per-adapter Subject cell (feat/events-json-subject).
|
|
|
|
|
|
|
|
|
|
The old `payload->>'subject'` SQL was always null (the CloudEvents envelope
|
|
|
|
|
has no top-level subject). Parameterized over discover_adapters() -- no
|
|
|
|
|
hardcoded adapter list.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_sample_covers_every_registered_adapter(self):
|
|
|
|
|
"""No hardcoded list: samples must track the live registry exactly."""
|
|
|
|
|
assert set(_SAMPLE_INNER) == set(discover_adapters())
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
|
|
|
|
|
def test_subject_non_null_per_adapter(self, adapter):
|
|
|
|
|
"""Every registered adapter derives a non-null subject for a real event."""
|
|
|
|
|
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
|
|
|
|
assert _derive_subject(event) is not None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
|
|
|
|
|
def test_subject_matches_rendered_partial(self, adapter):
|
|
|
|
|
"""Derived subject equals the adapter's own partial (unescaped) -- the
|
|
|
|
|
JSON path and the GUI Subject cell never diverge."""
|
|
|
|
|
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
|
|
|
|
oracle = html.unescape(
|
|
|
|
|
_gui_templates.env.get_template(f"_event_summaries/{adapter}.html").render(event=event)
|
|
|
|
|
).strip()
|
|
|
|
|
assert _derive_subject(event) == oracle
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("adapter", sorted(_EXPECTED_SUBJECT))
|
|
|
|
|
def test_subject_exact_human_text(self, adapter):
|
|
|
|
|
"""Pin the human-readable subject for the deterministic adapters."""
|
|
|
|
|
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
|
|
|
|
assert _derive_subject(event) == _EXPECTED_SUBJECT[adapter]
|
|
|
|
|
|
|
|
|
|
def test_swpc_alerts_prefixes_id_and_truncates_message(self):
|
|
|
|
|
"""swpc_alerts subject prefixes the product id and truncates the body."""
|
|
|
|
|
event = _subject_event("swpc_alerts", _SAMPLE_INNER["swpc_alerts"])
|
|
|
|
|
subject = _derive_subject(event)
|
|
|
|
|
assert subject is not None
|
|
|
|
|
assert subject.startswith("Space weather alert EF3A: ")
|
|
|
|
|
assert subject.endswith("...")
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_yields_none(self):
|
|
|
|
|
"""Unknown adapters fall back to _default.html -> no subject."""
|
|
|
|
|
assert _derive_subject(_subject_event("does_not_exist", {"x": 1})) is None
|
|
|
|
|
|
|
|
|
|
def test_missing_source_fields_yields_none(self):
|
|
|
|
|
"""An event lacking its adapter's source fields derives no subject."""
|
|
|
|
|
assert _derive_subject(_subject_event("usgs_quake", {})) is None
|
2026-05-26 22:14:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestShowRemovedToggle:
|
|
|
|
|
"""v0.9.11 — tombstone visibility wiring. filter_state.show_removed drives
|
|
|
|
|
the 'Show removed' checkbox in events_list.html; defaults to hidden."""
|
|
|
|
|
|
|
|
|
|
def _pool(self):
|
|
|
|
|
conn = AsyncMock()
|
|
|
|
|
conn.fetch.return_value = []
|
|
|
|
|
conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://t/{z}/{x}/{y}.png", "map_attribution": "OSM"}
|
|
|
|
|
pool = MagicMock()
|
|
|
|
|
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
|
|
|
|
|
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
return pool
|
|
|
|
|
|
|
|
|
|
async def _filter_state(self, query_params):
|
|
|
|
|
req = MagicMock()
|
|
|
|
|
req.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
req.state.csrf_token = "t"
|
|
|
|
|
req.query_params = query_params
|
|
|
|
|
templates = MagicMock()
|
|
|
|
|
templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=self._pool()):
|
|
|
|
|
await events_list(req)
|
|
|
|
|
return templates.TemplateResponse.call_args.kwargs["context"]["filter_state"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_default_hides_removed(self):
|
|
|
|
|
assert (await self._filter_state({}))["show_removed"] is False
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_show_removed_true_reflected(self):
|
|
|
|
|
assert (await self._filter_state({"show_removed": "true"}))["show_removed"] is True
|