mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 17:34:44 +02:00
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:
parent
82567c6a90
commit
5d8a277fa7
8 changed files with 663 additions and 852 deletions
|
|
@ -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
475
meshai/dashboard/static/assets/index-DcZj_ef-.js
Normal file
475
meshai/dashboard/static/assets/index-DcZj_ef-.js
Normal file
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-eNVU4AZQ.css
Normal file
1
meshai/dashboard/static/assets/index-eNVU4AZQ.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue