mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
Compare commits
4 commits
b4f7e24c26
...
23151f63ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 23151f63ba | |||
| 64faf33e3b | |||
| 7286c9ab44 | |||
| d90b787c12 |
17 changed files with 5079 additions and 4124 deletions
|
|
@ -216,80 +216,111 @@ environmental:
|
||||||
confidence_min: "nominal" # low, nominal, high
|
confidence_min: "nominal" # low, nominal, high
|
||||||
proximity_km: 10.0 # km to match known fire perimeters
|
proximity_km: 10.0 # km to match known fire perimeters
|
||||||
|
|
||||||
|
|
||||||
# === NOTIFICATION DELIVERY ===
|
# === NOTIFICATION DELIVERY ===
|
||||||
# Route alerts to channels (mesh, email, webhook) based on rules.
|
# Route alerts to channels (mesh, email, webhook) based on rules.
|
||||||
# Categories match alert types from alert_engine.py.
|
# Categories match alert types from alert_engine.py.
|
||||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
# Severity levels: info, advisory, watch, warning, critical, emergency
|
||||||
#
|
#
|
||||||
notifications:
|
notifications:
|
||||||
enabled: false
|
enabled: false
|
||||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||||
quiet_hours_end: "06:00"
|
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
||||||
|
quiet_hours_end: "06:00"
|
||||||
# Notification rules - each rule is self-contained with its own delivery config
|
|
||||||
rules:
|
# Notification rules - each rule is self-contained with its own delivery config
|
||||||
# All emergencies -> mesh broadcast
|
# Default baseline rules are created on fresh install
|
||||||
- name: "Emergency Broadcast"
|
rules:
|
||||||
enabled: true
|
# Emergency Broadcast - all emergencies go out immediately
|
||||||
trigger_type: condition
|
- name: "Emergency Broadcast"
|
||||||
categories: [] # Empty = all categories
|
enabled: true
|
||||||
min_severity: "emergency"
|
trigger_type: condition
|
||||||
delivery_type: mesh_broadcast
|
categories: [] # Empty = all categories
|
||||||
broadcast_channel: 0
|
min_severity: "emergency"
|
||||||
cooldown_minutes: 5
|
delivery_type: mesh_broadcast
|
||||||
override_quiet: true # Send even during quiet hours
|
broadcast_channel: 0
|
||||||
|
cooldown_minutes: 5
|
||||||
# Example: Fire alerts -> email
|
override_quiet: true # Send even during quiet hours
|
||||||
# - name: "Fire Alerts Email"
|
|
||||||
# enabled: true
|
# Infrastructure Down - critical node and infrastructure offline alerts
|
||||||
# trigger_type: condition
|
- name: "Infrastructure Down"
|
||||||
# categories: ["wildfire_proximity", "new_ignition"]
|
enabled: true
|
||||||
# min_severity: "advisory"
|
trigger_type: condition
|
||||||
# delivery_type: email
|
categories: ["infra_offline", "critical_node_down"]
|
||||||
# smtp_host: "smtp.gmail.com"
|
min_severity: "warning"
|
||||||
# smtp_port: 587
|
delivery_type: mesh_broadcast
|
||||||
# smtp_user: "you@gmail.com"
|
broadcast_channel: 0
|
||||||
# smtp_password: "${SMTP_PASSWORD}"
|
cooldown_minutes: 30
|
||||||
# smtp_tls: true
|
override_quiet: false
|
||||||
# from_address: "meshai@yourdomain.com"
|
|
||||||
# recipients: ["admin@yourdomain.com"]
|
# Fire Alert - wildfire proximity and new ignition
|
||||||
# cooldown_minutes: 30
|
- name: "Fire Alert"
|
||||||
|
enabled: true
|
||||||
# Example: All warnings -> Discord webhook
|
trigger_type: condition
|
||||||
# - name: "Discord Alerts"
|
categories: ["wildfire_proximity", "new_ignition"]
|
||||||
# enabled: true
|
min_severity: "advisory"
|
||||||
# trigger_type: condition
|
delivery_type: mesh_broadcast
|
||||||
# categories: []
|
broadcast_channel: 0
|
||||||
# min_severity: "warning"
|
cooldown_minutes: 60
|
||||||
# delivery_type: webhook
|
override_quiet: false
|
||||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
|
||||||
# cooldown_minutes: 10
|
# Severe Weather - weather warnings
|
||||||
|
- name: "Severe Weather"
|
||||||
# Example: Daily health report -> mesh broadcast
|
enabled: true
|
||||||
# - name: "Morning Briefing"
|
trigger_type: condition
|
||||||
# enabled: true
|
categories: ["weather_warning"]
|
||||||
# trigger_type: schedule
|
min_severity: "warning"
|
||||||
# schedule_frequency: daily
|
delivery_type: mesh_broadcast
|
||||||
# schedule_time: "07:00"
|
broadcast_channel: 0
|
||||||
# message_type: mesh_health_summary
|
cooldown_minutes: 30
|
||||||
# delivery_type: mesh_broadcast
|
override_quiet: false
|
||||||
# broadcast_channel: 0
|
|
||||||
|
# Example: Fire alerts -> email
|
||||||
# Example: Weekly digest -> email
|
# - name: "Fire Alerts Email"
|
||||||
# - name: "Weekly Digest"
|
# enabled: true
|
||||||
# enabled: true
|
# trigger_type: condition
|
||||||
# trigger_type: schedule
|
# categories: ["wildfire_proximity", "new_ignition"]
|
||||||
# schedule_frequency: weekly
|
# min_severity: "advisory"
|
||||||
# schedule_days: ["monday"]
|
# delivery_type: email
|
||||||
# schedule_time: "08:00"
|
# smtp_host: "smtp.gmail.com"
|
||||||
# message_type: alerts_digest
|
# smtp_port: 587
|
||||||
# delivery_type: email
|
# smtp_user: "you@gmail.com"
|
||||||
# smtp_host: "smtp.gmail.com"
|
# smtp_password: "${SMTP_PASSWORD}"
|
||||||
# recipients: ["admin@example.com"]
|
# smtp_tls: true
|
||||||
|
# from_address: "meshai@yourdomain.com"
|
||||||
# === WEB DASHBOARD ===
|
# recipients: ["admin@yourdomain.com"]
|
||||||
dashboard:
|
# cooldown_minutes: 30
|
||||||
enabled: true
|
|
||||||
port: 8080
|
# Example: All warnings -> Discord webhook
|
||||||
host: "0.0.0.0"
|
# - name: "Discord Alerts"
|
||||||
|
# enabled: true
|
||||||
|
# trigger_type: condition
|
||||||
|
# categories: []
|
||||||
|
# min_severity: "warning"
|
||||||
|
# delivery_type: webhook
|
||||||
|
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||||
|
# cooldown_minutes: 10
|
||||||
|
|
||||||
|
# Example: Daily health report -> mesh broadcast
|
||||||
|
# - name: "Morning Briefing"
|
||||||
|
# enabled: true
|
||||||
|
# trigger_type: schedule
|
||||||
|
# schedule_frequency: daily
|
||||||
|
# schedule_time: "07:00"
|
||||||
|
# message_type: mesh_health_summary
|
||||||
|
# delivery_type: mesh_broadcast
|
||||||
|
# broadcast_channel: 0
|
||||||
|
|
||||||
|
# Example: Rule with no delivery (matches and logs, but doesn't send)
|
||||||
|
# - name: "Monitor Only"
|
||||||
|
# enabled: true
|
||||||
|
# trigger_type: condition
|
||||||
|
# categories: ["battery_warning"]
|
||||||
|
# min_severity: "warning"
|
||||||
|
# delivery_type: "" # Empty = no delivery, just tracks matches
|
||||||
|
|
||||||
|
# === WEB DASHBOARD ===
|
||||||
|
dashboard:
|
||||||
|
enabled: true
|
||||||
|
port: 8080
|
||||||
|
host: "0.0.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,109 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { MeshHealth, Alert } from '@/lib/api'
|
import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
type: string
|
type: string
|
||||||
data: unknown
|
data?: unknown
|
||||||
}
|
event?: EnvEvent
|
||||||
|
}
|
||||||
interface UseWebSocketReturn {
|
|
||||||
connected: boolean
|
interface UseWebSocketReturn {
|
||||||
lastHealth: MeshHealth | null
|
connected: boolean
|
||||||
lastAlert: Alert | null
|
lastHealth: MeshHealth | null
|
||||||
}
|
lastAlert: Alert | null
|
||||||
|
lastMessage: WebSocketMessage | null
|
||||||
export function useWebSocket(): UseWebSocketReturn {
|
}
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
export function useWebSocket(): UseWebSocketReturn {
|
||||||
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
const [connected, setConnected] = useState(false)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
||||||
const reconnectDelayRef = useRef(1000)
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const connect = useCallback(() => {
|
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
const reconnectDelayRef = useRef(1000)
|
||||||
return
|
|
||||||
}
|
const connect = useCallback(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
return
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/live`
|
}
|
||||||
|
|
||||||
try {
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(wsUrl)
|
const wsUrl = `${protocol}//${window.location.host}/ws/live`
|
||||||
wsRef.current = ws
|
|
||||||
|
try {
|
||||||
ws.onopen = () => {
|
const ws = new WebSocket(wsUrl)
|
||||||
setConnected(true)
|
wsRef.current = ws
|
||||||
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
|
|
||||||
}
|
ws.onopen = () => {
|
||||||
|
setConnected(true)
|
||||||
ws.onmessage = (event) => {
|
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
|
||||||
try {
|
}
|
||||||
const message: WebSocketMessage = JSON.parse(event.data)
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
switch (message.type) {
|
try {
|
||||||
case 'health_update':
|
const message: WebSocketMessage = JSON.parse(event.data)
|
||||||
setLastHealth(message.data as MeshHealth)
|
|
||||||
break
|
// Store all messages for generic handling
|
||||||
case 'alert_fired':
|
setLastMessage(message)
|
||||||
setLastAlert(message.data as Alert)
|
|
||||||
break
|
switch (message.type) {
|
||||||
}
|
case 'health_update':
|
||||||
} catch (e) {
|
setLastHealth(message.data as MeshHealth)
|
||||||
console.error('Failed to parse WebSocket message:', e)
|
break
|
||||||
}
|
case 'alert_fired':
|
||||||
}
|
setLastAlert(message.data as Alert)
|
||||||
|
break
|
||||||
ws.onclose = () => {
|
// env_update messages are handled via lastMessage
|
||||||
setConnected(false)
|
}
|
||||||
wsRef.current = null
|
} catch (e) {
|
||||||
|
console.error('Failed to parse WebSocket message:', e)
|
||||||
// Schedule reconnect with exponential backoff
|
}
|
||||||
const delay = Math.min(reconnectDelayRef.current, 30000)
|
}
|
||||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
reconnectDelayRef.current = Math.min(delay * 2, 30000)
|
ws.onclose = () => {
|
||||||
connect()
|
setConnected(false)
|
||||||
}, delay)
|
wsRef.current = null
|
||||||
}
|
|
||||||
|
// Schedule reconnect with exponential backoff
|
||||||
ws.onerror = () => {
|
const delay = Math.min(reconnectDelayRef.current, 30000)
|
||||||
ws.close()
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||||
}
|
reconnectDelayRef.current = Math.min(delay * 2, 30000)
|
||||||
|
connect()
|
||||||
// Keepalive ping every 30 seconds
|
}, delay)
|
||||||
const pingInterval = setInterval(() => {
|
}
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send('ping')
|
ws.onerror = () => {
|
||||||
}
|
ws.close()
|
||||||
}, 30000)
|
}
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
// Keepalive ping every 30 seconds
|
||||||
clearInterval(pingInterval)
|
const pingInterval = setInterval(() => {
|
||||||
})
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
} catch (e) {
|
ws.send('ping')
|
||||||
console.error('Failed to create WebSocket:', e)
|
}
|
||||||
}
|
}, 30000)
|
||||||
}, [])
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
useEffect(() => {
|
clearInterval(pingInterval)
|
||||||
connect()
|
})
|
||||||
|
} catch (e) {
|
||||||
return () => {
|
console.error('Failed to create WebSocket:', e)
|
||||||
if (reconnectTimeoutRef.current) {
|
}
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
}, [])
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
useEffect(() => {
|
||||||
wsRef.current.close()
|
connect()
|
||||||
}
|
|
||||||
}
|
return () => {
|
||||||
}, [connect])
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
return { connected, lastHealth, lastAlert }
|
}
|
||||||
}
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [connect])
|
||||||
|
|
||||||
|
return { connected, lastHealth, lastAlert, lastMessage }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,440 +1,479 @@
|
||||||
// API types matching actual backend responses
|
// API types matching actual backend responses
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
version: string
|
version: string
|
||||||
uptime_seconds: number
|
uptime_seconds: number
|
||||||
bot_name: string
|
bot_name: string
|
||||||
connection_type: string
|
connection_type: string
|
||||||
connection_target: string
|
connection_target: string
|
||||||
connected: boolean
|
connected: boolean
|
||||||
node_count: number
|
node_count: number
|
||||||
source_count: number
|
source_count: number
|
||||||
env_feeds_enabled: boolean
|
env_feeds_enabled: boolean
|
||||||
dashboard_port: number
|
dashboard_port: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeshHealth {
|
export interface MeshHealth {
|
||||||
score: number
|
score: number
|
||||||
tier: string
|
tier: string
|
||||||
pillars: {
|
pillars: {
|
||||||
infrastructure: number
|
infrastructure: number
|
||||||
utilization: number
|
utilization: number
|
||||||
behavior: number
|
behavior: number
|
||||||
power: number
|
power: number
|
||||||
}
|
}
|
||||||
infra_online: number
|
infra_online: number
|
||||||
infra_total: number
|
infra_total: number
|
||||||
util_percent: number
|
util_percent: number
|
||||||
flagged_nodes: number
|
flagged_nodes: number
|
||||||
battery_warnings: number
|
battery_warnings: number
|
||||||
total_nodes: number
|
total_nodes: number
|
||||||
total_regions: number
|
total_regions: number
|
||||||
unlocated_count: number
|
unlocated_count: number
|
||||||
last_computed: string
|
last_computed: string
|
||||||
recommendations: string[]
|
recommendations: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
node_num: number
|
node_num: number
|
||||||
node_id_hex: string
|
node_id_hex: string
|
||||||
short_name: string
|
short_name: string
|
||||||
long_name: string
|
long_name: string
|
||||||
role: string
|
role: string
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
last_heard: string | null
|
last_heard: string | null
|
||||||
battery_level: number | null
|
battery_level: number | null
|
||||||
voltage: number | null
|
voltage: number | null
|
||||||
snr: number | null
|
snr: number | null
|
||||||
firmware: string
|
firmware: string
|
||||||
hardware: string
|
hardware: string
|
||||||
uptime: number | null
|
uptime: number | null
|
||||||
sources: string[]
|
sources: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeInfo {
|
export interface EdgeInfo {
|
||||||
from_node: number
|
from_node: number
|
||||||
to_node: number
|
to_node: number
|
||||||
snr: number
|
snr: number
|
||||||
quality: string
|
quality: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegionInfo {
|
export interface RegionInfo {
|
||||||
name: string
|
name: string
|
||||||
local_name: string
|
local_name: string
|
||||||
node_count: number
|
node_count: number
|
||||||
infra_count: number
|
infra_count: number
|
||||||
infra_online: number
|
infra_online: number
|
||||||
online_count: number
|
online_count: number
|
||||||
score: number
|
score: number
|
||||||
tier: string
|
tier: string
|
||||||
center_lat: number
|
center_lat: number
|
||||||
center_lon: number
|
center_lon: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceHealth {
|
export interface SourceHealth {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
url: string
|
url: string
|
||||||
is_loaded: boolean
|
is_loaded: boolean
|
||||||
last_error: string | null
|
last_error: string | null
|
||||||
consecutive_errors: number
|
consecutive_errors: number
|
||||||
response_time_ms: number | null
|
response_time_ms: number | null
|
||||||
tick_count: number
|
tick_count: number
|
||||||
node_count: number
|
node_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Alert {
|
export interface Alert {
|
||||||
type: string
|
type: string
|
||||||
severity: string
|
severity: string
|
||||||
message: string
|
message: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
scope_type?: string
|
scope_type?: string
|
||||||
scope_value?: string
|
scope_value?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertHistoryItem {
|
export interface AlertHistoryItem {
|
||||||
id?: number
|
id?: number
|
||||||
type: string
|
type: string
|
||||||
severity: string
|
severity: string
|
||||||
message: string
|
message: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
duration?: number
|
duration?: number
|
||||||
scope_type?: string
|
scope_type?: string
|
||||||
scope_value?: string
|
scope_value?: string
|
||||||
resolved_at?: string
|
resolved_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertHistoryResponse {
|
export interface AlertHistoryResponse {
|
||||||
items: AlertHistoryItem[]
|
items: AlertHistoryItem[]
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
id: number
|
id: number
|
||||||
user_id: string
|
user_id: string
|
||||||
sub_type: string
|
sub_type: string
|
||||||
schedule_time?: string
|
schedule_time?: string
|
||||||
schedule_day?: string
|
schedule_day?: string
|
||||||
scope_type: string
|
scope_type: string
|
||||||
scope_value?: string
|
scope_value?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvStatus {
|
export interface EnvStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
feeds: EnvFeedHealth[]
|
feeds: EnvFeedHealth[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvFeedHealth {
|
export interface EnvFeedHealth {
|
||||||
source: string
|
source: string
|
||||||
is_loaded: boolean
|
is_loaded: boolean
|
||||||
last_error: string | null
|
last_error: string | null
|
||||||
consecutive_errors: number
|
consecutive_errors: number
|
||||||
event_count: number
|
event_count: number
|
||||||
last_fetch: number
|
last_fetch: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvEvent {
|
export interface EnvEvent {
|
||||||
source: string
|
source: string
|
||||||
event_id: string
|
event_id: string
|
||||||
event_type: string
|
event_type: string
|
||||||
severity: string
|
severity: string
|
||||||
headline: string
|
headline: string
|
||||||
description?: string
|
description?: string
|
||||||
expires?: number
|
expires?: number
|
||||||
fetched_at: number
|
fetched_at: number
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SWPCStatus {
|
// Kp history entry for charting
|
||||||
enabled: boolean
|
export interface KpHistoryEntry {
|
||||||
kp_current?: number
|
time: string
|
||||||
kp_timestamp?: string
|
value: number
|
||||||
sfi?: number
|
}
|
||||||
r_scale?: number
|
|
||||||
s_scale?: number
|
// SFI history entry for charting
|
||||||
g_scale?: number
|
export interface SfiHistoryEntry {
|
||||||
active_warnings?: string[]
|
time: string
|
||||||
}
|
value: number
|
||||||
|
}
|
||||||
export interface DuctingStatus {
|
|
||||||
enabled: boolean
|
// Refractivity profile entry
|
||||||
condition?: string
|
export interface ProfileEntry {
|
||||||
min_gradient?: number
|
level_hPa: number
|
||||||
duct_thickness_m?: number | null
|
height_m: number
|
||||||
duct_base_m?: number | null
|
N: number
|
||||||
last_update?: string
|
M: number
|
||||||
}
|
T_C: number
|
||||||
|
RH: number
|
||||||
export interface RFPropagation {
|
}
|
||||||
hf: {
|
|
||||||
kp_current?: number
|
// Gradient entry
|
||||||
sfi?: number
|
export interface GradientEntry {
|
||||||
r_scale?: number
|
from_level: number
|
||||||
s_scale?: number
|
to_level: number
|
||||||
g_scale?: number
|
from_height_m: number
|
||||||
active_warnings?: string[]
|
to_height_m: number
|
||||||
}
|
gradient: number
|
||||||
uhf_ducting: {
|
}
|
||||||
condition?: string
|
|
||||||
min_gradient?: number
|
export interface SWPCStatus {
|
||||||
duct_thickness_m?: number | null
|
enabled: boolean
|
||||||
}
|
kp_current?: number
|
||||||
}
|
kp_timestamp?: string
|
||||||
|
sfi?: number
|
||||||
// API fetch helpers
|
r_scale?: number
|
||||||
|
s_scale?: number
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
g_scale?: number
|
||||||
const response = await fetch(url)
|
active_warnings?: string[]
|
||||||
if (!response.ok) {
|
kp_history?: KpHistoryEntry[]
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
sfi_history?: SfiHistoryEntry[]
|
||||||
}
|
}
|
||||||
return response.json()
|
|
||||||
}
|
export interface DuctingStatus {
|
||||||
|
enabled: boolean
|
||||||
export async function fetchStatus(): Promise<SystemStatus> {
|
condition?: string
|
||||||
return fetchJson<SystemStatus>('/api/status')
|
min_gradient?: number
|
||||||
}
|
duct_thickness_m?: number | null
|
||||||
|
duct_base_m?: number | null
|
||||||
export async function fetchHealth(): Promise<MeshHealth> {
|
last_update?: string
|
||||||
return fetchJson<MeshHealth>('/api/health')
|
profile?: ProfileEntry[]
|
||||||
}
|
gradients?: GradientEntry[]
|
||||||
|
assessment?: string
|
||||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
location?: { lat: number; lon: number }
|
||||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
}
|
||||||
}
|
|
||||||
|
export interface RFPropagation {
|
||||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
hf: {
|
||||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
kp_current?: number
|
||||||
}
|
sfi?: number
|
||||||
|
r_scale?: number
|
||||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
s_scale?: number
|
||||||
return fetchJson<SourceHealth[]>('/api/sources')
|
g_scale?: number
|
||||||
}
|
active_warnings?: string[]
|
||||||
|
kp_history?: KpHistoryEntry[]
|
||||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
}
|
||||||
const url = section ? `/api/config/${section}` : '/api/config'
|
uhf_ducting: {
|
||||||
return fetchJson(url)
|
condition?: string
|
||||||
}
|
min_gradient?: number
|
||||||
|
duct_thickness_m?: number | null
|
||||||
export async function updateConfig(
|
profile?: ProfileEntry[]
|
||||||
section: string,
|
}
|
||||||
data: unknown
|
}
|
||||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
|
||||||
const response = await fetch(`/api/config/${section}`, {
|
// API fetch helpers
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
body: JSON.stringify(data),
|
const response = await fetch(url)
|
||||||
})
|
if (!response.ok) {
|
||||||
if (!response.ok) {
|
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
}
|
||||||
}
|
return response.json()
|
||||||
return response.json()
|
}
|
||||||
}
|
|
||||||
|
export async function fetchStatus(): Promise<SystemStatus> {
|
||||||
export async function fetchAlerts(): Promise<Alert[]> {
|
return fetchJson<SystemStatus>('/api/status')
|
||||||
return fetchJson<Alert[]>('/api/alerts/active')
|
}
|
||||||
}
|
|
||||||
|
export async function fetchHealth(): Promise<MeshHealth> {
|
||||||
export async function fetchAlertHistory(
|
return fetchJson<MeshHealth>('/api/health')
|
||||||
limit: number = 50,
|
}
|
||||||
offset: number = 0,
|
|
||||||
type?: string,
|
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||||
severity?: string
|
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||||
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
}
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('limit', limit.toString())
|
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||||
params.set('offset', offset.toString())
|
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||||
if (type && type !== 'all') params.set('type', type)
|
}
|
||||||
if (severity && severity !== 'all') params.set('severity', severity)
|
|
||||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||||
}
|
return fetchJson<SourceHealth[]>('/api/sources')
|
||||||
|
}
|
||||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
|
||||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||||
}
|
const url = section ? `/api/config/${section}` : '/api/config'
|
||||||
|
return fetchJson(url)
|
||||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
}
|
||||||
return fetchJson<EnvStatus>('/api/env/status')
|
|
||||||
}
|
export async function updateConfig(
|
||||||
|
section: string,
|
||||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
data: unknown
|
||||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||||
}
|
const response = await fetch(`/api/config/${section}`, {
|
||||||
|
method: 'PUT',
|
||||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
body: JSON.stringify(data),
|
||||||
}
|
})
|
||||||
|
if (!response.ok) {
|
||||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
}
|
||||||
}
|
return response.json()
|
||||||
|
}
|
||||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
|
||||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
export async function fetchAlerts(): Promise<Alert[]> {
|
||||||
}
|
return fetchJson<Alert[]>('/api/alerts/active')
|
||||||
|
}
|
||||||
export interface FireEvent {
|
|
||||||
source: string
|
export async function fetchAlertHistory(
|
||||||
event_id: string
|
limit: number = 50,
|
||||||
event_type: string
|
offset: number = 0,
|
||||||
severity: string
|
type?: string,
|
||||||
headline: string
|
severity?: string
|
||||||
name: string
|
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
||||||
acres: number
|
const params = new URLSearchParams()
|
||||||
pct_contained: number
|
params.set('limit', limit.toString())
|
||||||
lat: number | null
|
params.set('offset', offset.toString())
|
||||||
lon: number | null
|
if (type && type !== 'all') params.set('type', type)
|
||||||
distance_km: number | null
|
if (severity && severity !== 'all') params.set('severity', severity)
|
||||||
nearest_anchor: string | null
|
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||||
state: string
|
}
|
||||||
expires: number
|
|
||||||
fetched_at: number
|
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
polygon?: number[][][]
|
return fetchJson<Subscription[]>('/api/subscriptions')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvalancheEvent {
|
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||||
source: string
|
return fetchJson<EnvStatus>('/api/env/status')
|
||||||
event_id: string
|
}
|
||||||
event_type: string
|
|
||||||
severity: string
|
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||||
headline: string
|
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||||
zone_name: string
|
}
|
||||||
center: string
|
|
||||||
center_id: string
|
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||||
center_link: string
|
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||||
forecast_link: string
|
}
|
||||||
danger: string
|
|
||||||
danger_level: number
|
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||||
danger_name: string
|
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||||
travel_advice: string
|
}
|
||||||
state: string
|
|
||||||
lat: number | null
|
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||||
lon: number | null
|
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||||
expires: number
|
}
|
||||||
fetched_at: number
|
|
||||||
}
|
export interface FireEvent {
|
||||||
|
source: string
|
||||||
export interface StreamGaugeEvent {
|
event_id: string
|
||||||
source: string
|
event_type: string
|
||||||
event_id: string
|
severity: string
|
||||||
event_type: string
|
headline: string
|
||||||
headline: string
|
name: string
|
||||||
severity: string
|
acres: number
|
||||||
lat?: number
|
pct_contained: number
|
||||||
lon?: number
|
lat: number | null
|
||||||
expires: number
|
lon: number | null
|
||||||
fetched_at: number
|
distance_km: number | null
|
||||||
properties: {
|
nearest_anchor: string | null
|
||||||
site_id: string
|
state: string
|
||||||
site_name: string
|
expires: number
|
||||||
parameter: string
|
fetched_at: number
|
||||||
value: number
|
polygon?: number[][][]
|
||||||
unit: string
|
}
|
||||||
timestamp: string
|
|
||||||
}
|
export interface AvalancheEvent {
|
||||||
}
|
source: string
|
||||||
|
event_id: string
|
||||||
export interface TrafficEvent {
|
event_type: string
|
||||||
source: string
|
severity: string
|
||||||
event_id: string
|
headline: string
|
||||||
event_type: string
|
zone_name: string
|
||||||
headline: string
|
center: string
|
||||||
severity: string
|
center_id: string
|
||||||
lat?: number
|
center_link: string
|
||||||
lon?: number
|
forecast_link: string
|
||||||
expires: number
|
danger: string
|
||||||
fetched_at: number
|
danger_level: number
|
||||||
properties: {
|
danger_name: string
|
||||||
corridor: string
|
travel_advice: string
|
||||||
currentSpeed: number
|
state: string
|
||||||
freeFlowSpeed: number
|
lat: number | null
|
||||||
speedRatio: number
|
lon: number | null
|
||||||
currentTravelTime: number
|
expires: number
|
||||||
freeFlowTravelTime: number
|
fetched_at: number
|
||||||
confidence: number
|
}
|
||||||
roadClosure: boolean
|
|
||||||
}
|
export interface StreamGaugeEvent {
|
||||||
}
|
source: string
|
||||||
|
event_id: string
|
||||||
export interface RoadEvent {
|
event_type: string
|
||||||
source: string
|
headline: string
|
||||||
event_id: string
|
severity: string
|
||||||
event_type: string
|
lat?: number
|
||||||
headline: string
|
lon?: number
|
||||||
description?: string
|
expires: number
|
||||||
severity: string
|
fetched_at: number
|
||||||
lat?: number
|
properties: {
|
||||||
lon?: number
|
site_id: string
|
||||||
expires: number
|
site_name: string
|
||||||
fetched_at: number
|
parameter: string
|
||||||
properties: {
|
value: number
|
||||||
roadway: string
|
unit: string
|
||||||
is_closure: boolean
|
timestamp: string
|
||||||
last_updated?: string
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
export interface TrafficEvent {
|
||||||
export interface HotspotEvent {
|
source: string
|
||||||
source: string
|
event_id: string
|
||||||
event_id: string
|
event_type: string
|
||||||
event_type: string
|
headline: string
|
||||||
headline: string
|
severity: string
|
||||||
severity: string
|
lat?: number
|
||||||
lat?: number
|
lon?: number
|
||||||
lon?: number
|
expires: number
|
||||||
expires: number
|
fetched_at: number
|
||||||
fetched_at: number
|
properties: {
|
||||||
properties: {
|
corridor: string
|
||||||
new_ignition: boolean
|
currentSpeed: number
|
||||||
confidence: string
|
freeFlowSpeed: number
|
||||||
frp?: number
|
speedRatio: number
|
||||||
brightness?: number
|
currentTravelTime: number
|
||||||
acq_date: string
|
freeFlowTravelTime: number
|
||||||
acq_time: string
|
confidence: number
|
||||||
near_fire?: string
|
roadClosure: boolean
|
||||||
distance_to_fire_km?: number
|
}
|
||||||
distance_km?: number
|
}
|
||||||
nearest_anchor?: string
|
|
||||||
}
|
export interface RoadEvent {
|
||||||
}
|
source: string
|
||||||
|
event_id: string
|
||||||
export interface HotspotsResponse {
|
event_type: string
|
||||||
enabled: boolean
|
headline: string
|
||||||
hotspots: HotspotEvent[]
|
description?: string
|
||||||
new_ignitions: number
|
severity: string
|
||||||
}
|
lat?: number
|
||||||
|
lon?: number
|
||||||
export interface AvalancheResponse {
|
expires: number
|
||||||
off_season: boolean
|
fetched_at: number
|
||||||
advisories: AvalancheEvent[]
|
properties: {
|
||||||
}
|
roadway: string
|
||||||
|
is_closure: boolean
|
||||||
export async function fetchFires(): Promise<FireEvent[]> {
|
last_updated?: string
|
||||||
return fetchJson<FireEvent[]>('/api/env/fires')
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
export interface HotspotEvent {
|
||||||
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
source: string
|
||||||
}
|
event_id: string
|
||||||
|
event_type: string
|
||||||
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
|
headline: string
|
||||||
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
|
severity: string
|
||||||
}
|
lat?: number
|
||||||
|
lon?: number
|
||||||
export async function fetchTraffic(): Promise<TrafficEvent[]> {
|
expires: number
|
||||||
return fetchJson<TrafficEvent[]>('/api/env/traffic')
|
fetched_at: number
|
||||||
}
|
properties: {
|
||||||
|
new_ignition: boolean
|
||||||
export async function fetchRoads(): Promise<RoadEvent[]> {
|
confidence: string
|
||||||
return fetchJson<RoadEvent[]>('/api/env/roads')
|
frp?: number
|
||||||
}
|
brightness?: number
|
||||||
|
acq_date: string
|
||||||
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
acq_time: string
|
||||||
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
near_fire?: string
|
||||||
}
|
distance_to_fire_km?: number
|
||||||
|
distance_km?: number
|
||||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
nearest_anchor?: string
|
||||||
return fetchJson<RegionInfo[]>('/api/regions')
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HotspotsResponse {
|
||||||
|
enabled: boolean
|
||||||
|
hotspots: HotspotEvent[]
|
||||||
|
new_ignitions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvalancheResponse {
|
||||||
|
off_season: boolean
|
||||||
|
advisories: AvalancheEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFires(): Promise<FireEvent[]> {
|
||||||
|
return fetchJson<FireEvent[]>('/api/env/fires')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||||
|
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
|
||||||
|
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTraffic(): Promise<TrafficEvent[]> {
|
||||||
|
return fetchJson<TrafficEvent[]>('/api/env/traffic')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoads(): Promise<RoadEvent[]> {
|
||||||
|
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
||||||
|
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||||
|
return fetchJson<RegionInfo[]>('/api/regions')
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,19 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
fetchHealth,
|
fetchHealth,
|
||||||
fetchSources,
|
fetchSources,
|
||||||
fetchAlerts,
|
fetchAlerts,
|
||||||
fetchEnvStatus,
|
fetchEnvStatus,
|
||||||
fetchRFPropagation,
|
fetchEnvActive,
|
||||||
|
fetchSWPC,
|
||||||
|
fetchDucting,
|
||||||
type MeshHealth,
|
type MeshHealth,
|
||||||
type SourceHealth,
|
type SourceHealth,
|
||||||
type Alert,
|
type Alert,
|
||||||
type EnvStatus,
|
type EnvStatus,
|
||||||
type RFPropagation,
|
type EnvEvent,
|
||||||
|
type SWPCStatus,
|
||||||
|
type DuctingStatus,
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,13 +26,63 @@ import {
|
||||||
Activity,
|
Activity,
|
||||||
MapPin,
|
MapPin,
|
||||||
Zap,
|
Zap,
|
||||||
|
Cloud,
|
||||||
|
Flame,
|
||||||
|
Mountain,
|
||||||
|
Droplets,
|
||||||
|
Car,
|
||||||
|
Construction,
|
||||||
|
Satellite,
|
||||||
|
Sun,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
ResponsiveContainer,
|
||||||
|
ReferenceLine,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
// Extended types for history data
|
||||||
|
interface KpHistoryEntry {
|
||||||
|
time: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileEntry {
|
||||||
|
level_hPa: number
|
||||||
|
height_m: number
|
||||||
|
N: number
|
||||||
|
M: number
|
||||||
|
T_C: number
|
||||||
|
RH: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtendedSWPCStatus extends SWPCStatus {
|
||||||
|
kp_history?: KpHistoryEntry[]
|
||||||
|
sfi_history?: { time: string; value: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtendedDuctingStatus extends DuctingStatus {
|
||||||
|
profile?: ProfileEntry[]
|
||||||
|
gradients?: {
|
||||||
|
from_level: number
|
||||||
|
to_level: number
|
||||||
|
from_height_m: number
|
||||||
|
to_height_m: number
|
||||||
|
gradient: number
|
||||||
|
}[]
|
||||||
|
assessment?: string
|
||||||
|
location?: { lat: number; lon: number }
|
||||||
|
}
|
||||||
|
|
||||||
function HealthGauge({ health }: { health: MeshHealth }) {
|
function HealthGauge({ health }: { health: MeshHealth }) {
|
||||||
const score = health.score
|
const score = health.score
|
||||||
const tier = health.tier
|
const tier = health.tier
|
||||||
|
|
||||||
// Color based on score
|
|
||||||
const getColor = (s: number) => {
|
const getColor = (s: number) => {
|
||||||
if (s >= 80) return '#22c55e'
|
if (s >= 80) return '#22c55e'
|
||||||
if (s >= 60) return '#f59e0b'
|
if (s >= 60) return '#f59e0b'
|
||||||
|
|
@ -42,46 +96,17 @@ function HealthGauge({ health }: { health: MeshHealth }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<svg width="140" height="140" viewBox="0 0 100 100">
|
<svg width="140" height="140" viewBox="0 0 100 100">
|
||||||
{/* Background circle */}
|
<circle cx="50" cy="50" r="45" fill="none" stroke="#1e2a3a" strokeWidth="8" />
|
||||||
<circle
|
<circle
|
||||||
cx="50"
|
cx="50" cy="50" r="45" fill="none" stroke={color} strokeWidth="8"
|
||||||
cy="50"
|
strokeLinecap="round" strokeDasharray={circumference}
|
||||||
r="45"
|
strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
|
||||||
fill="none"
|
|
||||||
stroke="#1e2a3a"
|
|
||||||
strokeWidth="8"
|
|
||||||
/>
|
|
||||||
{/* Progress arc */}
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="45"
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={circumference - progress}
|
|
||||||
transform="rotate(-90 50 50)"
|
|
||||||
className="transition-all duration-500"
|
className="transition-all duration-500"
|
||||||
/>
|
/>
|
||||||
{/* Score text */}
|
<text x="50" y="46" textAnchor="middle" className="fill-slate-100 font-mono text-2xl font-bold" style={{ fontSize: '24px' }}>
|
||||||
<text
|
|
||||||
x="50"
|
|
||||||
y="46"
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-slate-100 font-mono text-2xl font-bold"
|
|
||||||
style={{ fontSize: '24px' }}
|
|
||||||
>
|
|
||||||
{score.toFixed(1)}
|
{score.toFixed(1)}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
|
||||||
x="50"
|
|
||||||
y="62"
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-slate-400 text-xs"
|
|
||||||
style={{ fontSize: '10px' }}
|
|
||||||
>
|
|
||||||
{tier}
|
{tier}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -89,13 +114,7 @@ function HealthGauge({ health }: { health: MeshHealth }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PillarBar({
|
function PillarBar({ label, value }: { label: string; value: number }) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: number
|
|
||||||
}) {
|
|
||||||
const getColor = (v: number) => {
|
const getColor = (v: number) => {
|
||||||
if (v >= 80) return 'bg-green-500'
|
if (v >= 80) return 'bg-green-500'
|
||||||
if (v >= 60) return 'bg-amber-500'
|
if (v >= 60) return 'bg-amber-500'
|
||||||
|
|
@ -106,14 +125,9 @@ function PillarBar({
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-24 text-xs text-slate-400 truncate">{label}</div>
|
<div className="w-24 text-xs text-slate-400 truncate">{label}</div>
|
||||||
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
|
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
|
||||||
className={`h-full ${getColor(value)} transition-all duration-300`}
|
|
||||||
style={{ width: `${value}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 text-right text-xs font-mono text-slate-300">
|
|
||||||
{value.toFixed(1)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-12 text-right text-xs font-mono text-slate-300">{value.toFixed(1)}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -123,26 +137,11 @@ function AlertItem({ alert }: { alert: Alert }) {
|
||||||
switch (severity.toLowerCase()) {
|
switch (severity.toLowerCase()) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
case 'emergency':
|
case 'emergency':
|
||||||
return {
|
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
|
||||||
bg: 'bg-red-500/10',
|
|
||||||
border: 'border-red-500',
|
|
||||||
icon: AlertCircle,
|
|
||||||
iconColor: 'text-red-500',
|
|
||||||
}
|
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return {
|
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
|
||||||
bg: 'bg-amber-500/10',
|
|
||||||
border: 'border-amber-500',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
iconColor: 'text-amber-500',
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return {
|
return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
|
||||||
bg: 'bg-green-500/10',
|
|
||||||
border: 'border-green-500',
|
|
||||||
icon: Info,
|
|
||||||
iconColor: 'text-green-500',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,15 +149,11 @@ function AlertItem({ alert }: { alert: Alert }) {
|
||||||
const Icon = styles.icon
|
const Icon = styles.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
|
||||||
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
|
|
||||||
>
|
|
||||||
<Icon size={16} className={styles.iconColor} />
|
<Icon size={16} className={styles.iconColor} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
<div className="text-sm text-slate-200">{alert.message}</div>
|
||||||
<div className="text-xs text-slate-500 mt-1">
|
<div className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
|
||||||
{alert.timestamp || 'Just now'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -176,25 +171,13 @@ function SourceCard({ source }: { source: SourceHealth }) {
|
||||||
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
|
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-slate-200 truncate">{source.name}</div>
|
<div className="text-sm text-slate-200 truncate">{source.name}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">{source.node_count} nodes · {source.type}</div>
|
||||||
{source.node_count} nodes * {source.type}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
subvalue,
|
|
||||||
}: {
|
|
||||||
icon: typeof Radio
|
|
||||||
label: string
|
|
||||||
value: string | number
|
|
||||||
subvalue?: string
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 text-slate-400 mb-2">
|
<div className="flex items-center gap-2 text-slate-400 mb-2">
|
||||||
|
|
@ -202,100 +185,362 @@ function StatCard({
|
||||||
<span className="text-xs">{label}</span>
|
<span className="text-xs">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xl text-slate-100">{value}</div>
|
<div className="font-mono text-xl text-slate-100">{value}</div>
|
||||||
{subvalue && (
|
{subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
|
||||||
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
|
// Scale badge component for R/S/G
|
||||||
if (!propagation) {
|
function ScaleBadge({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
const getColor = () => {
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
|
||||||
RF Propagation
|
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||||
</h2>
|
}
|
||||||
<div className="text-slate-500">
|
|
||||||
<p>Loading propagation data...</p>
|
return (
|
||||||
</div>
|
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
|
||||||
|
{label}{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large value display for SFI/Kp
|
||||||
|
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
|
||||||
|
const color = value !== undefined ? getColor(value) : 'text-slate-400'
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||||
|
<div className={`font-mono text-3xl font-bold ${color}`}>
|
||||||
|
{value?.toFixed(0) ?? '—'}
|
||||||
</div>
|
</div>
|
||||||
|
{unit && <div className="text-xs text-slate-500">{unit}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kp trend sparkline chart
|
||||||
|
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!history || history.length === 0) return []
|
||||||
|
// Take last 16 entries (48 hours of 3-hourly data)
|
||||||
|
return history.slice(-16).map((entry, i) => ({
|
||||||
|
idx: i,
|
||||||
|
value: entry.value,
|
||||||
|
time: entry.time,
|
||||||
|
}))
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
if (chartData.length === 0) return null
|
||||||
|
|
||||||
|
const maxKp = Math.max(...chartData.map(d => d.value), 5)
|
||||||
|
const currentKp = chartData[chartData.length - 1]?.value ?? 0
|
||||||
|
|
||||||
|
// Gradient color based on max Kp
|
||||||
|
const getGradientId = () => {
|
||||||
|
if (maxKp > 5) return 'kpGradientRed'
|
||||||
|
if (maxKp > 3) return 'kpGradientAmber'
|
||||||
|
return 'kpGradientGreen'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-20 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
|
||||||
|
<XAxis dataKey="idx" hide />
|
||||||
|
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
|
||||||
|
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
|
||||||
|
fill={`url(#${getGradientId()})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex justify-between text-xs text-slate-600 px-1">
|
||||||
|
<span>48h ago</span>
|
||||||
|
<span>now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refractivity profile chart
|
||||||
|
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!profile || profile.length === 0) return []
|
||||||
|
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
|
||||||
|
height: p.height_m,
|
||||||
|
M: p.M,
|
||||||
|
}))
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
|
if (chartData.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-24 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="M"
|
||||||
|
type="number"
|
||||||
|
domain={['dataMin - 20', 'dataMax + 20']}
|
||||||
|
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#334155' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="height"
|
||||||
|
type="number"
|
||||||
|
domain={[0, 'dataMax']}
|
||||||
|
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#334155' }}
|
||||||
|
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="M"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: '#3b82f6' }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RF Propagation Card
|
||||||
|
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
|
||||||
|
const getSfiColor = (v: number) => {
|
||||||
|
if (v >= 120) return 'text-green-400'
|
||||||
|
if (v >= 80) return 'text-amber-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKpColor = (v: number) => {
|
||||||
|
if (v <= 3) return 'text-green-400'
|
||||||
|
if (v <= 5) return 'text-amber-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDuctingBadge = (condition?: string) => {
|
||||||
|
if (!condition) return null
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||||
|
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
|
||||||
|
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||||
|
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||||
|
}
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
normal: 'Normal',
|
||||||
|
super_refraction: 'Super Refraction',
|
||||||
|
surface_duct: 'Surface Duct',
|
||||||
|
elevated_duct: 'Elevated Duct',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
|
||||||
|
{labels[condition] || condition}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hf = propagation.hf
|
|
||||||
const ducting = propagation.uhf_ducting
|
|
||||||
|
|
||||||
const getDuctingColor = (condition?: string) => {
|
|
||||||
if (!condition) return 'text-slate-400'
|
|
||||||
switch (condition) {
|
|
||||||
case 'normal':
|
|
||||||
return 'text-green-500'
|
|
||||||
case 'super_refraction':
|
|
||||||
return 'text-amber-500'
|
|
||||||
case 'surface_duct':
|
|
||||||
case 'elevated_duct':
|
|
||||||
return 'text-blue-400'
|
|
||||||
default:
|
|
||||||
return 'text-slate-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHF = hf && (hf.sfi || hf.kp_current !== undefined)
|
|
||||||
const hasDucting = ducting && ducting.condition
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||||
<Zap size={14} />
|
<Zap size={14} />
|
||||||
RF Propagation
|
RF Propagation
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Solar/Geomagnetic Indices */}
|
{/* Top row: SFI and Kp big values */}
|
||||||
<div className="mb-4">
|
<div className="flex justify-around mb-4">
|
||||||
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
|
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
|
||||||
{hasHF ? (
|
<div className="w-px bg-border" />
|
||||||
<div className="space-y-1">
|
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
|
||||||
<div className="text-sm font-mono text-slate-200">
|
|
||||||
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400">
|
|
||||||
R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
|
|
||||||
</div>
|
|
||||||
{hf.r_scale !== undefined && hf.r_scale > 0 && (
|
|
||||||
<div className="text-xs text-amber-500">
|
|
||||||
R{hf.r_scale} Radio Blackout
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-slate-500">No data</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tropospheric Ducting */}
|
{/* R/S/G Scale badges */}
|
||||||
<div>
|
<div className="flex justify-center gap-2 mb-4">
|
||||||
<div className="text-xs text-slate-500 mb-1">Tropospheric</div>
|
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
|
||||||
{hasDucting ? (
|
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
|
||||||
<div className="space-y-1">
|
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
|
||||||
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
|
|
||||||
{ducting.condition === 'normal'
|
|
||||||
? 'Normal'
|
|
||||||
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 font-mono">
|
|
||||||
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
|
|
||||||
</div>
|
|
||||||
{ducting.duct_thickness_m && (
|
|
||||||
<div className="text-xs text-slate-400">
|
|
||||||
Duct: ~{ducting.duct_thickness_m}m thick
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-slate-500">No ducting data</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kp Trend Chart */}
|
||||||
|
{swpc?.kp_history && swpc.kp_history.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
|
||||||
|
<KpTrendChart history={swpc.kp_history} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border my-3" />
|
||||||
|
|
||||||
|
{/* Tropospheric section */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Cloud size={14} className="text-slate-400" />
|
||||||
|
<span className="text-xs text-slate-500">Tropospheric</span>
|
||||||
|
{getDuctingBadge(ducting?.condition)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ducting?.min_gradient !== undefined && (
|
||||||
|
<div className="text-xs text-slate-400 font-mono mb-2">
|
||||||
|
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Refractivity profile chart */}
|
||||||
|
{ducting?.profile && ducting.profile.length > 0 && (
|
||||||
|
<RefractivityChart profile={ducting.profile} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SWPC Warnings */}
|
||||||
|
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
|
||||||
|
<div className="mt-auto pt-3 border-t border-border">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{swpc.active_warnings.slice(0, 3).map((w, i) => (
|
||||||
|
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
|
||||||
|
{w.replace('Space Weather Message Code: ', '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source icon mapping
|
||||||
|
const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: string }> = {
|
||||||
|
nws: { icon: Cloud, color: 'text-blue-400', label: 'NWS' },
|
||||||
|
swpc: { icon: Sun, color: 'text-yellow-400', label: 'SWPC' },
|
||||||
|
ducting: { icon: Radio, color: 'text-cyan-400', label: 'Tropo' },
|
||||||
|
nifc: { icon: Flame, color: 'text-orange-400', label: 'NIFC' },
|
||||||
|
firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' },
|
||||||
|
avalanche: { icon: Mountain, color: 'text-slate-300', label: 'Avy' },
|
||||||
|
usgs: { icon: Droplets, color: 'text-blue-300', label: 'USGS' },
|
||||||
|
traffic: { icon: Car, color: 'text-purple-400', label: 'Traffic' },
|
||||||
|
roads: { icon: Construction, color: 'text-amber-400', label: '511' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity badge colors
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
|
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
|
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||||
|
warning: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
critical: 'bg-red-600/20 text-red-300 border-red-600/30',
|
||||||
|
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||||
|
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
||||||
|
const Icon = sourceConfig.icon
|
||||||
|
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now'
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
|
||||||
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
||||||
|
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||||
|
{event.severity || 'info'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
||||||
|
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Event Feed Card
|
||||||
|
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
||||||
|
const sortedEvents = useMemo(() => {
|
||||||
|
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
||||||
|
}, [events])
|
||||||
|
|
||||||
|
// Calculate feed health summary
|
||||||
|
const feedSummary = useMemo(() => {
|
||||||
|
if (!envStatus?.feeds) return null
|
||||||
|
const total = envStatus.feeds.length
|
||||||
|
const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length
|
||||||
|
const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source)
|
||||||
|
const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0))
|
||||||
|
const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null
|
||||||
|
|
||||||
|
return { total, active, errors, secAgo }
|
||||||
|
}, [envStatus])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||||
|
<h2 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
||||||
|
<Activity size={14} />
|
||||||
|
Live Event Feed
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{sortedEvents.length > 0 ? (
|
||||||
|
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||||
|
{sortedEvents.map((event, i) => (
|
||||||
|
<EventFeedItem key={event.event_id || i} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle size={24} className="text-green-500 mx-auto mb-2" />
|
||||||
|
<div className="text-slate-400">No active events</div>
|
||||||
|
<div className="text-xs text-slate-500">All clear</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feed health summary */}
|
||||||
|
{feedSummary && (
|
||||||
|
<div className={`text-xs mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||||
|
{feedSummary.active} of {feedSummary.total} feeds active
|
||||||
|
{feedSummary.secAgo !== null && ` · Last update ${feedSummary.secAgo}s ago`}
|
||||||
|
{feedSummary.errors.length > 0 && (
|
||||||
|
<span className="text-amber-400"> · {feedSummary.errors.join(', ')}: error</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -305,11 +550,13 @@ export default function Dashboard() {
|
||||||
const [sources, setSources] = useState<SourceHealth[]>([])
|
const [sources, setSources] = useState<SourceHealth[]>([])
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
||||||
const [rfProp, setRFProp] = useState<RFPropagation | null>(null)
|
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
||||||
|
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
|
||||||
|
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const { lastHealth } = useWebSocket()
|
const { lastHealth, lastMessage } = useWebSocket()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -317,21 +564,25 @@ export default function Dashboard() {
|
||||||
fetchSources(),
|
fetchSources(),
|
||||||
fetchAlerts(),
|
fetchAlerts(),
|
||||||
fetchEnvStatus(),
|
fetchEnvStatus(),
|
||||||
fetchRFPropagation().catch(() => null),
|
fetchEnvActive().catch(() => []),
|
||||||
|
fetchSWPC().catch(() => null),
|
||||||
|
fetchDucting().catch(() => null),
|
||||||
])
|
])
|
||||||
.then(([h, src, a, e, rf]) => {
|
.then(([h, src, a, e, events, sw, duct]) => {
|
||||||
setHealth(h)
|
setHealth(h)
|
||||||
setSources(src)
|
setSources(src)
|
||||||
setAlerts(a)
|
setAlerts(a)
|
||||||
setEnvStatus(e)
|
setEnvStatus(e)
|
||||||
setRFProp(rf)
|
setEnvEvents(events)
|
||||||
setLoading(false)
|
setSwpc(sw as ExtendedSWPCStatus)
|
||||||
document.title = 'Dashboard — MeshAI'
|
setDucting(duct as ExtendedDuctingStatus)
|
||||||
|
setLoading(false)
|
||||||
|
document.title = 'Dashboard — MeshAI'
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
document.title = 'Dashboard — MeshAI'
|
document.title = 'Dashboard — MeshAI'
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -342,6 +593,18 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
}, [lastHealth])
|
}, [lastHealth])
|
||||||
|
|
||||||
|
// Handle WebSocket env_update messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastMessage?.type === 'env_update' && lastMessage.event) {
|
||||||
|
setEnvEvents(prev => {
|
||||||
|
// Add new event, dedupe by event_id
|
||||||
|
const newEvent = lastMessage.event as EnvEvent
|
||||||
|
const filtered = prev.filter(e => e.event_id !== newEvent.event_id)
|
||||||
|
return [newEvent, ...filtered].slice(0, 100) // Keep last 100
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [lastMessage])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|
@ -359,114 +622,76 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="space-y-6">
|
||||||
{/* Mesh Health */}
|
{/* Top row: Health + Alerts + Stats */}
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
|
{/* Mesh Health */}
|
||||||
{health && (
|
|
||||||
<>
|
|
||||||
<HealthGauge health={health} />
|
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
<PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
|
|
||||||
<PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
|
|
||||||
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
|
|
||||||
<PillarBar label="Power" value={health.pillars?.power ?? 0} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts + Stats */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
{/* Active Alerts */}
|
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
|
||||||
Active Alerts
|
{health && (
|
||||||
</h2>
|
<>
|
||||||
{alerts.length > 0 ? (
|
<HealthGauge health={health} />
|
||||||
<div className="space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{alerts.map((alert, i) => (
|
<PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
|
||||||
<AlertItem key={i} alert={alert} />
|
<PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
|
||||||
))}
|
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
|
||||||
</div>
|
<PillarBar label="Power" value={health.pillars?.power ?? 0} />
|
||||||
) : (
|
</div>
|
||||||
<div className="flex items-center gap-2 text-slate-500 py-4">
|
</>
|
||||||
<CheckCircle size={16} className="text-green-500" />
|
|
||||||
<span>No active alerts</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Alerts + Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<StatCard
|
{/* Active Alerts */}
|
||||||
icon={Radio}
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
label="Nodes Online"
|
<h2 className="text-sm font-medium text-slate-400 mb-4">Active Alerts</h2>
|
||||||
value={health?.total_nodes || 0}
|
{alerts.length > 0 ? (
|
||||||
subvalue={`${health?.unlocated_count || 0} unlocated`}
|
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||||
/>
|
{alerts.map((alert, i) => (
|
||||||
<StatCard
|
<AlertItem key={i} alert={alert} />
|
||||||
icon={Cpu}
|
))}
|
||||||
label="Infrastructure"
|
</div>
|
||||||
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`}
|
) : (
|
||||||
subvalue={
|
<div className="flex items-center gap-2 text-slate-500 py-4">
|
||||||
health?.infra_online === health?.infra_total
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
? 'All online'
|
<span>No active alerts</span>
|
||||||
: 'Some offline'
|
</div>
|
||||||
}
|
)}
|
||||||
/>
|
</div>
|
||||||
<StatCard
|
|
||||||
icon={Activity}
|
{/* Quick Stats */}
|
||||||
label="Utilization"
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
value={`${health?.util_percent?.toFixed(1) || 0}%`}
|
<StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
|
||||||
subvalue={`${health?.flagged_nodes || 0} flagged`}
|
<StatCard icon={Cpu} label="Infrastructure" value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} subvalue={health?.infra_online === health?.infra_total ? 'All online' : 'Some offline'} />
|
||||||
/>
|
<StatCard icon={Activity} label="Utilization" value={`${health?.util_percent?.toFixed(1) || 0}%`} subvalue={`${health?.flagged_nodes || 0} flagged`} />
|
||||||
<StatCard
|
<StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
|
||||||
icon={MapPin}
|
</div>
|
||||||
label="Regions"
|
|
||||||
value={health?.total_regions || 0}
|
|
||||||
subvalue={`${health?.battery_warnings || 0} battery warnings`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mesh Sources */}
|
{/* Middle row: Sources + RF Propagation + Live Feed */}
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
{/* Mesh Sources */}
|
||||||
Mesh Sources ({sources.length})
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
</h2>
|
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Sources ({sources.length})</h2>
|
||||||
{sources.length > 0 ? (
|
{sources.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sources.map((source, i) => (
|
{sources.map((source, i) => (
|
||||||
<SourceCard key={i} source={source} />
|
<SourceCard key={i} source={source} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-slate-500 py-4">No sources configured</div>
|
<div className="text-slate-500 py-4">No sources configured</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environmental Feeds */}
|
{/* RF Propagation */}
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
|
||||||
Environmental Feeds
|
|
||||||
</h2>
|
|
||||||
{envStatus?.enabled ? (
|
|
||||||
<div className="text-slate-400">
|
|
||||||
{envStatus.feeds.length} feeds active
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-slate-500">
|
|
||||||
<p>Environmental feeds not enabled.</p>
|
|
||||||
<p className="text-xs mt-2">
|
|
||||||
Enable in config.yaml
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RF Propagation */}
|
{/* Live Event Feed */}
|
||||||
<RFPropagationCard propagation={rfProp} />
|
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
|
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
|
||||||
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
|
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
|
||||||
Calendar, AlertTriangle, Copy
|
Calendar, AlertTriangle, Copy, Moon, AlertCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
|
|
@ -11,20 +11,15 @@ import NodePicker from '@/components/NodePicker'
|
||||||
interface NotificationRuleConfig {
|
interface NotificationRuleConfig {
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
// Trigger
|
|
||||||
trigger_type: 'condition' | 'schedule'
|
trigger_type: 'condition' | 'schedule'
|
||||||
// Condition trigger
|
|
||||||
categories: string[]
|
categories: string[]
|
||||||
min_severity: string
|
min_severity: string
|
||||||
// Schedule trigger
|
schedule_frequency: 'daily' | 'twice_daily' | 'weekly'
|
||||||
schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom'
|
|
||||||
schedule_time: string
|
schedule_time: string
|
||||||
schedule_time_2: string // For twice_daily
|
schedule_time_2: string
|
||||||
schedule_days: string[] // For weekly
|
schedule_days: string[]
|
||||||
schedule_cron: string // For custom
|
|
||||||
message_type: string
|
message_type: string
|
||||||
custom_message: string
|
custom_message: string
|
||||||
// Delivery
|
|
||||||
delivery_type: string
|
delivery_type: string
|
||||||
broadcast_channel: number
|
broadcast_channel: number
|
||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
|
|
@ -37,13 +32,13 @@ interface NotificationRuleConfig {
|
||||||
recipients: string[]
|
recipients: string[]
|
||||||
webhook_url: string
|
webhook_url: string
|
||||||
webhook_headers: Record<string, string>
|
webhook_headers: Record<string, string>
|
||||||
// Behavior
|
|
||||||
cooldown_minutes: number
|
cooldown_minutes: number
|
||||||
override_quiet: boolean
|
override_quiet: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationsConfig {
|
interface NotificationsConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
quiet_hours_enabled: boolean
|
||||||
quiet_hours_start: string
|
quiet_hours_start: string
|
||||||
quiet_hours_end: string
|
quiet_hours_end: string
|
||||||
rules: NotificationRuleConfig[]
|
rules: NotificationRuleConfig[]
|
||||||
|
|
@ -54,8 +49,43 @@ interface AlertCategory {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
default_severity: string
|
default_severity: string
|
||||||
|
example_message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Severity levels with descriptions
|
||||||
|
const SEVERITY_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: 'info',
|
||||||
|
label: 'Info',
|
||||||
|
description: 'Routine updates (ducting detected, new router appeared)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'advisory',
|
||||||
|
label: 'Advisory',
|
||||||
|
description: 'Worth knowing (weather advisory, traffic slow, battery declining)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'watch',
|
||||||
|
label: 'Watch',
|
||||||
|
description: 'Pay attention (fire within 50km, weather watch, stream rising)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'warning',
|
||||||
|
label: 'Warning',
|
||||||
|
description: 'Act now (fire within 25km, severe weather, critical battery)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'critical',
|
||||||
|
label: 'Critical',
|
||||||
|
description: 'Serious issue (critical node down, battery emergency)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'emergency',
|
||||||
|
label: 'Emergency',
|
||||||
|
description: 'Life safety (extreme weather, fire at infrastructure, total blackout)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// InfoButton component
|
// InfoButton component
|
||||||
function InfoButton({ info }: { info: string }) {
|
function InfoButton({ info }: { info: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
@ -187,34 +217,6 @@ function Toggle({ label, checked, onChange, helper = '', info = '' }: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectInput({ label, value, onChange, options, helper = '', info = '' }: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
onChange: (v: string) => void
|
|
||||||
options: { value: string; label: string }[]
|
|
||||||
helper?: string
|
|
||||||
info?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
|
||||||
{label}
|
|
||||||
{info && <InfoButton info={info} />}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimeInput({ label, value, onChange, helper = '', info = '' }: {
|
function TimeInput({ label, value, onChange, helper = '', info = '' }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
|
|
@ -307,10 +309,63 @@ function ListInput({ label, value, onChange, placeholder = 'Add item...', helper
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Severity selector with descriptions
|
||||||
|
function SeveritySelector({ value, onChange }: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[3]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Severity Threshold
|
||||||
|
<InfoButton info="Only alerts at or above this severity trigger this rule. Lower threshold = more notifications. 'Warning' is recommended for most rules." />
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-left flex items-center justify-between hover:border-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-200">{selected.label}</span>
|
||||||
|
<span className="text-slate-500 ml-2">— {selected.description}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} className={`text-slate-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||||
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl overflow-hidden">
|
||||||
|
{SEVERITY_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(opt.value); setIsOpen(false) }}
|
||||||
|
className={`w-full px-3 py-2.5 text-left text-sm hover:bg-[#1e2a3a] transition-colors ${
|
||||||
|
value === opt.value ? 'bg-accent/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-slate-200">{opt.label}</div>
|
||||||
|
<div className="text-xs text-slate-500">{opt.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">Lower = more notifications. "Warning" recommended for most rules.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Notification Rule Card Component
|
// Notification Rule Card Component
|
||||||
function NotificationRuleCard({
|
function NotificationRuleCard({
|
||||||
rule,
|
rule,
|
||||||
categories,
|
categories,
|
||||||
|
quietHoursEnabled,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
|
@ -318,6 +373,7 @@ function NotificationRuleCard({
|
||||||
}: {
|
}: {
|
||||||
rule: NotificationRuleConfig
|
rule: NotificationRuleConfig
|
||||||
categories: AlertCategory[]
|
categories: AlertCategory[]
|
||||||
|
quietHoursEnabled: boolean
|
||||||
onChange: (r: NotificationRuleConfig) => void
|
onChange: (r: NotificationRuleConfig) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
|
|
@ -326,35 +382,26 @@ function NotificationRuleCard({
|
||||||
const [expanded, setExpanded] = useState(!rule.name)
|
const [expanded, setExpanded] = useState(!rule.name)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
const severityOptions = [
|
|
||||||
{ value: 'info', label: 'Info' },
|
|
||||||
{ value: 'advisory', label: 'Advisory' },
|
|
||||||
{ value: 'watch', label: 'Watch' },
|
|
||||||
{ value: 'warning', label: 'Warning' },
|
|
||||||
{ value: 'critical', label: 'Critical' },
|
|
||||||
{ value: 'emergency', label: 'Emergency' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const deliveryOptions = [
|
const deliveryOptions = [
|
||||||
{ value: 'mesh_broadcast', label: 'Mesh Broadcast' },
|
{ value: '', label: '(None)', description: 'Rule matches but does not deliver' },
|
||||||
{ value: 'mesh_dm', label: 'Mesh DM' },
|
{ value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' },
|
||||||
{ value: 'email', label: 'Email' },
|
{ value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' },
|
||||||
{ value: 'webhook', label: 'Webhook' },
|
{ value: 'email', label: 'Email', description: 'Send via SMTP' },
|
||||||
|
{ value: 'webhook', label: 'Webhook', description: 'POST to any URL' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const frequencyOptions = [
|
const frequencyOptions = [
|
||||||
{ value: 'daily', label: 'Once Daily' },
|
{ value: 'daily', label: 'Daily' },
|
||||||
{ value: 'twice_daily', label: 'Twice Daily' },
|
{ value: 'twice_daily', label: 'Twice Daily' },
|
||||||
{ value: 'weekly', label: 'Weekly' },
|
{ value: 'weekly', label: 'Weekly' },
|
||||||
{ value: 'custom', label: 'Custom Cron' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const messageTypeOptions = [
|
const messageTypeOptions = [
|
||||||
{ value: 'mesh_health_summary', label: 'Mesh Health Summary' },
|
{ value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' },
|
||||||
{ value: 'rf_propagation_report', label: 'RF Propagation Report' },
|
{ value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' },
|
||||||
{ value: 'alerts_digest', label: 'Active Alerts Digest' },
|
{ value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' },
|
||||||
{ value: 'environmental_conditions', label: 'Environmental Conditions' },
|
{ value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' },
|
||||||
{ value: 'custom', label: 'Custom Message' },
|
{ value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
|
@ -383,42 +430,57 @@ function NotificationRuleCard({
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get example message for display
|
||||||
|
const getExampleMessage = (): string => {
|
||||||
|
if (rule.trigger_type === 'schedule') {
|
||||||
|
return '[Scheduled report preview would appear here]'
|
||||||
|
}
|
||||||
|
const ruleCats = rule.categories || []
|
||||||
|
if (ruleCats.length === 0 && categories.length > 0) {
|
||||||
|
return categories[0].example_message || 'Alert notification'
|
||||||
|
}
|
||||||
|
const firstCat = categories.find(c => ruleCats.includes(c.id))
|
||||||
|
return firstCat?.example_message || 'Alert notification'
|
||||||
|
}
|
||||||
|
|
||||||
// Generate summary for collapsed view
|
// Generate summary for collapsed view
|
||||||
const getSummary = () => {
|
const getSummary = () => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (rule.trigger_type === 'schedule') {
|
if (rule.trigger_type === 'schedule') {
|
||||||
// Schedule summary
|
|
||||||
const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency
|
const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency
|
||||||
const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type
|
const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type
|
||||||
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
|
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
|
||||||
parts.push(msgType)
|
parts.push(msgType)
|
||||||
} else {
|
} else {
|
||||||
// Condition summary
|
|
||||||
const catCount = rule.categories?.length || 0
|
const catCount = rule.categories?.length || 0
|
||||||
const catText = catCount === 0 ? 'All categories' : `${catCount} categories`
|
const catText = catCount === 0 ? 'All' : categories.filter(c => rule.categories?.includes(c.id)).map(c => c.name).slice(0, 2).join(', ') + (catCount > 2 ? ` +${catCount - 2}` : '')
|
||||||
const severity = severityOptions.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
||||||
parts.push(`${catText} at ${severity}+`)
|
parts.push(`${catText} at ${severity}+`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery summary
|
// Delivery summary
|
||||||
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
if (!rule.delivery_type) {
|
||||||
let target = ''
|
parts.push('⚠️ No delivery')
|
||||||
if (rule.delivery_type === 'mesh_broadcast') {
|
} else {
|
||||||
target = `Ch ${rule.broadcast_channel}`
|
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
||||||
} else if (rule.delivery_type === 'mesh_dm') {
|
let target = ''
|
||||||
target = `${rule.node_ids?.length || 0} nodes`
|
if (rule.delivery_type === 'mesh_broadcast') {
|
||||||
} else if (rule.delivery_type === 'email') {
|
target = `Ch ${rule.broadcast_channel}`
|
||||||
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
|
} else if (rule.delivery_type === 'mesh_dm') {
|
||||||
} else if (rule.delivery_type === 'webhook') {
|
target = `${rule.node_ids?.length || 0} nodes`
|
||||||
try {
|
} else if (rule.delivery_type === 'email') {
|
||||||
const url = new URL(rule.webhook_url)
|
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
|
||||||
target = url.hostname
|
} else if (rule.delivery_type === 'webhook') {
|
||||||
} catch {
|
try {
|
||||||
target = rule.webhook_url?.slice(0, 30) || 'no URL'
|
const url = new URL(rule.webhook_url)
|
||||||
|
target = url.hostname
|
||||||
|
} catch {
|
||||||
|
target = rule.webhook_url?.slice(0, 20) || 'no URL'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
parts.push(`${delivery}${target ? ` (${target})` : ''}`)
|
||||||
}
|
}
|
||||||
parts.push(`${delivery}${target ? ` (${target})` : ''}`)
|
|
||||||
|
|
||||||
return parts.join(' → ')
|
return parts.join(' → ')
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +497,7 @@ function NotificationRuleCard({
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }}
|
onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }}
|
||||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`}
|
className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`}
|
||||||
title={rule.enabled ? 'Enabled - click to disable' : 'Disabled - click to enable'}
|
title={rule.enabled ? 'Enabled' : 'Disabled'}
|
||||||
/>
|
/>
|
||||||
{rule.trigger_type === 'schedule' ? (
|
{rule.trigger_type === 'schedule' ? (
|
||||||
<Clock size={14} className="text-blue-400 flex-shrink-0" />
|
<Clock size={14} className="text-blue-400 flex-shrink-0" />
|
||||||
|
|
@ -444,7 +506,7 @@ function NotificationRuleCard({
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
|
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
|
||||||
{!expanded && (
|
{!expanded && (
|
||||||
<span className="text-xs text-slate-500 truncate hidden sm:block">
|
<span className={`text-xs truncate hidden sm:block ${!rule.delivery_type ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||||
{getSummary()}
|
{getSummary()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -454,21 +516,21 @@ function NotificationRuleCard({
|
||||||
onClick={(e) => { e.stopPropagation(); handleTest() }}
|
onClick={(e) => { e.stopPropagation(); handleTest() }}
|
||||||
disabled={testing || !rule.name}
|
disabled={testing || !rule.name}
|
||||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
|
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
|
||||||
title="Send test"
|
title="Test rule"
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
|
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
|
||||||
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
|
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
|
||||||
title="Duplicate rule"
|
title="Duplicate"
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
title="Delete rule"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -487,7 +549,7 @@ function NotificationRuleCard({
|
||||||
helper="A descriptive name for this rule"
|
helper="A descriptive name for this rule"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Trigger type selector */}
|
{/* Trigger type toggle */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -518,8 +580,8 @@ function NotificationRuleCard({
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-600">
|
<p className="text-xs text-slate-600">
|
||||||
{rule.trigger_type === 'schedule'
|
{rule.trigger_type === 'schedule'
|
||||||
? 'Send messages on a schedule (daily reports, weekly digests)'
|
? 'Send reports on a schedule (daily briefings, weekly digests)'
|
||||||
: 'React to alert conditions (fires, outages, warnings)'}
|
: 'React to alert conditions (fires, outages, weather warnings)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -531,12 +593,9 @@ function NotificationRuleCard({
|
||||||
WHEN (Condition)
|
WHEN (Condition)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInput
|
<SeveritySelector
|
||||||
label="Minimum Severity"
|
|
||||||
value={rule.min_severity}
|
value={rule.min_severity}
|
||||||
onChange={(v) => onChange({ ...rule, min_severity: v })}
|
onChange={(v) => onChange({ ...rule, min_severity: v })}
|
||||||
options={severityOptions}
|
|
||||||
helper="Only alerts at or above this level"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -578,19 +637,24 @@ function NotificationRuleCard({
|
||||||
WHEN (Schedule)
|
WHEN (Schedule)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInput
|
<div className="space-y-1">
|
||||||
label="Frequency"
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
|
||||||
value={rule.schedule_frequency || 'daily'}
|
<select
|
||||||
onChange={(v) => onChange({ ...rule, schedule_frequency: v as any })}
|
value={rule.schedule_frequency || 'daily'}
|
||||||
options={frequencyOptions}
|
onChange={(e) => onChange({ ...rule, schedule_frequency: e.target.value as any })}
|
||||||
/>
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{frequencyOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<TimeInput
|
<TimeInput
|
||||||
label="Time"
|
label="Time"
|
||||||
value={rule.schedule_time || '07:00'}
|
value={rule.schedule_time || '07:00'}
|
||||||
onChange={(v) => onChange({ ...rule, schedule_time: v })}
|
onChange={(v) => onChange({ ...rule, schedule_time: v })}
|
||||||
helper="24-hour format"
|
|
||||||
/>
|
/>
|
||||||
{rule.schedule_frequency === 'twice_daily' && (
|
{rule.schedule_frequency === 'twice_daily' && (
|
||||||
<TimeInput
|
<TimeInput
|
||||||
|
|
@ -623,30 +687,27 @@ function NotificationRuleCard({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rule.schedule_frequency === 'custom' && (
|
<div className="space-y-1">
|
||||||
<TextInput
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
|
||||||
label="Cron Expression"
|
<select
|
||||||
value={rule.schedule_cron || ''}
|
value={rule.message_type || 'mesh_health_summary'}
|
||||||
onChange={(v) => onChange({ ...rule, schedule_cron: v })}
|
onChange={(e) => onChange({ ...rule, message_type: e.target.value })}
|
||||||
placeholder="0 7 * * *"
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||||
helper="Standard cron format"
|
>
|
||||||
info="Five-field cron: minute hour day-of-month month day-of-week. Example: '0 7 * * 1' = 7:00 AM every Monday."
|
{messageTypeOptions.map(opt => (
|
||||||
/>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
)}
|
))}
|
||||||
|
</select>
|
||||||
<SelectInput
|
<p className="text-xs text-slate-600">
|
||||||
label="Message Type"
|
{messageTypeOptions.find(m => m.value === rule.message_type)?.description}
|
||||||
value={rule.message_type || 'mesh_health_summary'}
|
</p>
|
||||||
onChange={(v) => onChange({ ...rule, message_type: v })}
|
</div>
|
||||||
options={messageTypeOptions}
|
|
||||||
info="The type of report or message to send."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{rule.message_type === 'custom' && (
|
{rule.message_type === 'custom' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
Custom Message
|
Custom Message
|
||||||
<InfoButton info="Use template tokens: {MESH_SCORE}, {NODE_COUNT}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
|
<InfoButton info="Available tokens: {MESH_SCORE}, {NODE_COUNT}, {NODES_ONLINE}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={rule.custom_message || ''}
|
value={rule.custom_message || ''}
|
||||||
|
|
@ -667,12 +728,34 @@ function NotificationRuleCard({
|
||||||
SEND VIA
|
SEND VIA
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInput
|
<div className="space-y-1">
|
||||||
label="Delivery Method"
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
value={rule.delivery_type || 'mesh_broadcast'}
|
Delivery Method
|
||||||
onChange={(v) => onChange({ ...rule, delivery_type: v })}
|
<InfoButton info="Where this notification gets delivered. Select (None) to save the rule without delivery — it will match conditions but won't send until you configure a delivery method." />
|
||||||
options={deliveryOptions}
|
</label>
|
||||||
/>
|
<select
|
||||||
|
value={rule.delivery_type || ''}
|
||||||
|
onChange={(e) => onChange({ ...rule, delivery_type: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{deliveryOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
{deliveryOptions.find(d => d.value === (rule.delivery_type || ''))?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No delivery warning */}
|
||||||
|
{!rule.delivery_type && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
||||||
|
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-amber-300">
|
||||||
|
Rule will log matches but not deliver until a delivery method is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mesh Broadcast fields */}
|
{/* Mesh Broadcast fields */}
|
||||||
{rule.delivery_type === 'mesh_broadcast' && (
|
{rule.delivery_type === 'mesh_broadcast' && (
|
||||||
|
|
@ -739,7 +822,7 @@ function NotificationRuleCard({
|
||||||
value={rule.smtp_password || ''}
|
value={rule.smtp_password || ''}
|
||||||
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
||||||
type="password"
|
type="password"
|
||||||
info="For Gmail, use an App Password from myaccount.google.com/apppasswords"
|
info="Gmail users: use an App Password from myaccount.google.com/apppasswords"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
|
@ -760,28 +843,14 @@ function NotificationRuleCard({
|
||||||
|
|
||||||
{/* Webhook fields */}
|
{/* Webhook fields */}
|
||||||
{rule.delivery_type === 'webhook' && (
|
{rule.delivery_type === 'webhook' && (
|
||||||
<div className="space-y-4">
|
<TextInput
|
||||||
<TextInput
|
label="Webhook URL"
|
||||||
label="Webhook URL"
|
value={rule.webhook_url || ''}
|
||||||
value={rule.webhook_url || ''}
|
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
||||||
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
helper="POST alert as JSON"
|
||||||
helper="POST endpoint for alerts"
|
info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint."
|
||||||
info="Works with Discord, Slack, ntfy.sh, Home Assistant, Pushover, or any HTTP POST endpoint."
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<details className="group">
|
|
||||||
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
|
|
||||||
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
|
|
||||||
Custom Headers (optional)
|
|
||||||
</summary>
|
|
||||||
<div className="mt-4 pl-6 border-l border-[#1e2a3a]">
|
|
||||||
<p className="text-xs text-slate-500 mb-2">
|
|
||||||
Headers are configured in config.yaml for security.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -795,15 +864,28 @@ function NotificationRuleCard({
|
||||||
helper="Min time between repeat sends"
|
helper="Min time between repeat sends"
|
||||||
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
|
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end pb-1">
|
{quietHoursEnabled && (
|
||||||
<Toggle
|
<div className="flex items-end pb-1">
|
||||||
label="Override Quiet Hours"
|
<Toggle
|
||||||
checked={rule.override_quiet ?? false}
|
label="Override Quiet Hours"
|
||||||
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
checked={rule.override_quiet ?? false}
|
||||||
helper="Send during quiet hours"
|
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
||||||
/>
|
helper="Deliver during quiet hours"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Example message */}
|
||||||
|
{rule.trigger_type !== 'schedule' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Example Message</label>
|
||||||
|
<div className="p-3 bg-[#1e2a3a]/50 rounded-lg border border-[#1e2a3a]">
|
||||||
|
<p className="text-sm text-slate-300 font-mono">{getExampleMessage()}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">This is an example of what this rule would send.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -902,10 +984,9 @@ export default function Notifications() {
|
||||||
schedule_time: '07:00',
|
schedule_time: '07:00',
|
||||||
schedule_time_2: '19:00',
|
schedule_time_2: '19:00',
|
||||||
schedule_days: ['monday'],
|
schedule_days: ['monday'],
|
||||||
schedule_cron: '',
|
|
||||||
message_type: 'mesh_health_summary',
|
message_type: 'mesh_health_summary',
|
||||||
custom_message: '',
|
custom_message: '',
|
||||||
delivery_type: 'mesh_broadcast',
|
delivery_type: '', // Start with no delivery
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
node_ids: [],
|
node_ids: [],
|
||||||
smtp_host: '',
|
smtp_host: '',
|
||||||
|
|
@ -965,11 +1046,11 @@ export default function Notifications() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* Header with actions */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Configure notification rules for alerts and scheduled reports.
|
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1025,31 +1106,47 @@ export default function Notifications() {
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onChange={(v) => setConfig({ ...config, enabled: v })}
|
onChange={(v) => setConfig({ ...config, enabled: v })}
|
||||||
helper="Master switch for all notification delivery"
|
helper="Master switch for all notification delivery"
|
||||||
info="When disabled, no alerts or scheduled messages will be delivered. The alert engine still runs and records alerts to history."
|
info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<>
|
<>
|
||||||
{/* Quiet Hours Section - at top */}
|
{/* Quiet Hours Section */}
|
||||||
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
|
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
<div className="flex items-center gap-2">
|
||||||
Quiet Hours
|
<Moon size={14} className="text-slate-400" />
|
||||||
<InfoButton info="Non-emergency alerts are held during these hours. Rules with 'Override Quiet Hours' enabled still deliver. Emergency and critical alerts always get through." />
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<TimeInput
|
|
||||||
label="Start Time"
|
|
||||||
value={config.quiet_hours_start || '22:00'}
|
|
||||||
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
|
|
||||||
helper="When quiet hours begin"
|
|
||||||
/>
|
|
||||||
<TimeInput
|
|
||||||
label="End Time"
|
|
||||||
value={config.quiet_hours_end || '06:00'}
|
|
||||||
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
|
|
||||||
helper="When quiet hours end"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
label="Enable Quiet Hours"
|
||||||
|
checked={config.quiet_hours_enabled ?? true}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
|
||||||
|
helper="Suppress non-emergency alerts during sleeping hours"
|
||||||
|
info="When enabled, alerts below emergency severity are held during quiet hours. When disabled, all alerts deliver anytime."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{config.quiet_hours_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<TimeInput
|
||||||
|
label="Start Time"
|
||||||
|
value={config.quiet_hours_start || '22:00'}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
|
||||||
|
helper="When quiet hours begin"
|
||||||
|
/>
|
||||||
|
<TimeInput
|
||||||
|
label="End Time"
|
||||||
|
value={config.quiet_hours_end || '06:00'}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
|
||||||
|
helper="When quiet hours end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Emergency alerts and rules with "Override Quiet Hours" enabled always deliver.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rules Section */}
|
{/* Rules Section */}
|
||||||
|
|
@ -1069,6 +1166,7 @@ export default function Notifications() {
|
||||||
key={i}
|
key={i}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
quietHoursEnabled={config.quiet_hours_enabled ?? true}
|
||||||
onChange={(r) => {
|
onChange={(r) => {
|
||||||
const newRules = [...(config.rules || [])]
|
const newRules = [...(config.rules || [])]
|
||||||
newRules[i] = r
|
newRules[i] = r
|
||||||
|
|
|
||||||
|
|
@ -246,18 +246,22 @@ class AlertRulesConfig:
|
||||||
battery_warning: bool = True
|
battery_warning: bool = True
|
||||||
battery_critical: bool = True
|
battery_critical: bool = True
|
||||||
battery_emergency: bool = True
|
battery_emergency: bool = True
|
||||||
battery_warning_threshold: int = 50
|
battery_warning_threshold: int = 30
|
||||||
battery_critical_threshold: int = 25
|
battery_critical_threshold: int = 15
|
||||||
battery_emergency_threshold: int = 10
|
battery_emergency_threshold: int = 5
|
||||||
|
# Voltage-based thresholds (more accurate than percentage)
|
||||||
|
battery_warning_voltage: float = 3.60
|
||||||
|
battery_critical_voltage: float = 3.50
|
||||||
|
battery_emergency_voltage: float = 3.40
|
||||||
power_source_change: bool = True
|
power_source_change: bool = True
|
||||||
solar_not_charging: bool = True
|
solar_not_charging: bool = True
|
||||||
|
|
||||||
# Utilization
|
# Utilization
|
||||||
sustained_high_util: bool = True
|
sustained_high_util: bool = True
|
||||||
high_util_threshold: float = 20.0
|
high_util_threshold: float = 40.0
|
||||||
high_util_hours: int = 6
|
high_util_hours: int = 6
|
||||||
packet_flood: bool = True
|
packet_flood: bool = True
|
||||||
packet_flood_threshold: int = 500
|
packet_flood_threshold: int = 10
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
infra_single_gateway: bool = True
|
infra_single_gateway: bool = True
|
||||||
|
|
@ -266,7 +270,7 @@ class AlertRulesConfig:
|
||||||
|
|
||||||
# Health Scores
|
# Health Scores
|
||||||
mesh_score_alert: bool = True
|
mesh_score_alert: bool = True
|
||||||
mesh_score_threshold: int = 70
|
mesh_score_threshold: int = 65
|
||||||
region_score_alert: bool = True
|
region_score_alert: bool = True
|
||||||
region_score_threshold: int = 60
|
region_score_threshold: int = 60
|
||||||
|
|
||||||
|
|
@ -448,7 +452,7 @@ class NotificationRuleConfig:
|
||||||
custom_message: str = ""
|
custom_message: str = ""
|
||||||
|
|
||||||
# Delivery type
|
# Delivery type
|
||||||
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook
|
delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
|
||||||
|
|
||||||
# Mesh broadcast fields
|
# Mesh broadcast fields
|
||||||
broadcast_channel: int = 0
|
broadcast_channel: int = 0
|
||||||
|
|
@ -482,6 +486,7 @@ class NotificationsConfig:
|
||||||
"""Notification system settings."""
|
"""Notification system settings."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
quiet_hours_enabled: bool = True # Master toggle for quiet hours
|
||||||
quiet_hours_start: str = "22:00"
|
quiet_hours_start: str = "22:00"
|
||||||
quiet_hours_end: str = "06:00"
|
quiet_hours_end: str = "06:00"
|
||||||
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
||||||
|
|
|
||||||
|
|
@ -1,163 +1,192 @@
|
||||||
"""Environmental data API routes."""
|
"""Environmental data API routes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
router = APIRouter(tags=["environment"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/status")
|
@router.get("/env/status")
|
||||||
async def get_env_status(request: Request):
|
async def get_env_status(request: Request):
|
||||||
"""Get environmental feeds status."""
|
"""Get environmental feeds status."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False, "feeds": []}
|
return {"enabled": False, "feeds": []}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"feeds": env_store.get_source_health(),
|
"feeds": env_store.get_source_health(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/active")
|
@router.get("/env/active")
|
||||||
async def get_active_env(request: Request):
|
async def get_active_env(request: Request):
|
||||||
"""Get active environmental events."""
|
"""Get active environmental events."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active()
|
return env_store.get_active()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
@router.get("/env/swpc")
|
||||||
async def get_swpc_data(request: Request):
|
async def get_swpc_data(request: Request):
|
||||||
"""Get SWPC space weather data."""
|
"""Get SWPC space weather data."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
status = env_store.get_swpc_status()
|
status = env_store.get_swpc_status()
|
||||||
if not status:
|
if not status:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
**status,
|
**status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/propagation")
|
@router.get("/env/propagation")
|
||||||
async def get_rf_propagation(request: Request):
|
async def get_rf_propagation(request: Request):
|
||||||
"""Get combined HF + UHF propagation data for dashboard."""
|
"""Get combined HF + UHF propagation data for dashboard."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"hf": {}, "uhf_ducting": {}}
|
return {"hf": {}, "uhf_ducting": {}}
|
||||||
|
|
||||||
return env_store.get_rf_propagation()
|
return env_store.get_rf_propagation()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/ducting")
|
@router.get("/env/ducting")
|
||||||
async def get_ducting_data(request: Request):
|
async def get_ducting_data(request: Request):
|
||||||
"""Get tropospheric ducting assessment."""
|
"""Get tropospheric ducting assessment."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
status = env_store.get_ducting_status()
|
status = env_store.get_ducting_status()
|
||||||
if not status:
|
if not status:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
**status,
|
**status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/fires")
|
@router.get("/env/fires")
|
||||||
async def get_fires_data(request: Request):
|
async def get_fires_data(request: Request):
|
||||||
"""Get active wildfire perimeters."""
|
"""Get active wildfire perimeters."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active(source="nifc")
|
return env_store.get_active(source="nifc")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/avalanche")
|
@router.get("/env/avalanche")
|
||||||
async def get_avalanche_data(request: Request):
|
async def get_avalanche_data(request: Request):
|
||||||
"""Get avalanche advisories."""
|
"""Get avalanche advisories."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"off_season": True, "advisories": []}
|
return {"off_season": True, "advisories": []}
|
||||||
|
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
avy_adapter = adapters.get("avalanche")
|
avy_adapter = adapters.get("avalanche")
|
||||||
|
|
||||||
if avy_adapter and avy_adapter.is_off_season():
|
if avy_adapter and avy_adapter.is_off_season():
|
||||||
return {"off_season": True, "advisories": []}
|
return {"off_season": True, "advisories": []}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"off_season": False,
|
"off_season": False,
|
||||||
"advisories": env_store.get_active(source="avalanche"),
|
"advisories": env_store.get_active(source="avalanche"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/env/streams")
|
|
||||||
async def get_streams_data(request: Request):
|
@router.get("/env/streams")
|
||||||
"""Get USGS stream gauge readings."""
|
async def get_streams_data(request: Request):
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
"""Get USGS stream gauge readings."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
if not env_store:
|
|
||||||
return []
|
if not env_store:
|
||||||
|
return []
|
||||||
return env_store.get_active(source="usgs")
|
|
||||||
|
return env_store.get_active(source="usgs")
|
||||||
|
|
||||||
@router.get("/env/traffic")
|
|
||||||
async def get_traffic_data(request: Request):
|
@router.get("/env/usgs/lookup/{site_id}")
|
||||||
"""Get TomTom traffic flow data."""
|
async def lookup_usgs_site(request: Request, site_id: str):
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
"""Lookup USGS site metadata and NWS flood stages.
|
||||||
|
|
||||||
if not env_store:
|
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
||||||
return []
|
Used by the config UI to auto-populate fields when adding a new gauge.
|
||||||
|
"""
|
||||||
return env_store.get_active(source="traffic")
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
@router.get("/env/roads")
|
return {"error": "Environmental feeds not enabled"}
|
||||||
async def get_roads_data(request: Request):
|
|
||||||
"""Get 511 road conditions."""
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
usgs_adapter = adapters.get("usgs")
|
||||||
|
|
||||||
if not env_store:
|
if not usgs_adapter:
|
||||||
return []
|
# Create a temporary adapter for lookup
|
||||||
|
from meshai.env.usgs import USGSStreamsAdapter
|
||||||
return env_store.get_active(source="511")
|
from meshai.config import USGSConfig
|
||||||
|
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||||
|
|
||||||
@router.get("/env/hotspots")
|
try:
|
||||||
async def get_hotspots_data(request: Request):
|
result = usgs_adapter.lookup_site(site_id)
|
||||||
"""Get NASA FIRMS satellite fire hotspots."""
|
return result
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
except Exception as e:
|
||||||
|
return {"error": str(e), "site_id": site_id}
|
||||||
if not env_store:
|
|
||||||
return {"hotspots": [], "new_ignitions": 0}
|
|
||||||
|
@router.get("/env/traffic")
|
||||||
firms_adapter = getattr(env_store, "_firms", None)
|
async def get_traffic_data(request: Request):
|
||||||
|
"""Get TomTom traffic flow data."""
|
||||||
if not firms_adapter:
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
|
||||||
|
if not env_store:
|
||||||
hotspots = env_store.get_active(source="firms")
|
return []
|
||||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
|
||||||
|
return env_store.get_active(source="traffic")
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"hotspots": hotspots,
|
@router.get("/env/roads")
|
||||||
"new_ignitions": len(new_ignitions),
|
async def get_roads_data(request: Request):
|
||||||
}
|
"""Get 511 road conditions."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return env_store.get_active(source="511")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/env/hotspots")
|
||||||
|
async def get_hotspots_data(request: Request):
|
||||||
|
"""Get NASA FIRMS satellite fire hotspots."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0}
|
||||||
|
|
||||||
|
firms_adapter = getattr(env_store, "_firms", None)
|
||||||
|
|
||||||
|
if not firms_adapter:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
||||||
|
|
||||||
|
hotspots = env_store.get_active(source="firms")
|
||||||
|
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"hotspots": hotspots,
|
||||||
|
"new_ignitions": len(new_ignitions),
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-CYHGOAxN.css
Normal file
1
meshai/dashboard/static/assets/index-CYHGOAxN.css
Normal file
File diff suppressed because one or more lines are too long
503
meshai/dashboard/static/assets/index-DARDkZhk.js
Normal file
503
meshai/dashboard/static/assets/index-DARDkZhk.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
<script type="module" crossorigin src="/assets/index-DARDkZhk.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CYHGOAxN.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
45
meshai/env/swpc.py
vendored
45
meshai/env/swpc.py
vendored
|
|
@ -140,15 +140,36 @@ class SWPCAdapter:
|
||||||
"""Parse noaa-planetary-k-index.json.
|
"""Parse noaa-planetary-k-index.json.
|
||||||
|
|
||||||
Data format: array of objects with time_tag, Kp, a_running, station_count
|
Data format: array of objects with time_tag, Kp, a_running, station_count
|
||||||
Last entry is most recent.
|
Last entry is most recent. Store full history for charting.
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get last entry (most recent)
|
# Store full history (last 24-48 hours of readings)
|
||||||
last_entry = data[-1]
|
kp_history = []
|
||||||
|
for entry in data:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
try:
|
||||||
|
kp_history.append({
|
||||||
|
"time": entry.get("time_tag", ""),
|
||||||
|
"value": float(entry.get("Kp", 0)),
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
elif isinstance(entry, list) and len(entry) > 1:
|
||||||
|
# Legacy array format fallback
|
||||||
|
try:
|
||||||
|
kp_history.append({
|
||||||
|
"time": entry[0] if len(entry) > 0 else "",
|
||||||
|
"value": float(entry[1]),
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
# Handle both dict format (new API) and list format (legacy)
|
self._status["kp_history"] = kp_history
|
||||||
|
|
||||||
|
# Get last entry (most recent) for current value
|
||||||
|
last_entry = data[-1]
|
||||||
if isinstance(last_entry, dict):
|
if isinstance(last_entry, dict):
|
||||||
try:
|
try:
|
||||||
self._status["kp_current"] = float(last_entry.get("Kp", 0))
|
self._status["kp_current"] = float(last_entry.get("Kp", 0))
|
||||||
|
|
@ -184,10 +205,26 @@ class SWPCAdapter:
|
||||||
"""Parse f107_cm_flux.json.
|
"""Parse f107_cm_flux.json.
|
||||||
|
|
||||||
Data format: array of objects with time_tag, flux
|
Data format: array of objects with time_tag, flux
|
||||||
|
Store history for potential charting.
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Store SFI history (last 30 days of readings)
|
||||||
|
sfi_history = []
|
||||||
|
if isinstance(data, list):
|
||||||
|
for entry in data[-30:]: # Last 30 entries
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
try:
|
||||||
|
sfi_history.append({
|
||||||
|
"time": entry.get("time_tag", ""),
|
||||||
|
"value": float(entry.get("flux", 0)),
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._status["sfi_history"] = sfi_history
|
||||||
|
|
||||||
# Get most recent entry (last in list)
|
# Get most recent entry (last in list)
|
||||||
if isinstance(data, list) and data:
|
if isinstance(data, list) and data:
|
||||||
last = data[-1]
|
last = data[-1]
|
||||||
|
|
|
||||||
241
meshai/env/usgs.py
vendored
241
meshai/env/usgs.py
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
"""USGS Water Services stream gauge adapter.
|
"""USGS Water Services stream gauge adapter with NWS flood stage auto-lookup.
|
||||||
|
|
||||||
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
|
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
|
||||||
# Legacy waterservices.usgs.gov will be decommissioned.
|
# Legacy waterservices.usgs.gov will be decommissioned.
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
@ -21,11 +21,17 @@ logger = logging.getLogger(__name__)
|
||||||
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
|
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
|
||||||
MIN_TICK_SECONDS = 900 # 15 minutes
|
MIN_TICK_SECONDS = 900 # 15 minutes
|
||||||
|
|
||||||
|
# Cache for NWS flood stages (rarely change)
|
||||||
|
_nwps_cache: dict[str, dict] = {}
|
||||||
|
_nwps_cache_time: dict[str, float] = {}
|
||||||
|
NWPS_CACHE_TTL = 86400 * 7 # 7 days
|
||||||
|
|
||||||
|
|
||||||
class USGSStreamsAdapter:
|
class USGSStreamsAdapter:
|
||||||
"""USGS instantaneous values for stream gauge readings."""
|
"""USGS instantaneous values for stream gauge readings with NWS flood stages."""
|
||||||
|
|
||||||
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
|
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
|
||||||
|
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
|
||||||
|
|
||||||
def __init__(self, config: "USGSConfig"):
|
def __init__(self, config: "USGSConfig"):
|
||||||
self._sites = config.sites or []
|
self._sites = config.sites or []
|
||||||
|
|
@ -37,6 +43,9 @@ class USGSStreamsAdapter:
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._is_loaded = False
|
self._is_loaded = False
|
||||||
|
|
||||||
|
# Site metadata cache (name, flood stages from NWPS)
|
||||||
|
self._site_metadata: dict[str, dict] = {}
|
||||||
|
|
||||||
if self._tick_interval < MIN_TICK_SECONDS:
|
if self._tick_interval < MIN_TICK_SECONDS:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
|
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
|
||||||
|
|
@ -61,15 +70,192 @@ class USGSStreamsAdapter:
|
||||||
self._last_tick = now
|
self._last_tick = now
|
||||||
return self._fetch()
|
return self._fetch()
|
||||||
|
|
||||||
|
def _get_site_ids(self) -> list[str]:
|
||||||
|
"""Extract site IDs from config (handles both string and dict formats)."""
|
||||||
|
site_ids = []
|
||||||
|
for site in self._sites:
|
||||||
|
if isinstance(site, str):
|
||||||
|
site_ids.append(site)
|
||||||
|
elif isinstance(site, dict):
|
||||||
|
site_ids.append(site.get("id", ""))
|
||||||
|
elif hasattr(site, "id"):
|
||||||
|
site_ids.append(site.id)
|
||||||
|
return [s for s in site_ids if s]
|
||||||
|
|
||||||
|
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
|
||||||
|
"""Lookup flood stages from NWS National Water Prediction Service.
|
||||||
|
|
||||||
|
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
|
||||||
|
We try a mapping lookup first, then fall back to direct lookup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
|
||||||
|
or None if not available
|
||||||
|
"""
|
||||||
|
global _nwps_cache, _nwps_cache_time
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
now = time.time()
|
||||||
|
if usgs_site_id in _nwps_cache:
|
||||||
|
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
|
||||||
|
return _nwps_cache[usgs_site_id]
|
||||||
|
|
||||||
|
# Try to find NWS gauge ID from USGS site ID
|
||||||
|
# First, query USGS site info to get the NWS ID crosswalk
|
||||||
|
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
|
||||||
|
if not nws_gauge_id:
|
||||||
|
# Fall back to using USGS ID directly (sometimes they match)
|
||||||
|
nws_gauge_id = usgs_site_id
|
||||||
|
|
||||||
|
# Query NWPS for flood stages
|
||||||
|
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(url, headers=headers)
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
# Extract flood stages
|
||||||
|
stages = {}
|
||||||
|
flood_info = data.get("flood", {})
|
||||||
|
|
||||||
|
if "action" in flood_info:
|
||||||
|
stages["action_stage"] = flood_info["action"].get("stage")
|
||||||
|
if "minor" in flood_info:
|
||||||
|
stages["flood_stage"] = flood_info["minor"].get("stage")
|
||||||
|
if "moderate" in flood_info:
|
||||||
|
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
|
||||||
|
if "major" in flood_info:
|
||||||
|
stages["major_flood_stage"] = flood_info["major"].get("stage")
|
||||||
|
|
||||||
|
# Also grab the official name if available
|
||||||
|
stages["nws_name"] = data.get("name", "")
|
||||||
|
stages["nws_gauge_id"] = nws_gauge_id
|
||||||
|
|
||||||
|
# Cache result
|
||||||
|
_nwps_cache[usgs_site_id] = stages
|
||||||
|
_nwps_cache_time[usgs_site_id] = now
|
||||||
|
|
||||||
|
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
|
||||||
|
return stages
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
# No NWPS data for this gauge - cache the miss
|
||||||
|
_nwps_cache[usgs_site_id] = {}
|
||||||
|
_nwps_cache_time[usgs_site_id] = now
|
||||||
|
logger.debug(f"No NWPS data for gauge {usgs_site_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
|
||||||
|
"""Try to find NWS gauge ID from USGS site ID.
|
||||||
|
|
||||||
|
The USGS provides a crosswalk in their site metadata, but it's not
|
||||||
|
always populated. This is a best-effort lookup.
|
||||||
|
"""
|
||||||
|
# Try USGS site service for metadata including NWS ID
|
||||||
|
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
content = resp.read().decode("utf-8")
|
||||||
|
|
||||||
|
# Parse RDB format - look for NWS ID in the data
|
||||||
|
# This is a simplified parser; full implementation would be more robust
|
||||||
|
for line in content.split("\n"):
|
||||||
|
if line.startswith(usgs_site_id):
|
||||||
|
# NWS station ID is typically in column ~30ish
|
||||||
|
# This varies by USGS response format
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lookup_site(self, site_id: str) -> dict:
|
||||||
|
"""Lookup site metadata for config UI auto-populate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"site_id": "13090500",
|
||||||
|
"name": "Snake River nr Twin Falls ID",
|
||||||
|
"lat": 42.xxx,
|
||||||
|
"lon": -114.xxx,
|
||||||
|
"flood_stages": {
|
||||||
|
"action_stage": 9.0,
|
||||||
|
"flood_stage": 10.5,
|
||||||
|
"moderate_flood_stage": 12.0,
|
||||||
|
"major_flood_stage": 14.0,
|
||||||
|
} or None
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
|
||||||
|
|
||||||
|
# Get USGS site info
|
||||||
|
params = {
|
||||||
|
"format": "json",
|
||||||
|
"sites": site_id,
|
||||||
|
"siteOutput": "expanded",
|
||||||
|
}
|
||||||
|
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
sites = data.get("value", {}).get("timeSeries", [])
|
||||||
|
if not sites:
|
||||||
|
# Try alternate format
|
||||||
|
sites_list = data.get("value", {}).get("sites", [])
|
||||||
|
if sites_list:
|
||||||
|
site_info = sites_list[0]
|
||||||
|
result["name"] = site_info.get("siteName", "")
|
||||||
|
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
|
||||||
|
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"USGS site lookup failed for {site_id}: {e}")
|
||||||
|
|
||||||
|
# Get NWS flood stages
|
||||||
|
stages = self._lookup_nwps_stages(site_id)
|
||||||
|
if stages:
|
||||||
|
result["flood_stages"] = {
|
||||||
|
"action_stage": stages.get("action_stage"),
|
||||||
|
"flood_stage": stages.get("flood_stage"),
|
||||||
|
"moderate_flood_stage": stages.get("moderate_flood_stage"),
|
||||||
|
"major_flood_stage": stages.get("major_flood_stage"),
|
||||||
|
}
|
||||||
|
if stages.get("nws_name") and not result["name"]:
|
||||||
|
result["name"] = stages["nws_name"]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def _fetch(self) -> bool:
|
def _fetch(self) -> bool:
|
||||||
"""Fetch instantaneous values from USGS Water Services.
|
"""Fetch instantaneous values from USGS Water Services.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if data changed
|
True if data changed
|
||||||
"""
|
"""
|
||||||
|
site_ids = self._get_site_ids()
|
||||||
|
if not site_ids:
|
||||||
|
return False
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"sites": ",".join(self._sites),
|
"sites": ",".join(site_ids),
|
||||||
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
||||||
"siteStatus": "active",
|
"siteStatus": "active",
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +307,10 @@ class USGSStreamsAdapter:
|
||||||
site_codes = source_info.get("siteCode", [])
|
site_codes = source_info.get("siteCode", [])
|
||||||
site_id = site_codes[0].get("value", "") if site_codes else ""
|
site_id = site_codes[0].get("value", "") if site_codes else ""
|
||||||
|
|
||||||
|
# Cache site name
|
||||||
|
if site_id and site_id not in self._site_metadata:
|
||||||
|
self._site_metadata[site_id] = {"name": site_name}
|
||||||
|
|
||||||
# Extract location
|
# Extract location
|
||||||
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
|
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
|
||||||
lat = geo_loc.get("latitude")
|
lat = geo_loc.get("latitude")
|
||||||
|
|
@ -159,11 +349,37 @@ class USGSStreamsAdapter:
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check flood threshold
|
# Get flood stages for this site
|
||||||
|
nwps_stages = self._lookup_nwps_stages(site_id)
|
||||||
|
|
||||||
|
# Determine severity based on flood stages (for gage height)
|
||||||
severity = "info"
|
severity = "info"
|
||||||
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
|
flood_status = None
|
||||||
if threshold and value > threshold:
|
|
||||||
severity = "warning"
|
if param_type == "height" and nwps_stages:
|
||||||
|
major = nwps_stages.get("major_flood_stage")
|
||||||
|
moderate = nwps_stages.get("moderate_flood_stage")
|
||||||
|
minor = nwps_stages.get("flood_stage")
|
||||||
|
action = nwps_stages.get("action_stage")
|
||||||
|
|
||||||
|
if major and value >= major:
|
||||||
|
severity = "critical"
|
||||||
|
flood_status = "Major Flood"
|
||||||
|
elif moderate and value >= moderate:
|
||||||
|
severity = "warning"
|
||||||
|
flood_status = "Moderate Flood"
|
||||||
|
elif minor and value >= minor:
|
||||||
|
severity = "warning"
|
||||||
|
flood_status = "Minor Flood"
|
||||||
|
elif action and value >= action:
|
||||||
|
severity = "advisory"
|
||||||
|
flood_status = "Action Stage"
|
||||||
|
|
||||||
|
# Fall back to legacy manual thresholds
|
||||||
|
if severity == "info":
|
||||||
|
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
|
||||||
|
if threshold and value > threshold:
|
||||||
|
severity = "warning"
|
||||||
|
|
||||||
# Format headline
|
# Format headline
|
||||||
if param_type == "flow":
|
if param_type == "flow":
|
||||||
|
|
@ -171,6 +387,9 @@ class USGSStreamsAdapter:
|
||||||
else:
|
else:
|
||||||
headline = f"{site_name}: {value:.1f} {unit_code}"
|
headline = f"{site_name}: {value:.1f} {unit_code}"
|
||||||
|
|
||||||
|
if flood_status:
|
||||||
|
headline += f" — {flood_status}"
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
"source": "usgs",
|
"source": "usgs",
|
||||||
"event_id": f"{site_id}_{param_type}",
|
"event_id": f"{site_id}_{param_type}",
|
||||||
|
|
@ -188,6 +407,8 @@ class USGSStreamsAdapter:
|
||||||
"value": value,
|
"value": value,
|
||||||
"unit": unit_code,
|
"unit": unit_code,
|
||||||
"timestamp": timestamp_str,
|
"timestamp": timestamp_str,
|
||||||
|
"flood_status": flood_status,
|
||||||
|
"flood_stages": nwps_stages if nwps_stages else None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,7 +431,7 @@ class USGSStreamsAdapter:
|
||||||
self._is_loaded = True
|
self._is_loaded = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(self._sites)} sites")
|
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(site_ids)} sites")
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
@ -228,5 +449,5 @@ class USGSStreamsAdapter:
|
||||||
"consecutive_errors": self._consecutive_errors,
|
"consecutive_errors": self._consecutive_errors,
|
||||||
"event_count": len(self._events),
|
"event_count": len(self._events),
|
||||||
"last_fetch": self._last_tick,
|
"last_fetch": self._last_tick,
|
||||||
"site_count": len(self._sites),
|
"site_count": len(self._get_site_ids()),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,139 +1,218 @@
|
||||||
"""Alert category registry.
|
"""Alert category registry.
|
||||||
|
|
||||||
Defines all alertable conditions with human-readable names and descriptions.
|
Defines all alertable conditions with human-readable names, descriptions,
|
||||||
|
and example messages showing what users will receive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALERT_CATEGORIES = {
|
ALERT_CATEGORIES = {
|
||||||
# Infrastructure alerts
|
# Infrastructure alerts
|
||||||
"infra_offline": {
|
"infra_offline": {
|
||||||
"name": "Infrastructure Offline",
|
"name": "Infrastructure Node Offline",
|
||||||
"description": "An infrastructure node stopped responding",
|
"description": "An infrastructure node (router/repeater) stopped responding",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
||||||
},
|
},
|
||||||
"critical_node_down": {
|
"critical_node_down": {
|
||||||
"name": "Critical Node Down",
|
"name": "Critical Node Down",
|
||||||
"description": "A node marked as critical went offline",
|
"description": "A node you marked as critical went offline",
|
||||||
"default_severity": "critical",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
||||||
},
|
},
|
||||||
"infra_recovery": {
|
"infra_recovery": {
|
||||||
"name": "Infrastructure Recovery",
|
"name": "Infrastructure Recovery",
|
||||||
"description": "An infrastructure node came back online",
|
"description": "An offline infrastructure node came back online",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
||||||
},
|
},
|
||||||
"new_router": {
|
"new_router": {
|
||||||
"name": "New Router",
|
"name": "New Router",
|
||||||
"description": "A new router appeared on the mesh",
|
"description": "A new router appeared on the mesh",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Power alerts
|
# Power alerts
|
||||||
"battery_warning": {
|
"battery_warning": {
|
||||||
"name": "Battery Warning",
|
"name": "Battery Warning",
|
||||||
"description": "Infrastructure node battery below warning threshold",
|
"description": "Infrastructure node battery below 30% (3.60V)",
|
||||||
"default_severity": "warning",
|
"default_severity": "advisory",
|
||||||
|
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
||||||
},
|
},
|
||||||
"battery_critical": {
|
"battery_critical": {
|
||||||
"name": "Battery Critical",
|
"name": "Battery Critical",
|
||||||
"description": "Infrastructure node battery below critical threshold",
|
"description": "Infrastructure node battery below 15% (3.50V)",
|
||||||
"default_severity": "critical",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
||||||
},
|
},
|
||||||
"battery_emergency": {
|
"battery_emergency": {
|
||||||
"name": "Battery Emergency",
|
"name": "Battery Emergency",
|
||||||
"description": "Infrastructure node battery critically low",
|
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
||||||
"default_severity": "emergency",
|
"default_severity": "critical",
|
||||||
|
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
||||||
},
|
},
|
||||||
"battery_trend": {
|
"battery_trend": {
|
||||||
"name": "Battery Declining",
|
"name": "Battery Declining",
|
||||||
"description": "Battery showing declining trend over 7 days",
|
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
||||||
"default_severity": "warning",
|
"default_severity": "advisory",
|
||||||
|
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
||||||
},
|
},
|
||||||
"power_source_change": {
|
"power_source_change": {
|
||||||
"name": "Power Source Change",
|
"name": "Power Source Change",
|
||||||
"description": "Node switched from USB to battery (possible outage)",
|
"description": "Node switched from USB to battery — possible power outage at site",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
||||||
},
|
},
|
||||||
"solar_not_charging": {
|
"solar_not_charging": {
|
||||||
"name": "Solar Not Charging",
|
"name": "Solar Not Charging",
|
||||||
"description": "Solar panel not charging during daylight hours",
|
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Utilization alerts
|
# Utilization alerts
|
||||||
|
"high_utilization": {
|
||||||
|
"name": "Channel Airtime High",
|
||||||
|
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
||||||
|
"default_severity": "advisory",
|
||||||
|
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
||||||
|
},
|
||||||
"sustained_high_util": {
|
"sustained_high_util": {
|
||||||
"name": "High Utilization",
|
"name": "Sustained High Utilization",
|
||||||
"description": "Channel utilization elevated for extended period",
|
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
||||||
},
|
},
|
||||||
"packet_flood": {
|
"packet_flood": {
|
||||||
"name": "Packet Flood",
|
"name": "Packet Flood",
|
||||||
"description": "Node sending excessive packets",
|
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Coverage alerts
|
# Coverage alerts
|
||||||
"infra_single_gateway": {
|
"infra_single_gateway": {
|
||||||
"name": "Single Gateway",
|
"name": "Single Gateway",
|
||||||
"description": "Infrastructure node dropped to single gateway coverage",
|
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
||||||
"default_severity": "warning",
|
"default_severity": "advisory",
|
||||||
|
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
||||||
},
|
},
|
||||||
"feeder_offline": {
|
"feeder_offline": {
|
||||||
"name": "Feeder Offline",
|
"name": "Feeder Offline",
|
||||||
"description": "A feeder gateway stopped responding",
|
"description": "A feeder gateway stopped responding — coverage gap possible",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
||||||
},
|
},
|
||||||
"region_total_blackout": {
|
"region_total_blackout": {
|
||||||
"name": "Region Blackout",
|
"name": "Region Blackout",
|
||||||
"description": "All infrastructure in a region is offline",
|
"description": "All infrastructure in a region is offline — complete coverage loss",
|
||||||
"default_severity": "emergency",
|
"default_severity": "critical",
|
||||||
|
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Health score alerts
|
# Health score alerts
|
||||||
"mesh_score_low": {
|
"mesh_score_low": {
|
||||||
"name": "Mesh Health Low",
|
"name": "Mesh Health Low",
|
||||||
"description": "Overall mesh health score below threshold",
|
"description": "Overall mesh health score dropped below threshold — multiple issues likely",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
||||||
},
|
},
|
||||||
"region_score_low": {
|
"region_score_low": {
|
||||||
"name": "Region Health Low",
|
"name": "Region Health Low",
|
||||||
"description": "A region's health score below threshold",
|
"description": "A region's health score below threshold — localized issues",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental alerts
|
# Environmental - Weather
|
||||||
"weather_warning": {
|
"weather_warning": {
|
||||||
"name": "Severe Weather",
|
"name": "Severe Weather",
|
||||||
"description": "NWS warning or advisory for mesh area",
|
"description": "NWS warning or advisory affecting your mesh area",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# Environmental - Space Weather
|
||||||
"hf_blackout": {
|
"hf_blackout": {
|
||||||
"name": "HF Radio Blackout",
|
"name": "HF Radio Blackout",
|
||||||
"description": "R3+ solar event degrading HF propagation",
|
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
||||||
},
|
},
|
||||||
|
"geomagnetic_storm": {
|
||||||
|
"name": "Geomagnetic Storm",
|
||||||
|
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
||||||
|
"default_severity": "advisory",
|
||||||
|
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# Environmental - Tropospheric
|
||||||
"tropospheric_ducting": {
|
"tropospheric_ducting": {
|
||||||
"name": "Tropospheric Ducting",
|
"name": "Tropospheric Ducting",
|
||||||
"description": "Atmospheric conditions extending VHF/UHF range",
|
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# Environmental - Fire
|
||||||
|
"fire_proximity": {
|
||||||
|
"name": "Fire Near Mesh",
|
||||||
|
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||||
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
||||||
},
|
},
|
||||||
"wildfire_proximity": {
|
"wildfire_proximity": {
|
||||||
"name": "Fire Near Mesh",
|
"name": "Fire Near Mesh",
|
||||||
"description": "Wildfire detected within configured distance",
|
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
||||||
},
|
},
|
||||||
"new_ignition": {
|
"new_ignition": {
|
||||||
"name": "New Fire Ignition",
|
"name": "New Fire Ignition",
|
||||||
"description": "Satellite hotspot not matching any known fire",
|
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
||||||
"default_severity": "warning",
|
"default_severity": "watch",
|
||||||
|
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
|
||||||
},
|
},
|
||||||
"flood_warning": {
|
|
||||||
"name": "Flood Warning",
|
# Environmental - Flood
|
||||||
"description": "Stream gauge exceeds flood threshold",
|
"stream_flood_warning": {
|
||||||
|
"name": "Stream Flood Warning",
|
||||||
|
"description": "River gauge exceeds NWS flood stage threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
||||||
},
|
},
|
||||||
|
"stream_high_water": {
|
||||||
|
"name": "Stream High Water",
|
||||||
|
"description": "River gauge approaching flood stage — monitoring recommended",
|
||||||
|
"default_severity": "advisory",
|
||||||
|
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# Environmental - Roads
|
||||||
"road_closure": {
|
"road_closure": {
|
||||||
"name": "Road Closure",
|
"name": "Road Closure",
|
||||||
"description": "Full road closure on monitored corridor",
|
"description": "Full road closure on a monitored corridor",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
||||||
|
},
|
||||||
|
"traffic_congestion": {
|
||||||
|
"name": "Traffic Congestion",
|
||||||
|
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
||||||
|
"default_severity": "advisory",
|
||||||
|
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
||||||
|
},
|
||||||
|
|
||||||
|
# Environmental - Avalanche
|
||||||
|
"avalanche_warning": {
|
||||||
|
"name": "Avalanche Danger High",
|
||||||
|
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
||||||
|
"default_severity": "warning",
|
||||||
|
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
||||||
|
},
|
||||||
|
"avalanche_considerable": {
|
||||||
|
"name": "Avalanche Danger Considerable",
|
||||||
|
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
||||||
|
"default_severity": "watch",
|
||||||
|
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +225,7 @@ def get_category(category_id: str) -> dict:
|
||||||
"name": category_id.replace("_", " ").title(),
|
"name": category_id.replace("_", " ").title(),
|
||||||
"description": f"Alert type: {category_id}",
|
"description": f"Alert type: {category_id}",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": f"Alert: {category_id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,11 @@ class NotificationRouter:
|
||||||
timezone: str = "America/Boise",
|
timezone: str = "America/Boise",
|
||||||
):
|
):
|
||||||
self._rules: list[dict] = []
|
self._rules: list[dict] = []
|
||||||
|
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
|
||||||
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
||||||
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
||||||
self._timezone = timezone
|
self._timezone = timezone
|
||||||
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
|
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
||||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
@ -56,9 +57,16 @@ class NotificationRouter:
|
||||||
logger.info("Notification router initialized: %d condition rules", len(self._rules))
|
logger.info("Notification router initialized: %d condition rules", len(self._rules))
|
||||||
|
|
||||||
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
|
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
|
||||||
"""Create a channel instance from a rule's inline delivery config."""
|
"""Create a channel instance from a rule's inline delivery config.
|
||||||
|
|
||||||
|
Returns None if delivery_type is empty or invalid.
|
||||||
|
"""
|
||||||
delivery_type = rule.get("delivery_type", "")
|
delivery_type = rule.get("delivery_type", "")
|
||||||
|
|
||||||
|
# Empty delivery type is valid - rule exists but doesn't deliver
|
||||||
|
if not delivery_type:
|
||||||
|
return None
|
||||||
|
|
||||||
if delivery_type == "mesh_broadcast":
|
if delivery_type == "mesh_broadcast":
|
||||||
config = {
|
config = {
|
||||||
"type": "mesh_broadcast",
|
"type": "mesh_broadcast",
|
||||||
|
|
@ -87,13 +95,13 @@ class NotificationRouter:
|
||||||
"headers": rule.get("webhook_headers", {}),
|
"headers": rule.get("webhook_headers", {}),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logger.warning("Unknown delivery type: %s", delivery_type)
|
logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return create_channel(config, self._connector)
|
return create_channel(config, self._connector)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to create channel for rule %s: %s", rule.get("name"), e)
|
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def process_alert(self, alert: dict) -> bool:
|
async def process_alert(self, alert: dict) -> bool:
|
||||||
|
|
@ -106,6 +114,8 @@ class NotificationRouter:
|
||||||
delivered = False
|
delivered = False
|
||||||
|
|
||||||
for rule in self._rules:
|
for rule in self._rules:
|
||||||
|
rule_name = rule.get("name", "unnamed")
|
||||||
|
|
||||||
# Check category match
|
# Check category match
|
||||||
rule_categories = rule.get("categories", [])
|
rule_categories = rule.get("categories", [])
|
||||||
if rule_categories and category not in rule_categories:
|
if rule_categories and category not in rule_categories:
|
||||||
|
|
@ -116,15 +126,18 @@ class NotificationRouter:
|
||||||
if not self._severity_meets(severity, min_severity):
|
if not self._severity_meets(severity, min_severity):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check quiet hours (emergencies and criticals override)
|
# Check quiet hours (only if quiet hours are enabled globally)
|
||||||
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
|
if self._quiet_enabled and self._in_quiet_hours():
|
||||||
if not rule.get("override_quiet", False):
|
# Emergencies and criticals always go through
|
||||||
continue
|
if severity not in ("emergency", "critical"):
|
||||||
|
# Check if rule overrides quiet hours
|
||||||
|
if not rule.get("override_quiet", False):
|
||||||
|
logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name)
|
||||||
|
continue
|
||||||
|
|
||||||
# Check cooldown
|
# Check cooldown
|
||||||
cooldown = rule.get("cooldown_minutes", 10) * 60
|
cooldown = rule.get("cooldown_minutes", 10) * 60
|
||||||
event_id = alert.get("event_id", alert.get("message", "")[:50])
|
event_id = alert.get("event_id", alert.get("message", "")[:50])
|
||||||
rule_name = rule.get("name", "unknown")
|
|
||||||
dedup_key = (rule_name, category, event_id)
|
dedup_key = (rule_name, category, event_id)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if dedup_key in self._recent:
|
if dedup_key in self._recent:
|
||||||
|
|
@ -133,9 +146,19 @@ class NotificationRouter:
|
||||||
continue
|
continue
|
||||||
self._recent[dedup_key] = now
|
self._recent[dedup_key] = now
|
||||||
|
|
||||||
|
# Log rule match
|
||||||
|
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
|
||||||
|
|
||||||
|
# Check if rule has delivery configured
|
||||||
|
delivery_type = rule.get("delivery_type", "")
|
||||||
|
if not delivery_type:
|
||||||
|
logger.info("Rule '%s' matched but has no delivery configured", rule_name)
|
||||||
|
continue
|
||||||
|
|
||||||
# Create channel and deliver
|
# Create channel and deliver
|
||||||
channel = self._create_channel_for_rule(rule)
|
channel = self._create_channel_for_rule(rule)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
logger.warning("Rule '%s' failed to create delivery channel", rule_name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -153,9 +176,9 @@ class NotificationRouter:
|
||||||
success = await channel.deliver(delivery_alert, rule)
|
success = await channel.deliver(delivery_alert, rule)
|
||||||
if success:
|
if success:
|
||||||
delivered = True
|
delivered = True
|
||||||
logger.info("Alert delivered via %s: %s", rule_name, category)
|
logger.info("Alert delivered via rule '%s': %s", rule_name, category)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Rule %s delivery failed: %s", rule_name, e)
|
logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
|
||||||
|
|
||||||
return delivered
|
return delivered
|
||||||
|
|
||||||
|
|
@ -170,6 +193,9 @@ class NotificationRouter:
|
||||||
|
|
||||||
def _in_quiet_hours(self) -> bool:
|
def _in_quiet_hours(self) -> bool:
|
||||||
"""Check if current time is within quiet hours."""
|
"""Check if current time is within quiet hours."""
|
||||||
|
if not self._quiet_enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
tz = ZoneInfo(self._timezone)
|
tz = ZoneInfo(self._timezone)
|
||||||
|
|
@ -204,12 +230,69 @@ class NotificationRouter:
|
||||||
else:
|
else:
|
||||||
rule_dict = dict(rule)
|
rule_dict = dict(rule)
|
||||||
|
|
||||||
|
# Check if delivery is configured
|
||||||
|
if not rule_dict.get("delivery_type"):
|
||||||
|
return False, "No delivery method configured for this rule"
|
||||||
|
|
||||||
channel = self._create_channel_for_rule(rule_dict)
|
channel = self._create_channel_for_rule(rule_dict)
|
||||||
if not channel:
|
if not channel:
|
||||||
return False, "Failed to create delivery channel"
|
return False, "Failed to create delivery channel"
|
||||||
|
|
||||||
return await channel.test()
|
return await channel.test()
|
||||||
|
|
||||||
|
async def preview_rule(self, rule_index: int) -> dict:
|
||||||
|
"""Preview what a rule would match right now.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"matches": bool,
|
||||||
|
"conditions": [...], # Current conditions that match
|
||||||
|
"preview": str, # Example message
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
rules_config = getattr(self._config, "rules", [])
|
||||||
|
if rule_index < 0 or rule_index >= len(rules_config):
|
||||||
|
return {"matches": False, "conditions": [], "preview": "Invalid rule index"}
|
||||||
|
|
||||||
|
rule = rules_config[rule_index]
|
||||||
|
if hasattr(rule, "__dict__"):
|
||||||
|
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||||
|
else:
|
||||||
|
rule_dict = dict(rule)
|
||||||
|
|
||||||
|
# For condition rules, show example based on categories
|
||||||
|
if rule_dict.get("trigger_type", "condition") == "condition":
|
||||||
|
from .categories import get_category
|
||||||
|
categories = rule_dict.get("categories", [])
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
# All categories - show first example
|
||||||
|
example = get_category("infra_offline")
|
||||||
|
return {
|
||||||
|
"matches": True,
|
||||||
|
"conditions": ["All alert categories"],
|
||||||
|
"preview": example.get("example_message", "Alert notification"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Show example from first category
|
||||||
|
cat_info = get_category(categories[0])
|
||||||
|
return {
|
||||||
|
"matches": True,
|
||||||
|
"conditions": [get_category(c)["name"] for c in categories],
|
||||||
|
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# For schedule rules, generate preview report
|
||||||
|
elif rule_dict.get("trigger_type") == "schedule":
|
||||||
|
message_type = rule_dict.get("message_type", "mesh_health_summary")
|
||||||
|
return {
|
||||||
|
"matches": True,
|
||||||
|
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
|
||||||
|
"preview": f"[{message_type}] Report content would appear here",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
||||||
|
|
||||||
def add_mesh_subscription(
|
def add_mesh_subscription(
|
||||||
self,
|
self,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue