diff --git a/config.example.yaml b/config.example.yaml index 0696890..6a68c1a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -216,80 +216,111 @@ environmental: 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" + +# === 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_enabled: true # Master toggle for quiet hours feature + 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 + # Default baseline rules are created on fresh install + rules: + # Emergency Broadcast - all emergencies go out immediately + - 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 + + # Infrastructure Down - critical node and infrastructure offline alerts + - name: "Infrastructure Down" + enabled: true + trigger_type: condition + categories: ["infra_offline", "critical_node_down"] + min_severity: "warning" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 30 + override_quiet: false + + # Fire Alert - wildfire proximity and new ignition + - name: "Fire Alert" + enabled: true + trigger_type: condition + categories: ["wildfire_proximity", "new_ignition"] + min_severity: "advisory" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 60 + override_quiet: false + + # Severe Weather - weather warnings + - name: "Severe Weather" + enabled: true + trigger_type: condition + categories: ["weather_warning"] + min_severity: "warning" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 30 + override_quiet: false + + # 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: Rule with no delivery (matches and logs, but doesn't send) + # - name: "Monitor Only" + # enabled: true + # trigger_type: condition + # categories: ["battery_warning"] + # min_severity: "warning" + # delivery_type: "" # Empty = no delivery, just tracks matches + +# === WEB DASHBOARD === +dashboard: + enabled: true + port: 8080 + host: "0.0.0.0" diff --git a/dashboard-frontend/src/hooks/useWebSocket.ts b/dashboard-frontend/src/hooks/useWebSocket.ts index 7df00f6..1ae63d9 100644 --- a/dashboard-frontend/src/hooks/useWebSocket.ts +++ b/dashboard-frontend/src/hooks/useWebSocket.ts @@ -1,102 +1,109 @@ -import { useEffect, useRef, useState, useCallback } from 'react' -import type { MeshHealth, Alert } from '@/lib/api' - -interface WebSocketMessage { - type: string - data: unknown -} - -interface UseWebSocketReturn { - connected: boolean - lastHealth: MeshHealth | null - lastAlert: Alert | null -} - -export function useWebSocket(): UseWebSocketReturn { - const [connected, setConnected] = useState(false) - const [lastHealth, setLastHealth] = useState(null) - const [lastAlert, setLastAlert] = useState(null) - const wsRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const reconnectDelayRef = useRef(1000) - - const connect = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - return - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const wsUrl = `${protocol}//${window.location.host}/ws/live` - - try { - const ws = new WebSocket(wsUrl) - wsRef.current = ws - - ws.onopen = () => { - setConnected(true) - reconnectDelayRef.current = 1000 // Reset backoff on successful connection - } - - ws.onmessage = (event) => { - try { - const message: WebSocketMessage = JSON.parse(event.data) - - switch (message.type) { - case 'health_update': - setLastHealth(message.data as MeshHealth) - break - case 'alert_fired': - setLastAlert(message.data as Alert) - break - } - } catch (e) { - console.error('Failed to parse WebSocket message:', e) - } - } - - ws.onclose = () => { - setConnected(false) - wsRef.current = null - - // Schedule reconnect with exponential backoff - const delay = Math.min(reconnectDelayRef.current, 30000) - reconnectTimeoutRef.current = window.setTimeout(() => { - reconnectDelayRef.current = Math.min(delay * 2, 30000) - connect() - }, delay) - } - - ws.onerror = () => { - ws.close() - } - - // Keepalive ping every 30 seconds - const pingInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send('ping') - } - }, 30000) - - ws.addEventListener('close', () => { - clearInterval(pingInterval) - }) - } catch (e) { - console.error('Failed to create WebSocket:', e) - } - }, []) - - useEffect(() => { - connect() - - return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - } - if (wsRef.current) { - wsRef.current.close() - } - } - }, [connect]) - - return { connected, lastHealth, lastAlert } -} +import { useEffect, useRef, useState, useCallback } from 'react' +import type { MeshHealth, Alert, EnvEvent } from '@/lib/api' + +interface WebSocketMessage { + type: string + data?: unknown + event?: EnvEvent +} + +interface UseWebSocketReturn { + connected: boolean + lastHealth: MeshHealth | null + lastAlert: Alert | null + lastMessage: WebSocketMessage | null +} + +export function useWebSocket(): UseWebSocketReturn { + const [connected, setConnected] = useState(false) + const [lastHealth, setLastHealth] = useState(null) + const [lastAlert, setLastAlert] = useState(null) + const [lastMessage, setLastMessage] = useState(null) + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const reconnectDelayRef = useRef(1000) + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsUrl = `${protocol}//${window.location.host}/ws/live` + + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setConnected(true) + reconnectDelayRef.current = 1000 // Reset backoff on successful connection + } + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data) + + // Store all messages for generic handling + setLastMessage(message) + + switch (message.type) { + case 'health_update': + setLastHealth(message.data as MeshHealth) + break + case 'alert_fired': + setLastAlert(message.data as Alert) + break + // env_update messages are handled via lastMessage + } + } catch (e) { + console.error('Failed to parse WebSocket message:', e) + } + } + + ws.onclose = () => { + setConnected(false) + wsRef.current = null + + // Schedule reconnect with exponential backoff + const delay = Math.min(reconnectDelayRef.current, 30000) + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectDelayRef.current = Math.min(delay * 2, 30000) + connect() + }, delay) + } + + ws.onerror = () => { + ws.close() + } + + // Keepalive ping every 30 seconds + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('ping') + } + }, 30000) + + ws.addEventListener('close', () => { + clearInterval(pingInterval) + }) + } catch (e) { + console.error('Failed to create WebSocket:', e) + } + }, []) + + useEffect(() => { + connect() + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + } + }, [connect]) + + return { connected, lastHealth, lastAlert, lastMessage } +} diff --git a/dashboard-frontend/src/lib/api.ts b/dashboard-frontend/src/lib/api.ts index c3e08ff..b2cf8d1 100644 --- a/dashboard-frontend/src/lib/api.ts +++ b/dashboard-frontend/src/lib/api.ts @@ -1,440 +1,479 @@ -// API types matching actual backend responses - -export interface SystemStatus { - version: string - uptime_seconds: number - bot_name: string - connection_type: string - connection_target: string - connected: boolean - node_count: number - source_count: number - env_feeds_enabled: boolean - dashboard_port: number -} - -export interface MeshHealth { - score: number - tier: string - pillars: { - infrastructure: number - utilization: number - behavior: number - power: number - } - infra_online: number - infra_total: number - util_percent: number - flagged_nodes: number - battery_warnings: number - total_nodes: number - total_regions: number - unlocated_count: number - last_computed: string - recommendations: string[] -} - -export interface NodeInfo { - node_num: number - node_id_hex: string - short_name: string - long_name: string - role: string - latitude: number | null - longitude: number | null - last_heard: string | null - battery_level: number | null - voltage: number | null - snr: number | null - firmware: string - hardware: string - uptime: number | null - sources: string[] -} - -export interface EdgeInfo { - from_node: number - to_node: number - snr: number - quality: string -} - -export interface RegionInfo { - name: string - local_name: string - node_count: number - infra_count: number - infra_online: number - online_count: number - score: number - tier: string - center_lat: number - center_lon: number -} - -export interface SourceHealth { - name: string - type: string - url: string - is_loaded: boolean - last_error: string | null - consecutive_errors: number - response_time_ms: number | null - tick_count: number - node_count: number -} - -export interface Alert { - type: string - severity: string - message: string - timestamp: string - scope_type?: string - 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[] -} - -export interface EnvFeedHealth { - source: string - is_loaded: boolean - last_error: string | null - consecutive_errors: number - event_count: number - last_fetch: number -} - -export interface EnvEvent { - source: string - event_id: string - event_type: string - severity: string - headline: string - description?: string - expires?: number - fetched_at: number - [key: string]: unknown -} - -export interface SWPCStatus { - enabled: boolean - kp_current?: number - kp_timestamp?: string - sfi?: number - r_scale?: number - s_scale?: number - g_scale?: number - active_warnings?: string[] -} - -export interface DuctingStatus { - enabled: boolean - condition?: string - min_gradient?: number - duct_thickness_m?: number | null - duct_base_m?: number | null - last_update?: string -} - -export interface RFPropagation { - hf: { - kp_current?: number - sfi?: number - r_scale?: number - s_scale?: number - g_scale?: number - active_warnings?: string[] - } - uhf_ducting: { - condition?: string - min_gradient?: number - duct_thickness_m?: number | null - } -} - -// API fetch helpers - -async function fetchJson(url: string): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) - } - return response.json() -} - -export async function fetchStatus(): Promise { - return fetchJson('/api/status') -} - -export async function fetchHealth(): Promise { - return fetchJson('/api/health') -} - -export async function fetchNodes(): Promise { - return fetchJson('/api/nodes') -} - -export async function fetchEdges(): Promise { - return fetchJson('/api/edges') -} - -export async function fetchSources(): Promise { - return fetchJson('/api/sources') -} - -export async function fetchConfig(section?: string): Promise { - const url = section ? `/api/config/${section}` : '/api/config' - return fetchJson(url) -} - -export async function updateConfig( - section: string, - data: unknown -): Promise<{ saved: boolean; restart_required: boolean }> { - const response = await fetch(`/api/config/${section}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) - } - return response.json() -} - -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') -} - -export async function fetchEnvActive(): Promise { - return fetchJson('/api/env/active') -} - -export async function fetchRFPropagation(): Promise { - return fetchJson('/api/env/propagation') -} - -export async function fetchSWPC(): Promise { - return fetchJson('/api/env/swpc') -} - -export async function fetchDucting(): Promise { - return fetchJson('/api/env/ducting') -} - -export interface FireEvent { - source: string - event_id: string - event_type: string - severity: string - headline: string - name: string - acres: number - pct_contained: number - lat: number | null - lon: number | null - distance_km: number | null - nearest_anchor: string | null - state: string - expires: number - fetched_at: number - polygon?: number[][][] -} - -export interface AvalancheEvent { - source: string - event_id: string - event_type: string - severity: string - headline: string - zone_name: string - center: string - center_id: string - center_link: string - forecast_link: string - danger: string - danger_level: number - danger_name: string - travel_advice: string - state: string - lat: number | null - lon: number | null - expires: number - fetched_at: number -} - -export interface StreamGaugeEvent { - source: string - event_id: string - event_type: string - headline: string - severity: string - lat?: number - lon?: number - expires: number - fetched_at: number - properties: { - site_id: string - site_name: string - parameter: string - value: number - unit: string - timestamp: string - } -} - -export interface TrafficEvent { - source: string - event_id: string - event_type: string - headline: string - severity: string - lat?: number - lon?: number - expires: number - fetched_at: number - properties: { - corridor: string - currentSpeed: number - freeFlowSpeed: number - speedRatio: number - currentTravelTime: number - freeFlowTravelTime: number - confidence: number - roadClosure: boolean - } -} - -export interface RoadEvent { - source: string - event_id: string - event_type: string - headline: string - description?: string - severity: string - lat?: number - lon?: number - expires: number - fetched_at: number - properties: { - roadway: string - is_closure: boolean - last_updated?: string - } -} - -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[] -} - -export async function fetchFires(): Promise { - return fetchJson('/api/env/fires') -} - -export async function fetchAvalanche(): Promise { - return fetchJson('/api/env/avalanche') -} - -export async function fetchStreams(): Promise { - return fetchJson('/api/env/streams') -} - -export async function fetchTraffic(): Promise { - return fetchJson('/api/env/traffic') -} - -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') -} +// API types matching actual backend responses + +export interface SystemStatus { + version: string + uptime_seconds: number + bot_name: string + connection_type: string + connection_target: string + connected: boolean + node_count: number + source_count: number + env_feeds_enabled: boolean + dashboard_port: number +} + +export interface MeshHealth { + score: number + tier: string + pillars: { + infrastructure: number + utilization: number + behavior: number + power: number + } + infra_online: number + infra_total: number + util_percent: number + flagged_nodes: number + battery_warnings: number + total_nodes: number + total_regions: number + unlocated_count: number + last_computed: string + recommendations: string[] +} + +export interface NodeInfo { + node_num: number + node_id_hex: string + short_name: string + long_name: string + role: string + latitude: number | null + longitude: number | null + last_heard: string | null + battery_level: number | null + voltage: number | null + snr: number | null + firmware: string + hardware: string + uptime: number | null + sources: string[] +} + +export interface EdgeInfo { + from_node: number + to_node: number + snr: number + quality: string +} + +export interface RegionInfo { + name: string + local_name: string + node_count: number + infra_count: number + infra_online: number + online_count: number + score: number + tier: string + center_lat: number + center_lon: number +} + +export interface SourceHealth { + name: string + type: string + url: string + is_loaded: boolean + last_error: string | null + consecutive_errors: number + response_time_ms: number | null + tick_count: number + node_count: number +} + +export interface Alert { + type: string + severity: string + message: string + timestamp: string + scope_type?: string + 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[] +} + +export interface EnvFeedHealth { + source: string + is_loaded: boolean + last_error: string | null + consecutive_errors: number + event_count: number + last_fetch: number +} + +export interface EnvEvent { + source: string + event_id: string + event_type: string + severity: string + headline: string + description?: string + expires?: number + fetched_at: number + [key: string]: unknown +} + +// Kp history entry for charting +export interface KpHistoryEntry { + time: string + value: number +} + +// SFI history entry for charting +export interface SfiHistoryEntry { + time: string + value: number +} + +// Refractivity profile entry +export interface ProfileEntry { + level_hPa: number + height_m: number + N: number + M: number + T_C: number + RH: number +} + +// Gradient entry +export interface GradientEntry { + from_level: number + to_level: number + from_height_m: number + to_height_m: number + gradient: number +} + +export interface SWPCStatus { + enabled: boolean + kp_current?: number + kp_timestamp?: string + sfi?: number + r_scale?: number + s_scale?: number + g_scale?: number + active_warnings?: string[] + kp_history?: KpHistoryEntry[] + sfi_history?: SfiHistoryEntry[] +} + +export interface DuctingStatus { + enabled: boolean + condition?: string + min_gradient?: number + duct_thickness_m?: number | null + duct_base_m?: number | null + last_update?: string + profile?: ProfileEntry[] + gradients?: GradientEntry[] + assessment?: string + location?: { lat: number; lon: number } +} + +export interface RFPropagation { + hf: { + kp_current?: number + sfi?: number + r_scale?: number + s_scale?: number + g_scale?: number + active_warnings?: string[] + kp_history?: KpHistoryEntry[] + } + uhf_ducting: { + condition?: string + min_gradient?: number + duct_thickness_m?: number | null + profile?: ProfileEntry[] + } +} + +// API fetch helpers + +async function fetchJson(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +export async function fetchStatus(): Promise { + return fetchJson('/api/status') +} + +export async function fetchHealth(): Promise { + return fetchJson('/api/health') +} + +export async function fetchNodes(): Promise { + return fetchJson('/api/nodes') +} + +export async function fetchEdges(): Promise { + return fetchJson('/api/edges') +} + +export async function fetchSources(): Promise { + return fetchJson('/api/sources') +} + +export async function fetchConfig(section?: string): Promise { + const url = section ? `/api/config/${section}` : '/api/config' + return fetchJson(url) +} + +export async function updateConfig( + section: string, + data: unknown +): Promise<{ saved: boolean; restart_required: boolean }> { + const response = await fetch(`/api/config/${section}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +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') +} + +export async function fetchEnvActive(): Promise { + return fetchJson('/api/env/active') +} + +export async function fetchRFPropagation(): Promise { + return fetchJson('/api/env/propagation') +} + +export async function fetchSWPC(): Promise { + return fetchJson('/api/env/swpc') +} + +export async function fetchDucting(): Promise { + return fetchJson('/api/env/ducting') +} + +export interface FireEvent { + source: string + event_id: string + event_type: string + severity: string + headline: string + name: string + acres: number + pct_contained: number + lat: number | null + lon: number | null + distance_km: number | null + nearest_anchor: string | null + state: string + expires: number + fetched_at: number + polygon?: number[][][] +} + +export interface AvalancheEvent { + source: string + event_id: string + event_type: string + severity: string + headline: string + zone_name: string + center: string + center_id: string + center_link: string + forecast_link: string + danger: string + danger_level: number + danger_name: string + travel_advice: string + state: string + lat: number | null + lon: number | null + expires: number + fetched_at: number +} + +export interface StreamGaugeEvent { + source: string + event_id: string + event_type: string + headline: string + severity: string + lat?: number + lon?: number + expires: number + fetched_at: number + properties: { + site_id: string + site_name: string + parameter: string + value: number + unit: string + timestamp: string + } +} + +export interface TrafficEvent { + source: string + event_id: string + event_type: string + headline: string + severity: string + lat?: number + lon?: number + expires: number + fetched_at: number + properties: { + corridor: string + currentSpeed: number + freeFlowSpeed: number + speedRatio: number + currentTravelTime: number + freeFlowTravelTime: number + confidence: number + roadClosure: boolean + } +} + +export interface RoadEvent { + source: string + event_id: string + event_type: string + headline: string + description?: string + severity: string + lat?: number + lon?: number + expires: number + fetched_at: number + properties: { + roadway: string + is_closure: boolean + last_updated?: string + } +} + +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[] +} + +export async function fetchFires(): Promise { + return fetchJson('/api/env/fires') +} + +export async function fetchAvalanche(): Promise { + return fetchJson('/api/env/avalanche') +} + +export async function fetchStreams(): Promise { + return fetchJson('/api/env/streams') +} + +export async function fetchTraffic(): Promise { + return fetchJson('/api/env/traffic') +} + +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/Config.tsx b/dashboard-frontend/src/pages/Config.tsx index 863868b..6ac9222 100644 --- a/dashboard-frontend/src/pages/Config.tsx +++ b/dashboard-frontend/src/pages/Config.tsx @@ -1,2399 +1,2421 @@ -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 ( -
- -