Compare commits

..

2 commits

Author SHA1 Message Date
240b9e70b3 Merge pull request 'feat(api): GET /api/stats HUD counters (counts only, never 500)' (#42) from osint-dashboard/t_75d2037f-osint-get-api-stats-hud-counters into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 20s
Reviewed-on: #42
2026-08-31 22:12:55 -04:00
Sirius DevOps
92dd9c5803 feat(api): GET /api/stats HUD counters (counts only, never 500) 2026-08-31 21:41:45 -04:00
3 changed files with 158 additions and 0 deletions

View file

@ -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:
@ -972,6 +977,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)
@ -1125,6 +1132,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 []:

View file

@ -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
@ -273,6 +274,67 @@ def overlay_json(data, max_age: int) -> JSONResponse:
return resp return resp
# ── HUD counters ─────────────────────────────────────────────────────────
# Cheap ~100 B2 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
View 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)