diff --git a/config.example.yaml b/config.example.yaml index cb45e3f..6a68c1a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,173 +1,326 @@ -# MeshAI Configuration -# LLM-powered Meshtastic assistant -# -# Copy this to config.yaml and customize as needed -# For Docker: mount as /data/config.yaml - -# === BOT IDENTITY === -bot: - name: ai # Bot's display name - owner: "" # Owner's callsign (optional) - respond_to_dms: true # Respond to direct messages - filter_bbs_protocols: true # Ignore advBBS sync/notification messages - -# === MESHTASTIC CONNECTION === -connection: - type: tcp # serial | tcp - serial_port: /dev/ttyUSB0 # For serial connection - tcp_host: localhost # For TCP connection (meshtasticd) - tcp_port: 4403 - -# === RESPONSE BEHAVIOR === -response: - delay_min: 2.2 # Min delay before responding (seconds) - delay_max: 3.0 # Max delay before responding - max_length: 200 # Max chars per message chunk - max_messages: 3 # Max message chunks per response - -# === CONVERSATION HISTORY === -history: - database: /data/conversations.db - max_messages_per_user: 50 # Messages to keep per user - conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h) - auto_cleanup: true # Auto-delete old conversations - cleanup_interval_hours: 24 # How often to run cleanup - max_age_days: 30 # Delete conversations older than this - -# === MEMORY OPTIMIZATION === -memory: - enabled: true # Enable rolling summary memory - window_size: 4 # Recent message pairs to keep in full - summarize_threshold: 8 # Messages before re-summarizing - -# === MESH CONTEXT === -context: - enabled: true # Observe channel traffic for LLM context - observe_channels: [] # Channel indices to observe (empty = all) - ignore_nodes: [] # Node IDs to exclude from observation - max_age: 2592000 # Max age in seconds (default 30 days) - max_context_items: 20 # Max observations injected into LLM context - -# === LLM BACKEND === -llm: - backend: openai # openai | anthropic | google - api_key: "" # API key (or use LLM_API_KEY env var) - base_url: https://api.openai.com/v1 # API base URL - model: gpt-4o-mini # Model name - timeout: 30 # Request timeout (seconds) - system_prompt: >- - You are a helpful assistant on a Meshtastic mesh network. - Keep responses very brief - 1-2 short sentences, under 300 characters. - Only give longer answers if the user explicitly asks for detail or explanation. - Be concise but friendly. No markdown formatting. - google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries) - -# === WEATHER === -weather: - primary: openmeteo # openmeteo | wttr | llm - fallback: llm # openmeteo | wttr | llm | none - default_location: "" # Default location for !weather (optional) - -# === MESHMONITOR INTEGRATION === -meshmonitor: - enabled: false # Enable MeshMonitor trigger sync - url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333) - inject_into_prompt: true # Include trigger list in LLM prompt - refresh_interval: 300 # Seconds between trigger refreshes - -# === KNOWLEDGE BASE (RAG) === -knowledge: - enabled: false # Enable knowledge base search - db_path: "" # Path to knowledge SQLite database - top_k: 5 # Number of chunks to retrieve per query - -# === MESH DATA SOURCES === -# Connect to Meshview and/or MeshMonitor instances for live mesh -# network analysis. Supports multiple sources. Configure via TUI -# with meshai --config (Mesh Sources menu). -# -# mesh_sources: -# - name: "my-meshview" -# type: meshview -# url: "https://meshview.example.com" -# refresh_interval: 300 -# enabled: true -# -# - name: "my-meshmonitor" -# type: meshmonitor -# url: "http://192.168.1.100:3333" -# api_token: "${MM_API_TOKEN}" -# refresh_interval: 300 -# enabled: true -mesh_sources: [] - -# === MESH INTELLIGENCE === -# Geographic clustering and health scoring for mesh analysis. -# Requires mesh_sources to be configured with at least one data source. -# -# mesh_intelligence: -# enabled: true -# region_radius_miles: 40.0 # Radius for region clustering -# locality_radius_miles: 8.0 # Radius for locality clustering -# offline_threshold_hours: 24 # Hours before node considered offline -# packet_threshold: 500 # Non-text packets per 24h to flag -# battery_warning_percent: 20 # Battery level for warnings -# infra_overrides: [] # Node IDs to exclude from infrastructure -# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"} -mesh_intelligence: - enabled: false - region_radius_miles: 40.0 - locality_radius_miles: 8.0 - offline_threshold_hours: 24 - packet_threshold: 500 - battery_warning_percent: 20 - infra_overrides: [] - region_labels: {} - -# === ENVIRONMENTAL FEEDS === -# Live situational awareness from NWS, NOAA Space Weather, and Open-Meteo. -# Provides weather alerts, HF propagation assessment, and tropospheric ducting. -# -environmental: - enabled: false - nws_zones: - - "IDZ016" # Western Magic Valley - - "IDZ030" # Southern Twin Falls County - - # NWS Weather Alerts (api.weather.gov) - nws: - enabled: true - tick_seconds: 60 - areas: ["ID"] - severity_min: "moderate" - user_agent: "(meshai.example.com, ops@example.com)" # REQUIRED by NWS - - # NOAA Space Weather (services.swpc.noaa.gov) - swpc: - enabled: true - - # Tropospheric ducting assessment (Open-Meteo GFS, no auth) - ducting: - enabled: true - tick_seconds: 10800 # 3 hours - latitude: 42.56 # center of mesh coverage area - longitude: -114.47 - - # NIFC Fire Perimeters (Phase 2) - fires: - enabled: false - tick_seconds: 600 - state: "US-ID" - - # Avalanche Advisories (Phase 2) - avalanche: - enabled: false - tick_seconds: 1800 - center_ids: ["SNFAC"] - season_months: [12, 1, 2, 3, 4] - -# === WEB DASHBOARD === -dashboard: - enabled: true - port: 8080 - host: "0.0.0.0" +# MeshAI Configuration +# LLM-powered Meshtastic assistant +# +# Copy this to config.yaml and customize as needed +# For Docker: mount as /data/config.yaml + +# === BOT IDENTITY === +bot: + name: ai # Bot's display name + owner: "" # Owner's callsign (optional) + respond_to_dms: true # Respond to direct messages + filter_bbs_protocols: true # Ignore advBBS sync/notification messages + +# === MESHTASTIC CONNECTION === +connection: + type: tcp # serial | tcp + serial_port: /dev/ttyUSB0 # For serial connection + tcp_host: localhost # For TCP connection (meshtasticd) + tcp_port: 4403 + +# === RESPONSE BEHAVIOR === +response: + delay_min: 2.2 # Min delay before responding (seconds) + delay_max: 3.0 # Max delay before responding + max_length: 200 # Max chars per message chunk + max_messages: 3 # Max message chunks per response + +# === CONVERSATION HISTORY === +history: + database: /data/conversations.db + max_messages_per_user: 50 # Messages to keep per user + conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h) + auto_cleanup: true # Auto-delete old conversations + cleanup_interval_hours: 24 # How often to run cleanup + max_age_days: 30 # Delete conversations older than this + +# === MEMORY OPTIMIZATION === +memory: + enabled: true # Enable rolling summary memory + window_size: 4 # Recent message pairs to keep in full + summarize_threshold: 8 # Messages before re-summarizing + +# === MESH CONTEXT === +context: + enabled: true # Observe channel traffic for LLM context + observe_channels: [] # Channel indices to observe (empty = all) + ignore_nodes: [] # Node IDs to exclude from observation + max_age: 2592000 # Max age in seconds (default 30 days) + max_context_items: 20 # Max observations injected into LLM context + +# === LLM BACKEND === +llm: + backend: openai # openai | anthropic | google + api_key: "" # API key (or use LLM_API_KEY env var) + base_url: https://api.openai.com/v1 # API base URL + model: gpt-4o-mini # Model name + timeout: 30 # Request timeout (seconds) + system_prompt: >- + You are a helpful assistant on a Meshtastic mesh network. + Keep responses very brief - 1-2 short sentences, under 300 characters. + Only give longer answers if the user explicitly asks for detail or explanation. + Be concise but friendly. No markdown formatting. + google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries) + +# === WEATHER === +weather: + primary: openmeteo # openmeteo | wttr | llm + fallback: llm # openmeteo | wttr | llm | none + default_location: "" # Default location for !weather (optional) + +# === MESHMONITOR INTEGRATION === +meshmonitor: + enabled: false # Enable MeshMonitor trigger sync + url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333) + inject_into_prompt: true # Include trigger list in LLM prompt + refresh_interval: 300 # Seconds between trigger refreshes + +# === KNOWLEDGE BASE (RAG) === +knowledge: + enabled: false # Enable knowledge base search + db_path: "" # Path to knowledge SQLite database + top_k: 5 # Number of chunks to retrieve per query + +# === MESH DATA SOURCES === +# Connect to Meshview and/or MeshMonitor instances for live mesh +# network analysis. Supports multiple sources. Configure via TUI +# with meshai --config (Mesh Sources menu). +# +# mesh_sources: +# - name: "my-meshview" +# type: meshview +# url: "https://meshview.example.com" +# refresh_interval: 300 +# enabled: true +# +# - name: "my-meshmonitor" +# type: meshmonitor +# url: "http://192.168.1.100:3333" +# api_token: "${MM_API_TOKEN}" +# refresh_interval: 300 +# enabled: true +# +# - name: "mqtt-broker" +# type: mqtt +# host: "mqtt.meshtastic.org" +# port: 1883 +# username: "meshdev" +# password: "large4cats" +# topic_root: "msh/US" +# use_tls: false +# enabled: true +mesh_sources: [] + +# === MESH INTELLIGENCE === +# Geographic clustering and health scoring for mesh analysis. +# Requires mesh_sources to be configured with at least one data source. +# +# mesh_intelligence: +# enabled: true +# region_radius_miles: 40.0 # Radius for region clustering +# locality_radius_miles: 8.0 # Radius for locality clustering +# offline_threshold_hours: 24 # Hours before node considered offline +# packet_threshold: 500 # Non-text packets per 24h to flag +# battery_warning_percent: 20 # Battery level for warnings +# infra_overrides: [] # Node IDs to exclude from infrastructure +# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"} +mesh_intelligence: + enabled: false + region_radius_miles: 40.0 + locality_radius_miles: 8.0 + offline_threshold_hours: 24 + packet_threshold: 500 + battery_warning_percent: 20 + infra_overrides: [] + region_labels: {} + +# === ENVIRONMENTAL FEEDS === +# Live situational awareness from NWS, NOAA Space Weather, and Open-Meteo. +# Provides weather alerts, HF propagation assessment, and tropospheric ducting. +# +environmental: + enabled: false + nws_zones: + - "IDZ016" # Western Magic Valley + - "IDZ030" # Southern Twin Falls County + + # NWS Weather Alerts (api.weather.gov) + nws: + enabled: true + tick_seconds: 60 + areas: ["ID"] + severity_min: "moderate" + user_agent: "(meshai.example.com, ops@example.com)" # REQUIRED by NWS + + # NOAA Space Weather (services.swpc.noaa.gov) + swpc: + enabled: true + + # Tropospheric ducting assessment (Open-Meteo GFS, no auth) + ducting: + enabled: true + tick_seconds: 10800 # 3 hours + latitude: 42.56 # center of mesh coverage area + longitude: -114.47 + + # NIFC Fire Perimeters (Phase 2) + fires: + enabled: false + tick_seconds: 600 + state: "US-ID" + + # Avalanche Advisories (Phase 2) + avalanche: + enabled: false + tick_seconds: 1800 + center_ids: ["SNFAC"] + season_months: [12, 1, 2, 3, 4] + + # USGS Stream Gauges (waterservices.usgs.gov) + # Find site IDs at https://waterdata.usgs.gov/nwis + usgs: + enabled: false + tick_seconds: 900 # Min 15 min per USGS guidelines + sites: [] # e.g. ["13090500", "13088000"] + + # TomTom Traffic Flow (api.tomtom.com, requires API key) + traffic: + enabled: false + tick_seconds: 300 + api_key: "" # Get key at developer.tomtom.com + corridors: [] + # Example corridors: + # - name: "I-84 Twin Falls" + # lat: 42.56 + # lon: -114.47 + + # 511 Road Conditions (state-specific, configurable base URL) + roads511: + enabled: false + tick_seconds: 300 + api_key: "" + base_url: "" # e.g. "https://511.idaho.gov/api/v2" + 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_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/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/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/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/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 6d4549d..b2cf8d1 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[] @@ -119,6 +147,37 @@ export interface EnvEvent { [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 @@ -128,6 +187,8 @@ export interface SWPCStatus { s_scale?: number g_scale?: number active_warnings?: string[] + kp_history?: KpHistoryEntry[] + sfi_history?: SfiHistoryEntry[] } export interface DuctingStatus { @@ -137,6 +198,10 @@ export interface DuctingStatus { 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 { @@ -147,11 +212,13 @@ export interface RFPropagation { 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[] } } @@ -209,6 +276,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') } @@ -270,6 +355,96 @@ export interface AvalancheEvent { 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[] @@ -283,6 +458,22 @@ 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/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 0246dd6..cfa26f3 100644 --- a/dashboard-frontend/src/pages/Config.tsx +++ b/dashboard-frontend/src/pages/Config.tsx @@ -1,91 +1,14 @@ import { useState, useEffect, useCallback, useRef } 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, HelpCircle + Check, X, Eye as EyeIcon, EyeOff, ExternalLink } from 'lucide-react' -// Voltage lookup for Li-ion battery percentages -const VOLTAGE_MAP: Record = { - 100: '4.20V', - 90: '4.10V', - 80: '4.00V', - 70: '3.90V', - 60: '3.80V', - 50: '3.70V', - 40: '3.65V', - 30: '3.60V', - 20: '3.55V', - 15: '3.50V', - 10: '3.45V', - 7: '3.40V', - 5: '3.38V', - 0: '3.30V', -} - -function getVoltageApprox(percent: number): string { - const keys = Object.keys(VOLTAGE_MAP).map(Number).sort((a, b) => b - a) - for (const key of keys) { - if (percent >= key) return VOLTAGE_MAP[key] - } - return '3.30V' -} - -// Section descriptions -const SECTION_DESCRIPTIONS: Record = { - bot: 'Configure the bot identity and basic behavior settings for the Meshtastic AI assistant.', - connection: 'Set up how the bot connects to your Meshtastic device — via serial port or TCP network connection.', - response: 'Control message timing and length limits. Delays help avoid channel congestion; length limits fit LoRa constraints.', - history: 'Manage conversation history storage. Messages are stored in SQLite for context and analytics.', - memory: 'Memory optimization summarizes old conversations to reduce token usage while preserving context.', - context: 'Passive context lets the bot observe channel traffic to understand ongoing conversations without being directly addressed.', - commands: 'Configure slash commands that users can send to trigger specific bot actions.', - llm: 'Configure the LLM backend (OpenAI, Anthropic, Google) and model settings for AI responses.', - weather: 'Set up weather providers for the !wx command. Open-Meteo is free; wttr.in has rate limits.', - meshmonitor: 'Connect to MeshMonitor for real-time mesh network telemetry and node information.', - knowledge: 'RAG (Retrieval-Augmented Generation) knowledge base for answering questions from your documents.', - mesh_sources: 'Connect to mesh visualization tools (MeshView, MeshMonitor) to aggregate node data.', - mesh_intelligence: 'Mesh Intelligence monitors network health, detects outages, and generates alerts.', - environmental: 'Environmental data feeds for weather alerts, space weather, fires, and avalanche conditions.', - dashboard: 'Configure the web dashboard server settings.', -} - -// Info button component with popover -function InfoButton({ info }: { info: string }) { - const [show, setShow] = useState(false) - const ref = useRef(null) - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setShow(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - - return ( -
- - {show && ( -
- {info} -
- )} -
- ) -} - // Types for config sections interface BotConfig { name: string @@ -190,6 +113,12 @@ interface MeshSourceConfig { refresh_interval: number polite_mode: boolean enabled: boolean + host?: string + port?: number + username?: string + password?: string + topic_root?: string + use_tls?: boolean } interface RegionAnchor { @@ -258,6 +187,10 @@ interface EnvironmentalConfig { 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 { @@ -304,8 +237,145 @@ const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [ { 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 with click-outside dismiss and X close button +function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; link?: string; linkText?: string }) { + const [open, setOpen] = useState(false) + const popoverRef = useRef(null) + + // Close on click outside + useEffect(() => { + if (!open) return + function handleClickOutside(e: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + const timer = setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0) + return () => { + clearTimeout(timer) + document.removeEventListener('mousedown', handleClickOutside) + } + }, [open]) + + return ( +
+ + {open && ( +
+ +
{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 = '' }: { +function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '', infoLink = '' }: { label: string value: string onChange: (v: string) => void @@ -313,6 +383,7 @@ function TextInput({ label, value, onChange, type = 'text', placeholder = '', he placeholder?: string helper?: string info?: string + infoLink?: string }) { const [showPassword, setShowPassword] = useState(false) const isPassword = type === 'password' @@ -321,7 +392,7 @@ function TextInput({ label, value, onChange, type = 'text', placeholder = '', he
void @@ -355,13 +426,13 @@ function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', step?: number helper?: string info?: string - suffix?: string + infoLink?: string }) { return (
void helper?: string info?: string + infoLink?: string }) { return (
{label} - {info && } + {info && } {helper &&

{helper}

}
@@ -410,20 +482,20 @@ function Toggle({ label, checked, onChange, helper = '', info = '' }: { ) } -function SelectInput({ label, value, onChange, options, helper = '', info = '' }: { +function SelectInput({ label, value, onChange, options, helper = '', info = '', infoLink = '' }: { label: string value: string onChange: (v: string) => void - options: { value: string; label: string; description?: string }[] + options: { value: string; label: string }[] helper?: string info?: string + infoLink?: string }) { - const selectedOption = options.find(o => o.value === value) return (
- {selectedOption?.description && ( -

{selectedOption.description}

- )} {helper &&

{helper}

}
) } -function TextArea({ label, value, onChange, rows = 4, helper = '', info = '' }: { +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 (