Files
Shadowbroker/backend/routers/health.py
T
BigBodyCobain 53ed63ffcf perf: UX-safe fetch trimming and instant layer-enable refresh.
Drop duplicate slow-tier weather/ukraine jobs, gate correlations when off, slim health probes, keyed layer-panel subscriptions, align backend layer defaults with the dashboard, and fetch CCTV/FIRMS/PSK/etc. synchronously on enable so toggles stay responsive without background prefetch waste.
2026-06-23 00:16:48 -06:00

174 lines
5.7 KiB
Python

import time as _time_mod
from fastapi import APIRouter, Request, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from limiter import limiter
from auth import require_admin
from services.schemas import HealthResponse
import os
# Health/SLO probes only need counts + freshness — not a full dashboard deepcopy.
_HEALTH_DATA_KEYS: tuple[str, ...] = (
"last_updated",
"commercial_flights",
"military_flights",
"private_jets",
"ships",
"satellites",
"earthquakes",
"cctv",
"news",
"uavs",
"firms_fires",
"liveuamap",
"gdelt",
"uap_sightings",
"wastewater",
"fimi",
"space_weather",
"weather_alerts",
"volcanoes",
"prediction_markets",
)
def _health_data_snapshot() -> dict:
from services.fetchers._store import get_latest_data_subset_refs
from services.slo import SLO_REGISTRY
keys = tuple(dict.fromkeys((*_HEALTH_DATA_KEYS, *SLO_REGISTRY.keys())))
return get_latest_data_subset_refs(*keys)
def _health_row_count(value) -> int:
if value is None:
return 0
try:
return len(value)
except TypeError:
return 0
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.82")
router = APIRouter()
def _get_app_version() -> str:
# Import lazily to avoid circular import; main sets APP_VERSION before including routers
try:
import main as _main
return _main.APP_VERSION
except Exception:
return APP_VERSION
_start_time_ref: dict = {"value": None}
def _get_start_time() -> float:
if _start_time_ref["value"] is None:
try:
import main as _main
_start_time_ref["value"] = _main._start_time
except Exception:
_start_time_ref["value"] = _time_mod.time()
return _start_time_ref["value"]
@router.get("/api/health", response_model=HealthResponse)
@limiter.limit("30/minute")
async def health_check(request: Request):
from services.fetchers._store import get_source_timestamps_snapshot
from services.slo import compute_all_statuses, summarise_statuses
d = _health_data_snapshot()
last = d.get("last_updated")
timestamps = get_source_timestamps_snapshot()
slo_statuses = compute_all_statuses(d, timestamps)
slo_summary = summarise_statuses(slo_statuses)
# Top-level status reflects worst SLO result — "degraded" if any
# yellow, "error" if any red, "ok" otherwise. This is the single
# field an external probe / pager can watch.
top_status = "ok"
if slo_summary.get("red", 0) > 0:
top_status = "error"
elif slo_summary.get("yellow", 0) > 0:
top_status = "degraded"
# Issue #258: surface AIS proxy degraded TLS state so operators can see
# when the SPKI-pinned fallback is in effect. The data plane keeps
# flowing (this is by design — see ais_proxy.js comments) but observers
# who care about MITM-protection posture deserve a visible signal.
#
# Plus connectivity health (added 2026-05-23 when stream.aisstream.io
# went fully offline): ``connected`` tells the frontend whether ship
# data is actually flowing. When false, a banner explains that ships
# are unavailable due to an upstream outage — better than the user
# silently seeing an empty ocean and assuming we broke something.
ais_status: dict = {}
try:
from services.ais_stream import ais_proxy_status
ais_status = ais_proxy_status() or {}
except Exception:
ais_status = {}
if ais_status.get("degraded_tls") and top_status == "ok":
# Don't override a worse top-level status if SLOs already failed,
# but escalate ok -> degraded so the field surfaces in dashboards.
top_status = "degraded"
# AIS_API_KEY not configured is "feature off", not "system broken" —
# so we only escalate when the operator opted into AIS (key set) AND
# the stream is currently offline.
if (
os.environ.get("AIS_API_KEY")
and ais_status.get("connected") is False
and top_status == "ok"
):
top_status = "degraded"
runtime: dict = {}
try:
from services.runtime_profile import get_runtime_profile
from analytics.settings import gt_analytics_status
runtime = {
**get_runtime_profile(),
"gt_analytics": gt_analytics_status(),
}
except Exception:
runtime = {}
return {
"status": top_status,
"version": _get_app_version(),
"last_updated": last,
"sources": {
"flights": _health_row_count(d.get("commercial_flights")),
"military": _health_row_count(d.get("military_flights")),
"ships": _health_row_count(d.get("ships")),
"satellites": _health_row_count(d.get("satellites")),
"earthquakes": _health_row_count(d.get("earthquakes")),
"cctv": _health_row_count(d.get("cctv")),
"news": _health_row_count(d.get("news")),
"uavs": _health_row_count(d.get("uavs")),
"firms_fires": _health_row_count(d.get("firms_fires")),
"liveuamap": _health_row_count(d.get("liveuamap")),
"gdelt": _health_row_count(d.get("gdelt")),
"uap_sightings": _health_row_count(d.get("uap_sightings")),
},
"freshness": timestamps,
"uptime_seconds": round(_time_mod.time() - _get_start_time()),
"slo": slo_statuses,
"slo_summary": slo_summary,
"ais_proxy": ais_status,
"runtime": runtime or None,
}
@router.get("/api/debug-latest", dependencies=[Depends(require_admin)])
@limiter.limit("30/minute")
async def debug_latest_data(request: Request):
from services.fetchers._store import latest_data, _data_lock
with _data_lock:
return list(latest_data.keys())