Compare commits

...

8 commits

Author SHA1 Message Date
496dd1626f
Merge pull request #48 from zvx-echo6/feat/l-c-events-table-readable
feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
2026-05-21 01:06:00 -06:00
zvx
5d4320bc73 feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:05:20 +00:00
339c980c9a
Merge pull request #47 from zvx-echo6/feat/l-b-events-tab-polish
feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
2026-05-21 00:08:01 -06:00
zvx
49d85021e8 feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:45:15 +00:00
1cf1eabb1c
Merge pull request #46 from zvx-echo6/feature/3-mb-apply-enrichment-coordless
fix(3-M.b): apply_enrichment always attaches _enriched (coordless events)
2026-05-20 22:09:33 -06:00
Matt Johnson
f0c044505f 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) <noreply@anthropic.com>
2026-05-21 04:04:25 +00:00
bb6db03a07
Merge pull request #45 from zvx-echo6/feature/3-la-enrichment-locations-finishing
feat(3-L.a): finish enrichment_locations across adapters
2026-05-20 20:51:34 -06:00
Matt Johnson
c918e8d259 feat(3-L.a): finish enrichment_locations across adapters
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) <noreply@anthropic.com>
2026-05-21 01:48:23 +00:00
44 changed files with 969 additions and 53 deletions

View file

@ -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):

View file

@ -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:

View file

@ -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,
},
)

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,
},
)

View file

@ -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,

View file

@ -4,7 +4,7 @@ import base64
import json
import logging
import re
from datetime import datetime
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger("central.gui.routes")
@ -2870,6 +2870,31 @@ def _geometry_summary(geometry: dict | None) -> str:
return geom_type
def _format_event_time(iso: str | None) -> str:
"""Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds)."""
if not iso:
return ""
try:
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
except (ValueError, TypeError):
return iso
return dt.strftime("%m-%d-%Y %H:%M") + " UTC"
def _decorate_table_events(events: list[dict]) -> None:
"""Add display-only fields used by the HTML events table (in place).
These are for the table chrome only and are deliberately NOT added in
_fetch_events, so the /events.json payload is unchanged. adapter_display
is sourced from the registry (display_name), with the bare name as fallback.
"""
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
for event in events:
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
event["time_human"] = _format_event_time(event.get("time"))
event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter"))
@router.get("/events.json")
async def events_json(request: Request):
@ -2958,9 +2983,15 @@ async def events_list(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)
# Registry-derived adapter list for the filter <select> and map legend.
# Sorted by name for stable ordering; index drives the legend color palette.
adapters = [
{"name": cls.name, "display_name": cls.display_name}
for cls in sorted(discover_adapters().values(), key=lambda c: c.name)
]
return templates.TemplateResponse(
request=request,
@ -2974,6 +3005,7 @@ async def events_list(request: Request) -> HTMLResponse:
"filter_error": error,
"tile_url": tile_url,
"tile_attribution": tile_attribution,
"adapters": adapters,
},
)
@ -3014,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,

View file

@ -0,0 +1,2 @@
{# Fallback for adapters without a bespoke partial. No curated fields — the
full payload is shown in the "Data" block rendered by _events_rows.html. #}

View file

@ -0,0 +1,6 @@
{# EONET natural-event tracker. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
{% if d.get('category_id') is not none %}<dt>Category</dt><dd>{{ d.category_id }}</dd>{% endif %}
{% if d.get('magnitudeValue') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitudeValue }} {{ d.get('magnitudeUnit', '') }}</dd>{% endif %}
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NASA FIRMS fire hotspots. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('confidence') is not none %}<dt>Confidence</dt><dd>{{ d.confidence }}</dd>{% endif %}
{% if d.get('frp') is not none %}<dt>FRP (MW)</dt><dd>{{ d.frp }}</dd>{% endif %}
{% if d.get('bright_ti4') is not none %}<dt>Brightness (TI4)</dt><dd>{{ d.bright_ti4 }}</dd>{% endif %}
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}{% if d.get('daynight') is not none %} ({{ d.daynight }}){% endif %}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# GDACS global disaster alerts. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
{% if d.get('eventtype') is not none %}<dt>Event type</dt><dd>{{ d.eventtype }}</dd>{% endif %}
{% if d.get('alertlevel') is not none %}<dt>Alert level</dt><dd>{{ d.alertlevel }}{% if d.get('alertscore') is not none %} ({{ d.alertscore }}){% endif %}</dd>{% endif %}
{% if d.get('country') is not none %}<dt>Country</dt><dd>{{ d.country }}</dd>{% endif %}

View file

@ -0,0 +1,5 @@
{# NIFC InciWeb wildfire narrative. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
{% if d.get('description') is not none %}<dt>Description</dt><dd>{{ d.description | truncate(200) }}</dd>{% endif %}
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# USGS NWIS water observations. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('parameter_code') is not none %}<dt>Parameter</dt><dd>{{ d.parameter_code }}</dd>{% endif %}
{% if d.get('value') is not none %}<dt>Value</dt><dd>{{ d.value }} {{ d.get('unit_of_measure', '') }}</dd>{% endif %}
{% if d.get('monitoring_location_id') is not none %}<dt>Site</dt><dd><code>{{ d.monitoring_location_id }}</code></dd>{% endif %}
{% if d.get('statistic_id') is not none %}<dt>Statistic</dt><dd>{{ d.statistic_id }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NWS weather alerts. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('event') is not none %}<dt>Event</dt><dd>{{ d.event }}</dd>{% endif %}
{% if d.get('severity') is not none %}<dt>Severity</dt><dd>{{ d.severity }}{% if d.get('certainty') is not none %} / {{ d.certainty }}{% endif %}</dd>{% endif %}
{% if d.get('headline') is not none %}<dt>Headline</dt><dd>{{ d.headline }}</dd>{% endif %}
{% if d.get('areaDesc') is not none %}<dt>Area</dt><dd>{{ d.areaDesc | truncate(200) }}</dd>{% endif %}

View file

@ -0,0 +1,5 @@
{# NOAA SWPC space-weather alerts. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('product_id') is not none %}<dt>Product</dt><dd><code>{{ d.product_id }}</code></dd>{% endif %}
{% if d.get('issue_datetime') is not none %}<dt>Issued</dt><dd>{{ d.issue_datetime }}</dd>{% endif %}
{% if d.get('message') is not none %}<dt>Message</dt><dd>{{ d.message | truncate(240) }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NOAA SWPC planetary K-index. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('Kp') is not none %}<dt>Kp</dt><dd>{{ d.Kp }}</dd>{% endif %}
{% if d.get('a_running') is not none %}<dt>A-running</dt><dd>{{ d.a_running }}</dd>{% endif %}
{% if d.get('station_count') is not none %}<dt>Stations</dt><dd>{{ d.station_count }}</dd>{% endif %}
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NOAA SWPC GOES proton flux. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('flux') is not none %}<dt>Flux</dt><dd>{{ d.flux }}</dd>{% endif %}
{% if d.get('energy') is not none %}<dt>Energy</dt><dd>{{ d.energy }}</dd>{% endif %}
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}</dd>{% endif %}
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# USGS earthquakes. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('magnitude') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitude }}{% if d.get('magType') is not none %} {{ d.magType }}{% endif %}</dd>{% endif %}
{% if d.get('place') is not none %}<dt>Place</dt><dd>{{ d.place }}</dd>{% endif %}
{% if d.get('depth') is not none %}<dt>Depth (km)</dt><dd>{{ d.depth }}</dd>{% endif %}
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NIFC WFIGS wildfire incidents. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NIFC WFIGS wildfire perimeters. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}

View file

@ -0,0 +1,2 @@
{# No per-adapter summary; the Subject cell falls back to "—" and the map
popup omits the subject line. #}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -10,30 +10,40 @@
<tr>
<th style="width: 2rem;"></th>
<th>Time</th>
<th>Adapter</th>
<th>Category</th>
<th>Geometry</th>
<th>Location</th>
<th>Subject</th>
<th>Adapter</th>
</tr>
</thead>
<tbody>
{% 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 %}
<tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}"
data-time="{{ event.time }}"
data-subject="{{ event.subject or '' }}"
data-subject="{{ subject_summary | trim }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td>
<td>{{ event.time }}</td>
<td>{{ event.adapter }}</td>
<td>{{ event.category }}</td>
<td>{{ event.geometry_summary }}</td>
<td>{{ event.subject or '—' }}</td>
<td title="{{ event.time }}">{{ event.time_human }}</td>
<td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
<td>{{ subject_summary | trim or '—' }}</td>
<td>{{ event.adapter_display }}</td>
</tr>
<tr class="event-detail" hidden>
<td colspan="6">
<td colspan="5">
<dl class="event-detail-list">
<dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd>
@ -43,6 +53,11 @@
<dt>Regions</dt>
<dd>{{ event.regions | join(", ") }}</dd>
{% endif %}
{# Per-adapter curated fields. Dispatch by adapter name with a
generic fallback; the chosen template is whichever exists
first, so new adapters fall through to _default.html with
no hardcoded adapter list here. #}
{% include ["_event_rows/" ~ event.adapter ~ ".html", "_event_rows/_default.html"] %}
<dt>Data</dt>
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
</dl>

View file

@ -18,7 +18,8 @@
}
.map-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem;
}
.map-legend-item {
@ -105,6 +106,12 @@
{% endblock %}
{% block content %}
{# 12-slot legend/marker palette. Adapters map to colors by their sorted index
(loop.index0 % palette length) — no per-adapter color is hardcoded. #}
{% set palette = [
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488"
] %}
<h1>Events</h1>
{% if filter_error %}
@ -123,9 +130,9 @@
<label for="adapter">Adapter</label>
<select id="adapter" name="adapter">
<option value="">All</option>
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option>
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option>
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option>
{% for a in adapters %}
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
{% endfor %}
</select>
</div>
<div>
@ -163,18 +170,12 @@
<div id="events-map"></div>
<div class="map-controls">
<div class="map-legend">
{% for a in adapters %}
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div>
<span>NWS (Weather)</span>
</div>
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #dc2626;"></div>
<span>FIRMS (Fire)</span>
</div>
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #7c3aed;"></div>
<span>USGS (Quake)</span>
<div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
<span>{{ a.display_name }}</span>
</div>
{% endfor %}
</div>
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
</div>
@ -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() {
@ -414,17 +465,80 @@
// 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;
}
// 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();
// Re-bind sort handlers to the new rows and re-apply the active sort.
bindSortHandlers();
applySort();
// Do NOT call fitToAllLayers - preserve user viewport
}
});

View file

@ -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).

View file

@ -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

View file

@ -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)

View file

@ -684,3 +684,404 @@ 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 <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")
# The list is exactly the registry, sorted by name (stable), no extras.
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
# Each entry carries name + display_name straight from the adapter class.
for cls in registry.values():
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
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
# --- 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:
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
def test_format_basic_utc(self):
from central.gui.routes import _format_event_time
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
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.
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
def test_format_empty_and_none(self):
from central.gui.routes import _format_event_time
assert _format_event_time("") == ""
assert _format_event_time(None) == ""
def test_format_no_seconds_no_offset_suffix(self):
from central.gui.routes import _format_event_time
out = _format_event_time("2026-01-02T03:04:59+00:00")
assert out == "01-02-2026 03:04 UTC"
assert ":59" not in out and "+00" not in out
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)
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
assert cells[4] == self._expected_adapter_display() # Adapter display_name
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)
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
assert cells[4] == self._expected_adapter_display() # Adapter display_name