From c918e8d259fb92b0d392005bb9c8cb88205a44d9 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Thu, 21 May 2026 01:48:23 +0000 Subject: [PATCH 1/4] feat(3-L.a): finish enrichment_locations across adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the split PR L (events-tab + map deferred to L-b). Only FIRMS declared enrichment_locations, so apply_enrichment silently bypassed every other adapter. This declares it for all 12. Pre-read finding (resolved per PM): apply_enrichment is a FLAT lookup (event.data.get(lat_path)); FIRMS/usgs_quake already carry top-level latitude/longitude in event.data, but the other point adapters kept coords only in Geo.centroid where the flat path can't reach them. Per PM (option b), the 5 centroid-only adapters now also write top-level latitude/longitude into event.data, mirroring their existing Geo.centroid (lon, lat) — 2-3 lines each, no framework refactor. Geo retained for existing rendering uses. Declarations (verbatim): firms [("latitude","longitude")] (unchanged) usgs_quake [("latitude","longitude")] (already top-level in data) nwis [("latitude","longitude")] + centroid mirror eonet [("latitude","longitude")] + centroid mirror gdacs [("latitude","longitude")] + centroid mirror wfigs_incidents [("latitude","longitude")] + centroid mirror (inline data) inciweb [("latitude","longitude")] + centroid mirror (inline data) wfigs_perimeters [] # polygons, no point nws [] # forecast zones/counties, no point swpc_alerts [] # space weather, no coordinate swpc_kindex [] # space weather, no coordinate swpc_protons [] # space weather, no coordinate Centroid mirror is `latitude = centroid[1]; longitude = centroid[0]` (centroid is GeoJSON (lon, lat)); guarded on centroid presence so coordinate-less events get no lat/lon keys (apply_enrichment then skips them). map_render_kind concept dropped — the existing /events map is already geometry-kind-agnostic (renders any row's data-geometry via L.geoJSON), so it was unnecessary. Events-tab enhancements are PR L-b. Tests (test_enrichment_locations_coverage.py, 6, all registry-derived): - every adapter explicitly declares enrichment_locations in its own class body - declarations are valid list[(str,str)] - point adapters all use the canonical ("latitude","longitude") paths - >=5 point adapters are non-empty (regression guard) - synthetic-event builders prove the keys resolve: usgs_quake._feature_to_event and nwis._build_event (the two adapters with isolated builders; the four inline-build adapters are covered by the post-merge live smoke). Verification: full pytest 552 passed, 1 skipped (was 546; +6). grep subject_for_event/_ADAPTER_REGISTRY and grep 100.64.0./192.168.1. in src empty. Follow-ups (NOT here): consumer-doc per-adapter _enriched.geocoder notes for the newly-enriched adapters belong in L-b's doc pass; live end-to-end smoke runs post-merge (USGS quake + one other) per the acceptance bar. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/central/adapters/eonet.py | 9 ++ src/central/adapters/gdacs.py | 9 ++ src/central/adapters/inciweb.py | 6 ++ src/central/adapters/nwis.py | 9 ++ src/central/adapters/nws.py | 3 + src/central/adapters/swpc_alerts.py | 3 + src/central/adapters/swpc_kindex.py | 3 + src/central/adapters/swpc_protons.py | 3 + src/central/adapters/usgs_quake.py | 3 + src/central/adapters/wfigs_incidents.py | 6 ++ src/central/adapters/wfigs_perimeters.py | 3 + tests/test_enrichment_locations_coverage.py | 110 ++++++++++++++++++++ 12 files changed, 167 insertions(+) create mode 100644 tests/test_enrichment_locations_coverage.py diff --git a/src/central/adapters/eonet.py b/src/central/adapters/eonet.py index ac4d46e..87003c7 100644 --- a/src/central/adapters/eonet.py +++ b/src/central/adapters/eonet.py @@ -148,6 +148,9 @@ class EONETAdapter(SourceAdapter): wizard_order = None default_cadence_s = 1800 + # Event lat/lon mirrored from Geo.centroid into event.data (see poll()). + enrichment_locations = [("latitude", "longitude")] + def __init__( self, config: AdapterConfig, @@ -370,6 +373,12 @@ class EONETAdapter(SourceAdapter): "latest_geometry_date": latest_date_iso or None, } + # Mirror centroid (lon, lat) into top-level data keys for the flat + # enrichment path (see enrichment_locations). + if centroid is not None: + data["latitude"] = centroid[1] + data["longitude"] = centroid[0] + dedup_key = _dedup_key(event_id, latest_date_iso) if self.is_published(dedup_key): diff --git a/src/central/adapters/gdacs.py b/src/central/adapters/gdacs.py index 9ff6d6e..cff6801 100644 --- a/src/central/adapters/gdacs.py +++ b/src/central/adapters/gdacs.py @@ -150,6 +150,9 @@ class GDACSAdapter(SourceAdapter): wizard_order = None default_cadence_s = 600 + # Event lat/lon mirrored from Geo.centroid into event.data (see poll()). + enrichment_locations = [("latitude", "longitude")] + def __init__( self, config: AdapterConfig, @@ -391,6 +394,12 @@ class GDACSAdapter(SourceAdapter): "iscurrent": iscurrent, } + # Mirror centroid (lon, lat) into top-level data keys for the flat + # enrichment path (see enrichment_locations). + if centroid is not None: + data["latitude"] = centroid[1] + data["longitude"] = centroid[0] + if not iscurrent: # Explicit tombstone from upstream. Only emit if we previously observed it. if guid in observed_before: diff --git a/src/central/adapters/inciweb.py b/src/central/adapters/inciweb.py index 2ae0634..853b120 100644 --- a/src/central/adapters/inciweb.py +++ b/src/central/adapters/inciweb.py @@ -171,6 +171,9 @@ class InciWebAdapter(SourceAdapter): wizard_order = None # Ships disabled default_cadence_s = 600 + # Coords parsed from the narrative, mirrored from Geo.centroid into event.data. + enrichment_locations = [("latitude", "longitude")] + def __init__( self, config: AdapterConfig, @@ -461,6 +464,9 @@ class InciWebAdapter(SourceAdapter): "url": item.get("link", ""), "guid": guid, "raw": item, + # Mirror centroid (lon, lat) for the flat enrichment path. + "latitude": centroid[1] if centroid else None, + "longitude": centroid[0] if centroid else None, }, ) diff --git a/src/central/adapters/nwis.py b/src/central/adapters/nwis.py index e2a922d..6948a69 100644 --- a/src/central/adapters/nwis.py +++ b/src/central/adapters/nwis.py @@ -124,6 +124,9 @@ class NWISAdapter(SourceAdapter): wizard_order = None default_cadence_s = 900 + # Site lat/lon mirrored from Geo.centroid into event.data (see _build_event). + enrichment_locations = [("latitude", "longitude")] + def __init__( self, config: AdapterConfig, @@ -372,6 +375,12 @@ class NWISAdapter(SourceAdapter): "last_modified": props.get("last_modified"), } + # Mirror centroid (lon, lat) into top-level data keys so the flat + # enrichment path can reach them (see enrichment_locations). + if centroid is not None: + data["latitude"] = centroid[1] + data["longitude"] = centroid[0] + return Event( id=f"{monitoring_location_id}:{parameter_code}:{time_iso}", adapter=self.name, diff --git a/src/central/adapters/nws.py b/src/central/adapters/nws.py index 8205a1f..e50922e 100644 --- a/src/central/adapters/nws.py +++ b/src/central/adapters/nws.py @@ -212,6 +212,9 @@ class NWSAdapter(SourceAdapter): wizard_order = 1 default_cadence_s = 60 + # Alerts cover forecast zones/counties (polygons), not a single point. + enrichment_locations = [] + def __init__( self, config: AdapterConfig, diff --git a/src/central/adapters/swpc_alerts.py b/src/central/adapters/swpc_alerts.py index 3368824..8bd3317 100644 --- a/src/central/adapters/swpc_alerts.py +++ b/src/central/adapters/swpc_alerts.py @@ -41,6 +41,9 @@ class SWPCAlertsAdapter(SourceAdapter): wizard_order = None default_cadence_s = 300 + # Space weather — no geographic coordinate to enrich. + enrichment_locations = [] + def __init__( self, config: AdapterConfig, diff --git a/src/central/adapters/swpc_kindex.py b/src/central/adapters/swpc_kindex.py index f05bcbb..ab5d29f 100644 --- a/src/central/adapters/swpc_kindex.py +++ b/src/central/adapters/swpc_kindex.py @@ -41,6 +41,9 @@ class SWPCKindexAdapter(SourceAdapter): wizard_order = None default_cadence_s = 600 + # Space weather — no geographic coordinate to enrich. + enrichment_locations = [] + def __init__( self, config: AdapterConfig, diff --git a/src/central/adapters/swpc_protons.py b/src/central/adapters/swpc_protons.py index 1a3876e..7c1d84c 100644 --- a/src/central/adapters/swpc_protons.py +++ b/src/central/adapters/swpc_protons.py @@ -40,6 +40,9 @@ class SWPCProtonsAdapter(SourceAdapter): wizard_order = None default_cadence_s = 600 + # Space weather — no geographic coordinate to enrich. + enrichment_locations = [] + def __init__( self, config: AdapterConfig, diff --git a/src/central/adapters/usgs_quake.py b/src/central/adapters/usgs_quake.py index 63009ee..4fd8911 100644 --- a/src/central/adapters/usgs_quake.py +++ b/src/central/adapters/usgs_quake.py @@ -79,6 +79,9 @@ class USGSQuakeAdapter(SourceAdapter): wizard_order = 3 default_cadence_s = 60 + # Epicenter lat/lon are top-level keys in event.data (see _build_event). + enrichment_locations = [("latitude", "longitude")] + def __init__( self, config: AdapterConfig, diff --git a/src/central/adapters/wfigs_incidents.py b/src/central/adapters/wfigs_incidents.py index 660d1de..be31f20 100644 --- a/src/central/adapters/wfigs_incidents.py +++ b/src/central/adapters/wfigs_incidents.py @@ -60,6 +60,9 @@ class WFIGSIncidentsAdapter(SourceAdapter): wizard_order = None # Not in setup wizard default_cadence_s = 300 + # Incident-point lat/lon mirrored from Geo.centroid into event.data. + enrichment_locations = [("latitude", "longitude")] + def __init__( self, config: AdapterConfig, @@ -326,6 +329,9 @@ class WFIGSIncidentsAdapter(SourceAdapter): "POOState_raw": state_raw, "POOCounty": county, "raw": props, + # Mirror centroid (lon, lat) for the flat enrichment path. + "latitude": centroid[1] if centroid else None, + "longitude": centroid[0] if centroid else None, }, ) diff --git a/src/central/adapters/wfigs_perimeters.py b/src/central/adapters/wfigs_perimeters.py index 669d635..1a1d336 100644 --- a/src/central/adapters/wfigs_perimeters.py +++ b/src/central/adapters/wfigs_perimeters.py @@ -60,6 +60,9 @@ class WFIGSPerimetersAdapter(SourceAdapter): wizard_order = None # Not in setup wizard default_cadence_s = 300 + # Perimeters are polygons, not a single point — no coordinate to enrich. + enrichment_locations = [] + def __init__( self, config: AdapterConfig, diff --git a/tests/test_enrichment_locations_coverage.py b/tests/test_enrichment_locations_coverage.py new file mode 100644 index 0000000..2be4c50 --- /dev/null +++ b/tests/test_enrichment_locations_coverage.py @@ -0,0 +1,110 @@ +"""Coverage tests for adapter enrichment_locations declarations (PR L-a). + +Every adapter must make a conscious enrichment_locations declaration — a +non-empty [(lat_field, lon_field)] for point adapters, or an explicit [] for +adapters with no point coordinate. Registry-derived (iterates +discover_adapters()), no hardcoded adapter lists. + +Plus synthetic-event tests for the two adapters with isolated event builders +(usgs_quake._feature_to_event, nwis._build_event) proving the declared +latitude/longitude paths actually resolve on event.data. The four inline-build +point adapters (eonet, gdacs, wfigs_incidents, inciweb) construct events only +inside poll() and are covered by the live end-to-end smoke instead. +""" + +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +from central.adapter_discovery import discover_adapters +from central.config_models import AdapterConfig + + +def test_every_adapter_explicitly_declares_enrichment_locations(): + """Each adapter declares enrichment_locations in its OWN class body (not + just inheriting the SourceAdapter default) — a conscious choice per adapter.""" + missing = [ + name for name, cls in discover_adapters().items() + if "enrichment_locations" not in cls.__dict__ + ] + assert not missing, f"adapters missing an explicit enrichment_locations: {missing}" + + +def test_enrichment_locations_shape_is_valid(): + """Each declaration is a list of (str, str) tuples (possibly empty).""" + for name, cls in discover_adapters().items(): + locs = cls.enrichment_locations + assert isinstance(locs, list), f"{name}: not a list" + for tup in locs: + assert isinstance(tup, tuple) and len(tup) == 2, f"{name}: bad tuple {tup!r}" + assert all(isinstance(p, str) for p in tup), f"{name}: non-str path {tup!r}" + + +def test_point_adapters_use_canonical_lat_lon_paths(): + """Every point adapter (non-empty declaration) uses the same protocol keys + ('latitude', 'longitude') — the convention FIRMS established.""" + for name, cls in discover_adapters().items(): + if cls.enrichment_locations: + assert cls.enrichment_locations == [("latitude", "longitude")], ( + f"{name} uses non-canonical paths: {cls.enrichment_locations}" + ) + + +def test_at_least_the_known_point_adapters_are_non_empty(): + """Registry-derived sanity: the adapters that carry a point coordinate have + a non-empty declaration. Derived by probing enrichment_locations, not a + hardcoded list — guards against a regression that blanks them all.""" + non_empty = { + name for name, cls in discover_adapters().items() if cls.enrichment_locations + } + # firms is the original; there must be several point adapters now. + assert "firms" in non_empty + assert len(non_empty) >= 5, f"expected several point adapters, got {sorted(non_empty)}" + + +# --- synthetic-event tests for the two isolated builders -------------------- + +def test_usgs_quake_event_exposes_top_level_latlon(): + from central.adapters.usgs_quake import USGSQuakeAdapter + + config = AdapterConfig( + name="usgs_quake", enabled=True, cadence_s=60, + settings={}, updated_at=datetime.now(timezone.utc), + ) + adapter = USGSQuakeAdapter(config, MagicMock(), Path("/tmp/never_used.db")) + feature = { + "type": "Feature", "id": "test_q1", + "properties": {"mag": 2.5, "place": "X", "time": 1715000000000, + "updated": 1715000000000}, + "geometry": {"type": "Point", "coordinates": [-116.2, 43.7, 10.5]}, + } + event = adapter._feature_to_event(feature) + assert event is not None + assert event.data["latitude"] == 43.7 + assert event.data["longitude"] == -116.2 + + +def test_nwis_event_mirrors_centroid_into_data(): + from central.adapters.nwis import NWISAdapter + + config = AdapterConfig( + name="nwis", enabled=True, cadence_s=900, + settings={}, updated_at=datetime.now(timezone.utc), + ) + adapter = NWISAdapter(config, MagicMock(), Path("/tmp/never_used.db")) + feature = { + "geometry": {"type": "Point", "coordinates": [-90.25, 41.78]}, # (lon, lat) + "properties": { + "monitoring_location_id": "USGS-05420500", + "time": "2026-05-20T00:00:00Z", + "value": "123.0", + "unit_of_measure": "ft", + }, + } + event = adapter._build_event(feature, "00060") + assert event is not None + # latitude = centroid[1], longitude = centroid[0]; no axis swap. + assert event.data["latitude"] == 41.78 + assert event.data["longitude"] == -90.25 + # Geo.centroid retained for existing rendering uses. + assert event.geo.centroid == (-90.25, 41.78) From f0c044505f8311626289e8f101f801352d0a82bd Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Thu, 21 May 2026 04:04:25 +0000 Subject: [PATCH 2/4] fix(3-M.b): apply_enrichment always attaches _enriched for declared adapters Coordless events such as removal tombstones with null lat/lon, from adapters that declare enrichment_locations, previously fell off the loop without writing _enriched and carried no geocoder bundle at all, violating the every-event-carries-_enriched design rule. Add a post-loop fallback that resolves the null location to an all-null bundle per enricher. Adapters with no enrichment_locations remain skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/central/supervisor.py | 18 +++++- tests/test_apply_enrichment_coordless.py | 78 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/test_apply_enrichment_coordless.py diff --git a/src/central/supervisor.py b/src/central/supervisor.py index 90913a7..26db1da 100644 --- a/src/central/supervisor.py +++ b/src/central/supervisor.py @@ -76,9 +76,12 @@ async def apply_enrichment( No-op when the adapter declares no enrichment_locations or no enrichers are registered. Uses the first (lat_path, lon_path) tuple that resolves to - a non-null coordinate pair in event.data. Each enricher's result is keyed - by enricher.name. Mutates the data dict in place (Event is frozen, but its - data dict is not — this avoids a model_copy on every published event). + a non-null coordinate pair in event.data. If no declared pair resolves to + coordinates, still attaches an all-null bundle so that every event from an + enriched adapter carries _enriched (consumers get a stable field set). + Each enricher's result is keyed by enricher.name. Mutates the data dict in + place (Event is frozen, but its data dict is not — this avoids a + model_copy on every published event). """ if not enrichment_locations or not enrichers: return @@ -93,6 +96,15 @@ async def apply_enrichment( enriched[enricher.name] = await enricher.enrich(location) event.data["_enriched"] = enriched return + # No declared pair resolved to coordinates. Still attach _enriched: each + # enricher resolves the null location to its own all-null bundle (per the + # never-raise contract), so coordless events (e.g. removal tombstones) + # carry the same shape as enriched ones. + null_location = {"lat": None, "lon": None} + enriched = {} + for enricher in enrichers: + enriched[enricher.name] = await enricher.enrich(null_location) + event.data["_enriched"] = enriched # Stream subject mappings -- derived from the registry; every stream is included # (META too: supervisor must create it in JetStream even though archive skips it). diff --git a/tests/test_apply_enrichment_coordless.py b/tests/test_apply_enrichment_coordless.py new file mode 100644 index 0000000..870485d --- /dev/null +++ b/tests/test_apply_enrichment_coordless.py @@ -0,0 +1,78 @@ +"""Regression tests for apply_enrichment's coordless path. + +Design principle: every event from an adapter that declares enrichment_locations +must carry data["_enriched"] — populated when coordinates resolve, an all-null +bundle when they don't (e.g. removal tombstones with no lat/lon). Adapters that +declare no enrichment_locations are still skipped entirely. +""" + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from central.config_models import EnrichmentConfig +from central.enrichment.cache import EnrichmentCache +from central.enrichment.geocoder import GeocoderEnricher, all_null_bundle +from central.models import Event, Geo +from central.supervisor import apply_enrichment, build_enrichers + + +def _make_event(data: dict[str, Any]) -> Event: + return Event( + id="evt-1", + adapter="usgs_quake", + category="quake.event.test", + time=datetime(2026, 1, 1, tzinfo=timezone.utc), + geo=Geo(), + data=data, + ) + + +class _PopulatingBackend: + """Deterministic backend that resolves any real coords to a fixed place.""" + + async def reverse(self, lat: float, lon: float) -> dict[str, Any]: + return {**all_null_bundle(), "city": "Boise", "state": "ID"} + + +@pytest.mark.asyncio +async def test_coordless_event_with_declared_locations_gets_null_bundle(tmp_path): + """An event whose declared coord paths are all None still gets _enriched.""" + cache = EnrichmentCache(tmp_path / "enrichment_cache.db") + enrichers = build_enrichers(EnrichmentConfig(), cache) + event = _make_event( + {"latitude": None, "longitude": None, "reason": "fallen_off_current_service"} + ) + assert "_enriched" not in event.data + + await apply_enrichment(event, [("latitude", "longitude")], enrichers) + + assert event.data["_enriched"]["geocoder"] == all_null_bundle() + + +@pytest.mark.asyncio +async def test_event_with_coords_still_enriches_normally(tmp_path): + """The coord-bearing path is unchanged: the backend is consulted and its + resolved fields land in the bundle.""" + cache = EnrichmentCache(tmp_path / "enrichment_cache.db") + enricher = GeocoderEnricher(_PopulatingBackend(), cache=cache) + event = _make_event({"latitude": 43.0, "longitude": -116.0}) + + await apply_enrichment(event, [("latitude", "longitude")], [enricher]) + + bundle = event.data["_enriched"]["geocoder"] + assert bundle["state"] == "ID" + assert bundle["city"] == "Boise" + + +@pytest.mark.asyncio +async def test_adapter_with_no_enrichment_locations_still_skipped(tmp_path): + """Adapters declaring no enrichment_locations are skipped — no _enriched.""" + cache = EnrichmentCache(tmp_path / "enrichment_cache.db") + enrichers = build_enrichers(EnrichmentConfig(), cache) + event = _make_event({"latitude": 43.0, "longitude": -116.0}) + + await apply_enrichment(event, [], enrichers) + + assert "_enriched" not in event.data From 49d85021e89baa629ffcf148029fd2b1660e90de Mon Sep 17 00:00:00 2001 From: zvx Date: Thu, 21 May 2026 05:45:15 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(L-b):=20operator=20/events=20tab=20pol?= =?UTF-8?q?ish=20=E2=80=94=20registry-derived=20filter,=20all-adapter=20ma?= =?UTF-8?q?p,=20per-adapter=20row=20partials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/central/gui/routes.py | 8 + .../gui/templates/_event_rows/_default.html | 2 + .../gui/templates/_event_rows/eonet.html | 6 + .../gui/templates/_event_rows/firms.html | 6 + .../gui/templates/_event_rows/gdacs.html | 6 + .../gui/templates/_event_rows/inciweb.html | 5 + .../gui/templates/_event_rows/nwis.html | 6 + .../gui/templates/_event_rows/nws.html | 6 + .../templates/_event_rows/swpc_alerts.html | 5 + .../templates/_event_rows/swpc_kindex.html | 6 + .../templates/_event_rows/swpc_protons.html | 6 + .../gui/templates/_event_rows/usgs_quake.html | 6 + .../_event_rows/wfigs_incidents.html | 6 + .../_event_rows/wfigs_perimeters.html | 6 + src/central/gui/templates/_events_rows.html | 5 + src/central/gui/templates/events_list.html | 118 +++++++++---- tests/test_events_feed_frontend.py | 158 ++++++++++++++++++ 17 files changed, 328 insertions(+), 33 deletions(-) create mode 100644 src/central/gui/templates/_event_rows/_default.html create mode 100644 src/central/gui/templates/_event_rows/eonet.html create mode 100644 src/central/gui/templates/_event_rows/firms.html create mode 100644 src/central/gui/templates/_event_rows/gdacs.html create mode 100644 src/central/gui/templates/_event_rows/inciweb.html create mode 100644 src/central/gui/templates/_event_rows/nwis.html create mode 100644 src/central/gui/templates/_event_rows/nws.html create mode 100644 src/central/gui/templates/_event_rows/swpc_alerts.html create mode 100644 src/central/gui/templates/_event_rows/swpc_kindex.html create mode 100644 src/central/gui/templates/_event_rows/swpc_protons.html create mode 100644 src/central/gui/templates/_event_rows/usgs_quake.html create mode 100644 src/central/gui/templates/_event_rows/wfigs_incidents.html create mode 100644 src/central/gui/templates/_event_rows/wfigs_perimeters.html diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index e6bb580..7673e21 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -2962,6 +2962,13 @@ async def events_list(request: Request) -> HTMLResponse: for event in events: event["geometry_summary"] = _geometry_summary(event.get("geometry")) + # Registry-derived adapter list for the filter - - - + {% for a in adapters %} + + {% endfor %}
@@ -163,18 +170,12 @@
+ {% for a in adapters %}
-
- NWS (Weather) -
-
-
- FIRMS (Fire) -
-
-
- USGS (Quake) +
+ {{ a.display_name }}
+ {% endfor %}
@@ -189,17 +190,57 @@ var tileUrl = {{ tile_url | tojson }}; var tileAttr = {{ tile_attribution | tojson }}; - // Adapter color mapping + // Adapter color mapping — built from the registry-derived adapter list and + // the same palette the legend uses, keyed by sorted index. No adapter name + // or color is hardcoded here. + var PALETTE = {{ palette | tojson }}; var ADAPTER_COLORS = { - "nws": "#f59e0b", - "firms": "#dc2626", - "usgs_quake": "#7c3aed" + {% for a in adapters %}{{ a.name | tojson }}: PALETTE[{{ loop.index0 }} % PALETTE.length]{{ "," if not loop.last }} + {% endfor %} }; function getAdapterColor(adapter) { return ADAPTER_COLORS[adapter] || "#3388ff"; } + // Flatten arbitrarily-nested GeoJSON coordinates into a flat [lng, lat] list. + function flattenCoords(coords, out) { + if (coords.length && typeof coords[0] === "number") { + out.push(coords); + return; + } + for (var i = 0; i < coords.length; i++) { + flattenCoords(coords[i], out); + } + } + + // A geometry whose vertices all collapse to a single point (zero extent in + // both dimensions). Enrichment stores some point-like sources as degenerate + // polygons; Leaflet would draw these invisibly, so we plot a marker instead. + function isDegenerate(geom) { + if (!geom || !geom.coordinates) return false; + var pts = []; + flattenCoords(geom.coordinates, pts); + if (pts.length === 0) return false; + var minX = pts[0][0], maxX = pts[0][0], minY = pts[0][1], maxY = pts[0][1]; + for (var i = 1; i < pts.length; i++) { + if (pts[i][0] < minX) minX = pts[i][0]; + if (pts[i][0] > maxX) maxX = pts[i][0]; + if (pts[i][1] < minY) minY = pts[i][1]; + if (pts[i][1] > maxY) maxY = pts[i][1]; + } + return (maxX - minX) < 1e-9 && (maxY - minY) < 1e-9; + } + + // Mean of all vertices, returned as Leaflet [lat, lng]. + function centroidLatLng(geom) { + var pts = []; + flattenCoords(geom.coordinates, pts); + var sx = 0, sy = 0; + for (var i = 0; i < pts.length; i++) { sx += pts[i][0]; sy += pts[i][1]; } + return [sy / pts.length, sx / pts.length]; + } + // Initialize map var map = L.map("events-map").setView([39, -98], 4); @@ -275,23 +316,33 @@ var adapter = row.dataset.adapter || ""; var color = getAdapterColor(adapter); - var layer = L.geoJSON(geom, { - style: { - color: color, - weight: 2, - fillColor: color, - fillOpacity: 0.25 - }, - pointToLayer: function(feature, latlng) { - return L.circleMarker(latlng, { - radius: 8, + var markerStyle = { + radius: 8, + color: color, + weight: 2, + fillColor: color, + fillOpacity: 0.25 + }; + + // Real polygons/lines render as geometry; zero-extent geometries + // (degenerate polygons from enrichment) render as a point marker + // so every non-null geometry is actually visible on the map. + var layer; + if (isDegenerate(geom)) { + layer = L.circleMarker(centroidLatLng(geom), markerStyle); + } else { + layer = L.geoJSON(geom, { + style: { color: color, weight: 2, fillColor: color, fillOpacity: 0.25 - }); - } - }); + }, + pointToLayer: function(feature, latlng) { + return L.circleMarker(latlng, markerStyle); + } + }); + } layer.bindPopup(buildPopup(row)); layer.on("click", function() { @@ -421,10 +472,11 @@ isInitialLoad = false; } - // Re-bind layers after HTMX swap (but do NOT fit bounds) + // Re-bind layers after HTMX swap so the map tracks the current (filtered / + // paginated) result set. Viewport is preserved — we never auto-fit here. document.body.addEventListener("htmx:afterSwap", function(evt) { if (evt.detail.target.id === "events-rows") { - // rebindEventLayers(); // DISABLED: map shows all events, only table filters + rebindEventLayers(); // Do NOT call fitToAllLayers - preserve user viewport } }); diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index 2ec1700..ce13739 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -684,3 +684,161 @@ class TestEventRowDataAttributes: assert context["events"][0]["adapter"] == "usgs_quake" assert context["events"][0]["category"] == "quake.event" assert context["events"][0]["subject"] == "M4.2 Earthquake" + + +# --- PR L-b: operator /events tab polish --------------------------------- + + +def _events_context(events): + """Minimal context for rendering _events_rows.html as a standalone fragment.""" + return { + "events": events, + "next_cursor": None, + "filter_error": None, + "filter_values": { + "adapter": "", "category": "", "since": "", "until": "", + "region_north": "", "region_south": "", "region_east": "", + "region_west": "", "limit": "50", + }, + } + + +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 and map legend. # Sorted by name for stable ordering; index drives the legend color palette. @@ -3022,9 +3046,8 @@ async def events_rows(request: Request) -> HTMLResponse: events = result.events next_cursor = result.next_cursor - # Add geometry summary to each event - for event in events: - event["geometry_summary"] = _geometry_summary(event.get("geometry")) + # Add table-only display fields (time_human, adapter_display, geometry_summary) + _decorate_table_events(events) return templates.TemplateResponse( request=request, diff --git a/src/central/gui/templates/_event_summaries/_default.html b/src/central/gui/templates/_event_summaries/_default.html new file mode 100644 index 0000000..3a32583 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/_default.html @@ -0,0 +1,2 @@ +{# No per-adapter summary; the Subject cell falls back to "—" and the map + popup omits the subject line. #} diff --git a/src/central/gui/templates/_event_summaries/eonet.html b/src/central/gui/templates/_event_summaries/eonet.html new file mode 100644 index 0000000..a11dff4 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/eonet.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('title') %}{{ d.title }}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/firms.html b/src/central/gui/templates/_event_summaries/firms.html new file mode 100644 index 0000000..70c00c2 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/firms.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('frp') is not none or d.get('confidence') %}Fire detected{% if d.get('frp') is not none %} — {{ d.frp }} MW radiative power{% endif %}{% if d.get('confidence') == 'high' %} (high confidence){% endif %}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/gdacs.html b/src/central/gui/templates/_event_summaries/gdacs.html new file mode 100644 index 0000000..6ecd4d8 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/gdacs.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('title') %}{{ d.title }}{% endif %}{% if d.get('alertlevel') %} — {{ d.alertlevel }} alert{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/inciweb.html b/src/central/gui/templates/_event_summaries/inciweb.html new file mode 100644 index 0000000..a11dff4 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/inciweb.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('title') %}{{ d.title }}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/nwis.html b/src/central/gui/templates/_event_summaries/nwis.html new file mode 100644 index 0000000..f628d04 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/nwis.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('value') is not none %}Water reading: {{ d.value }}{% if d.get('unit_of_measure') %} {{ d.unit_of_measure }}{% endif %}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/nws.html b/src/central/gui/templates/_event_summaries/nws.html new file mode 100644 index 0000000..a868718 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/nws.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('event') %}{{ d.event }}{% endif %}{% if d.get('severity') %} — {{ d.severity }}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/swpc_alerts.html b/src/central/gui/templates/_event_summaries/swpc_alerts.html new file mode 100644 index 0000000..e6fb2a9 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/swpc_alerts.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('product_id') or d.get('message') %}Space weather alert{% if d.get('product_id') %} {{ d.product_id }}{% endif %}{% if d.get('message') %}: {{ d.message | replace('\r', ' ') | replace('\n', ' ') | truncate(80) }}{% endif %}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/swpc_kindex.html b/src/central/gui/templates/_event_summaries/swpc_kindex.html new file mode 100644 index 0000000..3cc91a2 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/swpc_kindex.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('Kp') is not none %}Geomagnetic activity (Kp index): {{ d.Kp }}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/swpc_protons.html b/src/central/gui/templates/_event_summaries/swpc_protons.html new file mode 100644 index 0000000..a00e6d1 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/swpc_protons.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('flux') is not none %}Solar proton flux: {{ d.flux | round(2) }} pfu{% if d.get('energy') %} at {{ d.energy }}{% endif %}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/usgs_quake.html b/src/central/gui/templates/_event_summaries/usgs_quake.html new file mode 100644 index 0000000..71b347d --- /dev/null +++ b/src/central/gui/templates/_event_summaries/usgs_quake.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('magnitude') is not none %}Magnitude {{ d.magnitude | round(1) }}{% if d.get('place') %} — {{ d.place }}{% endif %}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/wfigs_incidents.html b/src/central/gui/templates/_event_summaries/wfigs_incidents.html new file mode 100644 index 0000000..cfd1451 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/wfigs_incidents.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('county') or d.get('state') %}Wildfire incident — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%} diff --git a/src/central/gui/templates/_event_summaries/wfigs_perimeters.html b/src/central/gui/templates/_event_summaries/wfigs_perimeters.html new file mode 100644 index 0000000..d31b9c2 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/wfigs_perimeters.html @@ -0,0 +1,2 @@ +{% set d = (event.data.get('data') or {}).get('data') or {} %} +{%- if d.get('county') or d.get('state') %}Wildfire perimeter — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%} diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html index ce78c60..a1eb264 100644 --- a/src/central/gui/templates/_events_rows.html +++ b/src/central/gui/templates/_events_rows.html @@ -10,30 +10,40 @@ Time - Adapter - Category - Geometry + Location Subject + Adapter {% for event in events %} + {# Per-adapter one-line summary, dispatched by adapter name with a generic + fallback (no hardcoded list). Captured once so it serves both the + Subject cell and the map popup (via data-subject). #} + {% set subject_summary %}{% include ["_event_summaries/" ~ event.adapter ~ ".html", "_event_summaries/_default.html"] %}{% endset %} + {# Location: generic _enriched.geocoder reader, then top-level named + fields, then coordinates. No adapter-specific logic. #} + {% set d = (event.data.get('data') or {}).get('data') or {} %} + {% set gc = (d.get('_enriched') or {}).get('geocoder') or {} %} + {% set loc_local = gc.get('city') or d.get('city') or gc.get('county') or d.get('county') %} + {% set loc_state = gc.get('state') or d.get('state') %} + {% set loc_country = gc.get('country') or d.get('country') %} + {% set loc_parts = [loc_local, loc_state, loc_country] | select | list %} - {{ event.time }} - {{ event.adapter }} - {{ event.category }} - {{ event.geometry_summary }} - {{ event.subject or '—' }} + {{ event.time_human }} + {% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %} + {{ subject_summary | trim or '—' }} + {{ event.adapter_display }} - +
Event ID
{{ event.id }}
diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index da4225c..82734c8 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -465,8 +465,67 @@ // Fit to results button document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers); + // Client-side sort of the displayed rows; state persists across HTMX swaps. + var sortState = { col: null, dir: 1 }; // dir: 1 asc, -1 desc + + function sortKey(row, col) { + // Time sorts on the ISO timestamp for true chronological order. + if (col === 1) return row.dataset.time || ""; + var cell = row.children[col]; + return cell ? cell.textContent.trim().toLowerCase() : ""; + } + + function applySort() { + if (sortState.col === null) return; + var tbody = document.querySelector("#events-rows table.events-table tbody"); + if (!tbody) return; + var pairs = []; + tbody.querySelectorAll("tr.event-row").forEach(function(r) { + pairs.push([r, r.nextElementSibling]); // main row + its detail row + }); + // Array.prototype.sort is stable (ES2019+), so equal keys keep order. + pairs.sort(function(a, b) { + var ka = sortKey(a[0], sortState.col), kb = sortKey(b[0], sortState.col); + if (ka < kb) return -sortState.dir; + if (ka > kb) return sortState.dir; + return 0; + }); + pairs.forEach(function(p) { + tbody.appendChild(p[0]); + if (p[1]) tbody.appendChild(p[1]); + }); + } + + function updateSortIndicators(ths) { + ths.forEach(function(th, idx) { + th.querySelectorAll(".sort-ind").forEach(function(s) { s.remove(); }); + if (idx === sortState.col) { + var s = document.createElement("span"); + s.className = "sort-ind"; + s.textContent = sortState.dir === 1 ? " ▲" : " ▼"; + th.appendChild(s); + } + }); + } + + function bindSortHandlers() { + var ths = document.querySelectorAll("#events-rows table.events-table thead th"); + ths.forEach(function(th, idx) { + if (idx === 0) return; // expand column is not sortable + th.style.cursor = "pointer"; + th.onclick = function() { + if (sortState.col === idx) { sortState.dir *= -1; } + else { sortState.col = idx; sortState.dir = 1; } + updateSortIndicators(ths); + applySort(); + }; + }); + updateSortIndicators(ths); + } + // Initial load - bind layers and fit bounds rebindEventLayers(); // Initial load only + bindSortHandlers(); if (false) { // DISABLED: map never auto-fits fitToAllLayers(); isInitialLoad = false; @@ -477,6 +536,9 @@ document.body.addEventListener("htmx:afterSwap", function(evt) { if (evt.detail.target.id === "events-rows") { rebindEventLayers(); + // Re-bind sort handlers to the new rows and re-apply the active sort. + bindSortHandlers(); + applySort(); // Do NOT call fitToAllLayers - preserve user viewport } }); diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index ce13739..a2b557c 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -842,3 +842,246 @@ class TestMapAllAdapterGeometry: ).read_text() assert "// rebindEventLayers(); // DISABLED" not in src assert "isDegenerate" in src + + +# --- PR L-c: readable Time / Location / Subject / Adapter columns --------- + + +def _first_row_cells(html): + """Visible 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