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 (
)
}
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 && (
)}
{/* 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 (
)
}
if (error) {
return (
)
}
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 */}
)
}