diff --git a/config.example.yaml b/config.example.yaml index 0696890..817a2ed 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -203,93 +203,8 @@ environmental: endpoints: ["/get/event"] bbox: [] # [west, south, east, north] - # 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" +# === 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 6c69cf8..b313ef7 100644 --- a/dashboard-frontend/src/App.tsx +++ b/dashboard-frontend/src/App.tsx @@ -1,28 +1,23 @@ -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 +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 diff --git a/dashboard-frontend/src/components/ChannelPicker.tsx b/dashboard-frontend/src/components/ChannelPicker.tsx deleted file mode 100644 index 28b2b27..0000000 --- a/dashboard-frontend/src/components/ChannelPicker.tsx +++ /dev/null @@ -1,156 +0,0 @@ -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 07b2fb6..c620da8 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -6,11 +6,9 @@ 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 @@ -22,7 +20,6 @@ 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 { @@ -42,21 +39,8 @@ function getPageTitle(pathname: string): string { export default function Layout({ children }: LayoutProps) { const location = useLocation() - const { connected, lastAlert } = useWebSocket() - const { addToast } = useToast() + const { connected } = useWebSocket() 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 deleted file mode 100644 index cf2cda8..0000000 --- a/dashboard-frontend/src/components/NodePicker.tsx +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index 902b6fc..0000000 --- a/dashboard-frontend/src/components/ToastProvider.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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 b578cce..8e9f519 100644 --- a/dashboard-frontend/src/index.css +++ b/dashboard-frontend/src/index.css @@ -47,28 +47,3 @@ 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 c3e08ff..8c4cba0 100644 --- a/dashboard-frontend/src/lib/api.ts +++ b/dashboard-frontend/src/lib/api.ts @@ -93,34 +93,6 @@ 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[] @@ -237,24 +209,6 @@ 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') } @@ -376,36 +330,6 @@ 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[] @@ -431,10 +355,6 @@ 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 f4bca9f..c096225 100644 --- a/dashboard-frontend/src/pages/Alerts.tsx +++ b/dashboard-frontend/src/pages/Alerts.tsx @@ -1,572 +1,15 @@ -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 { Bell } from 'lucide-react' + +export default function Alerts() { + return ( +
+
+ +
+

Alerts

+

+ Alert history and subscriptions coming in Phase 11 +

+
+ ) +} diff --git a/dashboard-frontend/src/pages/Config.tsx b/dashboard-frontend/src/pages/Config.tsx index 863868b..bc13a1d 100644 --- a/dashboard-frontend/src/pages/Config.tsx +++ b/dashboard-frontend/src/pages/Config.tsx @@ -1,2399 +1,1314 @@ -import { useState, useEffect, useCallback } from 'react' -import NodePicker from '@/components/NodePicker' -import ChannelPicker from '@/components/ChannelPicker' -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, ExternalLink -} 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 - 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[] } - firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: 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 }, -] - -// Section descriptions -const SECTION_DESCRIPTIONS: Record = { - bot: 'Identity and behavior settings for the bot on the mesh network.', - connection: 'How MeshAI connects to your Meshtastic radio.', - response: 'Controls how quickly and how much the bot responds on the mesh.', - history: 'Conversation history storage and cleanup.', - memory: 'Short-term conversation memory management. Controls how the bot maintains context within a conversation.', - context: 'Passive channel monitoring. The bot listens to mesh channels and uses recent messages as context when responding.', - commands: 'Mesh commands available via the configured prefix. Toggle individual commands on or off.', - llm: 'AI model configuration. MeshAI uses an LLM to understand questions and generate responses.', - weather: 'Weather data for the !weather command. This is separate from NWS environmental alerts.', - meshmonitor: 'AIDA MeshMonitor integration. An additional data source for mesh network monitoring.', - knowledge: 'Knowledge base for answering questions from stored documents. Connects to Qdrant vector database or local SQLite.', - mesh_sources: 'Data sources for mesh network information. MeshAI can pull data from multiple sources simultaneously and merge them into a unified view.', - mesh_intelligence: 'Advanced mesh analysis: health scoring, region management, and automated alerting. The intelligence engine monitors your mesh and detects problems automatically.', - environmental: 'Live environmental data feeds for situational awareness. Each feed polls a public or authenticated API for real-time conditions affecting your area.', - dashboard: "Web dashboard settings. You're looking at it right now.", -} - -// Available commands with descriptions -const AVAILABLE_COMMANDS = [ - { name: 'help', description: 'Show available commands and usage' }, - { name: 'health', description: 'Mesh network health overview with status dots' }, - { name: 'status', description: 'Quick mesh status summary' }, - { name: 'region', description: 'List regions or get detailed region breakdown' }, - { name: 'neighbors', description: 'Show top infrastructure neighbors with signal quality' }, - { name: 'ping', description: 'Test bot responsiveness' }, - { name: 'clear', description: 'Clear your conversation history' }, - { name: 'reset', description: 'Reset conversation context' }, - { name: 'sub', description: 'Subscribe to scheduled reports or alerts' }, - { name: 'unsub', description: 'Remove a subscription' }, - { name: 'mysubs', description: 'List your active subscriptions' }, - { name: 'alerts', description: 'Active NWS weather alerts for mesh area' }, - { name: 'solar', description: 'Space weather and HF propagation conditions' }, - { name: 'hf', description: 'HF radio propagation (alias for !solar)' }, - { name: 'fire', description: 'Active wildfires near the mesh' }, - { name: 'avy', description: 'Avalanche advisories for configured zones' }, - { name: 'hotspots', description: 'NASA FIRMS satellite fire detections' }, - { name: 'streams', description: 'USGS stream gauge readings' }, - { name: 'roads', description: 'Road conditions and closures' }, - { name: 'traffic', description: 'Traffic flow on monitored corridors' }, -] - -// US States for dropdown -const US_STATES = [ - { value: 'US-AL', label: 'Alabama' }, { value: 'US-AK', label: 'Alaska' }, - { value: 'US-AZ', label: 'Arizona' }, { value: 'US-AR', label: 'Arkansas' }, - { value: 'US-CA', label: 'California' }, { value: 'US-CO', label: 'Colorado' }, - { value: 'US-CT', label: 'Connecticut' }, { value: 'US-DE', label: 'Delaware' }, - { value: 'US-FL', label: 'Florida' }, { value: 'US-GA', label: 'Georgia' }, - { value: 'US-HI', label: 'Hawaii' }, { value: 'US-ID', label: 'Idaho' }, - { value: 'US-IL', label: 'Illinois' }, { value: 'US-IN', label: 'Indiana' }, - { value: 'US-IA', label: 'Iowa' }, { value: 'US-KS', label: 'Kansas' }, - { value: 'US-KY', label: 'Kentucky' }, { value: 'US-LA', label: 'Louisiana' }, - { value: 'US-ME', label: 'Maine' }, { value: 'US-MD', label: 'Maryland' }, - { value: 'US-MA', label: 'Massachusetts' }, { value: 'US-MI', label: 'Michigan' }, - { value: 'US-MN', label: 'Minnesota' }, { value: 'US-MS', label: 'Mississippi' }, - { value: 'US-MO', label: 'Missouri' }, { value: 'US-MT', label: 'Montana' }, - { value: 'US-NE', label: 'Nebraska' }, { value: 'US-NV', label: 'Nevada' }, - { value: 'US-NH', label: 'New Hampshire' }, { value: 'US-NJ', label: 'New Jersey' }, - { value: 'US-NM', label: 'New Mexico' }, { value: 'US-NY', label: 'New York' }, - { value: 'US-NC', label: 'North Carolina' }, { value: 'US-ND', label: 'North Dakota' }, - { value: 'US-OH', label: 'Ohio' }, { value: 'US-OK', label: 'Oklahoma' }, - { value: 'US-OR', label: 'Oregon' }, { value: 'US-PA', label: 'Pennsylvania' }, - { value: 'US-RI', label: 'Rhode Island' }, { value: 'US-SC', label: 'South Carolina' }, - { value: 'US-SD', label: 'South Dakota' }, { value: 'US-TN', label: 'Tennessee' }, - { value: 'US-TX', label: 'Texas' }, { value: 'US-UT', label: 'Utah' }, - { value: 'US-VT', label: 'Vermont' }, { value: 'US-VA', label: 'Virginia' }, - { value: 'US-WA', label: 'Washington' }, { value: 'US-WV', label: 'West Virginia' }, - { value: 'US-WI', label: 'Wisconsin' }, { value: 'US-WY', label: 'Wyoming' }, -] - -// InfoButton component -function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; link?: string; linkText?: string }) { - const [open, setOpen] = useState(false) - - return ( -
- - {open && ( - <> -
setOpen(false)} /> -
- {info} - {link && ( - e.stopPropagation()} - > - {linkText} - - )} -
- - )} -
- ) -} - -// Section description component -function SectionDescription({ text }: { text: string }) { - return ( -

{text}

- ) -} - -// Form components -function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '', infoLink = '' }: { - label: string - value: string - onChange: (v: string) => void - type?: string - placeholder?: string - helper?: string - info?: string - infoLink?: 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 = '', info = '', infoLink = '' }: { - label: string - value: number - onChange: (v: number) => void - min?: number - max?: number - step?: number - helper?: string - info?: string - infoLink?: 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 = '', info = '', infoLink = '' }: { - label: string - checked: boolean - onChange: (v: boolean) => void - helper?: string - info?: string - infoLink?: string -}) { - return ( -
-
- - {label} - {info && } - - {helper &&

{helper}

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

{helper}

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