feat(api): GET /api/stats HUD counters (counts only, never 500)
This commit is contained in:
parent
47c726d68d
commit
92dd9c5803
3 changed files with 158 additions and 0 deletions
|
|
@ -97,6 +97,11 @@ _MAX_VESSELS = 6000
|
||||||
# Last ADS-B snapshot + WFIGS points for fire↔tanker correlation.
|
# Last ADS-B snapshot + WFIGS points for fire↔tanker correlation.
|
||||||
aircraft_last_known: dict[str, dict] = {}
|
aircraft_last_known: dict[str, dict] = {}
|
||||||
fire_last_known: list[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:
|
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))
|
return transform_amtraker(await _get_json(AMTRAKER_TRAINS))
|
||||||
|
|
||||||
rows = await _ttl_get("amtraker:trains", 20.0, _load)
|
rows = await _ttl_get("amtraker:trains", 20.0, _load)
|
||||||
|
global train_count
|
||||||
|
train_count = len(rows)
|
||||||
if bbox:
|
if bbox:
|
||||||
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
||||||
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
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)
|
logger.warning("NWS alerts fetch failed: %s", exc)
|
||||||
nws_ok = False
|
nws_ok = False
|
||||||
nws_fc = {"features": []}
|
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)
|
sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem)
|
||||||
features = []
|
features = []
|
||||||
for feat in nws_fc.get("features") or []:
|
for feat in nws_fc.get("features") or []:
|
||||||
|
|
|
||||||
62
app/main.py
62
app/main.py
|
|
@ -15,6 +15,7 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -272,6 +273,67 @@ def overlay_json(data, max_age: int) -> JSONResponse:
|
||||||
return resp
|
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 ──────────────────────────────────────────────────────────
|
# ── Feed Sources ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/sources", response_model=list[FeedSourceOut])
|
@app.get("/api/sources", response_model=list[FeedSourceOut])
|
||||||
|
|
|
||||||
87
tests/test_api_stats.py
Normal file
87
tests/test_api_stats.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue