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

View file

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

View file

@ -1,440 +1,479 @@
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface AlertHistoryItem {
id?: number
type: string
severity: string
message: string
timestamp: string
duration?: number
scope_type?: string
scope_value?: string
resolved_at?: string
}
export interface AlertHistoryResponse {
items: AlertHistoryItem[]
total: number
}
export interface Subscription {
id: number
user_id: string
sub_type: string
schedule_time?: string
schedule_day?: string
scope_type: string
scope_value?: string
enabled: boolean
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
export interface SWPCStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchAlertHistory(
limit: number = 50,
offset: number = 0,
type?: string,
severity?: string
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
const params = new URLSearchParams()
params.set('limit', limit.toString())
params.set('offset', offset.toString())
if (type && type !== 'all') params.set('type', type)
if (severity && severity !== 'all') params.set('severity', severity)
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
}
export async function fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface StreamGaugeEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
site_id: string
site_name: string
parameter: string
value: number
unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface HotspotEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
new_ignition: boolean
confidence: string
frp?: number
brightness?: number
acq_date: string
acq_time: string
near_fire?: string
distance_to_fire_km?: number
distance_km?: number
nearest_anchor?: string
}
}
export interface HotspotsResponse {
enabled: boolean
hotspots: HotspotEvent[]
new_ignitions: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<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')
}
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface AlertHistoryItem {
id?: number
type: string
severity: string
message: string
timestamp: string
duration?: number
scope_type?: string
scope_value?: string
resolved_at?: string
}
export interface AlertHistoryResponse {
items: AlertHistoryItem[]
total: number
}
export interface Subscription {
id: number
user_id: string
sub_type: string
schedule_time?: string
schedule_day?: string
scope_type: string
scope_value?: string
enabled: boolean
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
// Kp history entry for charting
export interface KpHistoryEntry {
time: string
value: number
}
// SFI history entry for charting
export interface SfiHistoryEntry {
time: string
value: number
}
// Refractivity profile entry
export interface ProfileEntry {
level_hPa: number
height_m: number
N: number
M: number
T_C: number
RH: number
}
// Gradient entry
export interface GradientEntry {
from_level: number
to_level: number
from_height_m: number
to_height_m: number
gradient: number
}
export interface SWPCStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
kp_history?: KpHistoryEntry[]
sfi_history?: SfiHistoryEntry[]
}
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
profile?: ProfileEntry[]
gradients?: GradientEntry[]
assessment?: string
location?: { lat: number; lon: number }
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
kp_history?: KpHistoryEntry[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
profile?: ProfileEntry[]
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchAlertHistory(
limit: number = 50,
offset: number = 0,
type?: string,
severity?: string
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
const params = new URLSearchParams()
params.set('limit', limit.toString())
params.set('offset', offset.toString())
if (type && type !== 'all') params.set('type', type)
if (severity && severity !== 'all') params.set('severity', severity)
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
}
export async function fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface StreamGaugeEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
site_id: string
site_name: string
parameter: string
value: number
unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface HotspotEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
new_ignition: boolean
confidence: string
frp?: number
brightness?: number
acq_date: string
acq_time: string
near_fire?: string
distance_to_fire_km?: number
distance_km?: number
nearest_anchor?: string
}
}
export interface HotspotsResponse {
enabled: boolean
hotspots: HotspotEvent[]
new_ignitions: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<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 {
fetchHealth,
fetchSources,
fetchAlerts,
fetchEnvStatus,
fetchRFPropagation,
fetchEnvActive,
fetchSWPC,
fetchDucting,
type MeshHealth,
type SourceHealth,
type Alert,
type EnvStatus,
type RFPropagation,
type EnvEvent,
type SWPCStatus,
type DuctingStatus,
} from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
import {
@ -22,13 +26,63 @@ import {
Activity,
MapPin,
Zap,
Cloud,
Flame,
Mountain,
Droplets,
Car,
Construction,
Satellite,
Sun,
} 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 }) {
const score = health.score
const tier = health.tier
// Color based on score
const getColor = (s: number) => {
if (s >= 80) return '#22c55e'
if (s >= 60) return '#f59e0b'
@ -42,46 +96,17 @@ function HealthGauge({ health }: { health: MeshHealth }) {
return (
<div className="flex flex-col items-center">
<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
cx="50"
cy="50"
r="45"
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)"
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"
/>
{/* 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)}
</text>
<text
x="50"
y="62"
textAnchor="middle"
className="fill-slate-400 text-xs"
style={{ fontSize: '10px' }}
>
<text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
{tier}
</text>
</svg>
@ -89,13 +114,7 @@ function HealthGauge({ health }: { health: MeshHealth }) {
)
}
function PillarBar({
label,
value,
}: {
label: string
value: number
}) {
function PillarBar({ label, value }: { label: string; value: number }) {
const getColor = (v: number) => {
if (v >= 80) return 'bg-green-500'
if (v >= 60) return 'bg-amber-500'
@ -106,14 +125,9 @@ function PillarBar({
<div className="flex items-center gap-3">
<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={`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 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>
)
}
@ -123,26 +137,11 @@ function AlertItem({ alert }: { alert: Alert }) {
switch (severity.toLowerCase()) {
case 'critical':
case 'emergency':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
case 'warning':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
default:
return {
bg: 'bg-green-500/10',
border: 'border-green-500',
icon: Info,
iconColor: 'text-green-500',
}
return { 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
return (
<div
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
>
<div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
<Icon size={16} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200">{alert.message}</div>
<div className="text-xs text-slate-500 mt-1">
{alert.timestamp || 'Just now'}
</div>
<div className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
</div>
</div>
)
@ -176,25 +171,13 @@ function SourceCard({ source }: { source: SourceHealth }) {
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 truncate">{source.name}</div>
<div className="text-xs text-slate-500">
{source.node_count} nodes * {source.type}
</div>
<div className="text-xs text-slate-500">{source.node_count} nodes · {source.type}</div>
</div>
</div>
)
}
function StatCard({
icon: Icon,
label,
value,
subvalue,
}: {
icon: typeof Radio
label: string
value: string | number
subvalue?: string
}) {
function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
return (
<div className="bg-bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 text-slate-400 mb-2">
@ -202,100 +185,362 @@ function StatCard({
<span className="text-xs">{label}</span>
</div>
<div className="font-mono text-xl text-slate-100">{value}</div>
{subvalue && (
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
)}
{subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
</div>
)
}
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
if (!propagation) {
return (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">
RF Propagation
</h2>
<div className="text-slate-500">
<p>Loading propagation data...</p>
</div>
// Scale badge component for R/S/G
function ScaleBadge({ label, value }: { label: string; value: number }) {
const getColor = () => {
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
return 'bg-red-500/20 text-red-400 border-red-500/50'
}
return (
<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>
{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 (
<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">
<Zap size={14} />
RF Propagation
</h2>
{/* Solar/Geomagnetic Indices */}
<div className="mb-4">
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
{hasHF ? (
<div className="space-y-1">
<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>
)}
{/* Top row: SFI and Kp big values */}
<div className="flex justify-around mb-4">
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
<div className="w-px bg-border" />
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
</div>
{/* Tropospheric Ducting */}
<div>
<div className="text-xs text-slate-500 mb-1">Tropospheric</div>
{hasDucting ? (
<div className="space-y-1">
<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>
)}
{/* R/S/G Scale badges */}
<div className="flex justify-center gap-2 mb-4">
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
</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>
)
}
@ -305,11 +550,13 @@ export default function Dashboard() {
const [sources, setSources] = useState<SourceHealth[]>([])
const [alerts, setAlerts] = useState<Alert[]>([])
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 [error, setError] = useState<string | null>(null)
const { lastHealth } = useWebSocket()
const { lastHealth, lastMessage } = useWebSocket()
useEffect(() => {
Promise.all([
@ -317,21 +564,25 @@ export default function Dashboard() {
fetchSources(),
fetchAlerts(),
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)
setSources(src)
setAlerts(a)
setEnvStatus(e)
setRFProp(rf)
setLoading(false)
document.title = 'Dashboard — MeshAI'
setEnvEvents(events)
setSwpc(sw as ExtendedSWPCStatus)
setDucting(duct as ExtendedDuctingStatus)
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
.catch((err) => {
setError(err.message)
setLoading(false)
document.title = 'Dashboard — MeshAI'
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
}, [])
@ -342,6 +593,18 @@ export default function Dashboard() {
}
}, [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) {
return (
<div className="flex items-center justify-center h-64">
@ -359,114 +622,76 @@ export default function Dashboard() {
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Mesh Health */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
{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="space-y-6">
{/* Top row: Health + Alerts + Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Mesh Health */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">
Active Alerts
</h2>
{alerts.length > 0 ? (
<div className="space-y-3">
{alerts.map((alert, i) => (
<AlertItem key={i} alert={alert} />
))}
</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>
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
{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>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Radio}
label="Nodes Online"
value={health?.total_nodes || 0}
subvalue={`${health?.unlocated_count || 0} unlocated`}
/>
<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
icon={MapPin}
label="Regions"
value={health?.total_regions || 0}
subvalue={`${health?.battery_warnings || 0} battery warnings`}
/>
{/* 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">
<h2 className="text-sm font-medium text-slate-400 mb-4">Active Alerts</h2>
{alerts.length > 0 ? (
<div className="space-y-3 max-h-48 overflow-y-auto">
{alerts.map((alert, i) => (
<AlertItem key={i} alert={alert} />
))}
</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>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
<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 icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
</div>
</div>
</div>
{/* Mesh Sources */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">
Mesh Sources ({sources.length})
</h2>
{sources.length > 0 ? (
<div className="space-y-2">
{sources.map((source, i) => (
<SourceCard key={i} source={source} />
))}
</div>
) : (
<div className="text-slate-500 py-4">No sources configured</div>
)}
</div>
{/* Middle row: Sources + RF Propagation + Live Feed */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Mesh Sources */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Sources ({sources.length})</h2>
{sources.length > 0 ? (
<div className="space-y-2">
{sources.map((source, i) => (
<SourceCard key={i} source={source} />
))}
</div>
) : (
<div className="text-slate-500 py-4">No sources configured</div>
)}
</div>
{/* Environmental Feeds */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<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 */}
<RFPropagationCard swpc={swpc} ducting={ducting} />
{/* RF Propagation */}
<RFPropagationCard propagation={rfProp} />
{/* Live Event Feed */}
<LiveEventFeed events={envEvents} envStatus={envStatus} />
</div>
</div>
)
}

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import {
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
Calendar, AlertTriangle, Copy
Calendar, AlertTriangle, Copy, Moon, AlertCircle
} from 'lucide-react'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
@ -11,20 +11,15 @@ import NodePicker from '@/components/NodePicker'
interface NotificationRuleConfig {
name: string
enabled: boolean
// Trigger
trigger_type: 'condition' | 'schedule'
// Condition trigger
categories: string[]
min_severity: string
// Schedule trigger
schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom'
schedule_frequency: 'daily' | 'twice_daily' | 'weekly'
schedule_time: string
schedule_time_2: string // For twice_daily
schedule_days: string[] // For weekly
schedule_cron: string // For custom
schedule_time_2: string
schedule_days: string[]
message_type: string
custom_message: string
// Delivery
delivery_type: string
broadcast_channel: number
node_ids: string[]
@ -37,13 +32,13 @@ interface NotificationRuleConfig {
recipients: string[]
webhook_url: string
webhook_headers: Record<string, string>
// Behavior
cooldown_minutes: number
override_quiet: boolean
}
interface NotificationsConfig {
enabled: boolean
quiet_hours_enabled: boolean
quiet_hours_start: string
quiet_hours_end: string
rules: NotificationRuleConfig[]
@ -54,8 +49,43 @@ interface AlertCategory {
name: string
description: 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
function InfoButton({ info }: { info: string }) {
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 = '' }: {
label: 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
function NotificationRuleCard({
rule,
categories,
quietHoursEnabled,
onChange,
onDelete,
onDuplicate,
@ -318,6 +373,7 @@ function NotificationRuleCard({
}: {
rule: NotificationRuleConfig
categories: AlertCategory[]
quietHoursEnabled: boolean
onChange: (r: NotificationRuleConfig) => void
onDelete: () => void
onDuplicate: () => void
@ -326,35 +382,26 @@ function NotificationRuleCard({
const [expanded, setExpanded] = useState(!rule.name)
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 = [
{ value: 'mesh_broadcast', label: 'Mesh Broadcast' },
{ value: 'mesh_dm', label: 'Mesh DM' },
{ value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' },
{ value: '', label: '(None)', description: 'Rule matches but does not deliver' },
{ value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' },
{ value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' },
{ value: 'email', label: 'Email', description: 'Send via SMTP' },
{ value: 'webhook', label: 'Webhook', description: 'POST to any URL' },
]
const frequencyOptions = [
{ value: 'daily', label: 'Once Daily' },
{ value: 'daily', label: 'Daily' },
{ value: 'twice_daily', label: 'Twice Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'custom', label: 'Custom Cron' },
]
const messageTypeOptions = [
{ value: 'mesh_health_summary', label: 'Mesh Health Summary' },
{ value: 'rf_propagation_report', label: 'RF Propagation Report' },
{ value: 'alerts_digest', label: 'Active Alerts Digest' },
{ value: 'environmental_conditions', label: 'Environmental Conditions' },
{ value: 'custom', label: 'Custom Message' },
{ value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' },
{ value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' },
{ value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' },
{ value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' },
{ value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' },
]
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
@ -383,42 +430,57 @@ function NotificationRuleCard({
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
const getSummary = () => {
const parts: string[] = []
if (rule.trigger_type === 'schedule') {
// Schedule summary
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
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
parts.push(msgType)
} else {
// Condition summary
const catCount = rule.categories?.length || 0
const catText = catCount === 0 ? 'All categories' : `${catCount} categories`
const severity = severityOptions.find(s => s.value === rule.min_severity)?.label || rule.min_severity
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 = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity
parts.push(`${catText} at ${severity}+`)
}
// Delivery summary
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
let target = ''
if (rule.delivery_type === 'mesh_broadcast') {
target = `Ch ${rule.broadcast_channel}`
} else if (rule.delivery_type === 'mesh_dm') {
target = `${rule.node_ids?.length || 0} nodes`
} else if (rule.delivery_type === 'email') {
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
} else if (rule.delivery_type === 'webhook') {
try {
const url = new URL(rule.webhook_url)
target = url.hostname
} catch {
target = rule.webhook_url?.slice(0, 30) || 'no URL'
if (!rule.delivery_type) {
parts.push('⚠️ No delivery')
} else {
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
let target = ''
if (rule.delivery_type === 'mesh_broadcast') {
target = `Ch ${rule.broadcast_channel}`
} else if (rule.delivery_type === 'mesh_dm') {
target = `${rule.node_ids?.length || 0} nodes`
} else if (rule.delivery_type === 'email') {
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
} else if (rule.delivery_type === 'webhook') {
try {
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(' → ')
}
@ -435,7 +497,7 @@ function NotificationRuleCard({
<button
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'}`}
title={rule.enabled ? 'Enabled - click to disable' : 'Disabled - click to enable'}
title={rule.enabled ? 'Enabled' : 'Disabled'}
/>
{rule.trigger_type === 'schedule' ? (
<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>
{!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()}
</span>
)}
@ -454,21 +516,21 @@ function NotificationRuleCard({
onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !rule.name}
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} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
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} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
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} />
</button>
@ -487,7 +549,7 @@ function NotificationRuleCard({
helper="A descriptive name for this rule"
/>
{/* Trigger type selector */}
{/* Trigger type toggle */}
<div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
<div className="flex gap-2">
@ -518,8 +580,8 @@ function NotificationRuleCard({
</div>
<p className="text-xs text-slate-600">
{rule.trigger_type === 'schedule'
? 'Send messages on a schedule (daily reports, weekly digests)'
: 'React to alert conditions (fires, outages, warnings)'}
? 'Send reports on a schedule (daily briefings, weekly digests)'
: 'React to alert conditions (fires, outages, weather warnings)'}
</p>
</div>
@ -531,12 +593,9 @@ function NotificationRuleCard({
WHEN (Condition)
</div>
<SelectInput
label="Minimum Severity"
<SeveritySelector
value={rule.min_severity}
onChange={(v) => onChange({ ...rule, min_severity: v })}
options={severityOptions}
helper="Only alerts at or above this level"
/>
<div className="space-y-2">
@ -578,19 +637,24 @@ function NotificationRuleCard({
WHEN (Schedule)
</div>
<SelectInput
label="Frequency"
value={rule.schedule_frequency || 'daily'}
onChange={(v) => onChange({ ...rule, schedule_frequency: v as any })}
options={frequencyOptions}
/>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
<select
value={rule.schedule_frequency || 'daily'}
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">
<TimeInput
label="Time"
value={rule.schedule_time || '07:00'}
onChange={(v) => onChange({ ...rule, schedule_time: v })}
helper="24-hour format"
/>
{rule.schedule_frequency === 'twice_daily' && (
<TimeInput
@ -623,30 +687,27 @@ function NotificationRuleCard({
</div>
)}
{rule.schedule_frequency === 'custom' && (
<TextInput
label="Cron Expression"
value={rule.schedule_cron || ''}
onChange={(v) => onChange({ ...rule, schedule_cron: v })}
placeholder="0 7 * * *"
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."
/>
)}
<SelectInput
label="Message Type"
value={rule.message_type || 'mesh_health_summary'}
onChange={(v) => onChange({ ...rule, message_type: v })}
options={messageTypeOptions}
info="The type of report or message to send."
/>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
<select
value={rule.message_type || 'mesh_health_summary'}
onChange={(e) => onChange({ ...rule, message_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"
>
{messageTypeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-slate-600">
{messageTypeOptions.find(m => m.value === rule.message_type)?.description}
</p>
</div>
{rule.message_type === 'custom' && (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
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>
<textarea
value={rule.custom_message || ''}
@ -667,12 +728,34 @@ function NotificationRuleCard({
SEND VIA
</div>
<SelectInput
label="Delivery Method"
value={rule.delivery_type || 'mesh_broadcast'}
onChange={(v) => onChange({ ...rule, delivery_type: v })}
options={deliveryOptions}
/>
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Delivery Method
<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." />
</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 */}
{rule.delivery_type === 'mesh_broadcast' && (
@ -739,7 +822,7 @@ function NotificationRuleCard({
value={rule.smtp_password || ''}
onChange={(v) => onChange({ ...rule, smtp_password: v })}
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>
<Toggle
@ -760,28 +843,14 @@ function NotificationRuleCard({
{/* Webhook fields */}
{rule.delivery_type === 'webhook' && (
<div className="space-y-4">
<TextInput
label="Webhook URL"
value={rule.webhook_url || ''}
onChange={(v) => onChange({ ...rule, webhook_url: v })}
placeholder="https://discord.com/api/webhooks/..."
helper="POST endpoint for alerts"
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>
<TextInput
label="Webhook URL"
value={rule.webhook_url || ''}
onChange={(v) => onChange({ ...rule, webhook_url: v })}
placeholder="https://discord.com/api/webhooks/..."
helper="POST alert as JSON"
info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint."
/>
)}
</div>
@ -795,15 +864,28 @@ function NotificationRuleCard({
helper="Min time between repeat sends"
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
/>
<div className="flex items-end pb-1">
<Toggle
label="Override Quiet Hours"
checked={rule.override_quiet ?? false}
onChange={(v) => onChange({ ...rule, override_quiet: v })}
helper="Send during quiet hours"
/>
</div>
{quietHoursEnabled && (
<div className="flex items-end pb-1">
<Toggle
label="Override Quiet Hours"
checked={rule.override_quiet ?? false}
onChange={(v) => onChange({ ...rule, override_quiet: v })}
helper="Deliver during quiet hours"
/>
</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>
@ -902,10 +984,9 @@ export default function Notifications() {
schedule_time: '07:00',
schedule_time_2: '19:00',
schedule_days: ['monday'],
schedule_cron: '',
message_type: 'mesh_health_summary',
custom_message: '',
delivery_type: 'mesh_broadcast',
delivery_type: '', // Start with no delivery
broadcast_channel: 0,
node_ids: [],
smtp_host: '',
@ -965,11 +1046,11 @@ export default function Notifications() {
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header with actions */}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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>
</div>
<div className="flex items-center gap-2">
@ -1025,31 +1106,47 @@ export default function Notifications() {
checked={config.enabled}
onChange={(v) => setConfig({ ...config, enabled: v })}
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 && (
<>
{/* Quiet Hours Section - at top */}
{/* Quiet Hours Section */}
<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">
Quiet Hours
<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>
<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 className="flex items-center gap-2">
<Moon size={14} className="text-slate-400" />
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
</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>
{/* Rules Section */}
@ -1069,6 +1166,7 @@ export default function Notifications() {
key={i}
rule={rule}
categories={categories}
quietHoursEnabled={config.quiet_hours_enabled ?? true}
onChange={(r) => {
const newRules = [...(config.rules || [])]
newRules[i] = r

View file

@ -246,18 +246,22 @@ class AlertRulesConfig:
battery_warning: bool = True
battery_critical: bool = True
battery_emergency: bool = True
battery_warning_threshold: int = 50
battery_critical_threshold: int = 25
battery_emergency_threshold: int = 10
battery_warning_threshold: int = 30
battery_critical_threshold: int = 15
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
solar_not_charging: bool = True
# Utilization
sustained_high_util: bool = True
high_util_threshold: float = 20.0
high_util_threshold: float = 40.0
high_util_hours: int = 6
packet_flood: bool = True
packet_flood_threshold: int = 500
packet_flood_threshold: int = 10
# Coverage
infra_single_gateway: bool = True
@ -266,7 +270,7 @@ class AlertRulesConfig:
# Health Scores
mesh_score_alert: bool = True
mesh_score_threshold: int = 70
mesh_score_threshold: int = 65
region_score_alert: bool = True
region_score_threshold: int = 60
@ -448,7 +452,7 @@ class NotificationRuleConfig:
custom_message: str = ""
# 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
broadcast_channel: int = 0
@ -482,6 +486,7 @@ class NotificationsConfig:
"""Notification system settings."""
enabled: bool = False
quiet_hours_enabled: bool = True # Master toggle for quiet hours
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00"
rules: list = field(default_factory=list) # List of NotificationRuleConfig

View file

@ -1,163 +1,192 @@
"""Environmental data API routes."""
from fastapi import APIRouter, Request
router = APIRouter(tags=["environment"])
@router.get("/env/status")
async def get_env_status(request: Request):
"""Get environmental feeds status."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False, "feeds": []}
return {
"enabled": True,
"feeds": env_store.get_source_health(),
}
@router.get("/env/active")
async def get_active_env(request: Request):
"""Get active environmental events."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active()
@router.get("/env/swpc")
async def get_swpc_data(request: Request):
"""Get SWPC space weather data."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False}
status = env_store.get_swpc_status()
if not status:
return {"enabled": False}
return {
"enabled": True,
**status,
}
@router.get("/env/propagation")
async def get_rf_propagation(request: Request):
"""Get combined HF + UHF propagation data for dashboard."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"hf": {}, "uhf_ducting": {}}
return env_store.get_rf_propagation()
@router.get("/env/ducting")
async def get_ducting_data(request: Request):
"""Get tropospheric ducting assessment."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False}
status = env_store.get_ducting_status()
if not status:
return {"enabled": False}
return {
"enabled": True,
**status,
}
@router.get("/env/fires")
async def get_fires_data(request: Request):
"""Get active wildfire perimeters."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="nifc")
@router.get("/env/avalanche")
async def get_avalanche_data(request: Request):
"""Get avalanche advisories."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"off_season": True, "advisories": []}
adapters = getattr(env_store, "_adapters", {})
avy_adapter = adapters.get("avalanche")
if avy_adapter and avy_adapter.is_off_season():
return {"off_season": True, "advisories": []}
return {
"off_season": False,
"advisories": env_store.get_active(source="avalanche"),
}
@router.get("/env/streams")
async def get_streams_data(request: Request):
"""Get USGS stream gauge readings."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="usgs")
@router.get("/env/traffic")
async def get_traffic_data(request: Request):
"""Get TomTom traffic flow data."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="traffic")
@router.get("/env/roads")
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),
}
"""Environmental data API routes."""
from fastapi import APIRouter, Request
router = APIRouter(tags=["environment"])
@router.get("/env/status")
async def get_env_status(request: Request):
"""Get environmental feeds status."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False, "feeds": []}
return {
"enabled": True,
"feeds": env_store.get_source_health(),
}
@router.get("/env/active")
async def get_active_env(request: Request):
"""Get active environmental events."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active()
@router.get("/env/swpc")
async def get_swpc_data(request: Request):
"""Get SWPC space weather data."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False}
status = env_store.get_swpc_status()
if not status:
return {"enabled": False}
return {
"enabled": True,
**status,
}
@router.get("/env/propagation")
async def get_rf_propagation(request: Request):
"""Get combined HF + UHF propagation data for dashboard."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"hf": {}, "uhf_ducting": {}}
return env_store.get_rf_propagation()
@router.get("/env/ducting")
async def get_ducting_data(request: Request):
"""Get tropospheric ducting assessment."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False}
status = env_store.get_ducting_status()
if not status:
return {"enabled": False}
return {
"enabled": True,
**status,
}
@router.get("/env/fires")
async def get_fires_data(request: Request):
"""Get active wildfire perimeters."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="nifc")
@router.get("/env/avalanche")
async def get_avalanche_data(request: Request):
"""Get avalanche advisories."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"off_season": True, "advisories": []}
adapters = getattr(env_store, "_adapters", {})
avy_adapter = adapters.get("avalanche")
if avy_adapter and avy_adapter.is_off_season():
return {"off_season": True, "advisories": []}
return {
"off_season": False,
"advisories": env_store.get_active(source="avalanche"),
}
@router.get("/env/streams")
async def get_streams_data(request: Request):
"""Get USGS stream gauge readings."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="usgs")
@router.get("/env/usgs/lookup/{site_id}")
async def lookup_usgs_site(request: Request, site_id: str):
"""Lookup USGS site metadata and NWS flood stages.
Returns site name, location, and flood stage thresholds from NWS NWPS.
Used by the config UI to auto-populate fields when adding a new gauge.
"""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"error": "Environmental feeds not enabled"}
adapters = getattr(env_store, "_adapters", {})
usgs_adapter = adapters.get("usgs")
if not usgs_adapter:
# Create a temporary adapter for lookup
from meshai.env.usgs import USGSStreamsAdapter
from meshai.config import USGSConfig
usgs_adapter = USGSStreamsAdapter(USGSConfig())
try:
result = usgs_adapter.lookup_site(site_id)
return result
except Exception as e:
return {"error": str(e), "site_id": site_id}
@router.get("/env/traffic")
async def get_traffic_data(request: Request):
"""Get TomTom traffic flow data."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="traffic")
@router.get("/env/roads")
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.gstatic.com" crossorigin>
<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>
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
<script type="module" crossorigin src="/assets/index-DARDkZhk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CYHGOAxN.css">
</head>
<body>
<div id="root"></div>

45
meshai/env/swpc.py vendored
View file

@ -140,15 +140,36 @@ class SWPCAdapter:
"""Parse noaa-planetary-k-index.json.
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:
return
# Get last entry (most recent)
last_entry = data[-1]
# Store full history (last 24-48 hours of readings)
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):
try:
self._status["kp_current"] = float(last_entry.get("Kp", 0))
@ -184,10 +205,26 @@ class SWPCAdapter:
"""Parse f107_cm_flux.json.
Data format: array of objects with time_tag, flux
Store history for potential charting.
"""
if not data:
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)
if isinstance(data, list) and data:
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
# Legacy waterservices.usgs.gov will be decommissioned.
@ -8,7 +8,7 @@
import json
import logging
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
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)
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:
"""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/"
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
def __init__(self, config: "USGSConfig"):
self._sites = config.sites or []
@ -37,6 +43,9 @@ class USGSStreamsAdapter:
self._last_error = None
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:
logger.warning(
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
@ -61,15 +70,192 @@ class USGSStreamsAdapter:
self._last_tick = now
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:
"""Fetch instantaneous values from USGS Water Services.
Returns:
True if data changed
"""
site_ids = self._get_site_ids()
if not site_ids:
return False
params = {
"format": "json",
"sites": ",".join(self._sites),
"sites": ",".join(site_ids),
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
"siteStatus": "active",
}
@ -121,6 +307,10 @@ class USGSStreamsAdapter:
site_codes = source_info.get("siteCode", [])
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
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
lat = geo_loc.get("latitude")
@ -159,11 +349,37 @@ class USGSStreamsAdapter:
except (ValueError, TypeError):
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"
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
if threshold and value > threshold:
severity = "warning"
flood_status = None
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
if param_type == "flow":
@ -171,6 +387,9 @@ class USGSStreamsAdapter:
else:
headline = f"{site_name}: {value:.1f} {unit_code}"
if flood_status:
headline += f"{flood_status}"
event = {
"source": "usgs",
"event_id": f"{site_id}_{param_type}",
@ -188,6 +407,8 @@ class USGSStreamsAdapter:
"value": value,
"unit": unit_code,
"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
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
@ -228,5 +449,5 @@ class USGSStreamsAdapter:
"consecutive_errors": self._consecutive_errors,
"event_count": len(self._events),
"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.
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 = {
# Infrastructure alerts
"infra_offline": {
"name": "Infrastructure Offline",
"description": "An infrastructure node stopped responding",
"name": "Infrastructure Node Offline",
"description": "An infrastructure node (router/repeater) stopped responding",
"default_severity": "warning",
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
},
"critical_node_down": {
"name": "Critical Node Down",
"description": "A node marked as critical went offline",
"default_severity": "critical",
"description": "A node you marked as critical went offline",
"default_severity": "warning",
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
},
"infra_recovery": {
"name": "Infrastructure Recovery",
"description": "An infrastructure node came back online",
"description": "An offline infrastructure node came back online",
"default_severity": "info",
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
},
"new_router": {
"name": "New Router",
"description": "A new router appeared on the mesh",
"default_severity": "info",
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
},
# Power alerts
"battery_warning": {
"name": "Battery Warning",
"description": "Infrastructure node battery below warning threshold",
"default_severity": "warning",
"description": "Infrastructure node battery below 30% (3.60V)",
"default_severity": "advisory",
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
},
"battery_critical": {
"name": "Battery Critical",
"description": "Infrastructure node battery below critical threshold",
"default_severity": "critical",
"description": "Infrastructure node battery below 15% (3.50V)",
"default_severity": "warning",
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
},
"battery_emergency": {
"name": "Battery Emergency",
"description": "Infrastructure node battery critically low",
"default_severity": "emergency",
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
"default_severity": "critical",
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
},
"battery_trend": {
"name": "Battery Declining",
"description": "Battery showing declining trend over 7 days",
"default_severity": "warning",
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
"default_severity": "advisory",
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
},
"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",
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
},
"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",
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
},
# 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": {
"name": "High Utilization",
"description": "Channel utilization elevated for extended period",
"name": "Sustained High Utilization",
"description": "Channel airtime elevated for extended period — ongoing congestion",
"default_severity": "warning",
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
},
"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",
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
},
# Coverage alerts
"infra_single_gateway": {
"name": "Single Gateway",
"description": "Infrastructure node dropped to single gateway coverage",
"default_severity": "warning",
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
"default_severity": "advisory",
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
},
"feeder_offline": {
"name": "Feeder Offline",
"description": "A feeder gateway stopped responding",
"description": "A feeder gateway stopped responding — coverage gap possible",
"default_severity": "warning",
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
},
"region_total_blackout": {
"name": "Region Blackout",
"description": "All infrastructure in a region is offline",
"default_severity": "emergency",
"description": "All infrastructure in a region is offline — complete coverage loss",
"default_severity": "critical",
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
},
# Health score alerts
"mesh_score_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",
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
},
"region_score_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",
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
},
# Environmental alerts
# Environmental - Weather
"weather_warning": {
"name": "Severe Weather",
"description": "NWS warning or advisory for mesh area",
"description": "NWS warning or advisory affecting your mesh area",
"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": {
"name": "HF Radio Blackout",
"description": "R3+ solar event degrading HF propagation",
"description": "R3+ solar flare degrading HF propagation on sunlit side",
"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": {
"name": "Tropospheric Ducting",
"description": "Atmospheric conditions extending VHF/UHF range",
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
"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": {
"name": "Fire Near Mesh",
"description": "Wildfire detected within configured distance",
"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.",
},
"new_ignition": {
"name": "New Fire Ignition",
"description": "Satellite hotspot not matching any known fire",
"default_severity": "warning",
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
"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",
"description": "Stream gauge exceeds flood threshold",
# Environmental - Flood
"stream_flood_warning": {
"name": "Stream Flood Warning",
"description": "River gauge exceeds NWS flood stage threshold",
"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": {
"name": "Road Closure",
"description": "Full road closure on monitored corridor",
"description": "Full road closure on a monitored corridor",
"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(),
"description": f"Alert type: {category_id}",
"default_severity": "info",
"example_message": f"Alert: {category_id}",
}

View file

@ -29,10 +29,11 @@ class NotificationRouter:
timezone: str = "America/Boise",
):
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_end = getattr(config, "quiet_hours_end", "06:00")
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._connector = connector
self._config = config
@ -56,9 +57,16 @@ class NotificationRouter:
logger.info("Notification router initialized: %d condition rules", len(self._rules))
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", "")
# Empty delivery type is valid - rule exists but doesn't deliver
if not delivery_type:
return None
if delivery_type == "mesh_broadcast":
config = {
"type": "mesh_broadcast",
@ -87,13 +95,13 @@ class NotificationRouter:
"headers": rule.get("webhook_headers", {}),
}
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
try:
return create_channel(config, self._connector)
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
async def process_alert(self, alert: dict) -> bool:
@ -106,6 +114,8 @@ class NotificationRouter:
delivered = False
for rule in self._rules:
rule_name = rule.get("name", "unnamed")
# Check category match
rule_categories = rule.get("categories", [])
if rule_categories and category not in rule_categories:
@ -116,15 +126,18 @@ class NotificationRouter:
if not self._severity_meets(severity, min_severity):
continue
# Check quiet hours (emergencies and criticals override)
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
if not rule.get("override_quiet", False):
continue
# Check quiet hours (only if quiet hours are enabled globally)
if self._quiet_enabled and self._in_quiet_hours():
# Emergencies and criticals always go through
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
cooldown = rule.get("cooldown_minutes", 10) * 60
event_id = alert.get("event_id", alert.get("message", "")[:50])
rule_name = rule.get("name", "unknown")
dedup_key = (rule_name, category, event_id)
now = time.time()
if dedup_key in self._recent:
@ -133,9 +146,19 @@ class NotificationRouter:
continue
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
channel = self._create_channel_for_rule(rule)
if not channel:
logger.warning("Rule '%s' failed to create delivery channel", rule_name)
continue
try:
@ -153,9 +176,9 @@ class NotificationRouter:
success = await channel.deliver(delivery_alert, rule)
if success:
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:
logger.warning("Rule %s delivery failed: %s", rule_name, e)
logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
return delivered
@ -170,6 +193,9 @@ class NotificationRouter:
def _in_quiet_hours(self) -> bool:
"""Check if current time is within quiet hours."""
if not self._quiet_enabled:
return False
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone)
@ -204,12 +230,69 @@ class NotificationRouter:
else:
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)
if not channel:
return False, "Failed to create delivery channel"
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(
self,
node_id: str,