Compare commits
2 commits
f072a83ca8
...
240b9e70b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 240b9e70b3 | |||
|
|
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.
|
||||
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:
|
||||
|
|
@ -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))
|
||||
|
||||
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)
|
||||
|
|
@ -1125,6 +1132,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 []:
|
||||
|
|
|
|||
62
app/main.py
62
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
|
||||
|
|
@ -273,6 +274,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])
|
||||
|
|
|
|||
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