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

@ -179,19 +179,22 @@ export interface GradientEntry {
gradient: number
}
export interface SWPCStatus {
export interface BandConditionsStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
kp_history?: KpHistoryEntry[]
sfi_history?: SfiHistoryEntry[]
ratings?: {
"80-40m"?: string
"30-20m"?: string
"17-15m"?: string
"12-10m"?: string
}
slot_label?: string
sent_at?: number
source?: string
}
// Kept for backward compat references
export type SWPCStatus = BandConditionsStatus
export interface DuctingStatus {
enabled: boolean
condition?: string
@ -307,8 +310,8 @@ export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
export async function fetchSWPC(): Promise<BandConditionsStatus> {
return fetchJson<BandConditionsStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {

View file

@ -6,14 +6,12 @@ import {
fetchEnvStatus,
fetchEnvActive,
fetchSWPC,
fetchDucting,
type MeshHealth,
type SourceHealth,
type Alert,
type EnvStatus,
type EnvEvent,
type SWPCStatus,
type DuctingStatus,
type BandConditionsStatus,
} from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
import {
@ -35,49 +33,9 @@ import {
Satellite,
Sun,
} from 'lucide-react'
import {
AreaChart,
Area,
XAxis,
YAxis,
ResponsiveContainer,
ReferenceLine,
LineChart,
Line,
} from 'recharts'
// Extended types for history data
interface KpHistoryEntry {
time: string
value: number
}
interface ProfileEntry {
level_hPa: number
height_m: number
N: number
M: number
T_C: number
RH: number
}
interface ExtendedSWPCStatus extends SWPCStatus {
kp_history?: KpHistoryEntry[]
sfi_history?: { time: string; value: number }[]
}
interface ExtendedDuctingStatus extends DuctingStatus {
profile?: ProfileEntry[]
gradients?: {
from_level: number
to_level: number
from_height_m: number
to_height_m: number
gradient: number
}[]
assessment?: string
location?: { lat: number; lon: number }
}
function HealthGauge({ health }: { health: MeshHealth }) {
const score = health.score
@ -193,243 +151,88 @@ function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio;
)
}
// Scale badge component for R/S/G
function ScaleBadge({ label, value }: { label: string; value: number }) {
const getColor = () => {
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
return 'bg-red-500/20 text-red-400 border-red-500/50'
// Band Conditions Card
function BandConditionsCard({ bandConditions }: { bandConditions: BandConditionsStatus | null }) {
const getRatingEmoji = (rating?: string) => {
switch (rating) {
case 'Good': return '\ud83d\udfe2' // green circle
case 'Fair': return '\ud83d\udfe1' // yellow circle
case 'Poor': return '\ud83d\udd34' // red circle
default: return '\u2014'
}
}
return (
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
{label}{value}
</span>
)
}
// Large value display for SFI/Kp
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
const color = value !== undefined ? getColor(value) : 'text-slate-400'
return (
<div className="text-center">
<div className="text-xs text-slate-500 mb-1">{label}</div>
<div className={`font-mono text-3xl font-bold ${color}`}>
{value?.toFixed(0) ?? '—'}
</div>
{unit && <div className="text-xs text-slate-500">{unit}</div>}
</div>
)
}
// Kp trend sparkline chart
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
const chartData = useMemo(() => {
if (!history || history.length === 0) return []
// Take last 16 entries (48 hours of 3-hourly data)
return history.slice(-16).map((entry, i) => ({
idx: i,
value: entry.value,
time: entry.time,
}))
}, [history])
if (chartData.length === 0) return null
const maxKp = Math.max(...chartData.map(d => d.value), 5)
const currentKp = chartData[chartData.length - 1]?.value ?? 0
// Gradient color based on max Kp
const getGradientId = () => {
if (maxKp > 5) return 'kpGradientRed'
if (maxKp > 3) return 'kpGradientAmber'
return 'kpGradientGreen'
const getSlotEmoji = (label?: string) => {
if (!label) return ''
return label.includes('Night') ? '\ud83c\udf19' : '\u2600\ufe0f'
}
if (!bandConditions?.enabled || !bandConditions?.ratings) {
return (
<div className="h-20 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
</linearGradient>
</defs>
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
<XAxis dataKey="idx" hide />
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
<Area
type="monotone"
dataKey="value"
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
fill={`url(#${getGradientId()})`}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
<div className="flex justify-between text-xs text-slate-600 px-1">
<span>48h ago</span>
<span>now</span>
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Zap size={14} />
RF Propagation
</h2>
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<div className="text-slate-400">No band conditions data</div>
</div>
</div>
</div>
)
}
// Refractivity profile chart
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
const chartData = useMemo(() => {
if (!profile || profile.length === 0) return []
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
height: p.height_m,
M: p.M,
}))
}, [profile])
if (chartData.length === 0) return null
return (
<div className="h-24 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
<XAxis
dataKey="M"
type="number"
domain={['dataMin - 20', 'dataMax + 20']}
tick={{ fontSize: 10, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#334155' }}
/>
<YAxis
dataKey="height"
type="number"
domain={[0, 'dataMax']}
tick={{ fontSize: 10, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#334155' }}
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
/>
<Line
type="monotone"
dataKey="M"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3, fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
</div>
)
}
// RF Propagation Card
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
const getSfiColor = (v: number) => {
if (v >= 120) return 'text-green-400'
if (v >= 80) return 'text-amber-400'
return 'text-red-400'
}
const getKpColor = (v: number) => {
if (v <= 3) return 'text-green-400'
if (v <= 5) return 'text-amber-400'
return 'text-red-400'
}
const getDuctingBadge = (condition?: string) => {
if (!condition) return null
const styles: Record<string, string> = {
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
}
const labels: Record<string, string> = {
normal: 'Normal',
super_refraction: 'Super Refraction',
surface_duct: 'Surface Duct',
elevated_duct: 'Elevated Duct',
}
return (
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
{labels[condition] || condition}
</span>
)
}
const bands = ['80-40m', '30-20m', '17-15m', '12-10m'] as const
return (
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Zap size={14} />
<span title="R (Radio Blackouts), S (Solar Radiation Storms), G (Geomagnetic Storms) — NOAA SWPC scales. Kp 3 = quiet baseline, Kp >= 5 = aurora visible at mid-latitudes and HF degraded. See Reference → Solar &amp; Geomagnetic.">RF Propagation</span>
RF Propagation
</h2>
{/* Top row: SFI and Kp big values */}
<div className="flex justify-around mb-4">
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
<div className="w-px bg-border" />
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
{/* Slot label */}
<div className="text-center mb-4">
<span className="text-lg">{getSlotEmoji(bandConditions.slot_label)}</span>
<span className="text-sm text-slate-300 ml-2">{bandConditions.slot_label}</span>
</div>
{/* R/S/G Scale badges */}
<div className="flex justify-center gap-2 mb-4">
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
{/* Band conditions header */}
<div className="text-xs text-slate-500 mb-3 flex items-center gap-1">
<span>\ud83d\udce1</span> Band Conditions:
</div>
{/* Kp Trend Chart */}
{swpc?.kp_history && swpc.kp_history.length > 0 && (
<div className="mb-4">
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
<KpTrendChart history={swpc.kp_history} />
</div>
)}
{/* Divider */}
<div className="border-t border-border my-3" />
{/* Tropospheric section */}
<div className="flex items-center gap-2 mb-2">
<Cloud size={14} className="text-slate-400" />
<span className="text-xs text-slate-500">Tropospheric</span>
{getDuctingBadge(ducting?.condition)}
</div>
{ducting?.min_gradient !== undefined && (
<div className="text-xs text-slate-400 font-mono mb-2">
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
</div>
)}
{/* Refractivity profile chart */}
{ducting?.profile && ducting.profile.length > 0 && (
<RefractivityChart profile={ducting.profile} />
)}
{/* SWPC Warnings */}
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
<div className="mt-auto pt-3 border-t border-border">
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
<div className="flex flex-wrap gap-1">
{swpc.active_warnings.slice(0, 3).map((w, i) => (
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
{w.replace('Space Weather Message Code: ', '')}
{/* Band rows */}
<div className="space-y-2">
{bands.map(band => {
const rating = bandConditions.ratings?.[band]
return (
<div key={band} className="flex items-center justify-between px-2 py-1.5 rounded bg-bg-hover">
<span className="text-sm font-mono text-slate-300">{band}</span>
<span className="text-sm">
{getRatingEmoji(rating)} <span className="text-slate-300 ml-1">{rating || '\u2014'}</span>
</span>
))}
</div>
)
})}
</div>
{/* Footer: source and time */}
<div className="mt-auto pt-3 border-t border-border text-xs text-slate-500">
{bandConditions.source && (
<span>{bandConditions.source === 'swpc_local' ? 'SWPC' : 'HamQSL'}</span>
)}
{bandConditions.sent_at && (
<span className="ml-2">
{new Date(bandConditions.sent_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
)
}
@ -613,8 +416,7 @@ export default function Dashboard() {
const [alerts, setAlerts] = useState<Alert[]>([])
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
const [bandConditions, setBandConditions] = useState<BandConditionsStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -628,16 +430,14 @@ export default function Dashboard() {
fetchEnvStatus(),
fetchEnvActive().catch(() => []),
fetchSWPC().catch(() => null),
fetchDucting().catch(() => null),
])
.then(([h, src, a, e, events, sw, duct]) => {
.then(([h, src, a, e, events, bc]) => {
setHealth(h)
setSources(src)
setAlerts(a)
setEnvStatus(e)
setEnvEvents(events)
setSwpc(sw as ExtendedSWPCStatus)
setDucting(duct as ExtendedDuctingStatus)
setBandConditions(bc as BandConditionsStatus)
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
@ -786,7 +586,7 @@ export default function Dashboard() {
</div>
{/* RF Propagation */}
<RFPropagationCard swpc={swpc} ducting={ducting} />
<BandConditionsCard bandConditions={bandConditions} />
{/* Live Event Feed */}
<LiveEventFeed events={envEvents} envStatus={envStatus} />

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>