diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index 0d08e77..1315ff3 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -2236,6 +2236,274 @@ async def api_keys_delete(
return RedirectResponse(url="/api-keys", status_code=302)
+
+
+# --- Events query helper ---
+
+class EventsQueryResult:
+ """Result from events query."""
+ def __init__(self, events: list, next_cursor: str | None, error: str | None = None):
+ self.events = events
+ self.next_cursor = next_cursor
+ self.error = error
+
+
+def _parse_events_params(params) -> tuple[dict | None, str | None]:
+ """
+ Parse and validate events query parameters.
+
+ Returns:
+ (parsed_params, error_message)
+ If error_message is not None, parsed_params is None.
+ """
+ # Parse and validate limit
+ limit_str = params.get("limit", "50")
+ try:
+ limit = int(limit_str)
+ except ValueError:
+ return None, f"Invalid limit value: {limit_str}"
+
+ if limit < 1 or limit > 200:
+ return None, "limit must be between 1 and 200"
+
+ # Parse adapter filter
+ adapter = params.get("adapter")
+ if adapter == "":
+ adapter = None
+
+ # Parse category filter
+ category = params.get("category")
+ if category == "":
+ category = None
+
+ # Parse since/until filters
+ since = None
+ until = None
+
+ since_str = params.get("since")
+ if since_str:
+ try:
+ since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
+ except ValueError:
+ return None, f"Invalid ISO 8601 datetime for since: {since_str}"
+
+ until_str = params.get("until")
+ if until_str:
+ try:
+ until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
+ except ValueError:
+ return None, f"Invalid ISO 8601 datetime for until: {until_str}"
+
+ # Validate since <= until
+ if since and until and since > until:
+ return None, "since must be before or equal to until"
+
+ # Parse region bbox
+ region_north = params.get("region_north")
+ region_south = params.get("region_south")
+ region_east = params.get("region_east")
+ region_west = params.get("region_west")
+
+ # Treat empty strings as None
+ if region_north == "":
+ region_north = None
+ if region_south == "":
+ region_south = None
+ if region_east == "":
+ region_east = None
+ if region_west == "":
+ region_west = None
+
+ region_params = [region_north, region_south, region_east, region_west]
+ region_supplied = [p for p in region_params if p is not None]
+
+ if len(region_supplied) > 0 and len(region_supplied) < 4:
+ return None, "Region filter requires all four parameters: region_north, region_south, region_east, region_west"
+
+ bbox = None
+ if len(region_supplied) == 4:
+ try:
+ bbox = {
+ "north": float(region_north),
+ "south": float(region_south),
+ "east": float(region_east),
+ "west": float(region_west),
+ }
+ except ValueError:
+ return None, "Region parameters must be valid numbers"
+
+ # Parse cursor
+ cursor_time = None
+ cursor_id = None
+ cursor_str = params.get("cursor")
+
+ if cursor_str:
+ try:
+ decoded = base64.b64decode(cursor_str).decode("utf-8")
+ parts = decoded.split("|", 1)
+ if len(parts) != 2:
+ raise ValueError("Invalid cursor format")
+ cursor_time = datetime.fromisoformat(parts[0])
+ cursor_id = parts[1]
+ except Exception:
+ return None, "Invalid cursor"
+
+ return {
+ "limit": limit,
+ "adapter": adapter,
+ "category": category,
+ "since": since,
+ "until": until,
+ "bbox": bbox,
+ "cursor_time": cursor_time,
+ "cursor_id": cursor_id,
+ }, None
+
+
+async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
+ """
+ Fetch events from database using parsed parameters.
+
+ Returns EventsQueryResult with events list, next_cursor, and optional error.
+ """
+ pool = get_pool()
+
+ limit = parsed_params["limit"]
+ adapter = parsed_params["adapter"]
+ category = parsed_params["category"]
+ since = parsed_params["since"]
+ until = parsed_params["until"]
+ bbox = parsed_params["bbox"]
+ cursor_time = parsed_params["cursor_time"]
+ cursor_id = parsed_params["cursor_id"]
+
+ # Build query
+ conditions = []
+ query_params = []
+ param_idx = 1
+
+ if adapter:
+ conditions.append(f"adapter = ${param_idx}")
+ query_params.append(adapter)
+ param_idx += 1
+
+ if category:
+ conditions.append(f"category = ${param_idx}")
+ query_params.append(category)
+ param_idx += 1
+
+ if since:
+ conditions.append(f"time >= ${param_idx}")
+ query_params.append(since)
+ param_idx += 1
+
+ if until:
+ conditions.append(f"time < ${param_idx}")
+ query_params.append(until)
+ param_idx += 1
+
+ if bbox:
+ conditions.append(
+ f"ST_Intersects(geom, ST_MakeEnvelope(${param_idx}, ${param_idx+1}, ${param_idx+2}, ${param_idx+3}, 4326))"
+ )
+ query_params.extend([bbox["west"], bbox["south"], bbox["east"], bbox["north"]])
+ param_idx += 4
+
+ if cursor_time and cursor_id:
+ conditions.append(f"(time, id) < (${param_idx}, ${param_idx+1})")
+ query_params.append(cursor_time)
+ query_params.append(cursor_id)
+ param_idx += 2
+
+ where_clause = ""
+ if conditions:
+ where_clause = "WHERE " + " AND ".join(conditions)
+
+ # Fetch limit+1 to check for next page
+ query = f"""
+ SELECT
+ id,
+ time,
+ received,
+ adapter,
+ category,
+ payload->>'subject' as subject,
+ ST_AsGeoJSON(geom) as geometry,
+ payload as data,
+ regions
+ FROM public.events
+ {where_clause}
+ ORDER BY time DESC, id DESC
+ LIMIT ${param_idx}
+ """
+ query_params.append(limit + 1)
+
+ try:
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(query, *query_params)
+ except Exception as e:
+ logger.error(f"Database error in _fetch_events: {e}")
+ return EventsQueryResult([], None, "Database error")
+
+ # Check if there is a next page
+ has_next = len(rows) > limit
+ if has_next:
+ rows = rows[:limit]
+
+ # Build response
+ events = []
+ for row in rows:
+ geometry = None
+ if row["geometry"]:
+ geometry = json.loads(row["geometry"])
+
+ events.append({
+ "id": row["id"],
+ "time": row["time"].isoformat(),
+ "received": row["received"].isoformat(),
+ "adapter": row["adapter"],
+ "category": row["category"],
+ "subject": row["subject"],
+ "geometry": geometry,
+ "data": dict(row["data"]) if row["data"] else {},
+ "regions": list(row["regions"]) if row["regions"] else [],
+ })
+
+ # Build next_cursor if there are more results
+ next_cursor = None
+ if has_next and events:
+ last_event = rows[-1]
+ cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
+ next_cursor = base64.b64encode(cursor_data.encode("utf-8")).decode("utf-8")
+
+ return EventsQueryResult(events, next_cursor)
+
+
+def _geometry_summary(geometry: dict | None) -> str:
+ """Generate a human-readable summary of a geometry."""
+ if not geometry:
+ return "None"
+
+ geom_type = geometry.get("type", "Unknown")
+
+ if geom_type == "Point":
+ return "Point"
+ elif geom_type == "LineString":
+ coords = geometry.get("coordinates", [])
+ return f"Line ({len(coords)} pts)"
+ elif geom_type == "Polygon":
+ coords = geometry.get("coordinates", [[]])
+ if coords:
+ return f"Polygon ({len(coords[0])} pts)"
+ return "Polygon"
+ elif geom_type == "MultiPolygon":
+ coords = geometry.get("coordinates", [])
+ return f"MultiPolygon ({len(coords)} parts)"
+ else:
+ return geom_type
+
+
+
@router.get("/events.json")
async def events_json(request: Request):
"""
@@ -2468,3 +2736,125 @@ async def events_json(request: Request):
"events": events,
"next_cursor": next_cursor,
})
+
+
+# --- Events feed frontend routes ---
+
+@router.get("/events", response_class=HTMLResponse)
+async def events_list(request: Request) -> HTMLResponse:
+ """Events feed page with filter form, table, and map."""
+ templates = _get_templates()
+ operator = getattr(request.state, "operator", None)
+ csrf_token = getattr(request.state, "csrf_token", "")
+
+ params = request.query_params
+
+ # Parse parameters
+ parsed, error = _parse_events_params(params)
+
+ # Get system settings for map tiles
+ pool = get_pool()
+ async with pool.acquire() as conn:
+ system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system")
+
+ tile_url = system_row["map_tile_url"] if system_row else "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+ tile_attribution = system_row["map_attribution"] if system_row else "OpenStreetMap"
+
+ # Prepare filter values for template
+ filter_values = {
+ "adapter": params.get("adapter", ""),
+ "category": params.get("category", ""),
+ "since": params.get("since", ""),
+ "until": params.get("until", ""),
+ "region_north": params.get("region_north", ""),
+ "region_south": params.get("region_south", ""),
+ "region_east": params.get("region_east", ""),
+ "region_west": params.get("region_west", ""),
+ "limit": params.get("limit", "50"),
+ }
+
+ events = []
+ next_cursor = None
+
+ if error:
+ # Validation error - show error banner but don't fail the page
+ pass
+ else:
+ # Fetch events
+ result = await _fetch_events(parsed)
+ if result.error:
+ error = result.error
+ else:
+ 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"))
+
+ return templates.TemplateResponse(
+ request=request,
+ name="events_list.html",
+ context={
+ "operator": operator,
+ "csrf_token": csrf_token,
+ "events": events,
+ "next_cursor": next_cursor,
+ "filter_values": filter_values,
+ "filter_error": error,
+ "tile_url": tile_url,
+ "tile_attribution": tile_attribution,
+ },
+ )
+
+
+@router.get("/events/rows", response_class=HTMLResponse)
+async def events_rows(request: Request) -> HTMLResponse:
+ """HTMX fragment: events table rows only (no page chrome)."""
+ templates = _get_templates()
+
+ params = request.query_params
+
+ # Parse parameters
+ parsed, error = _parse_events_params(params)
+
+ # Prepare filter values for template
+ filter_values = {
+ "adapter": params.get("adapter", ""),
+ "category": params.get("category", ""),
+ "since": params.get("since", ""),
+ "until": params.get("until", ""),
+ "region_north": params.get("region_north", ""),
+ "region_south": params.get("region_south", ""),
+ "region_east": params.get("region_east", ""),
+ "region_west": params.get("region_west", ""),
+ "limit": params.get("limit", "50"),
+ }
+
+ events = []
+ next_cursor = None
+
+ if error:
+ pass
+ else:
+ result = await _fetch_events(parsed)
+ if result.error:
+ error = result.error
+ else:
+ 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"))
+
+ return templates.TemplateResponse(
+ request=request,
+ name="_events_rows.html",
+ context={
+ "events": events,
+ "next_cursor": next_cursor,
+ "filter_values": filter_values,
+ "filter_error": error,
+ },
+ )
diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html
new file mode 100644
index 0000000..75552e5
--- /dev/null
+++ b/src/central/gui/templates/_events_rows.html
@@ -0,0 +1,50 @@
+{% if filter_error %}
+
+ Filter Error: {{ filter_error }}
+
+{% endif %}
+
+{% if events %}
+
+
+
+ | Time |
+ Adapter |
+ Category |
+ Geometry |
+ Subject |
+
+
+
+ {% for event in events %}
+
+ | {{ event.time }} |
+ {{ event.adapter }} |
+ {{ event.category }} |
+ {{ event.geometry_summary }} |
+ {{ event.subject or '—' }} |
+
+ {% endfor %}
+
+
+
+
+{% else %}
+
+ No events match the filters.
+
+{% endif %}
diff --git a/src/central/gui/templates/base.html b/src/central/gui/templates/base.html
index 0cd7baa..a7a667d 100644
--- a/src/central/gui/templates/base.html
+++ b/src/central/gui/templates/base.html
@@ -17,6 +17,7 @@
{% if operator %}
Dashboard
Adapters
+ Events
Streams
API Keys
{{ operator.username }}
diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html
new file mode 100644
index 0000000..f1fe5be
--- /dev/null
+++ b/src/central/gui/templates/events_list.html
@@ -0,0 +1,378 @@
+{% extends "base.html" %}
+
+{% block title %}Events - Central{% endblock %}
+
+{% block head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+Events
+
+{% if filter_error %}
+
+ Filter Error: {{ filter_error }}
+
+{% endif %}
+
+
+ Filters
+
+
+
+
+
+
+ {% include "_events_rows.html" %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py
new file mode 100644
index 0000000..fcdefa4
--- /dev/null
+++ b/tests/test_events_feed_frontend.py
@@ -0,0 +1,460 @@
+"""Tests for events feed frontend routes."""
+
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from central.gui.routes import events_list, events_rows
+
+
+class TestEventsFeedFrontendUnauthenticated:
+ """Test events feed frontend without authentication."""
+
+ @pytest.mark.asyncio
+ async def test_events_unauthenticated_redirects(self):
+ """GET /events without auth redirects to /login."""
+ # This test verifies the session middleware behavior
+ # In practice, the middleware redirects before the route is called
+ mock_request = MagicMock()
+ mock_request.state.operator = None
+ # The middleware would redirect, verified via integration tests
+
+
+class TestEventsFeedFrontendAuthenticated:
+ """Test events feed frontend with authentication."""
+
+ @pytest.mark.asyncio
+ async def test_events_no_filters_returns_html(self):
+ """GET /events authenticated, no filters returns HTML with events."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {}
+
+ mock_events = [
+ {
+ "id": f"event_{i}",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "adapter": "nws",
+ "category": "Weather Alert",
+ "subject": f"Test Alert {i}",
+ "geometry": '{"type": "Point", "coordinates": [-122.4, 37.8]}' if i % 2 == 0 else None,
+ "data": {},
+ "regions": [],
+ }
+ for i in range(5)
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ call_args = mock_templates.TemplateResponse.call_args
+ context = call_args.kwargs.get("context", call_args[1].get("context"))
+ assert "events" in context
+ assert context["filter_error"] is None
+
+ @pytest.mark.asyncio
+ async def test_events_adapter_filter(self):
+ """GET /events?adapter=nws returns only nws events."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {"adapter": "nws"}
+
+ mock_events = [
+ {
+ "id": "nws_event_1",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "NWS Alert",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["filter_values"]["adapter"] == "nws"
+
+ @pytest.mark.asyncio
+ async def test_events_since_until_filter(self):
+ """GET /events?since=...&until=... filters by time window."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {
+ "since": "2026-05-17T00:00:00",
+ "until": "2026-05-17T12:00:00",
+ }
+
+ mock_events = [
+ {
+ "id": "in_range",
+ "time": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "In Range",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+
+ @pytest.mark.asyncio
+ async def test_events_region_filter(self):
+ """GET /events with full region bbox filters by location."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {
+ "region_north": "49.5",
+ "region_south": "31",
+ "region_east": "-102",
+ "region_west": "-124.5",
+ }
+
+ mock_events = [
+ {
+ "id": "in_bbox",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "In BBox",
+ "geometry": '{"type": "Point", "coordinates": [-120, 40]}',
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+
+ @pytest.mark.asyncio
+ async def test_events_partial_region_shows_error_banner(self):
+ """GET /events with partial region shows error banner, not 400."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {"region_north": "49"}
+
+ mock_conn = AsyncMock()
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ # Should be 200, not 400
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["filter_error"] is not None
+ assert "region" in context["filter_error"].lower()
+ # Events should be empty due to validation error
+ assert context["events"] == []
+
+ @pytest.mark.asyncio
+ async def test_events_with_limit_shows_next_button(self):
+ """GET /events?limit=5 shows Next button when more events exist."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {"limit": "5"}
+
+ # Return 6 events (limit+1) to trigger pagination
+ mock_events = [
+ {
+ "id": f"event_{i}",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": f"Event {i}",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ }
+ for i in range(6)
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["next_cursor"] is not None
+ assert len(context["events"]) == 5 # Should be trimmed to limit
+
+
+class TestEventsRowsFragment:
+ """Test /events/rows HTMX fragment."""
+
+ @pytest.mark.asyncio
+ async def test_events_rows_returns_fragment(self):
+ """GET /events/rows returns table fragment, not full page."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {"limit": "5"}
+
+ mock_events = [
+ {
+ "id": "event_1",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "Event 1",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_rows(mock_request)
+
+ assert result.status_code == 200
+ # Verify it uses the fragment template
+ call_args = mock_templates.TemplateResponse.call_args
+ assert call_args.kwargs.get("name") == "_events_rows.html"
+
+
+class TestGeometrySummary:
+ """Test geometry summary function."""
+
+ def test_geometry_summary_polygon(self):
+ """Polygon geometry shows point count."""
+ from central.gui.routes import _geometry_summary
+
+ geom = {
+ "type": "Polygon",
+ "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]
+ }
+ summary = _geometry_summary(geom)
+ assert "Polygon" in summary
+ assert "5 pts" in summary
+
+ def test_geometry_summary_point(self):
+ """Point geometry shows 'Point'."""
+ from central.gui.routes import _geometry_summary
+
+ geom = {"type": "Point", "coordinates": [-122.4, 37.8]}
+ summary = _geometry_summary(geom)
+ assert summary == "Point"
+
+ def test_geometry_summary_linestring(self):
+ """LineString geometry shows point count."""
+ from central.gui.routes import _geometry_summary
+
+ geom = {
+ "type": "LineString",
+ "coordinates": [[-122, 37], [-121, 38], [-120, 39]]
+ }
+ summary = _geometry_summary(geom)
+ assert "Line" in summary
+ assert "3 pts" in summary
+
+ def test_geometry_summary_none(self):
+ """None geometry shows 'None'."""
+ from central.gui.routes import _geometry_summary
+
+ summary = _geometry_summary(None)
+ assert summary == "None"
+
+
+class TestDataGeometryAttribute:
+ """Test that rows have valid geometry data attributes."""
+
+ @pytest.mark.asyncio
+ async def test_event_with_geometry_has_valid_json(self):
+ """Events with geometry have parseable JSON in data-geometry."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {}
+
+ mock_events = [
+ {
+ "id": "geom_event",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "With Geometry",
+ "geometry": '{"type": "Polygon", "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]}',
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_rows(mock_request)
+
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ event = context["events"][0]
+ # Geometry should be parsed dict, not string
+ assert isinstance(event["geometry"], dict)
+ assert event["geometry"]["type"] == "Polygon"
+
+ @pytest.mark.asyncio
+ async def test_event_without_geometry_has_none(self):
+ """Events without geometry have None for geometry field."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {}
+
+ mock_events = [
+ {
+ "id": "no_geom_event",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "No Geometry",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_rows(mock_request)
+
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ event = context["events"][0]
+ assert event["geometry"] is None