diff --git a/config.example.yaml b/config.example.yaml index 817a2ed..0696890 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -203,8 +203,93 @@ environmental: endpoints: ["/get/event"] bbox: [] # [west, south, east, north] -# === WEB DASHBOARD === -dashboard: - enabled: true - port: 8080 - host: "0.0.0.0" + # NASA FIRMS Satellite Fire Detection + # Early warning via satellite hotspots, hours before official perimeters + # Get MAP_KEY at: https://firms.modaps.eosdis.nasa.gov/api/area/ + firms: + enabled: false + tick_seconds: 1800 # 30 min default + map_key: "" # Required - NASA FIRMS MAP_KEY + source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT + bbox: [] # [west, south, east, north] - Required + day_range: 1 # 1-10 days of data + confidence_min: "nominal" # low, nominal, high + proximity_km: 10.0 # km to match known fire perimeters + + +# === NOTIFICATION DELIVERY === +# Route alerts to channels (mesh, email, webhook) based on rules. +# Categories match alert types from alert_engine.py. +# Severity levels: info, advisory, watch, warning, critical, emergency +# +notifications: + enabled: false + quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours + quiet_hours_end: "06:00" + + # Notification rules - each rule is self-contained with its own delivery config + rules: + # All emergencies -> mesh broadcast + - name: "Emergency Broadcast" + enabled: true + trigger_type: condition + categories: [] # Empty = all categories + min_severity: "emergency" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 5 + override_quiet: true # Send even during quiet hours + + # Example: Fire alerts -> email + # - name: "Fire Alerts Email" + # enabled: true + # trigger_type: condition + # categories: ["wildfire_proximity", "new_ignition"] + # min_severity: "advisory" + # delivery_type: email + # smtp_host: "smtp.gmail.com" + # smtp_port: 587 + # smtp_user: "you@gmail.com" + # smtp_password: "${SMTP_PASSWORD}" + # smtp_tls: true + # from_address: "meshai@yourdomain.com" + # recipients: ["admin@yourdomain.com"] + # cooldown_minutes: 30 + + # Example: All warnings -> Discord webhook + # - name: "Discord Alerts" + # enabled: true + # trigger_type: condition + # categories: [] + # min_severity: "warning" + # delivery_type: webhook + # webhook_url: "https://discord.com/api/webhooks/..." + # cooldown_minutes: 10 + + # Example: Daily health report -> mesh broadcast + # - name: "Morning Briefing" + # enabled: true + # trigger_type: schedule + # schedule_frequency: daily + # schedule_time: "07:00" + # message_type: mesh_health_summary + # delivery_type: mesh_broadcast + # broadcast_channel: 0 + + # Example: Weekly digest -> email + # - name: "Weekly Digest" + # enabled: true + # trigger_type: schedule + # schedule_frequency: weekly + # schedule_days: ["monday"] + # schedule_time: "08:00" + # message_type: alerts_digest + # delivery_type: email + # smtp_host: "smtp.gmail.com" + # recipients: ["admin@example.com"] + +# === WEB DASHBOARD === +dashboard: + enabled: true + port: 8080 + host: "0.0.0.0" diff --git a/dashboard-frontend/src/App.tsx b/dashboard-frontend/src/App.tsx index b313ef7..6c69cf8 100644 --- a/dashboard-frontend/src/App.tsx +++ b/dashboard-frontend/src/App.tsx @@ -1,23 +1,28 @@ -import { Routes, Route } from 'react-router-dom' -import Layout from './components/Layout' -import Dashboard from './pages/Dashboard' -import Mesh from './pages/Mesh' -import Environment from './pages/Environment' -import Config from './pages/Config' -import Alerts from './pages/Alerts' - -function App() { - return ( - - - } /> - } /> - } /> - } /> - } /> - - - ) -} - -export default App +import { Routes, Route } from 'react-router-dom' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Mesh from './pages/Mesh' +import Environment from './pages/Environment' +import Config from './pages/Config' +import Alerts from './pages/Alerts' +import Notifications from './pages/Notifications' +import { ToastProvider } from './components/ToastProvider' + +function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/dashboard-frontend/src/components/ChannelPicker.tsx b/dashboard-frontend/src/components/ChannelPicker.tsx new file mode 100644 index 0000000..28b2b27 --- /dev/null +++ b/dashboard-frontend/src/components/ChannelPicker.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { Check } from 'lucide-react' + +interface Channel { + index: number + name: string + role: string + enabled: boolean +} + +interface ChannelPickerSingleProps { + label: string + value: number + onChange: (value: number) => void + helper?: string + info?: string + mode: 'single' + includeDisabled?: boolean // Include a "Disabled (-1)" option +} + +interface ChannelPickerMultiProps { + label: string + value: number[] + onChange: (value: number[]) => void + helper?: string + info?: string + mode: 'multi' +} + +type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps + +export default function ChannelPicker(props: ChannelPickerProps) { + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/channels') + .then(res => res.json()) + .then(data => { + setChannels(data) + setLoading(false) + }) + .catch(() => { + setChannels([]) + setLoading(false) + }) + }, []) + + const formatChannel = (ch: Channel): string => { + const roleLabel = ch.role === 'PRIMARY' ? 'Primary' : + ch.role === 'SECONDARY' ? 'Secondary' : '' + return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}` + } + + // Fallback to number input if no channels loaded + if (!loading && channels.length === 0) { + if (props.mode === 'single') { + return ( +
+ + props.onChange(Number(e.target.value))} + min={props.includeDisabled ? -1 : 0} + max={7} + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + /> + {props.helper &&

{props.helper}

} +
+ ) + } else { + return ( +
+ + { + const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n)) + props.onChange(nums) + }} + placeholder="Enter channel numbers separated by commas" + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + /> + {props.helper &&

{props.helper}

} +
+ ) + } + } + + // Single select mode - dropdown + if (props.mode === 'single') { + const { value, onChange, label, helper, includeDisabled } = props + const enabledChannels = channels.filter(ch => ch.enabled) + + return ( +
+ + + {helper &&

{helper}

} +
+ ) + } + + // Multi select mode - checkboxes + const { value, onChange, label, helper } = props + const enabledChannels = channels.filter(ch => ch.enabled) + + const toggleChannel = (index: number) => { + if (value.includes(index)) { + onChange(value.filter(v => v !== index)) + } else { + onChange([...value, index].sort((a, b) => a - b)) + } + } + + return ( +
+ +
+ {enabledChannels.map((ch) => ( + + ))} + {enabledChannels.length === 0 && ( +
No channels available
+ )} +
+ {helper &&

{helper}

} +
+ ) +} diff --git a/dashboard-frontend/src/components/Layout.tsx b/dashboard-frontend/src/components/Layout.tsx index c620da8..07b2fb6 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -6,9 +6,11 @@ import { Cloud, Settings, Bell, + BellRing, } from 'lucide-react' import { fetchStatus, type SystemStatus } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' +import { useToast } from './ToastProvider' interface LayoutProps { children: ReactNode @@ -20,6 +22,7 @@ const navItems = [ { path: '/environment', label: 'Environment', icon: Cloud }, { path: '/config', label: 'Config', icon: Settings }, { path: '/alerts', label: 'Alerts', icon: Bell }, + { path: '/notifications', label: 'Notifications', icon: BellRing }, ] function formatUptime(seconds: number): string { @@ -39,8 +42,21 @@ function getPageTitle(pathname: string): string { export default function Layout({ children }: LayoutProps) { const location = useLocation() - const { connected } = useWebSocket() + const { connected, lastAlert } = useWebSocket() + const { addToast } = useToast() const [status, setStatus] = useState(null) + const [lastAlertId, setLastAlertId] = useState(null) + + // Trigger toast on new alerts + useEffect(() => { + if (lastAlert) { + const alertId = `${lastAlert.type}-${lastAlert.message}-${lastAlert.timestamp}` + if (alertId !== lastAlertId) { + setLastAlertId(alertId) + addToast(lastAlert) + } + } + }, [lastAlert, lastAlertId, addToast]) const [currentTime, setCurrentTime] = useState(new Date()) useEffect(() => { diff --git a/dashboard-frontend/src/components/NodePicker.tsx b/dashboard-frontend/src/components/NodePicker.tsx new file mode 100644 index 0000000..cf2cda8 --- /dev/null +++ b/dashboard-frontend/src/components/NodePicker.tsx @@ -0,0 +1,210 @@ +import { useState, useEffect, useMemo } from 'react' +import { Search, X, Check } from 'lucide-react' + +interface Node { + node_num: number + node_id_hex: string + short_name: string + long_name: string + role: string + is_infrastructure?: boolean +} + +interface NodePickerProps { + label: string + value: string[] + onChange: (value: string[]) => void + helper?: string + info?: string + roleFilter?: string // e.g., "ROUTER" to show only infrastructure + valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value +} + +export default function NodePicker({ + label, + value, + onChange, + helper, + info: _info, + roleFilter, + valueType = 'short_name', +}: NodePickerProps) { + const [nodes, setNodes] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + fetch('/api/nodes') + .then(res => res.json()) + .then(data => { + setNodes(data) + setLoading(false) + }) + .catch(() => { + setNodes([]) + setLoading(false) + }) + }, []) + + const filteredNodes = useMemo(() => { + let result = nodes + + // Filter by role if specified + if (roleFilter) { + result = result.filter(n => { + if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') { + return n.is_infrastructure || + n.role === 'ROUTER' || + n.role === 'ROUTER_CLIENT' || + n.role === 'REPEATER' + } + return n.role === roleFilter + }) + } + + // Filter by search + if (search.trim()) { + const s = search.toLowerCase() + result = result.filter(n => + n.short_name?.toLowerCase().includes(s) || + n.long_name?.toLowerCase().includes(s) || + n.role?.toLowerCase().includes(s) || + n.node_id_hex?.toLowerCase().includes(s) + ) + } + + return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || '')) + }, [nodes, search, roleFilter]) + + const getNodeValue = (node: Node): string => { + switch (valueType) { + case 'node_num': + return String(node.node_num) + case 'node_id_hex': + return node.node_id_hex + default: + return node.short_name || String(node.node_num) + } + } + + const isSelected = (node: Node): boolean => { + const nodeVal = getNodeValue(node) + return value.includes(nodeVal) + } + + const toggleNode = (node: Node) => { + const nodeVal = getNodeValue(node) + if (value.includes(nodeVal)) { + onChange(value.filter(v => v !== nodeVal)) + } else { + onChange([...value, nodeVal]) + } + } + + const formatNodeDisplay = (node: Node): string => { + const parts = [node.short_name] + if (node.long_name && node.long_name !== node.short_name) { + parts.push(`— ${node.long_name}`) + } + if (node.role) { + parts.push(`(${node.role})`) + } + return parts.join(' ') + } + + // Fallback to text input if no nodes loaded + if (!loading && nodes.length === 0) { + return ( +
+ + onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))} + placeholder="Enter node IDs separated by commas" + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + /> + {helper &&

{helper}

} +
+ ) + } + + return ( +
+ + + {/* Selected nodes display */} + {value.length > 0 && ( +
+ {value.map((v) => { + const node = nodes.find(n => getNodeValue(n) === v) + return ( + + {node ? node.short_name : v} + + + ) + })} +
+ )} + + {/* Search and dropdown */} +
+
+ + setSearch(e.target.value)} + onFocus={() => setIsOpen(true)} + placeholder={loading ? "Loading nodes..." : "Search nodes..."} + className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent" + /> +
+ + {isOpen && !loading && ( + <> +
setIsOpen(false)} /> +
+ {filteredNodes.length === 0 ? ( +
+ No nodes found +
+ ) : ( + filteredNodes.map((node) => ( + + )) + )} +
+ + )} +
+ + {helper &&

{helper}

} +
+ ) +} diff --git a/dashboard-frontend/src/components/ToastProvider.tsx b/dashboard-frontend/src/components/ToastProvider.tsx new file mode 100644 index 0000000..902b6fc --- /dev/null +++ b/dashboard-frontend/src/components/ToastProvider.tsx @@ -0,0 +1,141 @@ +import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react' +import type { Alert } from '@/lib/api' + +interface Toast { + id: string + alert: Alert + dismissedAt?: number +} + +interface ToastContextValue { + addToast: (alert: Alert) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +} + +function 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-blue-500/10', + border: 'border-blue-500', + icon: Info, + iconColor: 'text-blue-500', + } + } +} + +function ToastItem({ + toast, + onDismiss, + onNavigate, +}: { + toast: Toast + onDismiss: () => void + onNavigate: () => void +}) { + const styles = getSeverityStyles(toast.alert.severity) + const Icon = styles.icon + + // Auto-dismiss after 8 seconds + useEffect(() => { + const timer = setTimeout(onDismiss, 8000) + return () => clearTimeout(timer) + }, [onDismiss]) + + return ( +
+
+ {/* Severity bar */} +
+ + + +
+
+ {toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} +
+
+ {toast.alert.message} +
+
+ + +
+
+ ) +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const navigate = useNavigate() + + const addToast = useCallback((alert: Alert) => { + const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + setToasts((prev) => [...prev, { id, alert }]) + }, []) + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const handleNavigate = useCallback(() => { + navigate('/alerts') + }, [navigate]) + + return ( + + {children} + + {/* Toast container - fixed bottom right */} +
+ {toasts.map((toast) => ( +
+ dismissToast(toast.id)} + onNavigate={handleNavigate} + /> +
+ ))} +
+
+ ) +} diff --git a/dashboard-frontend/src/index.css b/dashboard-frontend/src/index.css index 8e9f519..b578cce 100644 --- a/dashboard-frontend/src/index.css +++ b/dashboard-frontend/src/index.css @@ -47,3 +47,28 @@ body { .animate-pulse-slow { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + + +/* Toast slide-in animation */ +@keyframes slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.animate-slide-in { + animation: slide-in 0.3s ease-out; +} + +/* Line clamp utility */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/dashboard-frontend/src/lib/api.ts b/dashboard-frontend/src/lib/api.ts index 8c4cba0..c3e08ff 100644 --- a/dashboard-frontend/src/lib/api.ts +++ b/dashboard-frontend/src/lib/api.ts @@ -93,6 +93,34 @@ export interface Alert { scope_value?: string } +export interface AlertHistoryItem { + id?: number + type: string + severity: string + message: string + timestamp: string + duration?: number + scope_type?: string + scope_value?: string + resolved_at?: string +} + +export interface AlertHistoryResponse { + items: AlertHistoryItem[] + total: number +} + +export interface Subscription { + id: number + user_id: string + sub_type: string + schedule_time?: string + schedule_day?: string + scope_type: string + scope_value?: string + enabled: boolean +} + export interface EnvStatus { enabled: boolean feeds: EnvFeedHealth[] @@ -209,6 +237,24 @@ export async function fetchAlerts(): Promise { return fetchJson('/api/alerts/active') } +export async function fetchAlertHistory( + limit: number = 50, + offset: number = 0, + type?: string, + severity?: string +): Promise { + const params = new URLSearchParams() + params.set('limit', limit.toString()) + params.set('offset', offset.toString()) + if (type && type !== 'all') params.set('type', type) + if (severity && severity !== 'all') params.set('severity', severity) + return fetchJson(`/api/alerts/history?${params.toString()}`) +} + +export async function fetchSubscriptions(): Promise { + return fetchJson('/api/subscriptions') +} + export async function fetchEnvStatus(): Promise { return fetchJson('/api/env/status') } @@ -330,6 +376,36 @@ export interface RoadEvent { } } +export interface HotspotEvent { + source: string + event_id: string + event_type: string + headline: string + severity: string + lat?: number + lon?: number + expires: number + fetched_at: number + properties: { + new_ignition: boolean + confidence: string + frp?: number + brightness?: number + acq_date: string + acq_time: string + near_fire?: string + distance_to_fire_km?: number + distance_km?: number + nearest_anchor?: string + } +} + +export interface HotspotsResponse { + enabled: boolean + hotspots: HotspotEvent[] + new_ignitions: number +} + export interface AvalancheResponse { off_season: boolean advisories: AvalancheEvent[] @@ -355,6 +431,10 @@ export async function fetchRoads(): Promise { return fetchJson('/api/env/roads') } +export async function fetchHotspots(): Promise { + return fetchJson('/api/env/hotspots') +} + export async function fetchRegions(): Promise { return fetchJson('/api/regions') } diff --git a/dashboard-frontend/src/pages/Alerts.tsx b/dashboard-frontend/src/pages/Alerts.tsx index c096225..f4bca9f 100644 --- a/dashboard-frontend/src/pages/Alerts.tsx +++ b/dashboard-frontend/src/pages/Alerts.tsx @@ -1,15 +1,572 @@ -import { Bell } from 'lucide-react' - -export default function Alerts() { - return ( -
-
- -
-

Alerts

-

- Alert history and subscriptions coming in Phase 11 -

-
- ) -} +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 +

+
+ )} +
+
+ ) +} diff --git a/dashboard-frontend/src/pages/Config.tsx b/dashboard-frontend/src/pages/Config.tsx index bc13a1d..863868b 100644 --- a/dashboard-frontend/src/pages/Config.tsx +++ b/dashboard-frontend/src/pages/Config.tsx @@ -1,1314 +1,2399 @@ -import { useState, useEffect, useCallback } from 'react' -import { - Settings, Bot, Wifi, MessageSquare, Database, Brain, Eye, - Terminal, Cpu, Cloud, Radio, BookOpen, Layers, Activity, - Thermometer, LayoutDashboard, Save, RotateCcw, RefreshCw, - Plus, Trash2, ChevronDown, ChevronRight, AlertTriangle, - Check, X, Eye as EyeIcon, EyeOff -} from 'lucide-react' - -// Types for config sections -interface BotConfig { - name: string - owner: string - respond_to_dms: boolean - filter_bbs_protocols: boolean -} - -interface ConnectionConfig { - type: string - serial_port: string - tcp_host: string - tcp_port: number -} - -interface ResponseConfig { - delay_min: number - delay_max: number - max_length: number - max_messages: number -} - -interface HistoryConfig { - database: string - max_messages_per_user: number - conversation_timeout: number - auto_cleanup: boolean - cleanup_interval_hours: number - max_age_days: number -} - -interface MemoryConfig { - enabled: boolean - window_size: number - summarize_threshold: number -} - -interface ContextConfig { - enabled: boolean - observe_channels: number[] - ignore_nodes: string[] - max_age: number - max_context_items: number -} - -interface CommandsConfig { - enabled: boolean - prefix: string - disabled_commands: string[] - custom_commands: Record -} - -interface LLMConfig { - backend: string - api_key: string - base_url: string - model: string - timeout: number - max_response_tokens: number - system_prompt: string - use_system_prompt: boolean - web_search: boolean - google_grounding: boolean -} - -interface WeatherConfig { - primary: string - fallback: string - default_location: string - openmeteo: { url: string } - wttr: { url: string } -} - -interface MeshMonitorConfig { - enabled: boolean - url: string - inject_into_prompt: boolean - refresh_interval: number - polite_mode: boolean -} - -interface KnowledgeConfig { - enabled: boolean - backend: string - qdrant_host: string - qdrant_port: number - qdrant_collection: string - tei_host: string - tei_port: number - sparse_host: string - sparse_port: number - use_sparse: boolean - db_path: string - top_k: number -} - -interface MeshSourceConfig { - name: string - type: string - url: string - api_token: string - refresh_interval: number - polite_mode: boolean - enabled: boolean - // MQTT-specific fields - host?: string - port?: number - username?: string - password?: string - topic_root?: string - use_tls?: boolean -} - -interface RegionAnchor { - name: string - lat: number - lon: number - local_name: string - description: string - aliases: string[] - cities: string[] -} - -interface AlertRulesConfig { - infra_offline: boolean - infra_recovery: boolean - new_router: boolean - battery_trend_declining: boolean - battery_warning: boolean - battery_critical: boolean - battery_emergency: boolean - battery_warning_threshold: number - battery_critical_threshold: number - battery_emergency_threshold: number - power_source_change: boolean - solar_not_charging: boolean - sustained_high_util: boolean - high_util_threshold: number - high_util_hours: number - packet_flood: boolean - packet_flood_threshold: number - infra_single_gateway: boolean - feeder_offline: boolean - region_total_blackout: boolean - mesh_score_alert: boolean - mesh_score_threshold: number - region_score_alert: boolean - region_score_threshold: number -} - -interface MeshIntelligenceConfig { - enabled: boolean - regions: RegionAnchor[] - locality_radius_miles: number - offline_threshold_hours: number - packet_threshold: number - battery_warning_percent: number - critical_nodes: string[] - alert_channel: number - alert_cooldown_minutes: number - alert_rules: AlertRulesConfig -} - -interface NWSConfig { - enabled: boolean - tick_seconds: number - areas: string[] - severity_min: string - user_agent: string -} - -interface EnvironmentalConfig { - enabled: boolean - nws_zones: string[] - nws: NWSConfig - swpc: { enabled: boolean } - ducting: { enabled: boolean; tick_seconds: number; latitude: number; longitude: number } - fires: { enabled: boolean; tick_seconds: number; state: string } - avalanche: { enabled: boolean; tick_seconds: number; center_ids: string[]; season_months: number[] } - usgs: { enabled: boolean; tick_seconds: number; sites: string[] } - traffic: { enabled: boolean; tick_seconds: number; api_key: string; corridors: { name: string; lat: number; lon: number }[] } - roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[] } -} - -interface DashboardConfig { - enabled: boolean - port: number - host: string -} - -interface FullConfig { - bot: BotConfig - connection: ConnectionConfig - response: ResponseConfig - history: HistoryConfig - memory: MemoryConfig - context: ContextConfig - commands: CommandsConfig - llm: LLMConfig - weather: WeatherConfig - meshmonitor: MeshMonitorConfig - knowledge: KnowledgeConfig - mesh_sources: MeshSourceConfig[] - mesh_intelligence: MeshIntelligenceConfig - environmental: EnvironmentalConfig - dashboard: DashboardConfig -} - -type SectionKey = keyof FullConfig - -const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [ - { key: 'bot', label: 'Bot', icon: Bot }, - { key: 'connection', label: 'Connection', icon: Wifi }, - { key: 'response', label: 'Response', icon: MessageSquare }, - { key: 'history', label: 'History', icon: Database }, - { key: 'memory', label: 'Memory', icon: Brain }, - { key: 'context', label: 'Context', icon: Eye }, - { key: 'commands', label: 'Commands', icon: Terminal }, - { key: 'llm', label: 'LLM', icon: Cpu }, - { key: 'weather', label: 'Weather', icon: Cloud }, - { key: 'meshmonitor', label: 'MeshMonitor', icon: Radio }, - { key: 'knowledge', label: 'Knowledge', icon: BookOpen }, - { key: 'mesh_sources', label: 'Mesh Sources', icon: Layers }, - { key: 'mesh_intelligence', label: 'Intelligence', icon: Activity }, - { key: 'environmental', label: 'Environmental', icon: Thermometer }, - { key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, -] - -// Form components -function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '' }: { - label: string - value: string - onChange: (v: string) => void - type?: string - placeholder?: string - helper?: string -}) { - const [showPassword, setShowPassword] = useState(false) - const isPassword = type === 'password' - - return ( -
- -
- onChange(e.target.value)} - placeholder={placeholder} - className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600" - /> - {isPassword && ( - - )} -
- {helper &&

{helper}

} -
- ) -} - -function NumberInput({ label, value, onChange, min, max, step = 1, helper = '' }: { - label: string - value: number - onChange: (v: number) => void - min?: number - max?: number - step?: number - helper?: string -}) { - return ( -
- - onChange(Number(e.target.value))} - min={min} - max={max} - step={step} - className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" - /> - {helper &&

{helper}

} -
- ) -} - -function Toggle({ label, checked, onChange, helper = '' }: { - label: string - checked: boolean - onChange: (v: boolean) => void - helper?: string -}) { - return ( -
-
- {label} - {helper &&

{helper}

} -
- -
- ) -} - -function SelectInput({ label, value, onChange, options, helper = '' }: { - label: string - value: string - onChange: (v: string) => void - options: { value: string; label: string }[] - helper?: string -}) { - return ( -
- - - {helper &&

{helper}

} -
- ) -} - -function TextArea({ label, value, onChange, rows = 4, helper = '' }: { - label: string - value: string - onChange: (v: string) => void - rows?: number - helper?: string -}) { - return ( -
- -