feat: dashboard — DB-backed live feed, active alerts, band conditions panel (replace env_store dependency)

- /api/env/active: direct DB queries for fires, nws_alerts, quake_events
  instead of env_store.get_active() (which depends on live NATS data)
- /api/env/swpc: reads band_conditions_broadcasts table, returns ratings
  with slot label (Day/Night Propagation) derived from Mountain Time
- Frontend: replace RFPropagationCard (SFI/Kp/R/S/G charts) with
  BandConditionsCard showing 4-band Good/Fair/Poor ratings
- Remove unused recharts dependency from Dashboard.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson (via Claude) 2026-06-10 05:53:55 +00:00
commit 5d8a277fa7
8 changed files with 663 additions and 852 deletions

View file

@ -1,8 +1,14 @@
"""Environmental data API routes."""
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from meshai.persistence import get_db
router = APIRouter(tags=["environment"])
log = logging.getLogger(__name__)
@router.get("/env/status")
@ -21,51 +27,127 @@ async def get_env_status(request: Request):
@router.get("/env/active")
async def get_active_env(request: Request):
"""Get active environmental events with local zone marking."""
env_store = getattr(request.app.state, "env_store", None)
"""Get active environmental events from DB."""
db = get_db()
if not env_store:
return []
rows = []
events = env_store.get_active()
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
# Fires: active (not tombstoned), seen in last 7 days
try:
fire_rows = db.execute(
"SELECT irwin_id AS event_id, 'nifc' AS source, 'wildfire' AS event_type,"
" incident_name AS headline, 'immediate' AS severity,"
" lat, lon, county, state, last_event_at AS fetched_at,"
" last_broadcast_at"
" FROM fires"
" WHERE tombstoned_at IS NULL"
" AND last_event_at > CAST(strftime('%s','now','-7 days') AS INTEGER)"
).fetchall()
rows.extend(fire_rows)
except Exception as e:
log.warning("env/active fires query failed: %s", e)
# Dedup by event_id and add is_local field
seen_ids = set()
# NWS alerts: not expired
try:
nws_rows = db.execute(
"SELECT event_id, 'nws' AS source, alert_type AS event_type,"
" headline, severity, county, state,"
" first_seen_at AS fetched_at, last_broadcast_at"
" FROM nws_alerts"
" WHERE expires_at IS NULL OR expires_at > CAST(strftime('%s','now') AS INTEGER)"
).fetchall()
rows.extend(nws_rows)
except Exception as e:
log.warning("env/active nws query failed: %s", e)
# Earthquakes: last 24h, magnitude >= 2.5
try:
quake_rows = db.execute(
"SELECT event_id, 'usgs' AS source, 'earthquake' AS event_type,"
" ('M' || ROUND(magnitude,1) || ' \u2014 ' || place) AS headline,"
" CASE WHEN magnitude >= 5.0 THEN 'immediate'"
" WHEN magnitude >= 3.5 THEN 'priority'"
" ELSE 'routine' END AS severity,"
" lat, lon, NULL AS county, NULL AS state,"
" occurred_at AS fetched_at, last_broadcast_at"
" FROM quake_events"
" WHERE occurred_at > CAST(strftime('%s','now','-1 day') AS INTEGER)"
" AND magnitude >= 2.5"
).fetchall()
rows.extend(quake_rows)
except Exception as e:
log.warning("env/active quake query failed: %s", e)
# Convert to dicts, add is_local, sort by fetched_at desc
result = []
for event in events:
event_id = event.get("event_id")
if event_id and event_id in seen_ids:
continue
if event_id:
seen_ids.add(event_id)
# Mark as local if event zones overlap with configured mesh zones
event_zones = set(event.get("areas", []))
event["is_local"] = bool(event_zones & mesh_zones)
result.append(event)
for row in rows:
d = dict(row)
d["is_local"] = False
result.append(d)
result.sort(key=lambda e: e.get("fetched_at") or 0, reverse=True)
return result
@router.get("/env/swpc")
async def get_swpc_data(request: Request):
"""Get SWPC space weather data."""
env_store = getattr(request.app.state, "env_store", None)
"""Get band conditions from DB."""
db = get_db()
if not env_store:
try:
row = db.execute(
"SELECT ratings_json, source, sent_at, scheduled_for"
" FROM band_conditions_broadcasts"
" WHERE ratings_json IS NOT NULL"
" AND source != 'skipped_no_data'"
" ORDER BY scheduled_for DESC"
" LIMIT 1"
).fetchone()
except Exception as e:
log.warning("env/swpc band_conditions query failed: %s", e)
return {"enabled": False}
status = env_store.get_swpc_status()
if not status:
if not row:
return {"enabled": False}
try:
ratings = json.loads(row["ratings_json"])
except (json.JSONDecodeError, TypeError):
return {"enabled": False}
# Derive slot_label from scheduled_for hour in Mountain Time
slot_label = _slot_label(row["scheduled_for"])
return {
"enabled": True,
**status,
"ratings": ratings,
"slot_label": slot_label,
"sent_at": row["sent_at"],
"source": row["source"],
}
def _slot_label(epoch: int) -> str:
"""Derive slot label from scheduled_for epoch in Mountain Time."""
from datetime import datetime, timezone
try:
from zoneinfo import ZoneInfo
mt = ZoneInfo("America/Boise")
except ImportError:
# Fallback: UTC offset -6/-7 not critical for label
mt = timezone.utc
dt = datetime.fromtimestamp(epoch, tz=mt)
hour = dt.hour
if 6 <= hour < 14:
return "Day Propagation"
elif 14 <= hour < 22:
return "Day Propagation"
else:
return "Night Propagation"
@router.get("/env/propagation")
async def get_rf_propagation(request: Request):
"""Get combined HF + UHF propagation data for dashboard."""
@ -163,12 +245,6 @@ async def lookup_usgs_site(request: Request, site_id: str):
usgs_adapter = adapters.get("usgs")
if not usgs_adapter:
# No native usgs adapter on the env_store means usgs is either
# disabled or running on a non-native feed_source (central). In
# central-feed mode meshai must NOT make direct upstream API calls;
# that's the AND-model anti-pattern Central's v0.10.2 report
# called out explicitly. Surface this to the UI as a 404 so the
# frontend can switch the form to manual-entry mode.
raise HTTPException(
status_code=404,
detail=("site lookup unavailable in central-feed mode; values "

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BCcZxs_h.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEgceSNC.css">
<script type="module" crossorigin src="/assets/index-DcZj_ef-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eNVU4AZQ.css">
</head>
<body>
<div id="root"></div>