Compare commits

...

4 commits

Author SHA1 Message Date
23151f63ba fix(dashboard): info popover toggle and click-outside dismiss
- Replace fixed overlay with useRef-based click-outside detection
- Add X close button in top-right corner of popover
- Click ? to toggle (open if closed, close if open)
- Click anywhere outside popover to dismiss
- Remove fixed inset-0 overlay that was blocking page interaction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 16:04:36 +00:00
64faf33e3b feat: researched defaults + USGS auto-lookup + category documentation
- Battery thresholds: 30%/15%/5% with voltage equivalents (3.60V/3.50V/3.40V)
- Channel utilization threshold: 40% (firmware throttles GPS at 25%)
- Packet flood threshold: 10 packets/min per node (was 500/day)
- Mesh health threshold: 65 (was 70)
- USGS adapter with NWS NWPS flood stage auto-lookup
- API endpoint: GET /api/env/usgs/lookup/{site_id}
- Alert categories with detailed descriptions and example messages
- Packet flood vs stream flood terminology fully disambiguated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:14:16 +00:00
7286c9ab44 feat(dashboard): RF propagation visualizations + live event feed
- SFI/Kp as prominent color-coded values with trend chart
- R/S/G scales as colored severity badges
- Tropospheric ducting condition with refractivity profile
- Environmental feeds replaced with scrolling live event timeline
- Unified activity log across all 9 feed adapters
- Source icons, severity badges, chronological order
- Real-time updates via WebSocket
- SWPC adapter stores Kp/SFI history for charting
- No wasted card space

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 14:47:15 +00:00
d90b787c12 refactor(notifications): complete UX redesign
- Self-contained rules replace abstract channels
- Inline delivery config (broadcast/DM/email/webhook or none)
- quiet_hours_enabled master toggle separate from start/end times
- delivery_type="" valid: rule matches but does not deliver
- Severity dropdown with plain-English descriptions
- Example messages per alert category
- Default baseline rules: Emergency Broadcast, Infrastructure Down, Fire Alert, Severe Weather
- Condition vs Schedule trigger types
- Test and preview buttons per rule
- stream_flood_warning renamed from flood_warning (distinct from packet_flood)
- Categories display with descriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 14:25:57 +00:00
17 changed files with 5079 additions and 4124 deletions

View file

@ -216,80 +216,111 @@ environmental:
confidence_min: "nominal" # low, nominal, high confidence_min: "nominal" # low, nominal, high
proximity_km: 10.0 # km to match known fire perimeters proximity_km: 10.0 # km to match known fire perimeters
# === NOTIFICATION DELIVERY === # === NOTIFICATION DELIVERY ===
# Route alerts to channels (mesh, email, webhook) based on rules. # Route alerts to channels (mesh, email, webhook) based on rules.
# Categories match alert types from alert_engine.py. # Categories match alert types from alert_engine.py.
# Severity levels: info, advisory, watch, warning, critical, emergency # Severity levels: info, advisory, watch, warning, critical, emergency
# #
notifications: notifications:
enabled: false enabled: false
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours quiet_hours_enabled: true # Master toggle for quiet hours feature
quiet_hours_end: "06:00" 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: # Notification rules - each rule is self-contained with its own delivery config
# All emergencies -> mesh broadcast # Default baseline rules are created on fresh install
- name: "Emergency Broadcast" rules:
enabled: true # Emergency Broadcast - all emergencies go out immediately
trigger_type: condition - name: "Emergency Broadcast"
categories: [] # Empty = all categories enabled: true
min_severity: "emergency" trigger_type: condition
delivery_type: mesh_broadcast categories: [] # Empty = all categories
broadcast_channel: 0 min_severity: "emergency"
cooldown_minutes: 5 delivery_type: mesh_broadcast
override_quiet: true # Send even during quiet hours broadcast_channel: 0
cooldown_minutes: 5
# Example: Fire alerts -> email override_quiet: true # Send even during quiet hours
# - name: "Fire Alerts Email"
# enabled: true # Infrastructure Down - critical node and infrastructure offline alerts
# trigger_type: condition - name: "Infrastructure Down"
# categories: ["wildfire_proximity", "new_ignition"] enabled: true
# min_severity: "advisory" trigger_type: condition
# delivery_type: email categories: ["infra_offline", "critical_node_down"]
# smtp_host: "smtp.gmail.com" min_severity: "warning"
# smtp_port: 587 delivery_type: mesh_broadcast
# smtp_user: "you@gmail.com" broadcast_channel: 0
# smtp_password: "${SMTP_PASSWORD}" cooldown_minutes: 30
# smtp_tls: true override_quiet: false
# from_address: "meshai@yourdomain.com"
# recipients: ["admin@yourdomain.com"] # Fire Alert - wildfire proximity and new ignition
# cooldown_minutes: 30 - name: "Fire Alert"
enabled: true
# Example: All warnings -> Discord webhook trigger_type: condition
# - name: "Discord Alerts" categories: ["wildfire_proximity", "new_ignition"]
# enabled: true min_severity: "advisory"
# trigger_type: condition delivery_type: mesh_broadcast
# categories: [] broadcast_channel: 0
# min_severity: "warning" cooldown_minutes: 60
# delivery_type: webhook override_quiet: false
# webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10 # Severe Weather - weather warnings
- name: "Severe Weather"
# Example: Daily health report -> mesh broadcast enabled: true
# - name: "Morning Briefing" trigger_type: condition
# enabled: true categories: ["weather_warning"]
# trigger_type: schedule min_severity: "warning"
# schedule_frequency: daily delivery_type: mesh_broadcast
# schedule_time: "07:00" broadcast_channel: 0
# message_type: mesh_health_summary cooldown_minutes: 30
# delivery_type: mesh_broadcast override_quiet: false
# broadcast_channel: 0
# Example: Fire alerts -> email
# Example: Weekly digest -> email # - name: "Fire Alerts Email"
# - name: "Weekly Digest" # enabled: true
# enabled: true # trigger_type: condition
# trigger_type: schedule # categories: ["wildfire_proximity", "new_ignition"]
# schedule_frequency: weekly # min_severity: "advisory"
# schedule_days: ["monday"] # delivery_type: email
# schedule_time: "08:00" # smtp_host: "smtp.gmail.com"
# message_type: alerts_digest # smtp_port: 587
# delivery_type: email # smtp_user: "you@gmail.com"
# smtp_host: "smtp.gmail.com" # smtp_password: "${SMTP_PASSWORD}"
# recipients: ["admin@example.com"] # smtp_tls: true
# from_address: "meshai@yourdomain.com"
# === WEB DASHBOARD === # recipients: ["admin@yourdomain.com"]
dashboard: # cooldown_minutes: 30
enabled: true
port: 8080 # Example: All warnings -> Discord webhook
host: "0.0.0.0" # - 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"

View file

@ -1,102 +1,109 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { MeshHealth, Alert } from '@/lib/api' import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
interface WebSocketMessage { interface WebSocketMessage {
type: string type: string
data: unknown data?: unknown
} event?: EnvEvent
}
interface UseWebSocketReturn {
connected: boolean interface UseWebSocketReturn {
lastHealth: MeshHealth | null connected: boolean
lastAlert: Alert | null lastHealth: MeshHealth | null
} lastAlert: Alert | null
lastMessage: WebSocketMessage | null
export function useWebSocket(): UseWebSocketReturn { }
const [connected, setConnected] = useState(false)
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null) export function useWebSocket(): UseWebSocketReturn {
const [lastAlert, setLastAlert] = useState<Alert | null>(null) const [connected, setConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null) const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null) const [lastAlert, setLastAlert] = useState<Alert | null>(null)
const reconnectDelayRef = useRef(1000) const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const connect = useCallback(() => { const reconnectTimeoutRef = useRef<number | null>(null)
if (wsRef.current?.readyState === WebSocket.OPEN) { const reconnectDelayRef = useRef(1000)
return
} const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return
const wsUrl = `${protocol}//${window.location.host}/ws/live` }
try { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(wsUrl) const wsUrl = `${protocol}//${window.location.host}/ws/live`
wsRef.current = ws
try {
ws.onopen = () => { const ws = new WebSocket(wsUrl)
setConnected(true) wsRef.current = ws
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
} ws.onopen = () => {
setConnected(true)
ws.onmessage = (event) => { reconnectDelayRef.current = 1000 // Reset backoff on successful connection
try { }
const message: WebSocketMessage = JSON.parse(event.data)
ws.onmessage = (event) => {
switch (message.type) { try {
case 'health_update': const message: WebSocketMessage = JSON.parse(event.data)
setLastHealth(message.data as MeshHealth)
break // Store all messages for generic handling
case 'alert_fired': setLastMessage(message)
setLastAlert(message.data as Alert)
break switch (message.type) {
} case 'health_update':
} catch (e) { setLastHealth(message.data as MeshHealth)
console.error('Failed to parse WebSocket message:', e) break
} case 'alert_fired':
} setLastAlert(message.data as Alert)
break
ws.onclose = () => { // env_update messages are handled via lastMessage
setConnected(false) }
wsRef.current = null } catch (e) {
console.error('Failed to parse WebSocket message:', e)
// Schedule reconnect with exponential backoff }
const delay = Math.min(reconnectDelayRef.current, 30000) }
reconnectTimeoutRef.current = window.setTimeout(() => {
reconnectDelayRef.current = Math.min(delay * 2, 30000) ws.onclose = () => {
connect() setConnected(false)
}, delay) wsRef.current = null
}
// Schedule reconnect with exponential backoff
ws.onerror = () => { const delay = Math.min(reconnectDelayRef.current, 30000)
ws.close() reconnectTimeoutRef.current = window.setTimeout(() => {
} reconnectDelayRef.current = Math.min(delay * 2, 30000)
connect()
// Keepalive ping every 30 seconds }, delay)
const pingInterval = setInterval(() => { }
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping') ws.onerror = () => {
} ws.close()
}, 30000) }
ws.addEventListener('close', () => { // Keepalive ping every 30 seconds
clearInterval(pingInterval) const pingInterval = setInterval(() => {
}) if (ws.readyState === WebSocket.OPEN) {
} catch (e) { ws.send('ping')
console.error('Failed to create WebSocket:', e) }
} }, 30000)
}, [])
ws.addEventListener('close', () => {
useEffect(() => { clearInterval(pingInterval)
connect() })
} catch (e) {
return () => { console.error('Failed to create WebSocket:', e)
if (reconnectTimeoutRef.current) { }
clearTimeout(reconnectTimeoutRef.current) }, [])
}
if (wsRef.current) { useEffect(() => {
wsRef.current.close() connect()
}
} return () => {
}, [connect]) if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
return { connected, lastHealth, lastAlert } }
} if (wsRef.current) {
wsRef.current.close()
}
}
}, [connect])
return { connected, lastHealth, lastAlert, lastMessage }
}

View file

@ -1,440 +1,479 @@
// API types matching actual backend responses // API types matching actual backend responses
export interface SystemStatus { export interface SystemStatus {
version: string version: string
uptime_seconds: number uptime_seconds: number
bot_name: string bot_name: string
connection_type: string connection_type: string
connection_target: string connection_target: string
connected: boolean connected: boolean
node_count: number node_count: number
source_count: number source_count: number
env_feeds_enabled: boolean env_feeds_enabled: boolean
dashboard_port: number dashboard_port: number
} }
export interface MeshHealth { export interface MeshHealth {
score: number score: number
tier: string tier: string
pillars: { pillars: {
infrastructure: number infrastructure: number
utilization: number utilization: number
behavior: number behavior: number
power: number power: number
} }
infra_online: number infra_online: number
infra_total: number infra_total: number
util_percent: number util_percent: number
flagged_nodes: number flagged_nodes: number
battery_warnings: number battery_warnings: number
total_nodes: number total_nodes: number
total_regions: number total_regions: number
unlocated_count: number unlocated_count: number
last_computed: string last_computed: string
recommendations: string[] recommendations: string[]
} }
export interface NodeInfo { export interface NodeInfo {
node_num: number node_num: number
node_id_hex: string node_id_hex: string
short_name: string short_name: string
long_name: string long_name: string
role: string role: string
latitude: number | null latitude: number | null
longitude: number | null longitude: number | null
last_heard: string | null last_heard: string | null
battery_level: number | null battery_level: number | null
voltage: number | null voltage: number | null
snr: number | null snr: number | null
firmware: string firmware: string
hardware: string hardware: string
uptime: number | null uptime: number | null
sources: string[] sources: string[]
} }
export interface EdgeInfo { export interface EdgeInfo {
from_node: number from_node: number
to_node: number to_node: number
snr: number snr: number
quality: string quality: string
} }
export interface RegionInfo { export interface RegionInfo {
name: string name: string
local_name: string local_name: string
node_count: number node_count: number
infra_count: number infra_count: number
infra_online: number infra_online: number
online_count: number online_count: number
score: number score: number
tier: string tier: string
center_lat: number center_lat: number
center_lon: number center_lon: number
} }
export interface SourceHealth { export interface SourceHealth {
name: string name: string
type: string type: string
url: string url: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
response_time_ms: number | null response_time_ms: number | null
tick_count: number tick_count: number
node_count: number node_count: number
} }
export interface Alert { export interface Alert {
type: string type: string
severity: string severity: string
message: string message: string
timestamp: string timestamp: string
scope_type?: string scope_type?: string
scope_value?: string scope_value?: string
} }
export interface AlertHistoryItem { export interface AlertHistoryItem {
id?: number id?: number
type: string type: string
severity: string severity: string
message: string message: string
timestamp: string timestamp: string
duration?: number duration?: number
scope_type?: string scope_type?: string
scope_value?: string scope_value?: string
resolved_at?: string resolved_at?: string
} }
export interface AlertHistoryResponse { export interface AlertHistoryResponse {
items: AlertHistoryItem[] items: AlertHistoryItem[]
total: number total: number
} }
export interface Subscription { export interface Subscription {
id: number id: number
user_id: string user_id: string
sub_type: string sub_type: string
schedule_time?: string schedule_time?: string
schedule_day?: string schedule_day?: string
scope_type: string scope_type: string
scope_value?: string scope_value?: string
enabled: boolean enabled: boolean
} }
export interface EnvStatus { export interface EnvStatus {
enabled: boolean enabled: boolean
feeds: EnvFeedHealth[] feeds: EnvFeedHealth[]
} }
export interface EnvFeedHealth { export interface EnvFeedHealth {
source: string source: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
event_count: number event_count: number
last_fetch: number last_fetch: number
} }
export interface EnvEvent { export interface EnvEvent {
source: string source: string
event_id: string event_id: string
event_type: string event_type: string
severity: string severity: string
headline: string headline: string
description?: string description?: string
expires?: number expires?: number
fetched_at: number fetched_at: number
[key: string]: unknown [key: string]: unknown
} }
export interface SWPCStatus { // Kp history entry for charting
enabled: boolean export interface KpHistoryEntry {
kp_current?: number time: string
kp_timestamp?: string value: number
sfi?: number }
r_scale?: number
s_scale?: number // SFI history entry for charting
g_scale?: number export interface SfiHistoryEntry {
active_warnings?: string[] time: string
} value: number
}
export interface DuctingStatus {
enabled: boolean // Refractivity profile entry
condition?: string export interface ProfileEntry {
min_gradient?: number level_hPa: number
duct_thickness_m?: number | null height_m: number
duct_base_m?: number | null N: number
last_update?: string M: number
} T_C: number
RH: number
export interface RFPropagation { }
hf: {
kp_current?: number // Gradient entry
sfi?: number export interface GradientEntry {
r_scale?: number from_level: number
s_scale?: number to_level: number
g_scale?: number from_height_m: number
active_warnings?: string[] to_height_m: number
} gradient: number
uhf_ducting: { }
condition?: string
min_gradient?: number export interface SWPCStatus {
duct_thickness_m?: number | null enabled: boolean
} kp_current?: number
} kp_timestamp?: string
sfi?: number
// API fetch helpers r_scale?: number
s_scale?: number
async function fetchJson<T>(url: string): Promise<T> { g_scale?: number
const response = await fetch(url) active_warnings?: string[]
if (!response.ok) { kp_history?: KpHistoryEntry[]
throw new Error(`API error: ${response.status} ${response.statusText}`) sfi_history?: SfiHistoryEntry[]
} }
return response.json()
} export interface DuctingStatus {
enabled: boolean
export async function fetchStatus(): Promise<SystemStatus> { condition?: string
return fetchJson<SystemStatus>('/api/status') min_gradient?: number
} duct_thickness_m?: number | null
duct_base_m?: number | null
export async function fetchHealth(): Promise<MeshHealth> { last_update?: string
return fetchJson<MeshHealth>('/api/health') profile?: ProfileEntry[]
} gradients?: GradientEntry[]
assessment?: string
export async function fetchNodes(): Promise<NodeInfo[]> { location?: { lat: number; lon: number }
return fetchJson<NodeInfo[]>('/api/nodes') }
}
export interface RFPropagation {
export async function fetchEdges(): Promise<EdgeInfo[]> { hf: {
return fetchJson<EdgeInfo[]>('/api/edges') kp_current?: number
} sfi?: number
r_scale?: number
export async function fetchSources(): Promise<SourceHealth[]> { s_scale?: number
return fetchJson<SourceHealth[]>('/api/sources') g_scale?: number
} active_warnings?: string[]
kp_history?: KpHistoryEntry[]
export async function fetchConfig(section?: string): Promise<unknown> { }
const url = section ? `/api/config/${section}` : '/api/config' uhf_ducting: {
return fetchJson(url) condition?: string
} min_gradient?: number
duct_thickness_m?: number | null
export async function updateConfig( profile?: ProfileEntry[]
section: string, }
data: unknown }
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, { // API fetch helpers
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, async function fetchJson<T>(url: string): Promise<T> {
body: JSON.stringify(data), const response = await fetch(url)
}) if (!response.ok) {
if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`)
throw new Error(`API error: ${response.status} ${response.statusText}`) }
} return response.json()
return response.json() }
}
export async function fetchStatus(): Promise<SystemStatus> {
export async function fetchAlerts(): Promise<Alert[]> { return fetchJson<SystemStatus>('/api/status')
return fetchJson<Alert[]>('/api/alerts/active') }
}
export async function fetchHealth(): Promise<MeshHealth> {
export async function fetchAlertHistory( return fetchJson<MeshHealth>('/api/health')
limit: number = 50, }
offset: number = 0,
type?: string, export async function fetchNodes(): Promise<NodeInfo[]> {
severity?: string return fetchJson<NodeInfo[]>('/api/nodes')
): Promise<AlertHistoryResponse | AlertHistoryItem[]> { }
const params = new URLSearchParams()
params.set('limit', limit.toString()) export async function fetchEdges(): Promise<EdgeInfo[]> {
params.set('offset', offset.toString()) return fetchJson<EdgeInfo[]>('/api/edges')
if (type && type !== 'all') params.set('type', type) }
if (severity && severity !== 'all') params.set('severity', severity)
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`) export async function fetchSources(): Promise<SourceHealth[]> {
} return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions') export async function fetchConfig(section?: string): Promise<unknown> {
} const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
export async function fetchEnvStatus(): Promise<EnvStatus> { }
return fetchJson<EnvStatus>('/api/env/status')
} export async function updateConfig(
section: string,
export async function fetchEnvActive(): Promise<EnvEvent[]> { data: unknown
return fetchJson<EnvEvent[]>('/api/env/active') ): Promise<{ saved: boolean; restart_required: boolean }> {
} const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
export async function fetchRFPropagation(): Promise<RFPropagation> { headers: { 'Content-Type': 'application/json' },
return fetchJson<RFPropagation>('/api/env/propagation') body: JSON.stringify(data),
} })
if (!response.ok) {
export async function fetchSWPC(): Promise<SWPCStatus> { throw new Error(`API error: ${response.status} ${response.statusText}`)
return fetchJson<SWPCStatus>('/api/env/swpc') }
} return response.json()
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting') export async function fetchAlerts(): Promise<Alert[]> {
} return fetchJson<Alert[]>('/api/alerts/active')
}
export interface FireEvent {
source: string export async function fetchAlertHistory(
event_id: string limit: number = 50,
event_type: string offset: number = 0,
severity: string type?: string,
headline: string severity?: string
name: string ): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
acres: number const params = new URLSearchParams()
pct_contained: number params.set('limit', limit.toString())
lat: number | null params.set('offset', offset.toString())
lon: number | null if (type && type !== 'all') params.set('type', type)
distance_km: number | null if (severity && severity !== 'all') params.set('severity', severity)
nearest_anchor: string | null return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
state: string }
expires: number
fetched_at: number export async function fetchSubscriptions(): Promise<Subscription[]> {
polygon?: number[][][] return fetchJson<Subscription[]>('/api/subscriptions')
} }
export interface AvalancheEvent { export async function fetchEnvStatus(): Promise<EnvStatus> {
source: string return fetchJson<EnvStatus>('/api/env/status')
event_id: string }
event_type: string
severity: string export async function fetchEnvActive(): Promise<EnvEvent[]> {
headline: string return fetchJson<EnvEvent[]>('/api/env/active')
zone_name: string }
center: string
center_id: string export async function fetchRFPropagation(): Promise<RFPropagation> {
center_link: string return fetchJson<RFPropagation>('/api/env/propagation')
forecast_link: string }
danger: string
danger_level: number export async function fetchSWPC(): Promise<SWPCStatus> {
danger_name: string return fetchJson<SWPCStatus>('/api/env/swpc')
travel_advice: string }
state: string
lat: number | null export async function fetchDucting(): Promise<DuctingStatus> {
lon: number | null return fetchJson<DuctingStatus>('/api/env/ducting')
expires: number }
fetched_at: number
} export interface FireEvent {
source: string
export interface StreamGaugeEvent { event_id: string
source: string event_type: string
event_id: string severity: string
event_type: string headline: string
headline: string name: string
severity: string acres: number
lat?: number pct_contained: number
lon?: number lat: number | null
expires: number lon: number | null
fetched_at: number distance_km: number | null
properties: { nearest_anchor: string | null
site_id: string state: string
site_name: string expires: number
parameter: string fetched_at: number
value: number polygon?: number[][][]
unit: string }
timestamp: string
} export interface AvalancheEvent {
} source: string
event_id: string
export interface TrafficEvent { event_type: string
source: string severity: string
event_id: string headline: string
event_type: string zone_name: string
headline: string center: string
severity: string center_id: string
lat?: number center_link: string
lon?: number forecast_link: string
expires: number danger: string
fetched_at: number danger_level: number
properties: { danger_name: string
corridor: string travel_advice: string
currentSpeed: number state: string
freeFlowSpeed: number lat: number | null
speedRatio: number lon: number | null
currentTravelTime: number expires: number
freeFlowTravelTime: number fetched_at: number
confidence: number }
roadClosure: boolean
} export interface StreamGaugeEvent {
} source: string
event_id: string
export interface RoadEvent { event_type: string
source: string headline: string
event_id: string severity: string
event_type: string lat?: number
headline: string lon?: number
description?: string expires: number
severity: string fetched_at: number
lat?: number properties: {
lon?: number site_id: string
expires: number site_name: string
fetched_at: number parameter: string
properties: { value: number
roadway: string unit: string
is_closure: boolean timestamp: string
last_updated?: string }
} }
}
export interface TrafficEvent {
export interface HotspotEvent { source: string
source: string event_id: string
event_id: string event_type: string
event_type: string headline: string
headline: string severity: string
severity: string lat?: number
lat?: number lon?: number
lon?: number expires: number
expires: number fetched_at: number
fetched_at: number properties: {
properties: { corridor: string
new_ignition: boolean currentSpeed: number
confidence: string freeFlowSpeed: number
frp?: number speedRatio: number
brightness?: number currentTravelTime: number
acq_date: string freeFlowTravelTime: number
acq_time: string confidence: number
near_fire?: string roadClosure: boolean
distance_to_fire_km?: number }
distance_km?: number }
nearest_anchor?: string
} export interface RoadEvent {
} source: string
event_id: string
export interface HotspotsResponse { event_type: string
enabled: boolean headline: string
hotspots: HotspotEvent[] description?: string
new_ignitions: number severity: string
} lat?: number
lon?: number
export interface AvalancheResponse { expires: number
off_season: boolean fetched_at: number
advisories: AvalancheEvent[] properties: {
} roadway: string
is_closure: boolean
export async function fetchFires(): Promise<FireEvent[]> { last_updated?: string
return fetchJson<FireEvent[]>('/api/env/fires') }
} }
export async function fetchAvalanche(): Promise<AvalancheResponse> { export interface HotspotEvent {
return fetchJson<AvalancheResponse>('/api/env/avalanche') source: string
} event_id: string
event_type: string
export async function fetchStreams(): Promise<StreamGaugeEvent[]> { headline: string
return fetchJson<StreamGaugeEvent[]>('/api/env/streams') severity: string
} lat?: number
lon?: number
export async function fetchTraffic(): Promise<TrafficEvent[]> { expires: number
return fetchJson<TrafficEvent[]>('/api/env/traffic') fetched_at: number
} properties: {
new_ignition: boolean
export async function fetchRoads(): Promise<RoadEvent[]> { confidence: string
return fetchJson<RoadEvent[]>('/api/env/roads') frp?: number
} brightness?: number
acq_date: string
export async function fetchHotspots(): Promise<HotspotsResponse> { acq_time: string
return fetchJson<HotspotsResponse>('/api/env/hotspots') near_fire?: string
} distance_to_fire_km?: number
distance_km?: number
export async function fetchRegions(): Promise<RegionInfo[]> { nearest_anchor?: string
return fetchJson<RegionInfo[]>('/api/regions') }
} }
export interface HotspotsResponse {
enabled: boolean
hotspots: HotspotEvent[]
new_ignitions: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<FireEvent[]> {
return fetchJson<FireEvent[]>('/api/env/fires')
}
export async function fetchAvalanche(): Promise<AvalancheResponse> {
return fetchJson<AvalancheResponse>('/api/env/avalanche')
}
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
}
export async function fetchTraffic(): Promise<TrafficEvent[]> {
return fetchJson<TrafficEvent[]>('/api/env/traffic')
}
export async function fetchRoads(): Promise<RoadEvent[]> {
return fetchJson<RoadEvent[]>('/api/env/roads')
}
export async function fetchHotspots(): Promise<HotspotsResponse> {
return fetchJson<HotspotsResponse>('/api/env/hotspots')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,19 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useMemo } from 'react'
import { import {
fetchHealth, fetchHealth,
fetchSources, fetchSources,
fetchAlerts, fetchAlerts,
fetchEnvStatus, fetchEnvStatus,
fetchRFPropagation, fetchEnvActive,
fetchSWPC,
fetchDucting,
type MeshHealth, type MeshHealth,
type SourceHealth, type SourceHealth,
type Alert, type Alert,
type EnvStatus, type EnvStatus,
type RFPropagation, type EnvEvent,
type SWPCStatus,
type DuctingStatus,
} from '@/lib/api' } from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket' import { useWebSocket } from '@/hooks/useWebSocket'
import { import {
@ -22,13 +26,63 @@ import {
Activity, Activity,
MapPin, MapPin,
Zap, Zap,
Cloud,
Flame,
Mountain,
Droplets,
Car,
Construction,
Satellite,
Sun,
} from 'lucide-react' } from 'lucide-react'
import {
AreaChart,
Area,
XAxis,
YAxis,
ResponsiveContainer,
ReferenceLine,
LineChart,
Line,
} from 'recharts'
// Extended types for history data
interface KpHistoryEntry {
time: string
value: number
}
interface ProfileEntry {
level_hPa: number
height_m: number
N: number
M: number
T_C: number
RH: number
}
interface ExtendedSWPCStatus extends SWPCStatus {
kp_history?: KpHistoryEntry[]
sfi_history?: { time: string; value: number }[]
}
interface ExtendedDuctingStatus extends DuctingStatus {
profile?: ProfileEntry[]
gradients?: {
from_level: number
to_level: number
from_height_m: number
to_height_m: number
gradient: number
}[]
assessment?: string
location?: { lat: number; lon: number }
}
function HealthGauge({ health }: { health: MeshHealth }) { function HealthGauge({ health }: { health: MeshHealth }) {
const score = health.score const score = health.score
const tier = health.tier const tier = health.tier
// Color based on score
const getColor = (s: number) => { const getColor = (s: number) => {
if (s >= 80) return '#22c55e' if (s >= 80) return '#22c55e'
if (s >= 60) return '#f59e0b' if (s >= 60) return '#f59e0b'
@ -42,46 +96,17 @@ function HealthGauge({ health }: { health: MeshHealth }) {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<svg width="140" height="140" viewBox="0 0 100 100"> <svg width="140" height="140" viewBox="0 0 100 100">
{/* Background circle */} <circle cx="50" cy="50" r="45" fill="none" stroke="#1e2a3a" strokeWidth="8" />
<circle <circle
cx="50" cx="50" cy="50" r="45" fill="none" stroke={color} strokeWidth="8"
cy="50" strokeLinecap="round" strokeDasharray={circumference}
r="45" strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
fill="none"
stroke="#1e2a3a"
strokeWidth="8"
/>
{/* Progress arc */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
transform="rotate(-90 50 50)"
className="transition-all duration-500" className="transition-all duration-500"
/> />
{/* Score text */} <text x="50" y="46" textAnchor="middle" className="fill-slate-100 font-mono text-2xl font-bold" style={{ fontSize: '24px' }}>
<text
x="50"
y="46"
textAnchor="middle"
className="fill-slate-100 font-mono text-2xl font-bold"
style={{ fontSize: '24px' }}
>
{score.toFixed(1)} {score.toFixed(1)}
</text> </text>
<text <text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
x="50"
y="62"
textAnchor="middle"
className="fill-slate-400 text-xs"
style={{ fontSize: '10px' }}
>
{tier} {tier}
</text> </text>
</svg> </svg>
@ -89,13 +114,7 @@ function HealthGauge({ health }: { health: MeshHealth }) {
) )
} }
function PillarBar({ function PillarBar({ label, value }: { label: string; value: number }) {
label,
value,
}: {
label: string
value: number
}) {
const getColor = (v: number) => { const getColor = (v: number) => {
if (v >= 80) return 'bg-green-500' if (v >= 80) return 'bg-green-500'
if (v >= 60) return 'bg-amber-500' if (v >= 60) return 'bg-amber-500'
@ -106,14 +125,9 @@ function PillarBar({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-24 text-xs text-slate-400 truncate">{label}</div> <div className="w-24 text-xs text-slate-400 truncate">{label}</div>
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden"> <div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
<div <div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
className={`h-full ${getColor(value)} transition-all duration-300`}
style={{ width: `${value}%` }}
/>
</div>
<div className="w-12 text-right text-xs font-mono text-slate-300">
{value.toFixed(1)}
</div> </div>
<div className="w-12 text-right text-xs font-mono text-slate-300">{value.toFixed(1)}</div>
</div> </div>
) )
} }
@ -123,26 +137,11 @@ function AlertItem({ alert }: { alert: Alert }) {
switch (severity.toLowerCase()) { switch (severity.toLowerCase()) {
case 'critical': case 'critical':
case 'emergency': case 'emergency':
return { return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
case 'warning': case 'warning':
return { return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
default: default:
return { return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
bg: 'bg-green-500/10',
border: 'border-green-500',
icon: Info,
iconColor: 'text-green-500',
}
} }
} }
@ -150,15 +149,11 @@ function AlertItem({ alert }: { alert: Alert }) {
const Icon = styles.icon const Icon = styles.icon
return ( return (
<div <div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
>
<Icon size={16} className={styles.iconColor} /> <Icon size={16} className={styles.iconColor} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-slate-200">{alert.message}</div> <div className="text-sm text-slate-200">{alert.message}</div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
{alert.timestamp || 'Just now'}
</div>
</div> </div>
</div> </div>
) )
@ -176,25 +171,13 @@ function SourceCard({ source }: { source: SourceHealth }) {
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} /> <div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 truncate">{source.name}</div> <div className="text-sm text-slate-200 truncate">{source.name}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">{source.node_count} nodes · {source.type}</div>
{source.node_count} nodes * {source.type}
</div>
</div> </div>
</div> </div>
) )
} }
function StatCard({ function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
icon: Icon,
label,
value,
subvalue,
}: {
icon: typeof Radio
label: string
value: string | number
subvalue?: string
}) {
return ( return (
<div className="bg-bg-card border border-border rounded-lg p-4"> <div className="bg-bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 text-slate-400 mb-2"> <div className="flex items-center gap-2 text-slate-400 mb-2">
@ -202,100 +185,362 @@ function StatCard({
<span className="text-xs">{label}</span> <span className="text-xs">{label}</span>
</div> </div>
<div className="font-mono text-xl text-slate-100">{value}</div> <div className="font-mono text-xl text-slate-100">{value}</div>
{subvalue && ( {subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
)}
</div> </div>
) )
} }
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) { // Scale badge component for R/S/G
if (!propagation) { function ScaleBadge({ label, value }: { label: string; value: number }) {
return ( const getColor = () => {
<div className="bg-bg-card border border-border rounded-lg p-6"> if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
<h2 className="text-sm font-medium text-slate-400 mb-4"> if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
RF Propagation return 'bg-red-500/20 text-red-400 border-red-500/50'
</h2> }
<div className="text-slate-500">
<p>Loading propagation data...</p> return (
</div> <span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
{label}{value}
</span>
)
}
// Large value display for SFI/Kp
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
const color = value !== undefined ? getColor(value) : 'text-slate-400'
return (
<div className="text-center">
<div className="text-xs text-slate-500 mb-1">{label}</div>
<div className={`font-mono text-3xl font-bold ${color}`}>
{value?.toFixed(0) ?? '—'}
</div> </div>
{unit && <div className="text-xs text-slate-500">{unit}</div>}
</div>
)
}
// Kp trend sparkline chart
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
const chartData = useMemo(() => {
if (!history || history.length === 0) return []
// Take last 16 entries (48 hours of 3-hourly data)
return history.slice(-16).map((entry, i) => ({
idx: i,
value: entry.value,
time: entry.time,
}))
}, [history])
if (chartData.length === 0) return null
const maxKp = Math.max(...chartData.map(d => d.value), 5)
const currentKp = chartData[chartData.length - 1]?.value ?? 0
// Gradient color based on max Kp
const getGradientId = () => {
if (maxKp > 5) return 'kpGradientRed'
if (maxKp > 3) return 'kpGradientAmber'
return 'kpGradientGreen'
}
return (
<div className="h-20 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
</linearGradient>
</defs>
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
<XAxis dataKey="idx" hide />
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
<Area
type="monotone"
dataKey="value"
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
fill={`url(#${getGradientId()})`}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
<div className="flex justify-between text-xs text-slate-600 px-1">
<span>48h ago</span>
<span>now</span>
</div>
</div>
)
}
// Refractivity profile chart
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
const chartData = useMemo(() => {
if (!profile || profile.length === 0) return []
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
height: p.height_m,
M: p.M,
}))
}, [profile])
if (chartData.length === 0) return null
return (
<div className="h-24 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
<XAxis
dataKey="M"
type="number"
domain={['dataMin - 20', 'dataMax + 20']}
tick={{ fontSize: 10, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#334155' }}
/>
<YAxis
dataKey="height"
type="number"
domain={[0, 'dataMax']}
tick={{ fontSize: 10, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#334155' }}
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
/>
<Line
type="monotone"
dataKey="M"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3, fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
</div>
)
}
// RF Propagation Card
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
const getSfiColor = (v: number) => {
if (v >= 120) return 'text-green-400'
if (v >= 80) return 'text-amber-400'
return 'text-red-400'
}
const getKpColor = (v: number) => {
if (v <= 3) return 'text-green-400'
if (v <= 5) return 'text-amber-400'
return 'text-red-400'
}
const getDuctingBadge = (condition?: string) => {
if (!condition) return null
const styles: Record<string, string> = {
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
}
const labels: Record<string, string> = {
normal: 'Normal',
super_refraction: 'Super Refraction',
surface_duct: 'Surface Duct',
elevated_duct: 'Elevated Duct',
}
return (
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
{labels[condition] || condition}
</span>
) )
} }
const hf = propagation.hf
const ducting = propagation.uhf_ducting
const getDuctingColor = (condition?: string) => {
if (!condition) return 'text-slate-400'
switch (condition) {
case 'normal':
return 'text-green-500'
case 'super_refraction':
return 'text-amber-500'
case 'surface_duct':
case 'elevated_duct':
return 'text-blue-400'
default:
return 'text-slate-400'
}
}
const hasHF = hf && (hf.sfi || hf.kp_current !== undefined)
const hasDucting = ducting && ducting.condition
return ( return (
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2"> <h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Zap size={14} /> <Zap size={14} />
RF Propagation RF Propagation
</h2> </h2>
{/* Solar/Geomagnetic Indices */} {/* Top row: SFI and Kp big values */}
<div className="mb-4"> <div className="flex justify-around mb-4">
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div> <BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
{hasHF ? ( <div className="w-px bg-border" />
<div className="space-y-1"> <BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
<div className="text-sm font-mono text-slate-200">
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
</div>
<div className="text-xs text-slate-400">
R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
</div>
{hf.r_scale !== undefined && hf.r_scale > 0 && (
<div className="text-xs text-amber-500">
R{hf.r_scale} Radio Blackout
</div>
)}
</div>
) : (
<div className="text-sm text-slate-500">No data</div>
)}
</div> </div>
{/* Tropospheric Ducting */} {/* R/S/G Scale badges */}
<div> <div className="flex justify-center gap-2 mb-4">
<div className="text-xs text-slate-500 mb-1">Tropospheric</div> <ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
{hasDucting ? ( <ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
<div className="space-y-1"> <ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
{ducting.condition === 'normal'
? 'Normal'
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</div>
<div className="text-xs text-slate-400 font-mono">
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
</div>
{ducting.duct_thickness_m && (
<div className="text-xs text-slate-400">
Duct: ~{ducting.duct_thickness_m}m thick
</div>
)}
</div>
) : (
<div className="text-sm text-slate-500">No ducting data</div>
)}
</div> </div>
{/* Kp Trend Chart */}
{swpc?.kp_history && swpc.kp_history.length > 0 && (
<div className="mb-4">
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
<KpTrendChart history={swpc.kp_history} />
</div>
)}
{/* Divider */}
<div className="border-t border-border my-3" />
{/* Tropospheric section */}
<div className="flex items-center gap-2 mb-2">
<Cloud size={14} className="text-slate-400" />
<span className="text-xs text-slate-500">Tropospheric</span>
{getDuctingBadge(ducting?.condition)}
</div>
{ducting?.min_gradient !== undefined && (
<div className="text-xs text-slate-400 font-mono mb-2">
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
</div>
)}
{/* Refractivity profile chart */}
{ducting?.profile && ducting.profile.length > 0 && (
<RefractivityChart profile={ducting.profile} />
)}
{/* SWPC Warnings */}
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
<div className="mt-auto pt-3 border-t border-border">
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
<div className="flex flex-wrap gap-1">
{swpc.active_warnings.slice(0, 3).map((w, i) => (
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
{w.replace('Space Weather Message Code: ', '')}
</span>
))}
</div>
</div>
)}
</div>
)
}
// Source icon mapping
const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: string }> = {
nws: { icon: Cloud, color: 'text-blue-400', label: 'NWS' },
swpc: { icon: Sun, color: 'text-yellow-400', label: 'SWPC' },
ducting: { icon: Radio, color: 'text-cyan-400', label: 'Tropo' },
nifc: { icon: Flame, color: 'text-orange-400', label: 'NIFC' },
firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' },
avalanche: { icon: Mountain, color: 'text-slate-300', label: 'Avy' },
usgs: { icon: Droplets, color: 'text-blue-300', label: 'USGS' },
traffic: { icon: Car, color: 'text-purple-400', label: 'Traffic' },
roads: { icon: Construction, color: 'text-amber-400', label: '511' },
}
// Severity badge colors
const SEVERITY_COLORS: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
warning: 'bg-red-500/20 text-red-400 border-red-500/30',
critical: 'bg-red-600/20 text-red-300 border-red-600/30',
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
}
function EventFeedItem({ event }: { event: EnvEvent }) {
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
const Icon = sourceConfig.icon
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
// Format timestamp
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
return (
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
{event.severity || 'info'}
</span>
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
</div>
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
</div>
</div>
)
}
// Live Event Feed Card
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
}, [events])
// Calculate feed health summary
const feedSummary = useMemo(() => {
if (!envStatus?.feeds) return null
const total = envStatus.feeds.length
const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length
const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source)
const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0))
const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null
return { total, active, errors, secAgo }
}, [envStatus])
return (
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
<h2 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
<Activity size={14} />
Live Event Feed
</h2>
{sortedEvents.length > 0 ? (
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
{sortedEvents.map((event, i) => (
<EventFeedItem key={event.event_id || i} event={event} />
))}
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<CheckCircle size={24} className="text-green-500 mx-auto mb-2" />
<div className="text-slate-400">No active events</div>
<div className="text-xs text-slate-500">All clear</div>
</div>
</div>
)}
{/* Feed health summary */}
{feedSummary && (
<div className={`text-xs mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-amber-400' : 'text-slate-500'}`}>
{feedSummary.active} of {feedSummary.total} feeds active
{feedSummary.secAgo !== null && ` · Last update ${feedSummary.secAgo}s ago`}
{feedSummary.errors.length > 0 && (
<span className="text-amber-400"> · {feedSummary.errors.join(', ')}: error</span>
)}
</div>
)}
</div> </div>
) )
} }
@ -305,11 +550,13 @@ export default function Dashboard() {
const [sources, setSources] = useState<SourceHealth[]>([]) const [sources, setSources] = useState<SourceHealth[]>([])
const [alerts, setAlerts] = useState<Alert[]>([]) const [alerts, setAlerts] = useState<Alert[]>([])
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null) const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
const [rfProp, setRFProp] = useState<RFPropagation | null>(null) const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { lastHealth } = useWebSocket() const { lastHealth, lastMessage } = useWebSocket()
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@ -317,21 +564,25 @@ export default function Dashboard() {
fetchSources(), fetchSources(),
fetchAlerts(), fetchAlerts(),
fetchEnvStatus(), fetchEnvStatus(),
fetchRFPropagation().catch(() => null), fetchEnvActive().catch(() => []),
fetchSWPC().catch(() => null),
fetchDucting().catch(() => null),
]) ])
.then(([h, src, a, e, rf]) => { .then(([h, src, a, e, events, sw, duct]) => {
setHealth(h) setHealth(h)
setSources(src) setSources(src)
setAlerts(a) setAlerts(a)
setEnvStatus(e) setEnvStatus(e)
setRFProp(rf) setEnvEvents(events)
setLoading(false) setSwpc(sw as ExtendedSWPCStatus)
document.title = 'Dashboard — MeshAI' setDucting(duct as ExtendedDuctingStatus)
setLoading(false)
document.title = 'Dashboard — MeshAI'
}) })
.catch((err) => { .catch((err) => {
setError(err.message) setError(err.message)
setLoading(false) setLoading(false)
document.title = 'Dashboard — MeshAI' document.title = 'Dashboard — MeshAI'
}) })
}, []) }, [])
@ -342,6 +593,18 @@ export default function Dashboard() {
} }
}, [lastHealth]) }, [lastHealth])
// Handle WebSocket env_update messages
useEffect(() => {
if (lastMessage?.type === 'env_update' && lastMessage.event) {
setEnvEvents(prev => {
// Add new event, dedupe by event_id
const newEvent = lastMessage.event as EnvEvent
const filtered = prev.filter(e => e.event_id !== newEvent.event_id)
return [newEvent, ...filtered].slice(0, 100) // Keep last 100
})
}
}, [lastMessage])
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@ -359,114 +622,76 @@ export default function Dashboard() {
} }
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="space-y-6">
{/* Mesh Health */} {/* Top row: Health + Alerts + Stats */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2> {/* Mesh Health */}
{health && (
<>
<HealthGauge health={health} />
<div className="mt-6 space-y-3">
<PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
<PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
<PillarBar label="Power" value={health.pillars?.power ?? 0} />
</div>
</>
)}
</div>
{/* Alerts + Stats */}
<div className="lg:col-span-2 space-y-6">
{/* Active Alerts */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4"> <h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
Active Alerts {health && (
</h2> <>
{alerts.length > 0 ? ( <HealthGauge health={health} />
<div className="space-y-3"> <div className="mt-6 space-y-3">
{alerts.map((alert, i) => ( <PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
<AlertItem key={i} alert={alert} /> <PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
))} <PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
</div> <PillarBar label="Power" value={health.pillars?.power ?? 0} />
) : ( </div>
<div className="flex items-center gap-2 text-slate-500 py-4"> </>
<CheckCircle size={16} className="text-green-500" />
<span>No active alerts</span>
</div>
)} )}
</div> </div>
{/* Quick Stats */} {/* Alerts + Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="lg:col-span-2 space-y-6">
<StatCard {/* Active Alerts */}
icon={Radio} <div className="bg-bg-card border border-border rounded-lg p-6">
label="Nodes Online" <h2 className="text-sm font-medium text-slate-400 mb-4">Active Alerts</h2>
value={health?.total_nodes || 0} {alerts.length > 0 ? (
subvalue={`${health?.unlocated_count || 0} unlocated`} <div className="space-y-3 max-h-48 overflow-y-auto">
/> {alerts.map((alert, i) => (
<StatCard <AlertItem key={i} alert={alert} />
icon={Cpu} ))}
label="Infrastructure" </div>
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} ) : (
subvalue={ <div className="flex items-center gap-2 text-slate-500 py-4">
health?.infra_online === health?.infra_total <CheckCircle size={16} className="text-green-500" />
? 'All online' <span>No active alerts</span>
: 'Some offline' </div>
} )}
/> </div>
<StatCard
icon={Activity} {/* Quick Stats */}
label="Utilization" <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
value={`${health?.util_percent?.toFixed(1) || 0}%`} <StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
subvalue={`${health?.flagged_nodes || 0} flagged`} <StatCard icon={Cpu} label="Infrastructure" value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} subvalue={health?.infra_online === health?.infra_total ? 'All online' : 'Some offline'} />
/> <StatCard icon={Activity} label="Utilization" value={`${health?.util_percent?.toFixed(1) || 0}%`} subvalue={`${health?.flagged_nodes || 0} flagged`} />
<StatCard <StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
icon={MapPin} </div>
label="Regions"
value={health?.total_regions || 0}
subvalue={`${health?.battery_warnings || 0} battery warnings`}
/>
</div> </div>
</div> </div>
{/* Mesh Sources */} {/* Middle row: Sources + RF Propagation + Live Feed */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<h2 className="text-sm font-medium text-slate-400 mb-4"> {/* Mesh Sources */}
Mesh Sources ({sources.length}) <div className="bg-bg-card border border-border rounded-lg p-6">
</h2> <h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Sources ({sources.length})</h2>
{sources.length > 0 ? ( {sources.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{sources.map((source, i) => ( {sources.map((source, i) => (
<SourceCard key={i} source={source} /> <SourceCard key={i} source={source} />
))} ))}
</div> </div>
) : ( ) : (
<div className="text-slate-500 py-4">No sources configured</div> <div className="text-slate-500 py-4">No sources configured</div>
)} )}
</div> </div>
{/* Environmental Feeds */} {/* RF Propagation */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <RFPropagationCard swpc={swpc} ducting={ducting} />
<h2 className="text-sm font-medium text-slate-400 mb-4">
Environmental Feeds
</h2>
{envStatus?.enabled ? (
<div className="text-slate-400">
{envStatus.feeds.length} feeds active
</div>
) : (
<div className="text-slate-500">
<p>Environmental feeds not enabled.</p>
<p className="text-xs mt-2">
Enable in config.yaml
</p>
</div>
)}
</div>
{/* RF Propagation */} {/* Live Event Feed */}
<RFPropagationCard propagation={rfProp} /> <LiveEventFeed events={envEvents} envStatus={envStatus} />
</div>
</div> </div>
) )
} }

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { import {
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap, Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
Calendar, AlertTriangle, Copy Calendar, AlertTriangle, Copy, Moon, AlertCircle
} from 'lucide-react' } from 'lucide-react'
import ChannelPicker from '@/components/ChannelPicker' import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker' import NodePicker from '@/components/NodePicker'
@ -11,20 +11,15 @@ import NodePicker from '@/components/NodePicker'
interface NotificationRuleConfig { interface NotificationRuleConfig {
name: string name: string
enabled: boolean enabled: boolean
// Trigger
trigger_type: 'condition' | 'schedule' trigger_type: 'condition' | 'schedule'
// Condition trigger
categories: string[] categories: string[]
min_severity: string min_severity: string
// Schedule trigger schedule_frequency: 'daily' | 'twice_daily' | 'weekly'
schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom'
schedule_time: string schedule_time: string
schedule_time_2: string // For twice_daily schedule_time_2: string
schedule_days: string[] // For weekly schedule_days: string[]
schedule_cron: string // For custom
message_type: string message_type: string
custom_message: string custom_message: string
// Delivery
delivery_type: string delivery_type: string
broadcast_channel: number broadcast_channel: number
node_ids: string[] node_ids: string[]
@ -37,13 +32,13 @@ interface NotificationRuleConfig {
recipients: string[] recipients: string[]
webhook_url: string webhook_url: string
webhook_headers: Record<string, string> webhook_headers: Record<string, string>
// Behavior
cooldown_minutes: number cooldown_minutes: number
override_quiet: boolean override_quiet: boolean
} }
interface NotificationsConfig { interface NotificationsConfig {
enabled: boolean enabled: boolean
quiet_hours_enabled: boolean
quiet_hours_start: string quiet_hours_start: string
quiet_hours_end: string quiet_hours_end: string
rules: NotificationRuleConfig[] rules: NotificationRuleConfig[]
@ -54,8 +49,43 @@ interface AlertCategory {
name: string name: string
description: string description: string
default_severity: string default_severity: string
example_message: string
} }
// Severity levels with descriptions
const SEVERITY_OPTIONS = [
{
value: 'info',
label: 'Info',
description: 'Routine updates (ducting detected, new router appeared)',
},
{
value: 'advisory',
label: 'Advisory',
description: 'Worth knowing (weather advisory, traffic slow, battery declining)',
},
{
value: 'watch',
label: 'Watch',
description: 'Pay attention (fire within 50km, weather watch, stream rising)',
},
{
value: 'warning',
label: 'Warning',
description: 'Act now (fire within 25km, severe weather, critical battery)',
},
{
value: 'critical',
label: 'Critical',
description: 'Serious issue (critical node down, battery emergency)',
},
{
value: 'emergency',
label: 'Emergency',
description: 'Life safety (extreme weather, fire at infrastructure, total blackout)',
},
]
// InfoButton component // InfoButton component
function InfoButton({ info }: { info: string }) { function InfoButton({ info }: { info: string }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -187,34 +217,6 @@ function Toggle({ label, checked, onChange, helper = '', info = '' }: {
) )
} }
function SelectInput({ label, value, onChange, options, helper = '', info = '' }: {
label: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
helper?: string
info?: string
}) {
return (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoButton info={info} />}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
function TimeInput({ label, value, onChange, helper = '', info = '' }: { function TimeInput({ label, value, onChange, helper = '', info = '' }: {
label: string label: string
value: string value: string
@ -307,10 +309,63 @@ function ListInput({ label, value, onChange, placeholder = 'Add item...', helper
) )
} }
// Severity selector with descriptions
function SeveritySelector({ value, onChange }: {
value: string
onChange: (v: string) => void
}) {
const [isOpen, setIsOpen] = useState(false)
const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[3]
return (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Severity Threshold
<InfoButton info="Only alerts at or above this severity trigger this rule. Lower threshold = more notifications. 'Warning' is recommended for most rules." />
</label>
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-left flex items-center justify-between hover:border-accent transition-colors"
>
<div>
<span className="text-slate-200">{selected.label}</span>
<span className="text-slate-500 ml-2"> {selected.description}</span>
</div>
<ChevronDown size={16} className={`text-slate-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl overflow-hidden">
{SEVERITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setIsOpen(false) }}
className={`w-full px-3 py-2.5 text-left text-sm hover:bg-[#1e2a3a] transition-colors ${
value === opt.value ? 'bg-accent/10' : ''
}`}
>
<div className="font-medium text-slate-200">{opt.label}</div>
<div className="text-xs text-slate-500">{opt.description}</div>
</button>
))}
</div>
</>
)}
</div>
<p className="text-xs text-slate-600">Lower = more notifications. "Warning" recommended for most rules.</p>
</div>
)
}
// Notification Rule Card Component // Notification Rule Card Component
function NotificationRuleCard({ function NotificationRuleCard({
rule, rule,
categories, categories,
quietHoursEnabled,
onChange, onChange,
onDelete, onDelete,
onDuplicate, onDuplicate,
@ -318,6 +373,7 @@ function NotificationRuleCard({
}: { }: {
rule: NotificationRuleConfig rule: NotificationRuleConfig
categories: AlertCategory[] categories: AlertCategory[]
quietHoursEnabled: boolean
onChange: (r: NotificationRuleConfig) => void onChange: (r: NotificationRuleConfig) => void
onDelete: () => void onDelete: () => void
onDuplicate: () => void onDuplicate: () => void
@ -326,35 +382,26 @@ function NotificationRuleCard({
const [expanded, setExpanded] = useState(!rule.name) const [expanded, setExpanded] = useState(!rule.name)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const severityOptions = [
{ value: 'info', label: 'Info' },
{ value: 'advisory', label: 'Advisory' },
{ value: 'watch', label: 'Watch' },
{ value: 'warning', label: 'Warning' },
{ value: 'critical', label: 'Critical' },
{ value: 'emergency', label: 'Emergency' },
]
const deliveryOptions = [ const deliveryOptions = [
{ value: 'mesh_broadcast', label: 'Mesh Broadcast' }, { value: '', label: '(None)', description: 'Rule matches but does not deliver' },
{ value: 'mesh_dm', label: 'Mesh DM' }, { value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' },
{ value: 'email', label: 'Email' }, { value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' },
{ value: 'webhook', label: 'Webhook' }, { value: 'email', label: 'Email', description: 'Send via SMTP' },
{ value: 'webhook', label: 'Webhook', description: 'POST to any URL' },
] ]
const frequencyOptions = [ const frequencyOptions = [
{ value: 'daily', label: 'Once Daily' }, { value: 'daily', label: 'Daily' },
{ value: 'twice_daily', label: 'Twice Daily' }, { value: 'twice_daily', label: 'Twice Daily' },
{ value: 'weekly', label: 'Weekly' }, { value: 'weekly', label: 'Weekly' },
{ value: 'custom', label: 'Custom Cron' },
] ]
const messageTypeOptions = [ const messageTypeOptions = [
{ value: 'mesh_health_summary', label: 'Mesh Health Summary' }, { value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' },
{ value: 'rf_propagation_report', label: 'RF Propagation Report' }, { value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' },
{ value: 'alerts_digest', label: 'Active Alerts Digest' }, { value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' },
{ value: 'environmental_conditions', label: 'Environmental Conditions' }, { value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' },
{ value: 'custom', label: 'Custom Message' }, { value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' },
] ]
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
@ -383,42 +430,57 @@ function NotificationRuleCard({
setTesting(false) setTesting(false)
} }
// Get example message for display
const getExampleMessage = (): string => {
if (rule.trigger_type === 'schedule') {
return '[Scheduled report preview would appear here]'
}
const ruleCats = rule.categories || []
if (ruleCats.length === 0 && categories.length > 0) {
return categories[0].example_message || 'Alert notification'
}
const firstCat = categories.find(c => ruleCats.includes(c.id))
return firstCat?.example_message || 'Alert notification'
}
// Generate summary for collapsed view // Generate summary for collapsed view
const getSummary = () => { const getSummary = () => {
const parts: string[] = [] const parts: string[] = []
if (rule.trigger_type === 'schedule') { if (rule.trigger_type === 'schedule') {
// Schedule summary
const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency
const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`) parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
parts.push(msgType) parts.push(msgType)
} else { } else {
// Condition summary
const catCount = rule.categories?.length || 0 const catCount = rule.categories?.length || 0
const catText = catCount === 0 ? 'All categories' : `${catCount} categories` const catText = catCount === 0 ? 'All' : categories.filter(c => rule.categories?.includes(c.id)).map(c => c.name).slice(0, 2).join(', ') + (catCount > 2 ? ` +${catCount - 2}` : '')
const severity = severityOptions.find(s => s.value === rule.min_severity)?.label || rule.min_severity const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity
parts.push(`${catText} at ${severity}+`) parts.push(`${catText} at ${severity}+`)
} }
// Delivery summary // Delivery summary
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type if (!rule.delivery_type) {
let target = '' parts.push('⚠️ No delivery')
if (rule.delivery_type === 'mesh_broadcast') { } else {
target = `Ch ${rule.broadcast_channel}` const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
} else if (rule.delivery_type === 'mesh_dm') { let target = ''
target = `${rule.node_ids?.length || 0} nodes` if (rule.delivery_type === 'mesh_broadcast') {
} else if (rule.delivery_type === 'email') { target = `Ch ${rule.broadcast_channel}`
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients' } else if (rule.delivery_type === 'mesh_dm') {
} else if (rule.delivery_type === 'webhook') { target = `${rule.node_ids?.length || 0} nodes`
try { } else if (rule.delivery_type === 'email') {
const url = new URL(rule.webhook_url) target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
target = url.hostname } else if (rule.delivery_type === 'webhook') {
} catch { try {
target = rule.webhook_url?.slice(0, 30) || 'no URL' const url = new URL(rule.webhook_url)
target = url.hostname
} catch {
target = rule.webhook_url?.slice(0, 20) || 'no URL'
}
} }
parts.push(`${delivery}${target ? ` (${target})` : ''}`)
} }
parts.push(`${delivery}${target ? ` (${target})` : ''}`)
return parts.join(' → ') return parts.join(' → ')
} }
@ -435,7 +497,7 @@ function NotificationRuleCard({
<button <button
onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }} onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }}
className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`} className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`}
title={rule.enabled ? 'Enabled - click to disable' : 'Disabled - click to enable'} title={rule.enabled ? 'Enabled' : 'Disabled'}
/> />
{rule.trigger_type === 'schedule' ? ( {rule.trigger_type === 'schedule' ? (
<Clock size={14} className="text-blue-400 flex-shrink-0" /> <Clock size={14} className="text-blue-400 flex-shrink-0" />
@ -444,7 +506,7 @@ function NotificationRuleCard({
)} )}
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span> <span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
{!expanded && ( {!expanded && (
<span className="text-xs text-slate-500 truncate hidden sm:block"> <span className={`text-xs truncate hidden sm:block ${!rule.delivery_type ? 'text-amber-400' : 'text-slate-500'}`}>
{getSummary()} {getSummary()}
</span> </span>
)} )}
@ -454,21 +516,21 @@ function NotificationRuleCard({
onClick={(e) => { e.stopPropagation(); handleTest() }} onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !rule.name} disabled={testing || !rule.name}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50" className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
title="Send test" title="Test rule"
> >
<Send size={14} /> <Send size={14} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onDuplicate() }} onClick={(e) => { e.stopPropagation(); onDuplicate() }}
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded" className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
title="Duplicate rule" title="Duplicate"
> >
<Copy size={14} /> <Copy size={14} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onDelete() }} onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded" className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
title="Delete rule" title="Delete"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@ -487,7 +549,7 @@ function NotificationRuleCard({
helper="A descriptive name for this rule" helper="A descriptive name for this rule"
/> />
{/* Trigger type selector */} {/* Trigger type toggle */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label> <label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
<div className="flex gap-2"> <div className="flex gap-2">
@ -518,8 +580,8 @@ function NotificationRuleCard({
</div> </div>
<p className="text-xs text-slate-600"> <p className="text-xs text-slate-600">
{rule.trigger_type === 'schedule' {rule.trigger_type === 'schedule'
? 'Send messages on a schedule (daily reports, weekly digests)' ? 'Send reports on a schedule (daily briefings, weekly digests)'
: 'React to alert conditions (fires, outages, warnings)'} : 'React to alert conditions (fires, outages, weather warnings)'}
</p> </p>
</div> </div>
@ -531,12 +593,9 @@ function NotificationRuleCard({
WHEN (Condition) WHEN (Condition)
</div> </div>
<SelectInput <SeveritySelector
label="Minimum Severity"
value={rule.min_severity} value={rule.min_severity}
onChange={(v) => onChange({ ...rule, min_severity: v })} onChange={(v) => onChange({ ...rule, min_severity: v })}
options={severityOptions}
helper="Only alerts at or above this level"
/> />
<div className="space-y-2"> <div className="space-y-2">
@ -578,19 +637,24 @@ function NotificationRuleCard({
WHEN (Schedule) WHEN (Schedule)
</div> </div>
<SelectInput <div className="space-y-1">
label="Frequency" <label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
value={rule.schedule_frequency || 'daily'} <select
onChange={(v) => onChange({ ...rule, schedule_frequency: v as any })} value={rule.schedule_frequency || 'daily'}
options={frequencyOptions} onChange={(e) => onChange({ ...rule, schedule_frequency: e.target.value as any })}
/> className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{frequencyOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<TimeInput <TimeInput
label="Time" label="Time"
value={rule.schedule_time || '07:00'} value={rule.schedule_time || '07:00'}
onChange={(v) => onChange({ ...rule, schedule_time: v })} onChange={(v) => onChange({ ...rule, schedule_time: v })}
helper="24-hour format"
/> />
{rule.schedule_frequency === 'twice_daily' && ( {rule.schedule_frequency === 'twice_daily' && (
<TimeInput <TimeInput
@ -623,30 +687,27 @@ function NotificationRuleCard({
</div> </div>
)} )}
{rule.schedule_frequency === 'custom' && ( <div className="space-y-1">
<TextInput <label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
label="Cron Expression" <select
value={rule.schedule_cron || ''} value={rule.message_type || 'mesh_health_summary'}
onChange={(v) => onChange({ ...rule, schedule_cron: v })} onChange={(e) => onChange({ ...rule, message_type: e.target.value })}
placeholder="0 7 * * *" className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
helper="Standard cron format" >
info="Five-field cron: minute hour day-of-month month day-of-week. Example: '0 7 * * 1' = 7:00 AM every Monday." {messageTypeOptions.map(opt => (
/> <option key={opt.value} value={opt.value}>{opt.label}</option>
)} ))}
</select>
<SelectInput <p className="text-xs text-slate-600">
label="Message Type" {messageTypeOptions.find(m => m.value === rule.message_type)?.description}
value={rule.message_type || 'mesh_health_summary'} </p>
onChange={(v) => onChange({ ...rule, message_type: v })} </div>
options={messageTypeOptions}
info="The type of report or message to send."
/>
{rule.message_type === 'custom' && ( {rule.message_type === 'custom' && (
<div className="space-y-1"> <div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide"> <label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Custom Message Custom Message
<InfoButton info="Use template tokens: {MESH_SCORE}, {NODE_COUNT}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" /> <InfoButton info="Available tokens: {MESH_SCORE}, {NODE_COUNT}, {NODES_ONLINE}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
</label> </label>
<textarea <textarea
value={rule.custom_message || ''} value={rule.custom_message || ''}
@ -667,12 +728,34 @@ function NotificationRuleCard({
SEND VIA SEND VIA
</div> </div>
<SelectInput <div className="space-y-1">
label="Delivery Method" <label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
value={rule.delivery_type || 'mesh_broadcast'} Delivery Method
onChange={(v) => onChange({ ...rule, delivery_type: v })} <InfoButton info="Where this notification gets delivered. Select (None) to save the rule without delivery — it will match conditions but won't send until you configure a delivery method." />
options={deliveryOptions} </label>
/> <select
value={rule.delivery_type || ''}
onChange={(e) => onChange({ ...rule, delivery_type: e.target.value })}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{deliveryOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-slate-600">
{deliveryOptions.find(d => d.value === (rule.delivery_type || ''))?.description}
</p>
</div>
{/* No delivery warning */}
{!rule.delivery_type && (
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-amber-300">
Rule will log matches but not deliver until a delivery method is configured.
</div>
</div>
)}
{/* Mesh Broadcast fields */} {/* Mesh Broadcast fields */}
{rule.delivery_type === 'mesh_broadcast' && ( {rule.delivery_type === 'mesh_broadcast' && (
@ -739,7 +822,7 @@ function NotificationRuleCard({
value={rule.smtp_password || ''} value={rule.smtp_password || ''}
onChange={(v) => onChange({ ...rule, smtp_password: v })} onChange={(v) => onChange({ ...rule, smtp_password: v })}
type="password" type="password"
info="For Gmail, use an App Password from myaccount.google.com/apppasswords" info="Gmail users: use an App Password from myaccount.google.com/apppasswords"
/> />
</div> </div>
<Toggle <Toggle
@ -760,28 +843,14 @@ function NotificationRuleCard({
{/* Webhook fields */} {/* Webhook fields */}
{rule.delivery_type === 'webhook' && ( {rule.delivery_type === 'webhook' && (
<div className="space-y-4"> <TextInput
<TextInput label="Webhook URL"
label="Webhook URL" value={rule.webhook_url || ''}
value={rule.webhook_url || ''} onChange={(v) => onChange({ ...rule, webhook_url: v })}
onChange={(v) => onChange({ ...rule, webhook_url: v })} placeholder="https://discord.com/api/webhooks/..."
placeholder="https://discord.com/api/webhooks/..." helper="POST alert as JSON"
helper="POST endpoint for alerts" info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint."
info="Works with Discord, Slack, ntfy.sh, Home Assistant, Pushover, or any HTTP POST endpoint." />
/>
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
Custom Headers (optional)
</summary>
<div className="mt-4 pl-6 border-l border-[#1e2a3a]">
<p className="text-xs text-slate-500 mb-2">
Headers are configured in config.yaml for security.
</p>
</div>
</details>
</div>
)} )}
</div> </div>
@ -795,15 +864,28 @@ function NotificationRuleCard({
helper="Min time between repeat sends" helper="Min time between repeat sends"
info="Prevents alert spam. Same condition won't re-trigger this rule within this window." info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
/> />
<div className="flex items-end pb-1"> {quietHoursEnabled && (
<Toggle <div className="flex items-end pb-1">
label="Override Quiet Hours" <Toggle
checked={rule.override_quiet ?? false} label="Override Quiet Hours"
onChange={(v) => onChange({ ...rule, override_quiet: v })} checked={rule.override_quiet ?? false}
helper="Send during quiet hours" onChange={(v) => onChange({ ...rule, override_quiet: v })}
/> helper="Deliver during quiet hours"
</div> />
</div>
)}
</div> </div>
{/* Example message */}
{rule.trigger_type !== 'schedule' && (
<div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Example Message</label>
<div className="p-3 bg-[#1e2a3a]/50 rounded-lg border border-[#1e2a3a]">
<p className="text-sm text-slate-300 font-mono">{getExampleMessage()}</p>
</div>
<p className="text-xs text-slate-600">This is an example of what this rule would send.</p>
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -902,10 +984,9 @@ export default function Notifications() {
schedule_time: '07:00', schedule_time: '07:00',
schedule_time_2: '19:00', schedule_time_2: '19:00',
schedule_days: ['monday'], schedule_days: ['monday'],
schedule_cron: '',
message_type: 'mesh_health_summary', message_type: 'mesh_health_summary',
custom_message: '', custom_message: '',
delivery_type: 'mesh_broadcast', delivery_type: '', // Start with no delivery
broadcast_channel: 0, broadcast_channel: 0,
node_ids: [], node_ids: [],
smtp_host: '', smtp_host: '',
@ -965,11 +1046,11 @@ export default function Notifications() {
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Header with actions */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Configure notification rules for alerts and scheduled reports. Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1025,31 +1106,47 @@ export default function Notifications() {
checked={config.enabled} checked={config.enabled}
onChange={(v) => setConfig({ ...config, enabled: v })} onChange={(v) => setConfig({ ...config, enabled: v })}
helper="Master switch for all notification delivery" helper="Master switch for all notification delivery"
info="When disabled, no alerts or scheduled messages will be delivered. The alert engine still runs and records alerts to history." info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
/> />
{config.enabled && ( {config.enabled && (
<> <>
{/* Quiet Hours Section - at top */} {/* Quiet Hours Section */}
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide"> <div className="flex items-center gap-2">
Quiet Hours <Moon size={14} className="text-slate-400" />
<InfoButton info="Non-emergency alerts are held during these hours. Rules with 'Override Quiet Hours' enabled still deliver. Emergency and critical alerts always get through." /> <label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
</label>
<div className="grid grid-cols-2 gap-4">
<TimeInput
label="Start Time"
value={config.quiet_hours_start || '22:00'}
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
helper="When quiet hours begin"
/>
<TimeInput
label="End Time"
value={config.quiet_hours_end || '06:00'}
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
helper="When quiet hours end"
/>
</div> </div>
<Toggle
label="Enable Quiet Hours"
checked={config.quiet_hours_enabled ?? true}
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
helper="Suppress non-emergency alerts during sleeping hours"
info="When enabled, alerts below emergency severity are held during quiet hours. When disabled, all alerts deliver anytime."
/>
{config.quiet_hours_enabled && (
<>
<div className="grid grid-cols-2 gap-4">
<TimeInput
label="Start Time"
value={config.quiet_hours_start || '22:00'}
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
helper="When quiet hours begin"
/>
<TimeInput
label="End Time"
value={config.quiet_hours_end || '06:00'}
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
helper="When quiet hours end"
/>
</div>
<p className="text-xs text-slate-600">
Emergency alerts and rules with "Override Quiet Hours" enabled always deliver.
</p>
</>
)}
</div> </div>
{/* Rules Section */} {/* Rules Section */}
@ -1069,6 +1166,7 @@ export default function Notifications() {
key={i} key={i}
rule={rule} rule={rule}
categories={categories} categories={categories}
quietHoursEnabled={config.quiet_hours_enabled ?? true}
onChange={(r) => { onChange={(r) => {
const newRules = [...(config.rules || [])] const newRules = [...(config.rules || [])]
newRules[i] = r newRules[i] = r

View file

@ -246,18 +246,22 @@ class AlertRulesConfig:
battery_warning: bool = True battery_warning: bool = True
battery_critical: bool = True battery_critical: bool = True
battery_emergency: bool = True battery_emergency: bool = True
battery_warning_threshold: int = 50 battery_warning_threshold: int = 30
battery_critical_threshold: int = 25 battery_critical_threshold: int = 15
battery_emergency_threshold: int = 10 battery_emergency_threshold: int = 5
# Voltage-based thresholds (more accurate than percentage)
battery_warning_voltage: float = 3.60
battery_critical_voltage: float = 3.50
battery_emergency_voltage: float = 3.40
power_source_change: bool = True power_source_change: bool = True
solar_not_charging: bool = True solar_not_charging: bool = True
# Utilization # Utilization
sustained_high_util: bool = True sustained_high_util: bool = True
high_util_threshold: float = 20.0 high_util_threshold: float = 40.0
high_util_hours: int = 6 high_util_hours: int = 6
packet_flood: bool = True packet_flood: bool = True
packet_flood_threshold: int = 500 packet_flood_threshold: int = 10
# Coverage # Coverage
infra_single_gateway: bool = True infra_single_gateway: bool = True
@ -266,7 +270,7 @@ class AlertRulesConfig:
# Health Scores # Health Scores
mesh_score_alert: bool = True mesh_score_alert: bool = True
mesh_score_threshold: int = 70 mesh_score_threshold: int = 65
region_score_alert: bool = True region_score_alert: bool = True
region_score_threshold: int = 60 region_score_threshold: int = 60
@ -448,7 +452,7 @@ class NotificationRuleConfig:
custom_message: str = "" custom_message: str = ""
# Delivery type # Delivery type
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
# Mesh broadcast fields # Mesh broadcast fields
broadcast_channel: int = 0 broadcast_channel: int = 0
@ -482,6 +486,7 @@ class NotificationsConfig:
"""Notification system settings.""" """Notification system settings."""
enabled: bool = False enabled: bool = False
quiet_hours_enabled: bool = True # Master toggle for quiet hours
quiet_hours_start: str = "22:00" quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00" quiet_hours_end: str = "06:00"
rules: list = field(default_factory=list) # List of NotificationRuleConfig rules: list = field(default_factory=list) # List of NotificationRuleConfig

View file

@ -1,163 +1,192 @@
"""Environmental data API routes.""" """Environmental data API routes."""
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
router = APIRouter(tags=["environment"]) router = APIRouter(tags=["environment"])
@router.get("/env/status") @router.get("/env/status")
async def get_env_status(request: Request): async def get_env_status(request: Request):
"""Get environmental feeds status.""" """Get environmental feeds status."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False, "feeds": []} return {"enabled": False, "feeds": []}
return { return {
"enabled": True, "enabled": True,
"feeds": env_store.get_source_health(), "feeds": env_store.get_source_health(),
} }
@router.get("/env/active") @router.get("/env/active")
async def get_active_env(request: Request): async def get_active_env(request: Request):
"""Get active environmental events.""" """Get active environmental events."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
return env_store.get_active() return env_store.get_active()
@router.get("/env/swpc") @router.get("/env/swpc")
async def get_swpc_data(request: Request): async def get_swpc_data(request: Request):
"""Get SWPC space weather data.""" """Get SWPC space weather data."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return {"enabled": False}
status = env_store.get_swpc_status() status = env_store.get_swpc_status()
if not status: if not status:
return {"enabled": False} return {"enabled": False}
return { return {
"enabled": True, "enabled": True,
**status, **status,
} }
@router.get("/env/propagation") @router.get("/env/propagation")
async def get_rf_propagation(request: Request): async def get_rf_propagation(request: Request):
"""Get combined HF + UHF propagation data for dashboard.""" """Get combined HF + UHF propagation data for dashboard."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"hf": {}, "uhf_ducting": {}} return {"hf": {}, "uhf_ducting": {}}
return env_store.get_rf_propagation() return env_store.get_rf_propagation()
@router.get("/env/ducting") @router.get("/env/ducting")
async def get_ducting_data(request: Request): async def get_ducting_data(request: Request):
"""Get tropospheric ducting assessment.""" """Get tropospheric ducting assessment."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return {"enabled": False}
status = env_store.get_ducting_status() status = env_store.get_ducting_status()
if not status: if not status:
return {"enabled": False} return {"enabled": False}
return { return {
"enabled": True, "enabled": True,
**status, **status,
} }
@router.get("/env/fires") @router.get("/env/fires")
async def get_fires_data(request: Request): async def get_fires_data(request: Request):
"""Get active wildfire perimeters.""" """Get active wildfire perimeters."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
return env_store.get_active(source="nifc") return env_store.get_active(source="nifc")
@router.get("/env/avalanche") @router.get("/env/avalanche")
async def get_avalanche_data(request: Request): async def get_avalanche_data(request: Request):
"""Get avalanche advisories.""" """Get avalanche advisories."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"off_season": True, "advisories": []} return {"off_season": True, "advisories": []}
adapters = getattr(env_store, "_adapters", {}) adapters = getattr(env_store, "_adapters", {})
avy_adapter = adapters.get("avalanche") avy_adapter = adapters.get("avalanche")
if avy_adapter and avy_adapter.is_off_season(): if avy_adapter and avy_adapter.is_off_season():
return {"off_season": True, "advisories": []} return {"off_season": True, "advisories": []}
return { return {
"off_season": False, "off_season": False,
"advisories": env_store.get_active(source="avalanche"), "advisories": env_store.get_active(source="avalanche"),
} }
@router.get("/env/streams")
async def get_streams_data(request: Request): @router.get("/env/streams")
"""Get USGS stream gauge readings.""" async def get_streams_data(request: Request):
env_store = getattr(request.app.state, "env_store", None) """Get USGS stream gauge readings."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return [] if not env_store:
return []
return env_store.get_active(source="usgs")
return env_store.get_active(source="usgs")
@router.get("/env/traffic")
async def get_traffic_data(request: Request): @router.get("/env/usgs/lookup/{site_id}")
"""Get TomTom traffic flow data.""" async def lookup_usgs_site(request: Request, site_id: str):
env_store = getattr(request.app.state, "env_store", None) """Lookup USGS site metadata and NWS flood stages.
if not env_store: Returns site name, location, and flood stage thresholds from NWS NWPS.
return [] Used by the config UI to auto-populate fields when adding a new gauge.
"""
return env_store.get_active(source="traffic") env_store = getattr(request.app.state, "env_store", None)
if not env_store:
@router.get("/env/roads") return {"error": "Environmental feeds not enabled"}
async def get_roads_data(request: Request):
"""Get 511 road conditions.""" adapters = getattr(env_store, "_adapters", {})
env_store = getattr(request.app.state, "env_store", None) usgs_adapter = adapters.get("usgs")
if not env_store: if not usgs_adapter:
return [] # Create a temporary adapter for lookup
from meshai.env.usgs import USGSStreamsAdapter
return env_store.get_active(source="511") from meshai.config import USGSConfig
usgs_adapter = USGSStreamsAdapter(USGSConfig())
@router.get("/env/hotspots") try:
async def get_hotspots_data(request: Request): result = usgs_adapter.lookup_site(site_id)
"""Get NASA FIRMS satellite fire hotspots.""" return result
env_store = getattr(request.app.state, "env_store", None) except Exception as e:
return {"error": str(e), "site_id": site_id}
if not env_store:
return {"hotspots": [], "new_ignitions": 0}
@router.get("/env/traffic")
firms_adapter = getattr(env_store, "_firms", None) async def get_traffic_data(request: Request):
"""Get TomTom traffic flow data."""
if not firms_adapter: env_store = getattr(request.app.state, "env_store", None)
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
if not env_store:
hotspots = env_store.get_active(source="firms") return []
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
return env_store.get_active(source="traffic")
return {
"enabled": True,
"hotspots": hotspots, @router.get("/env/roads")
"new_ignitions": len(new_ignitions), async def get_roads_data(request: Request):
} """Get 511 road conditions."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="511")
@router.get("/env/hotspots")
async def get_hotspots_data(request: Request):
"""Get NASA FIRMS satellite fire hotspots."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"hotspots": [], "new_ignitions": 0}
firms_adapter = getattr(env_store, "_firms", None)
if not firms_adapter:
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
hotspots = env_store.get_active(source="firms")
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
return {
"enabled": True,
"hotspots": hotspots,
"new_ignitions": len(new_ignitions),
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script> <script type="module" crossorigin src="/assets/index-DARDkZhk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css"> <link rel="stylesheet" crossorigin href="/assets/index-CYHGOAxN.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

45
meshai/env/swpc.py vendored
View file

@ -140,15 +140,36 @@ class SWPCAdapter:
"""Parse noaa-planetary-k-index.json. """Parse noaa-planetary-k-index.json.
Data format: array of objects with time_tag, Kp, a_running, station_count Data format: array of objects with time_tag, Kp, a_running, station_count
Last entry is most recent. Last entry is most recent. Store full history for charting.
""" """
if not data: if not data:
return return
# Get last entry (most recent) # Store full history (last 24-48 hours of readings)
last_entry = data[-1] kp_history = []
for entry in data:
if isinstance(entry, dict):
try:
kp_history.append({
"time": entry.get("time_tag", ""),
"value": float(entry.get("Kp", 0)),
})
except (ValueError, TypeError):
continue
elif isinstance(entry, list) and len(entry) > 1:
# Legacy array format fallback
try:
kp_history.append({
"time": entry[0] if len(entry) > 0 else "",
"value": float(entry[1]),
})
except (ValueError, TypeError):
continue
# Handle both dict format (new API) and list format (legacy) self._status["kp_history"] = kp_history
# Get last entry (most recent) for current value
last_entry = data[-1]
if isinstance(last_entry, dict): if isinstance(last_entry, dict):
try: try:
self._status["kp_current"] = float(last_entry.get("Kp", 0)) self._status["kp_current"] = float(last_entry.get("Kp", 0))
@ -184,10 +205,26 @@ class SWPCAdapter:
"""Parse f107_cm_flux.json. """Parse f107_cm_flux.json.
Data format: array of objects with time_tag, flux Data format: array of objects with time_tag, flux
Store history for potential charting.
""" """
if not data: if not data:
return return
# Store SFI history (last 30 days of readings)
sfi_history = []
if isinstance(data, list):
for entry in data[-30:]: # Last 30 entries
if isinstance(entry, dict):
try:
sfi_history.append({
"time": entry.get("time_tag", ""),
"value": float(entry.get("flux", 0)),
})
except (ValueError, TypeError):
continue
self._status["sfi_history"] = sfi_history
# Get most recent entry (last in list) # Get most recent entry (last in list)
if isinstance(data, list) and data: if isinstance(data, list) and data:
last = data[-1] last = data[-1]

241
meshai/env/usgs.py vendored
View file

@ -1,4 +1,4 @@
"""USGS Water Services stream gauge adapter. """USGS Water Services stream gauge adapter with NWS flood stage auto-lookup.
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027 # TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
# Legacy waterservices.usgs.gov will be decommissioned. # Legacy waterservices.usgs.gov will be decommissioned.
@ -8,7 +8,7 @@
import json import json
import logging import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.parse import urlencode from urllib.parse import urlencode
@ -21,11 +21,17 @@ logger = logging.getLogger(__name__)
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly) # Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
MIN_TICK_SECONDS = 900 # 15 minutes MIN_TICK_SECONDS = 900 # 15 minutes
# Cache for NWS flood stages (rarely change)
_nwps_cache: dict[str, dict] = {}
_nwps_cache_time: dict[str, float] = {}
NWPS_CACHE_TTL = 86400 * 7 # 7 days
class USGSStreamsAdapter: class USGSStreamsAdapter:
"""USGS instantaneous values for stream gauge readings.""" """USGS instantaneous values for stream gauge readings with NWS flood stages."""
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/" BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
def __init__(self, config: "USGSConfig"): def __init__(self, config: "USGSConfig"):
self._sites = config.sites or [] self._sites = config.sites or []
@ -37,6 +43,9 @@ class USGSStreamsAdapter:
self._last_error = None self._last_error = None
self._is_loaded = False self._is_loaded = False
# Site metadata cache (name, flood stages from NWPS)
self._site_metadata: dict[str, dict] = {}
if self._tick_interval < MIN_TICK_SECONDS: if self._tick_interval < MIN_TICK_SECONDS:
logger.warning( logger.warning(
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}" f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
@ -61,15 +70,192 @@ class USGSStreamsAdapter:
self._last_tick = now self._last_tick = now
return self._fetch() return self._fetch()
def _get_site_ids(self) -> list[str]:
"""Extract site IDs from config (handles both string and dict formats)."""
site_ids = []
for site in self._sites:
if isinstance(site, str):
site_ids.append(site)
elif isinstance(site, dict):
site_ids.append(site.get("id", ""))
elif hasattr(site, "id"):
site_ids.append(site.id)
return [s for s in site_ids if s]
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
"""Lookup flood stages from NWS National Water Prediction Service.
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
We try a mapping lookup first, then fall back to direct lookup.
Returns:
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
or None if not available
"""
global _nwps_cache, _nwps_cache_time
# Check cache
now = time.time()
if usgs_site_id in _nwps_cache:
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
return _nwps_cache[usgs_site_id]
# Try to find NWS gauge ID from USGS site ID
# First, query USGS site info to get the NWS ID crosswalk
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
if not nws_gauge_id:
# Fall back to using USGS ID directly (sometimes they match)
nws_gauge_id = usgs_site_id
# Query NWPS for flood stages
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
headers = {
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
"Accept": "application/json",
}
try:
req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
# Extract flood stages
stages = {}
flood_info = data.get("flood", {})
if "action" in flood_info:
stages["action_stage"] = flood_info["action"].get("stage")
if "minor" in flood_info:
stages["flood_stage"] = flood_info["minor"].get("stage")
if "moderate" in flood_info:
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
if "major" in flood_info:
stages["major_flood_stage"] = flood_info["major"].get("stage")
# Also grab the official name if available
stages["nws_name"] = data.get("name", "")
stages["nws_gauge_id"] = nws_gauge_id
# Cache result
_nwps_cache[usgs_site_id] = stages
_nwps_cache_time[usgs_site_id] = now
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
return stages
except HTTPError as e:
if e.code == 404:
# No NWPS data for this gauge - cache the miss
_nwps_cache[usgs_site_id] = {}
_nwps_cache_time[usgs_site_id] = now
logger.debug(f"No NWPS data for gauge {usgs_site_id}")
else:
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
return None
except Exception as e:
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
return None
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
"""Try to find NWS gauge ID from USGS site ID.
The USGS provides a crosswalk in their site metadata, but it's not
always populated. This is a best-effort lookup.
"""
# Try USGS site service for metadata including NWS ID
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
try:
req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
with urlopen(req, timeout=10) as resp:
content = resp.read().decode("utf-8")
# Parse RDB format - look for NWS ID in the data
# This is a simplified parser; full implementation would be more robust
for line in content.split("\n"):
if line.startswith(usgs_site_id):
# NWS station ID is typically in column ~30ish
# This varies by USGS response format
pass
except Exception:
pass
return None
def lookup_site(self, site_id: str) -> dict:
"""Lookup site metadata for config UI auto-populate.
Returns:
{
"site_id": "13090500",
"name": "Snake River nr Twin Falls ID",
"lat": 42.xxx,
"lon": -114.xxx,
"flood_stages": {
"action_stage": 9.0,
"flood_stage": 10.5,
"moderate_flood_stage": 12.0,
"major_flood_stage": 14.0,
} or None
}
"""
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
# Get USGS site info
params = {
"format": "json",
"sites": site_id,
"siteOutput": "expanded",
}
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
try:
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
sites = data.get("value", {}).get("timeSeries", [])
if not sites:
# Try alternate format
sites_list = data.get("value", {}).get("sites", [])
if sites_list:
site_info = sites_list[0]
result["name"] = site_info.get("siteName", "")
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
except Exception as e:
logger.warning(f"USGS site lookup failed for {site_id}: {e}")
# Get NWS flood stages
stages = self._lookup_nwps_stages(site_id)
if stages:
result["flood_stages"] = {
"action_stage": stages.get("action_stage"),
"flood_stage": stages.get("flood_stage"),
"moderate_flood_stage": stages.get("moderate_flood_stage"),
"major_flood_stage": stages.get("major_flood_stage"),
}
if stages.get("nws_name") and not result["name"]:
result["name"] = stages["nws_name"]
return result
def _fetch(self) -> bool: def _fetch(self) -> bool:
"""Fetch instantaneous values from USGS Water Services. """Fetch instantaneous values from USGS Water Services.
Returns: Returns:
True if data changed True if data changed
""" """
site_ids = self._get_site_ids()
if not site_ids:
return False
params = { params = {
"format": "json", "format": "json",
"sites": ",".join(self._sites), "sites": ",".join(site_ids),
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft) "parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
"siteStatus": "active", "siteStatus": "active",
} }
@ -121,6 +307,10 @@ class USGSStreamsAdapter:
site_codes = source_info.get("siteCode", []) site_codes = source_info.get("siteCode", [])
site_id = site_codes[0].get("value", "") if site_codes else "" site_id = site_codes[0].get("value", "") if site_codes else ""
# Cache site name
if site_id and site_id not in self._site_metadata:
self._site_metadata[site_id] = {"name": site_name}
# Extract location # Extract location
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {}) geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
lat = geo_loc.get("latitude") lat = geo_loc.get("latitude")
@ -159,11 +349,37 @@ class USGSStreamsAdapter:
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
# Check flood threshold # Get flood stages for this site
nwps_stages = self._lookup_nwps_stages(site_id)
# Determine severity based on flood stages (for gage height)
severity = "info" severity = "info"
threshold = self._flood_thresholds.get(site_id, {}).get(param_type) flood_status = None
if threshold and value > threshold:
severity = "warning" if param_type == "height" and nwps_stages:
major = nwps_stages.get("major_flood_stage")
moderate = nwps_stages.get("moderate_flood_stage")
minor = nwps_stages.get("flood_stage")
action = nwps_stages.get("action_stage")
if major and value >= major:
severity = "critical"
flood_status = "Major Flood"
elif moderate and value >= moderate:
severity = "warning"
flood_status = "Moderate Flood"
elif minor and value >= minor:
severity = "warning"
flood_status = "Minor Flood"
elif action and value >= action:
severity = "advisory"
flood_status = "Action Stage"
# Fall back to legacy manual thresholds
if severity == "info":
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
if threshold and value > threshold:
severity = "warning"
# Format headline # Format headline
if param_type == "flow": if param_type == "flow":
@ -171,6 +387,9 @@ class USGSStreamsAdapter:
else: else:
headline = f"{site_name}: {value:.1f} {unit_code}" headline = f"{site_name}: {value:.1f} {unit_code}"
if flood_status:
headline += f"{flood_status}"
event = { event = {
"source": "usgs", "source": "usgs",
"event_id": f"{site_id}_{param_type}", "event_id": f"{site_id}_{param_type}",
@ -188,6 +407,8 @@ class USGSStreamsAdapter:
"value": value, "value": value,
"unit": unit_code, "unit": unit_code,
"timestamp": timestamp_str, "timestamp": timestamp_str,
"flood_status": flood_status,
"flood_stages": nwps_stages if nwps_stages else None,
}, },
} }
@ -210,7 +431,7 @@ class USGSStreamsAdapter:
self._is_loaded = True self._is_loaded = True
if changed: if changed:
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(self._sites)} sites") logger.info(f"USGS streams updated: {len(new_events)} readings from {len(site_ids)} sites")
return changed return changed
@ -228,5 +449,5 @@ class USGSStreamsAdapter:
"consecutive_errors": self._consecutive_errors, "consecutive_errors": self._consecutive_errors,
"event_count": len(self._events), "event_count": len(self._events),
"last_fetch": self._last_tick, "last_fetch": self._last_tick,
"site_count": len(self._sites), "site_count": len(self._get_site_ids()),
} }

View file

@ -1,139 +1,218 @@
"""Alert category registry. """Alert category registry.
Defines all alertable conditions with human-readable names and descriptions. Defines all alertable conditions with human-readable names, descriptions,
and example messages showing what users will receive.
""" """
ALERT_CATEGORIES = { ALERT_CATEGORIES = {
# Infrastructure alerts # Infrastructure alerts
"infra_offline": { "infra_offline": {
"name": "Infrastructure Offline", "name": "Infrastructure Node Offline",
"description": "An infrastructure node stopped responding", "description": "An infrastructure node (router/repeater) stopped responding",
"default_severity": "warning", "default_severity": "warning",
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
}, },
"critical_node_down": { "critical_node_down": {
"name": "Critical Node Down", "name": "Critical Node Down",
"description": "A node marked as critical went offline", "description": "A node you marked as critical went offline",
"default_severity": "critical", "default_severity": "warning",
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
}, },
"infra_recovery": { "infra_recovery": {
"name": "Infrastructure Recovery", "name": "Infrastructure Recovery",
"description": "An infrastructure node came back online", "description": "An offline infrastructure node came back online",
"default_severity": "info", "default_severity": "info",
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
}, },
"new_router": { "new_router": {
"name": "New Router", "name": "New Router",
"description": "A new router appeared on the mesh", "description": "A new router appeared on the mesh",
"default_severity": "info", "default_severity": "info",
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
}, },
# Power alerts # Power alerts
"battery_warning": { "battery_warning": {
"name": "Battery Warning", "name": "Battery Warning",
"description": "Infrastructure node battery below warning threshold", "description": "Infrastructure node battery below 30% (3.60V)",
"default_severity": "warning", "default_severity": "advisory",
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
}, },
"battery_critical": { "battery_critical": {
"name": "Battery Critical", "name": "Battery Critical",
"description": "Infrastructure node battery below critical threshold", "description": "Infrastructure node battery below 15% (3.50V)",
"default_severity": "critical", "default_severity": "warning",
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
}, },
"battery_emergency": { "battery_emergency": {
"name": "Battery Emergency", "name": "Battery Emergency",
"description": "Infrastructure node battery critically low", "description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
"default_severity": "emergency", "default_severity": "critical",
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
}, },
"battery_trend": { "battery_trend": {
"name": "Battery Declining", "name": "Battery Declining",
"description": "Battery showing declining trend over 7 days", "description": "Battery showing declining trend over 7 days — possible solar or charging issue",
"default_severity": "warning", "default_severity": "advisory",
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
}, },
"power_source_change": { "power_source_change": {
"name": "Power Source Change", "name": "Power Source Change",
"description": "Node switched from USB to battery (possible outage)", "description": "Node switched from USB to battery — possible power outage at site",
"default_severity": "warning", "default_severity": "warning",
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
}, },
"solar_not_charging": { "solar_not_charging": {
"name": "Solar Not Charging", "name": "Solar Not Charging",
"description": "Solar panel not charging during daylight hours", "description": "Solar panel not charging during daylight hours — panel issue or obstruction",
"default_severity": "warning", "default_severity": "warning",
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
}, },
# Utilization alerts # Utilization alerts
"high_utilization": {
"name": "Channel Airtime High",
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
"default_severity": "advisory",
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
},
"sustained_high_util": { "sustained_high_util": {
"name": "High Utilization", "name": "Sustained High Utilization",
"description": "Channel utilization elevated for extended period", "description": "Channel airtime elevated for extended period — ongoing congestion",
"default_severity": "warning", "default_severity": "warning",
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
}, },
"packet_flood": { "packet_flood": {
"name": "Packet Flood", "name": "Packet Flood",
"description": "Node sending excessive packets", "description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
"default_severity": "warning", "default_severity": "warning",
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
}, },
# Coverage alerts # Coverage alerts
"infra_single_gateway": { "infra_single_gateway": {
"name": "Single Gateway", "name": "Single Gateway",
"description": "Infrastructure node dropped to single gateway coverage", "description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
"default_severity": "warning", "default_severity": "advisory",
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
}, },
"feeder_offline": { "feeder_offline": {
"name": "Feeder Offline", "name": "Feeder Offline",
"description": "A feeder gateway stopped responding", "description": "A feeder gateway stopped responding — coverage gap possible",
"default_severity": "warning", "default_severity": "warning",
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
}, },
"region_total_blackout": { "region_total_blackout": {
"name": "Region Blackout", "name": "Region Blackout",
"description": "All infrastructure in a region is offline", "description": "All infrastructure in a region is offline — complete coverage loss",
"default_severity": "emergency", "default_severity": "critical",
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
}, },
# Health score alerts # Health score alerts
"mesh_score_low": { "mesh_score_low": {
"name": "Mesh Health Low", "name": "Mesh Health Low",
"description": "Overall mesh health score below threshold", "description": "Overall mesh health score dropped below threshold — multiple issues likely",
"default_severity": "warning", "default_severity": "warning",
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
}, },
"region_score_low": { "region_score_low": {
"name": "Region Health Low", "name": "Region Health Low",
"description": "A region's health score below threshold", "description": "A region's health score below threshold — localized issues",
"default_severity": "warning", "default_severity": "warning",
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
}, },
# Environmental alerts # Environmental - Weather
"weather_warning": { "weather_warning": {
"name": "Severe Weather", "name": "Severe Weather",
"description": "NWS warning or advisory for mesh area", "description": "NWS warning or advisory affecting your mesh area",
"default_severity": "warning", "default_severity": "warning",
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
}, },
# Environmental - Space Weather
"hf_blackout": { "hf_blackout": {
"name": "HF Radio Blackout", "name": "HF Radio Blackout",
"description": "R3+ solar event degrading HF propagation", "description": "R3+ solar flare degrading HF propagation on sunlit side",
"default_severity": "warning", "default_severity": "warning",
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
}, },
"geomagnetic_storm": {
"name": "Geomagnetic Storm",
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
"default_severity": "advisory",
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
},
# Environmental - Tropospheric
"tropospheric_ducting": { "tropospheric_ducting": {
"name": "Tropospheric Ducting", "name": "Tropospheric Ducting",
"description": "Atmospheric conditions extending VHF/UHF range", "description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
"default_severity": "info", "default_severity": "info",
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
},
# Environmental - Fire
"fire_proximity": {
"name": "Fire Near Mesh",
"description": "Active wildfire within alert radius of mesh infrastructure",
"default_severity": "warning",
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
}, },
"wildfire_proximity": { "wildfire_proximity": {
"name": "Fire Near Mesh", "name": "Fire Near Mesh",
"description": "Wildfire detected within configured distance", "description": "Active wildfire within alert radius of mesh infrastructure",
"default_severity": "warning", "default_severity": "warning",
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
}, },
"new_ignition": { "new_ignition": {
"name": "New Fire Ignition", "name": "New Fire Ignition",
"description": "Satellite hotspot not matching any known fire", "description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
"default_severity": "warning", "default_severity": "watch",
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
}, },
"flood_warning": {
"name": "Flood Warning", # Environmental - Flood
"description": "Stream gauge exceeds flood threshold", "stream_flood_warning": {
"name": "Stream Flood Warning",
"description": "River gauge exceeds NWS flood stage threshold",
"default_severity": "warning", "default_severity": "warning",
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
}, },
"stream_high_water": {
"name": "Stream High Water",
"description": "River gauge approaching flood stage — monitoring recommended",
"default_severity": "advisory",
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
},
# Environmental - Roads
"road_closure": { "road_closure": {
"name": "Road Closure", "name": "Road Closure",
"description": "Full road closure on monitored corridor", "description": "Full road closure on a monitored corridor",
"default_severity": "warning", "default_severity": "warning",
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
},
"traffic_congestion": {
"name": "Traffic Congestion",
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
"default_severity": "advisory",
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
},
# Environmental - Avalanche
"avalanche_warning": {
"name": "Avalanche Danger High",
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
"default_severity": "warning",
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
},
"avalanche_considerable": {
"name": "Avalanche Danger Considerable",
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
"default_severity": "watch",
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
}, },
} }
@ -146,6 +225,7 @@ def get_category(category_id: str) -> dict:
"name": category_id.replace("_", " ").title(), "name": category_id.replace("_", " ").title(),
"description": f"Alert type: {category_id}", "description": f"Alert type: {category_id}",
"default_severity": "info", "default_severity": "info",
"example_message": f"Alert: {category_id}",
} }

View file

@ -29,10 +29,11 @@ class NotificationRouter:
timezone: str = "America/Boise", timezone: str = "America/Boise",
): ):
self._rules: list[dict] = [] self._rules: list[dict] = []
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
self._quiet_start = getattr(config, "quiet_hours_start", "22:00") self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
self._quiet_end = getattr(config, "quiet_hours_end", "06:00") self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
self._timezone = timezone self._timezone = timezone
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._connector = connector self._connector = connector
self._config = config self._config = config
@ -56,9 +57,16 @@ class NotificationRouter:
logger.info("Notification router initialized: %d condition rules", len(self._rules)) logger.info("Notification router initialized: %d condition rules", len(self._rules))
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]: def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
"""Create a channel instance from a rule's inline delivery config.""" """Create a channel instance from a rule's inline delivery config.
Returns None if delivery_type is empty or invalid.
"""
delivery_type = rule.get("delivery_type", "") delivery_type = rule.get("delivery_type", "")
# Empty delivery type is valid - rule exists but doesn't deliver
if not delivery_type:
return None
if delivery_type == "mesh_broadcast": if delivery_type == "mesh_broadcast":
config = { config = {
"type": "mesh_broadcast", "type": "mesh_broadcast",
@ -87,13 +95,13 @@ class NotificationRouter:
"headers": rule.get("webhook_headers", {}), "headers": rule.get("webhook_headers", {}),
} }
else: else:
logger.warning("Unknown delivery type: %s", delivery_type) logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
return None return None
try: try:
return create_channel(config, self._connector) return create_channel(config, self._connector)
except Exception as e: except Exception as e:
logger.warning("Failed to create channel for rule %s: %s", rule.get("name"), e) logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
return None return None
async def process_alert(self, alert: dict) -> bool: async def process_alert(self, alert: dict) -> bool:
@ -106,6 +114,8 @@ class NotificationRouter:
delivered = False delivered = False
for rule in self._rules: for rule in self._rules:
rule_name = rule.get("name", "unnamed")
# Check category match # Check category match
rule_categories = rule.get("categories", []) rule_categories = rule.get("categories", [])
if rule_categories and category not in rule_categories: if rule_categories and category not in rule_categories:
@ -116,15 +126,18 @@ class NotificationRouter:
if not self._severity_meets(severity, min_severity): if not self._severity_meets(severity, min_severity):
continue continue
# Check quiet hours (emergencies and criticals override) # Check quiet hours (only if quiet hours are enabled globally)
if self._in_quiet_hours() and severity not in ("emergency", "critical"): if self._quiet_enabled and self._in_quiet_hours():
if not rule.get("override_quiet", False): # Emergencies and criticals always go through
continue if severity not in ("emergency", "critical"):
# Check if rule overrides quiet hours
if not rule.get("override_quiet", False):
logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name)
continue
# Check cooldown # Check cooldown
cooldown = rule.get("cooldown_minutes", 10) * 60 cooldown = rule.get("cooldown_minutes", 10) * 60
event_id = alert.get("event_id", alert.get("message", "")[:50]) event_id = alert.get("event_id", alert.get("message", "")[:50])
rule_name = rule.get("name", "unknown")
dedup_key = (rule_name, category, event_id) dedup_key = (rule_name, category, event_id)
now = time.time() now = time.time()
if dedup_key in self._recent: if dedup_key in self._recent:
@ -133,9 +146,19 @@ class NotificationRouter:
continue continue
self._recent[dedup_key] = now self._recent[dedup_key] = now
# Log rule match
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
# Check if rule has delivery configured
delivery_type = rule.get("delivery_type", "")
if not delivery_type:
logger.info("Rule '%s' matched but has no delivery configured", rule_name)
continue
# Create channel and deliver # Create channel and deliver
channel = self._create_channel_for_rule(rule) channel = self._create_channel_for_rule(rule)
if not channel: if not channel:
logger.warning("Rule '%s' failed to create delivery channel", rule_name)
continue continue
try: try:
@ -153,9 +176,9 @@ class NotificationRouter:
success = await channel.deliver(delivery_alert, rule) success = await channel.deliver(delivery_alert, rule)
if success: if success:
delivered = True delivered = True
logger.info("Alert delivered via %s: %s", rule_name, category) logger.info("Alert delivered via rule '%s': %s", rule_name, category)
except Exception as e: except Exception as e:
logger.warning("Rule %s delivery failed: %s", rule_name, e) logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
return delivered return delivered
@ -170,6 +193,9 @@ class NotificationRouter:
def _in_quiet_hours(self) -> bool: def _in_quiet_hours(self) -> bool:
"""Check if current time is within quiet hours.""" """Check if current time is within quiet hours."""
if not self._quiet_enabled:
return False
try: try:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone) tz = ZoneInfo(self._timezone)
@ -204,12 +230,69 @@ class NotificationRouter:
else: else:
rule_dict = dict(rule) rule_dict = dict(rule)
# Check if delivery is configured
if not rule_dict.get("delivery_type"):
return False, "No delivery method configured for this rule"
channel = self._create_channel_for_rule(rule_dict) channel = self._create_channel_for_rule(rule_dict)
if not channel: if not channel:
return False, "Failed to create delivery channel" return False, "Failed to create delivery channel"
return await channel.test() return await channel.test()
async def preview_rule(self, rule_index: int) -> dict:
"""Preview what a rule would match right now.
Returns:
{
"matches": bool,
"conditions": [...], # Current conditions that match
"preview": str, # Example message
}
"""
rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config):
return {"matches": False, "conditions": [], "preview": "Invalid rule index"}
rule = rules_config[rule_index]
if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
rule_dict = dict(rule)
# For condition rules, show example based on categories
if rule_dict.get("trigger_type", "condition") == "condition":
from .categories import get_category
categories = rule_dict.get("categories", [])
if not categories:
# All categories - show first example
example = get_category("infra_offline")
return {
"matches": True,
"conditions": ["All alert categories"],
"preview": example.get("example_message", "Alert notification"),
}
else:
# Show example from first category
cat_info = get_category(categories[0])
return {
"matches": True,
"conditions": [get_category(c)["name"] for c in categories],
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
}
# For schedule rules, generate preview report
elif rule_dict.get("trigger_type") == "schedule":
message_type = rule_dict.get("message_type", "mesh_health_summary")
return {
"matches": True,
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
"preview": f"[{message_type}] Report content would appear here",
}
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
def add_mesh_subscription( def add_mesh_subscription(
self, self,
node_id: str, node_id: str,