diff --git a/app/live_layers.py b/app/live_layers.py index fa632b1..c79714b 100644 --- a/app/live_layers.py +++ b/app/live_layers.py @@ -97,6 +97,11 @@ _MAX_VESSELS = 6000 # Last ADS-B snapshot + WFIGS points for fire↔tanker correlation. aircraft_last_known: dict[str, dict] = {} fire_last_known: list[dict] = [] +# Last-known counts for the cheap GET /api/stats HUD counter. Updated by the +# upstream fetchers so the stats endpoint never does its own network/SQL fan-out +# for these layers; reads are O(1) in-process. +train_count: int = 0 +nws_alert_count: int = 0 def overlay_catalog() -> dict: @@ -966,6 +971,8 @@ async def fetch_trains(bbox: str | None, limit: int = DEFAULT_LIMIT) -> list[dic return transform_amtraker(await _get_json(AMTRAKER_TRAINS)) rows = await _ttl_get("amtraker:trains", 20.0, _load) + global train_count + train_count = len(rows) if bbox: minlon, minlat, maxlon, maxlat = parse_bbox(bbox) return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit) @@ -1119,6 +1126,8 @@ async def fetch_weather_alerts(area: str | None, bbox: str | None) -> dict: logger.warning("NWS alerts fetch failed: %s", exc) nws_ok = False nws_fc = {"features": []} + global nws_alert_count + nws_alert_count = len(nws_fc.get("features") or []) sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem) features = [] for feat in nws_fc.get("features") or []: diff --git a/app/main.py b/app/main.py index 2d12e25..177e1c7 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ import asyncio import json import logging import re +import time from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from decimal import Decimal @@ -272,6 +273,67 @@ def overlay_json(data, max_age: int) -> JSONResponse: return resp +# ── HUD counters ───────────────────────────────────────────────────────── + +# Cheap ~100 B–2 KB counts for the layer rail. Cached in-process so the HUD +# can poll every second without re-hitting SQL or upstream feeds. +_STATS_TTL = 20.0 +_stats_cache: dict[str, tuple[float, dict]] = {} + + +async def _stats_counts() -> dict: + """Fan out to in-memory last-known / cheap SQL counts. Never raises.""" + from live_layers import ( + aircraft_last_known, vessel_last_known, train_count, nws_alert_count, + ) + + counts: dict[str, int | str] = { + "aircraft": len(aircraft_last_known), + "vessels": len(vessel_last_known), + "trains": train_count, + "cameras": 0, + "fires": 0, + "quakes": 0, + "alerts": nws_alert_count, + } + + # SQL counts are best-effort: a down DB or missing table must not 500 the + # rail — the frontend still renders with zeros. + try: + from camera_models import cameras as cam_table + async with async_session() as session: + counts["cameras"] = int( + (await session.execute(select(func.count()).select_from(cam_table))).scalar() or 0 + ) + counts["fires"] = int( + (await session.execute(select(func.count()).select_from(fires))).scalar() or 0 + ) + counts["quakes"] = int( + (await session.execute( + select(func.count()).select_from(events).where( + events.c.source_type == "earthquake" + ) + )).scalar() or 0 + ) + except Exception as exc: # noqa: BLE001 + logger.warning("stats_db_failed", error=str(exc)) + + counts["timestamp"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + return counts + + +@app.get("/api/stats") +async def api_stats(): + """Cheap HUD counters (counts only — no GeoJSON). Cached ~20 s.""" + now = time.monotonic() + cached = _stats_cache.get("stats") + if cached and now - cached[0] < _STATS_TTL: + return cached[1] + payload = await _stats_counts() + _stats_cache["stats"] = (now, payload) + return overlay_json(payload, 15) + + # ── Feed Sources ────────────────────────────────────────────────────────── @app.get("/api/sources", response_model=list[FeedSourceOut]) diff --git a/tests/test_api_stats.py b/tests/test_api_stats.py new file mode 100644 index 0000000..870965d --- /dev/null +++ b/tests/test_api_stats.py @@ -0,0 +1,87 @@ +"""GET /api/stats HUD counter contract (counts only, small, never 500).""" + +from __future__ import annotations + +import asyncio +import re +from datetime import timezone + +import httpx + +from main import app, _stats_counts + +BASE = "http://test" + +EXPECTED_KEYS = ("aircraft", "vessels", "trains", "cameras", + "fires", "quakes", "alerts", "timestamp") + + +async def _get(path: str) -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url=BASE) as client: + return await client.get(path) + + +def test_stats_200_all_keys_present(): + resp = asyncio.run(_get("/api/stats")) + assert resp.status_code == 200 + body = resp.json() + for key in EXPECTED_KEYS: + assert key in body, f"missing key {key}" + assert "max-age" in (resp.headers.get("cache-control") or "").lower() + + +def test_stats_counters_are_ints(): + body = asyncio.run(_get("/api/stats")).json() + for key in EXPECTED_KEYS: + if key == "timestamp": + continue + assert isinstance(body[key], int), f"{key} is not an int: {body[key]!r}" + + +def test_stats_timestamp_is_iso8601_z(): + body = asyncio.run(_get("/api/stats")).json() + ts = body["timestamp"] + # ISO8601 with a trailing Z (we normalize +00:00 -> Z). + assert isinstance(ts, str) and ts.endswith("Z") + assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", ts) + + +def test_stats_payload_is_tiny(): + resp = asyncio.run(_get("/api/stats")) + assert len(resp.content) < 2048, "stats payload must be counts-only, not GeoJSON" + + +def test_stats_counts_reflect_last_known(monkeypatch): + """aircraft/vessels/trains/alerts come from in-memory last-known state.""" + import live_layers + + monkeypatch.setattr(live_layers, "aircraft_last_known", {str(i): {} for i in range(7)}) + monkeypatch.setattr(live_layers, "vessel_last_known", {str(i): {} for i in range(3)}) + monkeypatch.setattr(live_layers, "train_count", 11) + monkeypatch.setattr(live_layers, "nws_alert_count", 5) + + # _stats_counts imports the dicts/counters inside the function from live_layers, + # so monkeypatching the module attributes is what it observes. + from main import _stats_counts as fn + + body = asyncio.run(fn()) + assert body["aircraft"] == 7 + assert body["vessels"] == 3 + assert body["trains"] == 11 + assert body["alerts"] == 5 + + +def test_stats_db_failure_degrades_to_zero(monkeypatch): + """A down DB yields zeros for the SQL-backed counters, never a 500.""" + # Make the session factory raise synchronously so the try/except in + # _stats_counts degrades the SQL counters to zero (no dangling coroutine). + def _raise(*args, **kwargs): + raise RuntimeError("db down") + + monkeypatch.setattr("main.async_session", _raise) + body = asyncio.run(_stats_counts()) + assert body["cameras"] == 0 + assert body["fires"] == 0 + assert body["quakes"] == 0 + assert isinstance(body["timestamp"], str) \ No newline at end of file