import { useEffect, useState, useMemo } from 'react' import { fetchHealth, fetchSources, fetchAlerts, fetchEnvStatus, fetchEnvActive, fetchSWPC, fetchDucting, type MeshHealth, type SourceHealth, type Alert, type EnvStatus, type EnvEvent, type SWPCStatus, type DuctingStatus, } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' import { AlertTriangle, AlertCircle, Info, CheckCircle, Radio, Cpu, Activity, MapPin, Zap, Cloud, Flame, Mountain, Droplets, Car, Construction, 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 const tier = health.tier const getColor = (s: number) => { if (s >= 80) return '#22c55e' if (s >= 60) return '#f59e0b' return '#ef4444' } const color = getColor(score) const circumference = 2 * Math.PI * 45 const progress = (score / 100) * circumference return (
{score.toFixed(1)} {tier}
) } function PillarBar({ label, value }: { label: string; value: number }) { const getColor = (v: number) => { if (v >= 80) return 'bg-green-500' if (v >= 60) return 'bg-amber-500' return 'bg-red-500' } return (
{label}
{value.toFixed(1)}
) } function AlertItem({ alert }: { alert: Alert }) { const getSeverityStyles = (severity: string) => { switch (severity.toLowerCase()) { case 'critical': case 'emergency': return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' } case 'warning': return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' } default: return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' } } } const styles = getSeverityStyles(alert.severity) const Icon = styles.icon return (
{alert.message}
{alert.timestamp || 'Just now'}
) } function SourceCard({ source }: { source: SourceHealth }) { const getStatusColor = () => { if (!source.is_loaded) return 'bg-red-500' if (source.last_error) return 'bg-amber-500' return 'bg-green-500' } return (
{source.name}
{source.node_count} nodes · {source.type}
) } function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) { return (
{label}
{value}
{subvalue &&
{subvalue}
}
) } // 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' } return ( {label}{value} ) } // 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 (
{label}
{value?.toFixed(0) ?? '—'}
{unit &&
{unit}
}
) } // 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' } return (
5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'} fill={`url(#${getGradientId()})`} strokeWidth={2} />
48h ago now
) } // 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 (
`${(v/1000).toFixed(1)}k`} />
M-units vs Height (km)
) } // 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 = { 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 = { normal: 'Normal', super_refraction: 'Super Refraction', surface_duct: 'Surface Duct', elevated_duct: 'Elevated Duct', } return ( {labels[condition] || condition} ) } return (

RF Propagation

{/* Top row: SFI and Kp big values */}
{/* R/S/G Scale badges */}
{/* Kp Trend Chart */} {swpc?.kp_history && swpc.kp_history.length > 0 && (
Kp Trend (48h)
)} {/* Divider */}
{/* Tropospheric section */}
Tropospheric {getDuctingBadge(ducting?.condition)}
{ducting?.min_gradient !== undefined && (
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
)} {/* Refractivity profile chart */} {ducting?.profile && ducting.profile.length > 0 && ( )} {/* SWPC Warnings */} {swpc?.active_warnings && swpc.active_warnings.length > 0 && (
SWPC Alerts
{swpc.active_warnings.slice(0, 3).map((w, i) => ( {w.replace('Space Weather Message Code: ', '')} ))}
)}
) } // Source icon mapping const SOURCE_ICONS: Record = { nws: { icon: Cloud, color: 'text-blue-400', label: 'NWS' }, swpc: { icon: Sun, color: 'text-yellow-400', label: 'SWPC' }, ducting: { icon: Radio, color: 'text-cyan-400', label: 'Tropo' }, nifc: { icon: Flame, color: 'text-orange-400', label: 'NIFC' }, firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' }, avalanche: { icon: Mountain, color: 'text-slate-300', label: 'Avy' }, usgs: { icon: Droplets, color: 'text-blue-300', label: 'USGS' }, traffic: { icon: Car, color: 'text-purple-400', label: 'Traffic' }, roads: { icon: Construction, color: 'text-amber-400', label: '511' }, } // Severity badge colors const SEVERITY_COLORS: Record = { info: 'bg-blue-500/20 text-blue-400 border-blue-500/30', advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30', moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30', watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30', warning: 'bg-red-500/20 text-red-400 border-red-500/30', critical: 'bg-red-600/20 text-red-300 border-red-600/30', emergency: 'bg-red-700/20 text-red-200 border-red-700/30', } function EventFeedItem({ event }: { event: EnvEvent }) { const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source } const Icon = sourceConfig.icon const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info // Format timestamp const formatTime = (ts: number) => { const date = new Date(ts * 1000) const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMins = Math.floor(diffMs / 60000) if (diffMins < 1) return 'just now' if (diffMins < 60) return `${diffMins}m ago` if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago` return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } return (
{event.severity || 'info'} {sourceConfig.label} {formatTime(event.fetched_at)}
{event.headline}
) } // Live Event Feed Card function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) { const sortedEvents = useMemo(() => { return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0)) }, [events]) // Calculate feed health summary const feedSummary = useMemo(() => { if (!envStatus?.feeds) return null const total = envStatus.feeds.length const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source) const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0)) const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null return { total, active, errors, secAgo } }, [envStatus]) return (

Live Event Feed

{sortedEvents.length > 0 ? (
{sortedEvents.map((event, i) => ( ))}
) : (
No active events
All clear
)} {/* Feed health summary */} {feedSummary && (
0 ? 'text-amber-400' : 'text-slate-500'}`}> {feedSummary.active} of {feedSummary.total} feeds active {feedSummary.secAgo !== null && ` · Last update ${feedSummary.secAgo}s ago`} {feedSummary.errors.length > 0 && ( · {feedSummary.errors.join(', ')}: error )}
)}
) } export default function Dashboard() { const [health, setHealth] = useState(null) const [sources, setSources] = useState([]) const [alerts, setAlerts] = useState([]) const [envStatus, setEnvStatus] = useState(null) const [envEvents, setEnvEvents] = useState([]) const [swpc, setSwpc] = useState(null) const [ducting, setDucting] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const { lastHealth, lastMessage } = useWebSocket() useEffect(() => { Promise.all([ fetchHealth(), fetchSources(), fetchAlerts(), fetchEnvStatus(), fetchEnvActive().catch(() => []), fetchSWPC().catch(() => null), fetchDucting().catch(() => null), ]) .then(([h, src, a, e, events, sw, duct]) => { setHealth(h) setSources(src) setAlerts(a) setEnvStatus(e) setEnvEvents(events) setSwpc(sw as ExtendedSWPCStatus) setDucting(duct as ExtendedDuctingStatus) setLoading(false) document.title = 'Dashboard — MeshAI' }) .catch((err) => { setError(err.message) setLoading(false) document.title = 'Dashboard — MeshAI' }) }, []) // Update health from WebSocket useEffect(() => { if (lastHealth) { setHealth(lastHealth) } }, [lastHealth]) // Handle WebSocket env_update messages useEffect(() => { if (lastMessage?.type === 'env_update' && lastMessage.event) { setEnvEvents(prev => { // Add new event, dedupe by event_id const newEvent = lastMessage.event as EnvEvent const filtered = prev.filter(e => e.event_id !== newEvent.event_id) return [newEvent, ...filtered].slice(0, 100) // Keep last 100 }) } }, [lastMessage]) if (loading) { return (
Loading...
) } if (error) { return (
Error: {error}
) } return (
{/* Top row: Health + Alerts + Stats */}
{/* Mesh Health */}

Mesh Health

{health && ( <>
)}
{/* Alerts + Stats */}
{/* Active Alerts */}

Active Alerts

{alerts.length > 0 ? (
{alerts.map((alert, i) => ( ))}
) : (
No active alerts
)}
{/* Quick Stats */}
{/* Middle row: Sources + RF Propagation + Live Feed */}
{/* Mesh Sources */}

Mesh Sources ({sources.length})

{sources.length > 0 ? (
{sources.map((source, i) => ( ))}
) : (
No sources configured
)}
{/* RF Propagation */} {/* Live Event Feed */}
) }