From 49f2838048185d347779aa7f96efbc22d3670637 Mon Sep 17 00:00:00 2001 From: zvx-echo6 Date: Wed, 13 May 2026 19:05:50 -0600 Subject: [PATCH] refactor: simplify severity to 3 levels (routine/priority/immediate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 6-level system (info/advisory/watch/warning/critical/emergency) with 3-level military precedence (routine/priority/immediate) - Every adapter remapped: NWS, NIFC, FIRMS, USGS, SWPC, avalanche, traffic, 511, mesh alerts - is_critical flag removed — severity covers it - Quiet hours: suppress routine only, priority+immediate always deliver - Dashboard: blue/amber/red for routine/priority/immediate - Fix hex node ID parsing in Mesh DM channel (!23261b70 format) --- dashboard-frontend/src/pages/Alerts.tsx | 1135 +++++++------ dashboard-frontend/src/pages/Dashboard.tsx | 1404 +++++++++-------- dashboard-frontend/src/pages/Environment.tsx | 15 +- .../src/pages/Notifications.tsx | 27 +- meshai/alert_engine.py | 49 +- meshai/config.py | 2 +- meshai/env/avalanche.py | 8 +- meshai/env/fires.py | 8 +- meshai/env/firms.py | 730 ++++----- meshai/env/nws.py | 396 ++--- meshai/env/roads511.py | 732 ++++----- meshai/env/swpc.py | 544 +++---- meshai/env/traffic.py | 508 +++--- meshai/env/usgs.py | 906 +++++------ meshai/notifications/categories.py | 69 +- meshai/notifications/channels.py | 13 +- meshai/notifications/router.py | 4 +- 17 files changed, 3285 insertions(+), 3265 deletions(-) diff --git a/dashboard-frontend/src/pages/Alerts.tsx b/dashboard-frontend/src/pages/Alerts.tsx index f4bca9f..b1d5e37 100644 --- a/dashboard-frontend/src/pages/Alerts.tsx +++ b/dashboard-frontend/src/pages/Alerts.tsx @@ -1,572 +1,563 @@ -import { useEffect, useState, useCallback } from 'react' -import { - Bell, - AlertTriangle, - AlertCircle, - - CheckCircle, - Clock, - Filter, - ChevronLeft, - ChevronRight, - Radio, - Zap, - - Cloud, - Wifi, - WifiOff, - Battery, - Users, -} from 'lucide-react' -import { - fetchAlerts, - fetchAlertHistory, - fetchSubscriptions, - type Alert, - type AlertHistoryItem, - type Subscription, -} from '@/lib/api' - -interface Node { - node_num: number - node_id_hex: string - short_name: string - long_name: string -} -import { useWebSocket } from '@/hooks/useWebSocket' - -// Alert type icons mapping -const alertTypeIcons: Record = { - infra_offline: WifiOff, - infra_recovery: Wifi, - battery_warning: Battery, - battery_critical: Battery, - battery_emergency: Battery, - hf_blackout: Zap, - uhf_ducting: Radio, - weather_warning: Cloud, - weather_watch: Cloud, - new_router: Radio, - packet_flood: AlertTriangle, - sustained_high_util: AlertTriangle, - region_blackout: AlertCircle, - default: Bell, -} - -function getAlertIcon(type: string) { - return alertTypeIcons[type] || alertTypeIcons.default -} - -function getSeverityStyles(severity: string) { - switch (severity?.toLowerCase()) { - case 'critical': - case 'emergency': - return { - bg: 'bg-red-500/10', - border: 'border-red-500', - badge: 'bg-red-500/20 text-red-400', - iconColor: 'text-red-500', - } - case 'warning': - return { - bg: 'bg-amber-500/10', - border: 'border-amber-500', - badge: 'bg-amber-500/20 text-amber-400', - iconColor: 'text-amber-500', - } - case 'watch': - return { - bg: 'bg-yellow-500/10', - border: 'border-yellow-500', - badge: 'bg-yellow-500/20 text-yellow-400', - iconColor: 'text-yellow-500', - } - case 'advisory': - case 'info': - default: - return { - bg: 'bg-blue-500/10', - border: 'border-blue-500', - badge: 'bg-blue-500/20 text-blue-400', - iconColor: 'text-blue-500', - } - } -} - -function formatTimeAgo(timestamp: string | number): string { - const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffSec = Math.floor(diffMs / 1000) - const diffMin = Math.floor(diffSec / 60) - const diffHour = Math.floor(diffMin / 60) - const diffDay = Math.floor(diffHour / 24) - - if (diffSec < 60) return 'Just now' - if (diffMin < 60) return `${diffMin}m ago` - if (diffHour < 24) return `${diffHour}h ago` - return `${diffDay}d ago` -} - -function formatDateTime(timestamp: string | number): string { - const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp) - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) -} - -function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s` - if (seconds < 3600) return `${Math.floor(seconds / 60)}m` - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` - return `${Math.floor(seconds / 86400)}d` -} - -// Active Alert Card Component -function ActiveAlertCard({ - alert, - onAcknowledge, -}: { - alert: Alert - onAcknowledge: (alert: Alert) => void -}) { - const styles = getSeverityStyles(alert.severity) - const Icon = getAlertIcon(alert.type) - - return ( -
-
- -
-
- - {alert.severity?.toUpperCase()} - - {alert.type} -
-
{alert.message}
-
- - - {alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'} - - {alert.scope_value && ( - {alert.scope_type}: {alert.scope_value} - )} -
-
- -
-
- ) -} - -// Alert History Table Component -function AlertHistoryTable({ - history, - typeFilter, - severityFilter, - onTypeFilterChange, - onSeverityFilterChange, - page, - totalPages, - onPageChange, -}: { - history: AlertHistoryItem[] - typeFilter: string - severityFilter: string - onTypeFilterChange: (v: string) => void - onSeverityFilterChange: (v: string) => void - page: number - totalPages: number - onPageChange: (p: number) => void -}) { - const alertTypes = [ - 'all', - 'infra_offline', - 'infra_recovery', - 'battery_warning', - 'battery_critical', - 'hf_blackout', - 'uhf_ducting', - 'weather_warning', - 'new_router', - 'packet_flood', - ] - - const severities = ['all', 'critical', 'warning', 'watch', 'info'] - - return ( -
- {/* Filters */} -
-
- - Filter: -
- - -
- - {/* Table */} -
- - - - - - - - - - - - {history.length > 0 ? ( - history.map((item, i) => { - const styles = getSeverityStyles(item.severity) - return ( - - - - - - - - ) - }) - ) : ( - - - - )} - -
TimeTypeSeverityMessageDuration
- {formatDateTime(item.timestamp)} - - {item.type.replace(/_/g, ' ')} - - - {item.severity} - - - {item.message} - - {item.duration ? formatDuration(item.duration) : '-'} -
- No alert history available -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - Page {page} of {totalPages} - -
- - -
-
- )} -
- ) -} - -// Subscription Card Component -function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) { - const resolveNodeName = (userId: string): string => { - const node = nodes.find(n => - n.node_id_hex === userId || - String(n.node_num) === userId || - n.short_name === userId - ) - if (node) { - return node.long_name && node.long_name !== node.short_name - ? `${node.short_name} (${node.long_name})` - : node.short_name - } - return userId - } - const formatSchedule = () => { - if (subscription.sub_type === 'alerts') { - return 'Real-time' - } - const time = subscription.schedule_time || '0000' - const hours = parseInt(time.slice(0, 2)) - const minutes = time.slice(2) - const period = hours >= 12 ? 'PM' : 'AM' - const displayHour = hours % 12 || 12 - let schedule = `${displayHour}:${minutes} ${period}` - if (subscription.sub_type === 'weekly' && subscription.schedule_day) { - schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}` - } - return schedule - } - - const getTypeIcon = () => { - switch (subscription.sub_type) { - case 'alerts': - return Bell - case 'daily': - return Clock - case 'weekly': - return Clock - default: - return Bell - } - } - - const Icon = getTypeIcon() - - return ( -
-
-
- -
-
-
- {subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)} - {subscription.scope_type !== 'mesh' && subscription.scope_value && ( - - ({subscription.scope_type}: {subscription.scope_value}) - - )} -
-
- {formatSchedule()} • {resolveNodeName(subscription.user_id)} -
-
-
-
-
- ) -} - -export default function Alerts() { - const [activeAlerts, setActiveAlerts] = useState([]) - const [history, setHistory] = useState([]) - const [subscriptions, setSubscriptions] = useState([]) - const [nodes, setNodes] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - // Filters and pagination - const [typeFilter, setTypeFilter] = useState('all') - const [severityFilter, setSeverityFilter] = useState('all') - const [page, setPage] = useState(1) - const [totalPages, setTotalPages] = useState(1) - const pageSize = 20 - - // Acknowledged alerts (local state only) - const [acknowledged, setAcknowledged] = useState>(new Set()) - - const { lastAlert } = useWebSocket() - - // Set page title - useEffect(() => { - document.title = 'Alerts — MeshAI' - }, []) - - // Load data - useEffect(() => { - Promise.all([ - fetchAlerts().catch(() => []), - fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })), - fetchSubscriptions().catch(() => []), - fetch('/api/nodes').then(r => r.json()).catch(() => []), - ]) - .then(([alerts, historyData, subs, nodeData]) => { - setActiveAlerts(alerts) - if (Array.isArray(historyData)) { - setHistory(historyData) - setTotalPages(1) - } else { - setHistory(historyData.items || []) - setTotalPages(Math.ceil((historyData.total || 0) / pageSize)) - } - setSubscriptions(subs) - setNodes(nodeData) - setLoading(false) - }) - .catch((err) => { - setError(err.message) - setLoading(false) - }) - }, []) - - // Handle new alerts from WebSocket - useEffect(() => { - if (lastAlert) { - setActiveAlerts((prev) => { - // Avoid duplicates - const exists = prev.some( - (a) => a.type === lastAlert.type && a.message === lastAlert.message - ) - if (exists) return prev - return [lastAlert, ...prev] - }) - } - }, [lastAlert]) - - // Reload history when filters or page change - useEffect(() => { - const offset = (page - 1) * pageSize - fetchAlertHistory(pageSize, offset, typeFilter, severityFilter) - .then((data) => { - if (Array.isArray(data)) { - setHistory(data) - setTotalPages(1) - } else { - setHistory(data.items || []) - setTotalPages(Math.ceil((data.total || 0) / pageSize)) - } - }) - .catch(() => { - // Keep current data on error - }) - }, [page, typeFilter, severityFilter]) - - const handleAcknowledge = useCallback((alert: Alert) => { - const key = `${alert.type}-${alert.message}-${alert.timestamp}` - setAcknowledged((prev) => new Set([...prev, key])) - }, []) - - // Filter out acknowledged alerts - const visibleAlerts = activeAlerts.filter((alert) => { - const key = `${alert.type}-${alert.message}-${alert.timestamp}` - return !acknowledged.has(key) - }) - - if (loading) { - return ( -
-
Loading alerts...
-
- ) - } - - if (error) { - return ( -
-
Error: {error}
-
- ) - } - - return ( -
- {/* Active Alerts */} -
-

- - Active Alerts ({visibleAlerts.length}) -

- {visibleAlerts.length > 0 ? ( -
- {visibleAlerts.map((alert, i) => ( - - ))} -
- ) : ( -
- - No active alerts — all systems nominal -
- )} -
- - {/* Alert History */} -
-

- - Alert History -

- { - setTypeFilter(v) - setPage(1) - }} - onSeverityFilterChange={(v) => { - setSeverityFilter(v) - setPage(1) - }} - page={page} - totalPages={totalPages} - onPageChange={setPage} - /> -
- - {/* Subscriptions */} -
-

- - Mesh Subscriptions ({subscriptions.length}) -

- {subscriptions.length > 0 ? ( -
- {subscriptions.map((sub) => ( - - ))} -
- ) : ( -
-

No active subscriptions.

-

- Manage subscriptions via !subscribe on mesh -

-
- )} -
-
- ) -} +import { useEffect, useState, useCallback } from 'react' +import { + Bell, + AlertTriangle, + AlertCircle, + + CheckCircle, + Clock, + Filter, + ChevronLeft, + ChevronRight, + Radio, + Zap, + + Cloud, + Wifi, + WifiOff, + Battery, + Users, +} from 'lucide-react' +import { + fetchAlerts, + fetchAlertHistory, + fetchSubscriptions, + type Alert, + type AlertHistoryItem, + type Subscription, +} from '@/lib/api' + +interface Node { + node_num: number + node_id_hex: string + short_name: string + long_name: string +} +import { useWebSocket } from '@/hooks/useWebSocket' + +// Alert type icons mapping +const alertTypeIcons: Record = { + infra_offline: WifiOff, + infra_recovery: Wifi, + battery_warning: Battery, + battery_critical: Battery, + battery_emergency: Battery, + hf_blackout: Zap, + uhf_ducting: Radio, + weather_warning: Cloud, + weather_watch: Cloud, + new_router: Radio, + packet_flood: AlertTriangle, + sustained_high_util: AlertTriangle, + region_blackout: AlertCircle, + default: Bell, +} + +function getAlertIcon(type: string) { + return alertTypeIcons[type] || alertTypeIcons.default +} + +function getSeverityStyles(severity: string) { + switch (severity?.toLowerCase()) { + case 'immediate': + return { + bg: 'bg-red-500/10', + border: 'border-red-500', + badge: 'bg-red-500/20 text-red-400', + iconColor: 'text-red-500', + } + case 'priority': + return { + bg: 'bg-amber-500/10', + border: 'border-amber-500', + badge: 'bg-amber-500/20 text-amber-400', + iconColor: 'text-amber-500', + } + case 'routine': + default: + return { + bg: 'bg-blue-500/10', + border: 'border-blue-500', + badge: 'bg-blue-500/20 text-blue-400', + iconColor: 'text-blue-500', + } + } +} + +function formatTimeAgo(timestamp: string | number): string { + const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSec = Math.floor(diffMs / 1000) + const diffMin = Math.floor(diffSec / 60) + const diffHour = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHour / 24) + + if (diffSec < 60) return 'Just now' + if (diffMin < 60) return `${diffMin}m ago` + if (diffHour < 24) return `${diffHour}h ago` + return `${diffDay}d ago` +} + +function formatDateTime(timestamp: string | number): string { + const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp) + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` + return `${Math.floor(seconds / 86400)}d` +} + +// Active Alert Card Component +function ActiveAlertCard({ + alert, + onAcknowledge, +}: { + alert: Alert + onAcknowledge: (alert: Alert) => void +}) { + const styles = getSeverityStyles(alert.severity) + const Icon = getAlertIcon(alert.type) + + return ( +
+
+ +
+
+ + {alert.severity?.toUpperCase()} + + {alert.type} +
+
{alert.message}
+
+ + + {alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'} + + {alert.scope_value && ( + {alert.scope_type}: {alert.scope_value} + )} +
+
+ +
+
+ ) +} + +// Alert History Table Component +function AlertHistoryTable({ + history, + typeFilter, + severityFilter, + onTypeFilterChange, + onSeverityFilterChange, + page, + totalPages, + onPageChange, +}: { + history: AlertHistoryItem[] + typeFilter: string + severityFilter: string + onTypeFilterChange: (v: string) => void + onSeverityFilterChange: (v: string) => void + page: number + totalPages: number + onPageChange: (p: number) => void +}) { + const alertTypes = [ + 'all', + 'infra_offline', + 'infra_recovery', + 'battery_warning', + 'battery_critical', + 'hf_blackout', + 'uhf_ducting', + 'weather_warning', + 'new_router', + 'packet_flood', + ] + + const severities = ['all', 'critical', 'warning', 'watch', 'info'] + + return ( +
+ {/* Filters */} +
+
+ + Filter: +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + + {history.length > 0 ? ( + history.map((item, i) => { + const styles = getSeverityStyles(item.severity) + return ( + + + + + + + + ) + }) + ) : ( + + + + )} + +
TimeTypeSeverityMessageDuration
+ {formatDateTime(item.timestamp)} + + {item.type.replace(/_/g, ' ')} + + + {item.severity} + + + {item.message} + + {item.duration ? formatDuration(item.duration) : '-'} +
+ No alert history available +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} +
+ ) +} + +// Subscription Card Component +function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) { + const resolveNodeName = (userId: string): string => { + const node = nodes.find(n => + n.node_id_hex === userId || + String(n.node_num) === userId || + n.short_name === userId + ) + if (node) { + return node.long_name && node.long_name !== node.short_name + ? `${node.short_name} (${node.long_name})` + : node.short_name + } + return userId + } + const formatSchedule = () => { + if (subscription.sub_type === 'alerts') { + return 'Real-time' + } + const time = subscription.schedule_time || '0000' + const hours = parseInt(time.slice(0, 2)) + const minutes = time.slice(2) + const period = hours >= 12 ? 'PM' : 'AM' + const displayHour = hours % 12 || 12 + let schedule = `${displayHour}:${minutes} ${period}` + if (subscription.sub_type === 'weekly' && subscription.schedule_day) { + schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}` + } + return schedule + } + + const getTypeIcon = () => { + switch (subscription.sub_type) { + case 'alerts': + return Bell + case 'daily': + return Clock + case 'weekly': + return Clock + default: + return Bell + } + } + + const Icon = getTypeIcon() + + return ( +
+
+
+ +
+
+
+ {subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)} + {subscription.scope_type !== 'mesh' && subscription.scope_value && ( + + ({subscription.scope_type}: {subscription.scope_value}) + + )} +
+
+ {formatSchedule()} • {resolveNodeName(subscription.user_id)} +
+
+
+
+
+ ) +} + +export default function Alerts() { + const [activeAlerts, setActiveAlerts] = useState([]) + const [history, setHistory] = useState([]) + const [subscriptions, setSubscriptions] = useState([]) + const [nodes, setNodes] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters and pagination + const [typeFilter, setTypeFilter] = useState('all') + const [severityFilter, setSeverityFilter] = useState('all') + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const pageSize = 20 + + // Acknowledged alerts (local state only) + const [acknowledged, setAcknowledged] = useState>(new Set()) + + const { lastAlert } = useWebSocket() + + // Set page title + useEffect(() => { + document.title = 'Alerts — MeshAI' + }, []) + + // Load data + useEffect(() => { + Promise.all([ + fetchAlerts().catch(() => []), + fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })), + fetchSubscriptions().catch(() => []), + fetch('/api/nodes').then(r => r.json()).catch(() => []), + ]) + .then(([alerts, historyData, subs, nodeData]) => { + setActiveAlerts(alerts) + if (Array.isArray(historyData)) { + setHistory(historyData) + setTotalPages(1) + } else { + setHistory(historyData.items || []) + setTotalPages(Math.ceil((historyData.total || 0) / pageSize)) + } + setSubscriptions(subs) + setNodes(nodeData) + setLoading(false) + }) + .catch((err) => { + setError(err.message) + setLoading(false) + }) + }, []) + + // Handle new alerts from WebSocket + useEffect(() => { + if (lastAlert) { + setActiveAlerts((prev) => { + // Avoid duplicates + const exists = prev.some( + (a) => a.type === lastAlert.type && a.message === lastAlert.message + ) + if (exists) return prev + return [lastAlert, ...prev] + }) + } + }, [lastAlert]) + + // Reload history when filters or page change + useEffect(() => { + const offset = (page - 1) * pageSize + fetchAlertHistory(pageSize, offset, typeFilter, severityFilter) + .then((data) => { + if (Array.isArray(data)) { + setHistory(data) + setTotalPages(1) + } else { + setHistory(data.items || []) + setTotalPages(Math.ceil((data.total || 0) / pageSize)) + } + }) + .catch(() => { + // Keep current data on error + }) + }, [page, typeFilter, severityFilter]) + + const handleAcknowledge = useCallback((alert: Alert) => { + const key = `${alert.type}-${alert.message}-${alert.timestamp}` + setAcknowledged((prev) => new Set([...prev, key])) + }, []) + + // Filter out acknowledged alerts + const visibleAlerts = activeAlerts.filter((alert) => { + const key = `${alert.type}-${alert.message}-${alert.timestamp}` + return !acknowledged.has(key) + }) + + if (loading) { + return ( +
+
Loading alerts...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+
+ ) + } + + return ( +
+ {/* Active Alerts */} +
+

+ + Active Alerts ({visibleAlerts.length}) +

+ {visibleAlerts.length > 0 ? ( +
+ {visibleAlerts.map((alert, i) => ( + + ))} +
+ ) : ( +
+ + No active alerts — all systems nominal +
+ )} +
+ + {/* Alert History */} +
+

+ + Alert History +

+ { + setTypeFilter(v) + setPage(1) + }} + onSeverityFilterChange={(v) => { + setSeverityFilter(v) + setPage(1) + }} + page={page} + totalPages={totalPages} + onPageChange={setPage} + /> +
+ + {/* Subscriptions */} +
+

+ + Mesh Subscriptions ({subscriptions.length}) +

+ {subscriptions.length > 0 ? ( +
+ {subscriptions.map((sub) => ( + + ))} +
+ ) : ( +
+

No active subscriptions.

+

+ Manage subscriptions via !subscribe on mesh +

+
+ )} +
+
+ ) +} diff --git a/dashboard-frontend/src/pages/Dashboard.tsx b/dashboard-frontend/src/pages/Dashboard.tsx index 8befb97..8732e0e 100644 --- a/dashboard-frontend/src/pages/Dashboard.tsx +++ b/dashboard-frontend/src/pages/Dashboard.tsx @@ -1,697 +1,707 @@ -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 */} - -
-
- ) -} +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': + case 'immediate': + return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' } + case 'warning': + case 'priority': + return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' } + case 'routine': + default: + return { bg: 'bg-blue-500/10', border: 'border-blue-500', icon: Info, iconColor: 'text-blue-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 (3-level system + legacy support) +const SEVERITY_COLORS: Record = { + // New 3-level system + routine: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + priority: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + immediate: 'bg-red-600/20 text-red-300 border-red-600/30', + // NWS native (for raw event display) + info: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + advisory: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + watch: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + warning: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + severe: 'bg-red-500/20 text-red-400 border-red-500/30', + extreme: 'bg-red-600/20 text-red-300 border-red-600/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 */} + +
+
+ ) +} diff --git a/dashboard-frontend/src/pages/Environment.tsx b/dashboard-frontend/src/pages/Environment.tsx index 7ce2f35..d6a1833 100644 --- a/dashboard-frontend/src/pages/Environment.tsx +++ b/dashboard-frontend/src/pages/Environment.tsx @@ -82,28 +82,37 @@ function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; function AlertEventCard({ event }: { event: EnvEvent }) { const getSeverityStyles = (severity: string) => { switch (severity.toLowerCase()) { + // NWS native severity levels case 'extreme': case 'severe': + // Our 3-level system + case 'immediate': return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500', } + // NWS native case 'moderate': case 'warning': + // Our 3-level system + case 'priority': return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500', } + // NWS native case 'minor': + // Our 3-level system + case 'routine': return { - bg: 'bg-yellow-500/10', - border: 'border-yellow-500', + bg: 'bg-blue-500/10', + border: 'border-blue-500', icon: Info, - iconColor: 'text-yellow-500', + iconColor: 'text-blue-500', } default: return { diff --git a/dashboard-frontend/src/pages/Notifications.tsx b/dashboard-frontend/src/pages/Notifications.tsx index 256e670..8cfb6cd 100644 --- a/dashboard-frontend/src/pages/Notifications.tsx +++ b/dashboard-frontend/src/pages/Notifications.tsx @@ -98,12 +98,9 @@ interface ChannelTestResult { // Severity levels with descriptions const SEVERITY_OPTIONS = [ - { value: 'info', label: 'Info', description: 'Routine updates (ducting detected, new router appeared)' }, - { value: 'advisory', label: 'Advisory', description: 'Worth knowing (weather advisory, traffic slow, battery declining)' }, - { value: 'watch', label: 'Watch', description: 'Pay attention (fire within 50km, weather watch, stream rising)' }, - { value: 'warning', label: 'Warning', description: 'Act now (fire within 25km, severe weather, critical battery)' }, - { value: 'critical', label: 'Critical', description: 'Serious issue (critical node down, battery emergency)' }, - { value: 'emergency', label: 'Emergency', description: 'Life safety (extreme weather, fire at infrastructure, total blackout)' }, + { value: 'routine', label: 'Routine', description: 'Informational, no time pressure (ducting, new node, weather advisory, battery declining)' }, + { value: 'priority', label: 'Priority', description: 'Needs attention soon (severe weather, fire nearby, node offline, HF blackout)' }, + { value: 'immediate', label: 'Immediate', description: 'Act now, drop everything (fire at infrastructure, extreme weather, region blackout)' }, ] // Notification rule templates @@ -117,7 +114,7 @@ const RULE_TEMPLATES = [ enabled: true, trigger_type: "condition" as const, categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"], - min_severity: "advisory", + min_severity: "routine", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 30, @@ -149,7 +146,7 @@ const RULE_TEMPLATES = [ enabled: true, trigger_type: "condition" as const, categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"], - min_severity: "warning", + min_severity: "priority", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 15, @@ -181,7 +178,7 @@ const RULE_TEMPLATES = [ enabled: true, trigger_type: "condition" as const, categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"], - min_severity: "info", + min_severity: "routine", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 60, @@ -213,7 +210,7 @@ const RULE_TEMPLATES = [ enabled: true, trigger_type: "condition" as const, categories: ["road_closure", "traffic_congestion"], - min_severity: "warning", + min_severity: "routine", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 30, @@ -245,7 +242,7 @@ const RULE_TEMPLATES = [ enabled: true, trigger_type: "condition" as const, categories: [] as string[], - min_severity: "emergency", + min_severity: "immediate", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 5, @@ -543,13 +540,13 @@ function SeveritySelector({ value, onChange }: { onChange: (v: string) => void }) { const [isOpen, setIsOpen] = useState(false) - const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[3] + const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[0] return (