mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 08:54:36 +02:00
Compare commits
No commits in common. "23151f63ba40830728e64bf993b5ffa45d8fb4ad" and "b4f7e24c2644c9ab91e3fed97e5e7caee65393d1" have entirely different histories.
23151f63ba
...
b4f7e24c26
17 changed files with 4124 additions and 5079 deletions
|
|
@ -216,111 +216,80 @@ 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_enabled: true # Master toggle for quiet hours feature
|
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
||||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
quiet_hours_end: "06:00"
|
||||||
quiet_hours_end: "06:00"
|
|
||||||
|
# Notification rules - each rule is self-contained with its own delivery config
|
||||||
# Notification rules - each rule is self-contained with its own delivery config
|
rules:
|
||||||
# Default baseline rules are created on fresh install
|
# All emergencies -> mesh broadcast
|
||||||
rules:
|
- name: "Emergency Broadcast"
|
||||||
# Emergency Broadcast - all emergencies go out immediately
|
enabled: true
|
||||||
- name: "Emergency Broadcast"
|
trigger_type: condition
|
||||||
enabled: true
|
categories: [] # Empty = all categories
|
||||||
trigger_type: condition
|
min_severity: "emergency"
|
||||||
categories: [] # Empty = all categories
|
delivery_type: mesh_broadcast
|
||||||
min_severity: "emergency"
|
broadcast_channel: 0
|
||||||
delivery_type: mesh_broadcast
|
cooldown_minutes: 5
|
||||||
broadcast_channel: 0
|
override_quiet: true # Send even during quiet hours
|
||||||
cooldown_minutes: 5
|
|
||||||
override_quiet: true # Send even during quiet hours
|
# Example: Fire alerts -> email
|
||||||
|
# - name: "Fire Alerts Email"
|
||||||
# Infrastructure Down - critical node and infrastructure offline alerts
|
# enabled: true
|
||||||
- name: "Infrastructure Down"
|
# trigger_type: condition
|
||||||
enabled: true
|
# categories: ["wildfire_proximity", "new_ignition"]
|
||||||
trigger_type: condition
|
# min_severity: "advisory"
|
||||||
categories: ["infra_offline", "critical_node_down"]
|
# delivery_type: email
|
||||||
min_severity: "warning"
|
# smtp_host: "smtp.gmail.com"
|
||||||
delivery_type: mesh_broadcast
|
# smtp_port: 587
|
||||||
broadcast_channel: 0
|
# smtp_user: "you@gmail.com"
|
||||||
cooldown_minutes: 30
|
# smtp_password: "${SMTP_PASSWORD}"
|
||||||
override_quiet: false
|
# smtp_tls: true
|
||||||
|
# from_address: "meshai@yourdomain.com"
|
||||||
# Fire Alert - wildfire proximity and new ignition
|
# recipients: ["admin@yourdomain.com"]
|
||||||
- name: "Fire Alert"
|
# cooldown_minutes: 30
|
||||||
enabled: true
|
|
||||||
trigger_type: condition
|
# Example: All warnings -> Discord webhook
|
||||||
categories: ["wildfire_proximity", "new_ignition"]
|
# - name: "Discord Alerts"
|
||||||
min_severity: "advisory"
|
# enabled: true
|
||||||
delivery_type: mesh_broadcast
|
# trigger_type: condition
|
||||||
broadcast_channel: 0
|
# categories: []
|
||||||
cooldown_minutes: 60
|
# min_severity: "warning"
|
||||||
override_quiet: false
|
# delivery_type: webhook
|
||||||
|
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||||
# Severe Weather - weather warnings
|
# cooldown_minutes: 10
|
||||||
- name: "Severe Weather"
|
|
||||||
enabled: true
|
# Example: Daily health report -> mesh broadcast
|
||||||
trigger_type: condition
|
# - name: "Morning Briefing"
|
||||||
categories: ["weather_warning"]
|
# enabled: true
|
||||||
min_severity: "warning"
|
# trigger_type: schedule
|
||||||
delivery_type: mesh_broadcast
|
# schedule_frequency: daily
|
||||||
broadcast_channel: 0
|
# schedule_time: "07:00"
|
||||||
cooldown_minutes: 30
|
# message_type: mesh_health_summary
|
||||||
override_quiet: false
|
# delivery_type: mesh_broadcast
|
||||||
|
# broadcast_channel: 0
|
||||||
# Example: Fire alerts -> email
|
|
||||||
# - name: "Fire Alerts Email"
|
# Example: Weekly digest -> email
|
||||||
# enabled: true
|
# - name: "Weekly Digest"
|
||||||
# trigger_type: condition
|
# enabled: true
|
||||||
# categories: ["wildfire_proximity", "new_ignition"]
|
# trigger_type: schedule
|
||||||
# min_severity: "advisory"
|
# schedule_frequency: weekly
|
||||||
# delivery_type: email
|
# schedule_days: ["monday"]
|
||||||
# smtp_host: "smtp.gmail.com"
|
# schedule_time: "08:00"
|
||||||
# smtp_port: 587
|
# message_type: alerts_digest
|
||||||
# smtp_user: "you@gmail.com"
|
# delivery_type: email
|
||||||
# smtp_password: "${SMTP_PASSWORD}"
|
# smtp_host: "smtp.gmail.com"
|
||||||
# smtp_tls: true
|
# recipients: ["admin@example.com"]
|
||||||
# from_address: "meshai@yourdomain.com"
|
|
||||||
# recipients: ["admin@yourdomain.com"]
|
# === WEB DASHBOARD ===
|
||||||
# cooldown_minutes: 30
|
dashboard:
|
||||||
|
enabled: true
|
||||||
# Example: All warnings -> Discord webhook
|
port: 8080
|
||||||
# - name: "Discord Alerts"
|
host: "0.0.0.0"
|
||||||
# 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,109 +1,102 @@
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
|
import type { MeshHealth, Alert } from '@/lib/api'
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
type: string
|
type: string
|
||||||
data?: unknown
|
data: unknown
|
||||||
event?: EnvEvent
|
}
|
||||||
}
|
|
||||||
|
interface UseWebSocketReturn {
|
||||||
interface UseWebSocketReturn {
|
connected: boolean
|
||||||
connected: boolean
|
lastHealth: MeshHealth | null
|
||||||
lastHealth: MeshHealth | null
|
lastAlert: Alert | null
|
||||||
lastAlert: Alert | null
|
}
|
||||||
lastMessage: WebSocketMessage | null
|
|
||||||
}
|
export function useWebSocket(): UseWebSocketReturn {
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
export function useWebSocket(): UseWebSocketReturn {
|
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
||||||
const [connected, setConnected] = useState(false)
|
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
||||||
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
const reconnectDelayRef = useRef(1000)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
const connect = useCallback(() => {
|
||||||
const reconnectDelayRef = useRef(1000)
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
return
|
||||||
const connect = useCallback(() => {
|
}
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
return
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
}
|
const wsUrl = `${protocol}//${window.location.host}/ws/live`
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
try {
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/live`
|
const ws = new WebSocket(wsUrl)
|
||||||
|
wsRef.current = ws
|
||||||
try {
|
|
||||||
const ws = new WebSocket(wsUrl)
|
ws.onopen = () => {
|
||||||
wsRef.current = ws
|
setConnected(true)
|
||||||
|
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
|
||||||
ws.onopen = () => {
|
}
|
||||||
setConnected(true)
|
|
||||||
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
|
ws.onmessage = (event) => {
|
||||||
}
|
try {
|
||||||
|
const message: WebSocketMessage = JSON.parse(event.data)
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
switch (message.type) {
|
||||||
const message: WebSocketMessage = JSON.parse(event.data)
|
case 'health_update':
|
||||||
|
setLastHealth(message.data as MeshHealth)
|
||||||
// Store all messages for generic handling
|
break
|
||||||
setLastMessage(message)
|
case 'alert_fired':
|
||||||
|
setLastAlert(message.data as Alert)
|
||||||
switch (message.type) {
|
break
|
||||||
case 'health_update':
|
}
|
||||||
setLastHealth(message.data as MeshHealth)
|
} catch (e) {
|
||||||
break
|
console.error('Failed to parse WebSocket message:', e)
|
||||||
case 'alert_fired':
|
}
|
||||||
setLastAlert(message.data as Alert)
|
}
|
||||||
break
|
|
||||||
// env_update messages are handled via lastMessage
|
ws.onclose = () => {
|
||||||
}
|
setConnected(false)
|
||||||
} catch (e) {
|
wsRef.current = null
|
||||||
console.error('Failed to parse WebSocket message:', e)
|
|
||||||
}
|
// Schedule reconnect with exponential backoff
|
||||||
}
|
const delay = Math.min(reconnectDelayRef.current, 30000)
|
||||||
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||||
ws.onclose = () => {
|
reconnectDelayRef.current = Math.min(delay * 2, 30000)
|
||||||
setConnected(false)
|
connect()
|
||||||
wsRef.current = null
|
}, delay)
|
||||||
|
}
|
||||||
// Schedule reconnect with exponential backoff
|
|
||||||
const delay = Math.min(reconnectDelayRef.current, 30000)
|
ws.onerror = () => {
|
||||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
ws.close()
|
||||||
reconnectDelayRef.current = Math.min(delay * 2, 30000)
|
}
|
||||||
connect()
|
|
||||||
}, delay)
|
// Keepalive ping every 30 seconds
|
||||||
}
|
const pingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.onerror = () => {
|
ws.send('ping')
|
||||||
ws.close()
|
}
|
||||||
}
|
}, 30000)
|
||||||
|
|
||||||
// Keepalive ping every 30 seconds
|
ws.addEventListener('close', () => {
|
||||||
const pingInterval = setInterval(() => {
|
clearInterval(pingInterval)
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
})
|
||||||
ws.send('ping')
|
} catch (e) {
|
||||||
}
|
console.error('Failed to create WebSocket:', e)
|
||||||
}, 30000)
|
}
|
||||||
|
}, [])
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
clearInterval(pingInterval)
|
useEffect(() => {
|
||||||
})
|
connect()
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to create WebSocket:', e)
|
return () => {
|
||||||
}
|
if (reconnectTimeoutRef.current) {
|
||||||
}, [])
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
useEffect(() => {
|
if (wsRef.current) {
|
||||||
connect()
|
wsRef.current.close()
|
||||||
|
}
|
||||||
return () => {
|
}
|
||||||
if (reconnectTimeoutRef.current) {
|
}, [connect])
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
|
||||||
}
|
return { connected, lastHealth, lastAlert }
|
||||||
if (wsRef.current) {
|
}
|
||||||
wsRef.current.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [connect])
|
|
||||||
|
|
||||||
return { connected, lastHealth, lastAlert, lastMessage }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,479 +1,440 @@
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kp history entry for charting
|
export interface SWPCStatus {
|
||||||
export interface KpHistoryEntry {
|
enabled: boolean
|
||||||
time: string
|
kp_current?: number
|
||||||
value: number
|
kp_timestamp?: string
|
||||||
}
|
sfi?: number
|
||||||
|
r_scale?: number
|
||||||
// SFI history entry for charting
|
s_scale?: number
|
||||||
export interface SfiHistoryEntry {
|
g_scale?: number
|
||||||
time: string
|
active_warnings?: string[]
|
||||||
value: number
|
}
|
||||||
}
|
|
||||||
|
export interface DuctingStatus {
|
||||||
// Refractivity profile entry
|
enabled: boolean
|
||||||
export interface ProfileEntry {
|
condition?: string
|
||||||
level_hPa: number
|
min_gradient?: number
|
||||||
height_m: number
|
duct_thickness_m?: number | null
|
||||||
N: number
|
duct_base_m?: number | null
|
||||||
M: number
|
last_update?: string
|
||||||
T_C: number
|
}
|
||||||
RH: number
|
|
||||||
}
|
export interface RFPropagation {
|
||||||
|
hf: {
|
||||||
// Gradient entry
|
kp_current?: number
|
||||||
export interface GradientEntry {
|
sfi?: number
|
||||||
from_level: number
|
r_scale?: number
|
||||||
to_level: number
|
s_scale?: number
|
||||||
from_height_m: number
|
g_scale?: number
|
||||||
to_height_m: number
|
active_warnings?: string[]
|
||||||
gradient: number
|
}
|
||||||
}
|
uhf_ducting: {
|
||||||
|
condition?: string
|
||||||
export interface SWPCStatus {
|
min_gradient?: number
|
||||||
enabled: boolean
|
duct_thickness_m?: number | null
|
||||||
kp_current?: number
|
}
|
||||||
kp_timestamp?: string
|
}
|
||||||
sfi?: number
|
|
||||||
r_scale?: number
|
// API fetch helpers
|
||||||
s_scale?: number
|
|
||||||
g_scale?: number
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
active_warnings?: string[]
|
const response = await fetch(url)
|
||||||
kp_history?: KpHistoryEntry[]
|
if (!response.ok) {
|
||||||
sfi_history?: SfiHistoryEntry[]
|
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
return response.json()
|
||||||
export interface DuctingStatus {
|
}
|
||||||
enabled: boolean
|
|
||||||
condition?: string
|
export async function fetchStatus(): Promise<SystemStatus> {
|
||||||
min_gradient?: number
|
return fetchJson<SystemStatus>('/api/status')
|
||||||
duct_thickness_m?: number | null
|
}
|
||||||
duct_base_m?: number | null
|
|
||||||
last_update?: string
|
export async function fetchHealth(): Promise<MeshHealth> {
|
||||||
profile?: ProfileEntry[]
|
return fetchJson<MeshHealth>('/api/health')
|
||||||
gradients?: GradientEntry[]
|
}
|
||||||
assessment?: string
|
|
||||||
location?: { lat: number; lon: number }
|
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||||
}
|
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||||
|
}
|
||||||
export interface RFPropagation {
|
|
||||||
hf: {
|
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||||
kp_current?: number
|
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||||
sfi?: number
|
}
|
||||||
r_scale?: number
|
|
||||||
s_scale?: number
|
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||||
g_scale?: number
|
return fetchJson<SourceHealth[]>('/api/sources')
|
||||||
active_warnings?: string[]
|
}
|
||||||
kp_history?: KpHistoryEntry[]
|
|
||||||
}
|
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||||
uhf_ducting: {
|
const url = section ? `/api/config/${section}` : '/api/config'
|
||||||
condition?: string
|
return fetchJson(url)
|
||||||
min_gradient?: number
|
}
|
||||||
duct_thickness_m?: number | null
|
|
||||||
profile?: ProfileEntry[]
|
export async function updateConfig(
|
||||||
}
|
section: string,
|
||||||
}
|
data: unknown
|
||||||
|
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||||
// API fetch helpers
|
const response = await fetch(`/api/config/${section}`, {
|
||||||
|
method: 'PUT',
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const response = await fetch(url)
|
body: JSON.stringify(data),
|
||||||
if (!response.ok) {
|
})
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
if (!response.ok) {
|
||||||
}
|
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||||
return response.json()
|
}
|
||||||
}
|
return response.json()
|
||||||
|
}
|
||||||
export async function fetchStatus(): Promise<SystemStatus> {
|
|
||||||
return fetchJson<SystemStatus>('/api/status')
|
export async function fetchAlerts(): Promise<Alert[]> {
|
||||||
}
|
return fetchJson<Alert[]>('/api/alerts/active')
|
||||||
|
}
|
||||||
export async function fetchHealth(): Promise<MeshHealth> {
|
|
||||||
return fetchJson<MeshHealth>('/api/health')
|
export async function fetchAlertHistory(
|
||||||
}
|
limit: number = 50,
|
||||||
|
offset: number = 0,
|
||||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
type?: string,
|
||||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
severity?: string
|
||||||
}
|
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
params.set('limit', limit.toString())
|
||||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
params.set('offset', offset.toString())
|
||||||
}
|
if (type && type !== 'all') params.set('type', type)
|
||||||
|
if (severity && severity !== 'all') params.set('severity', severity)
|
||||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||||
return fetchJson<SourceHealth[]>('/api/sources')
|
}
|
||||||
}
|
|
||||||
|
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
return fetchJson<Subscription[]>('/api/subscriptions')
|
||||||
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,
|
|
||||||
data: unknown
|
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||||
const response = await fetch(`/api/config/${section}`, {
|
}
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||||
body: JSON.stringify(data),
|
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||||
})
|
}
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||||
}
|
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||||
return response.json()
|
}
|
||||||
}
|
|
||||||
|
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||||
export async function fetchAlerts(): Promise<Alert[]> {
|
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||||
return fetchJson<Alert[]>('/api/alerts/active')
|
}
|
||||||
}
|
|
||||||
|
export interface FireEvent {
|
||||||
export async function fetchAlertHistory(
|
source: string
|
||||||
limit: number = 50,
|
event_id: string
|
||||||
offset: number = 0,
|
event_type: string
|
||||||
type?: string,
|
severity: string
|
||||||
severity?: string
|
headline: string
|
||||||
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
name: string
|
||||||
const params = new URLSearchParams()
|
acres: number
|
||||||
params.set('limit', limit.toString())
|
pct_contained: number
|
||||||
params.set('offset', offset.toString())
|
lat: number | null
|
||||||
if (type && type !== 'all') params.set('type', type)
|
lon: number | null
|
||||||
if (severity && severity !== 'all') params.set('severity', severity)
|
distance_km: number | null
|
||||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
nearest_anchor: string | null
|
||||||
}
|
state: string
|
||||||
|
expires: number
|
||||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
fetched_at: number
|
||||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
polygon?: number[][][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
export interface AvalancheEvent {
|
||||||
return fetchJson<EnvStatus>('/api/env/status')
|
source: string
|
||||||
}
|
event_id: string
|
||||||
|
event_type: string
|
||||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
severity: string
|
||||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
headline: string
|
||||||
}
|
zone_name: string
|
||||||
|
center: string
|
||||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
center_id: string
|
||||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
center_link: string
|
||||||
}
|
forecast_link: string
|
||||||
|
danger: string
|
||||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
danger_level: number
|
||||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
danger_name: string
|
||||||
}
|
travel_advice: string
|
||||||
|
state: string
|
||||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
lat: number | null
|
||||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
lon: number | null
|
||||||
}
|
expires: number
|
||||||
|
fetched_at: number
|
||||||
export interface FireEvent {
|
}
|
||||||
source: string
|
|
||||||
event_id: string
|
export interface StreamGaugeEvent {
|
||||||
event_type: string
|
source: string
|
||||||
severity: string
|
event_id: string
|
||||||
headline: string
|
event_type: string
|
||||||
name: string
|
headline: string
|
||||||
acres: number
|
severity: string
|
||||||
pct_contained: number
|
lat?: number
|
||||||
lat: number | null
|
lon?: number
|
||||||
lon: number | null
|
expires: number
|
||||||
distance_km: number | null
|
fetched_at: number
|
||||||
nearest_anchor: string | null
|
properties: {
|
||||||
state: string
|
site_id: string
|
||||||
expires: number
|
site_name: string
|
||||||
fetched_at: number
|
parameter: string
|
||||||
polygon?: number[][][]
|
value: number
|
||||||
}
|
unit: string
|
||||||
|
timestamp: string
|
||||||
export interface AvalancheEvent {
|
}
|
||||||
source: string
|
}
|
||||||
event_id: string
|
|
||||||
event_type: string
|
export interface TrafficEvent {
|
||||||
severity: string
|
source: string
|
||||||
headline: string
|
event_id: string
|
||||||
zone_name: string
|
event_type: string
|
||||||
center: string
|
headline: string
|
||||||
center_id: string
|
severity: string
|
||||||
center_link: string
|
lat?: number
|
||||||
forecast_link: string
|
lon?: number
|
||||||
danger: string
|
expires: number
|
||||||
danger_level: number
|
fetched_at: number
|
||||||
danger_name: string
|
properties: {
|
||||||
travel_advice: string
|
corridor: string
|
||||||
state: string
|
currentSpeed: number
|
||||||
lat: number | null
|
freeFlowSpeed: number
|
||||||
lon: number | null
|
speedRatio: number
|
||||||
expires: number
|
currentTravelTime: number
|
||||||
fetched_at: number
|
freeFlowTravelTime: number
|
||||||
}
|
confidence: number
|
||||||
|
roadClosure: boolean
|
||||||
export interface StreamGaugeEvent {
|
}
|
||||||
source: string
|
}
|
||||||
event_id: string
|
|
||||||
event_type: string
|
export interface RoadEvent {
|
||||||
headline: string
|
source: string
|
||||||
severity: string
|
event_id: string
|
||||||
lat?: number
|
event_type: string
|
||||||
lon?: number
|
headline: string
|
||||||
expires: number
|
description?: string
|
||||||
fetched_at: number
|
severity: string
|
||||||
properties: {
|
lat?: number
|
||||||
site_id: string
|
lon?: number
|
||||||
site_name: string
|
expires: number
|
||||||
parameter: string
|
fetched_at: number
|
||||||
value: number
|
properties: {
|
||||||
unit: string
|
roadway: string
|
||||||
timestamp: string
|
is_closure: boolean
|
||||||
}
|
last_updated?: string
|
||||||
}
|
}
|
||||||
|
}
|
||||||
export interface TrafficEvent {
|
|
||||||
source: string
|
export interface HotspotEvent {
|
||||||
event_id: string
|
source: string
|
||||||
event_type: string
|
event_id: string
|
||||||
headline: string
|
event_type: string
|
||||||
severity: string
|
headline: string
|
||||||
lat?: number
|
severity: string
|
||||||
lon?: number
|
lat?: number
|
||||||
expires: number
|
lon?: number
|
||||||
fetched_at: number
|
expires: number
|
||||||
properties: {
|
fetched_at: number
|
||||||
corridor: string
|
properties: {
|
||||||
currentSpeed: number
|
new_ignition: boolean
|
||||||
freeFlowSpeed: number
|
confidence: string
|
||||||
speedRatio: number
|
frp?: number
|
||||||
currentTravelTime: number
|
brightness?: number
|
||||||
freeFlowTravelTime: number
|
acq_date: string
|
||||||
confidence: number
|
acq_time: string
|
||||||
roadClosure: boolean
|
near_fire?: string
|
||||||
}
|
distance_to_fire_km?: number
|
||||||
}
|
distance_km?: number
|
||||||
|
nearest_anchor?: string
|
||||||
export interface RoadEvent {
|
}
|
||||||
source: string
|
}
|
||||||
event_id: string
|
|
||||||
event_type: string
|
export interface HotspotsResponse {
|
||||||
headline: string
|
enabled: boolean
|
||||||
description?: string
|
hotspots: HotspotEvent[]
|
||||||
severity: string
|
new_ignitions: number
|
||||||
lat?: number
|
}
|
||||||
lon?: number
|
|
||||||
expires: number
|
export interface AvalancheResponse {
|
||||||
fetched_at: number
|
off_season: boolean
|
||||||
properties: {
|
advisories: AvalancheEvent[]
|
||||||
roadway: string
|
}
|
||||||
is_closure: boolean
|
|
||||||
last_updated?: string
|
export async function fetchFires(): Promise<FireEvent[]> {
|
||||||
}
|
return fetchJson<FireEvent[]>('/api/env/fires')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HotspotEvent {
|
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||||
source: string
|
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
||||||
event_id: string
|
}
|
||||||
event_type: string
|
|
||||||
headline: string
|
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
|
||||||
severity: string
|
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
|
||||||
lat?: number
|
}
|
||||||
lon?: number
|
|
||||||
expires: number
|
export async function fetchTraffic(): Promise<TrafficEvent[]> {
|
||||||
fetched_at: number
|
return fetchJson<TrafficEvent[]>('/api/env/traffic')
|
||||||
properties: {
|
}
|
||||||
new_ignition: boolean
|
|
||||||
confidence: string
|
export async function fetchRoads(): Promise<RoadEvent[]> {
|
||||||
frp?: number
|
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||||
brightness?: number
|
}
|
||||||
acq_date: string
|
|
||||||
acq_time: string
|
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
||||||
near_fire?: string
|
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||||
distance_to_fire_km?: number
|
}
|
||||||
distance_km?: number
|
|
||||||
nearest_anchor?: string
|
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||||
}
|
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,19 +1,15 @@
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
fetchHealth,
|
fetchHealth,
|
||||||
fetchSources,
|
fetchSources,
|
||||||
fetchAlerts,
|
fetchAlerts,
|
||||||
fetchEnvStatus,
|
fetchEnvStatus,
|
||||||
fetchEnvActive,
|
fetchRFPropagation,
|
||||||
fetchSWPC,
|
|
||||||
fetchDucting,
|
|
||||||
type MeshHealth,
|
type MeshHealth,
|
||||||
type SourceHealth,
|
type SourceHealth,
|
||||||
type Alert,
|
type Alert,
|
||||||
type EnvStatus,
|
type EnvStatus,
|
||||||
type EnvEvent,
|
type RFPropagation,
|
||||||
type SWPCStatus,
|
|
||||||
type DuctingStatus,
|
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,63 +22,13 @@ 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'
|
||||||
|
|
@ -96,17 +42,46 @@ 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">
|
||||||
<circle cx="50" cy="50" r="45" fill="none" stroke="#1e2a3a" strokeWidth="8" />
|
{/* Background circle */}
|
||||||
<circle
|
<circle
|
||||||
cx="50" cy="50" r="45" fill="none" stroke={color} strokeWidth="8"
|
cx="50"
|
||||||
strokeLinecap="round" strokeDasharray={circumference}
|
cy="50"
|
||||||
strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
|
r="45"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1e2a3a"
|
||||||
|
strokeWidth="8"
|
||||||
|
/>
|
||||||
|
{/* Progress arc */}
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="45"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={circumference - progress}
|
||||||
|
transform="rotate(-90 50 50)"
|
||||||
className="transition-all duration-500"
|
className="transition-all duration-500"
|
||||||
/>
|
/>
|
||||||
<text x="50" y="46" textAnchor="middle" className="fill-slate-100 font-mono text-2xl font-bold" style={{ fontSize: '24px' }}>
|
{/* Score text */}
|
||||||
|
<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 x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
|
<text
|
||||||
|
x="50"
|
||||||
|
y="62"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-slate-400 text-xs"
|
||||||
|
style={{ fontSize: '10px' }}
|
||||||
|
>
|
||||||
{tier}
|
{tier}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -114,7 +89,13 @@ function HealthGauge({ health }: { health: MeshHealth }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PillarBar({ label, value }: { label: string; value: number }) {
|
function PillarBar({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
}) {
|
||||||
const getColor = (v: number) => {
|
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'
|
||||||
|
|
@ -125,9 +106,14 @@ function PillarBar({ label, value }: { label: string; value: number }) {
|
||||||
<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 className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
|
<div
|
||||||
|
className={`h-full ${getColor(value)} transition-all duration-300`}
|
||||||
|
style={{ width: `${value}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 text-right text-xs font-mono text-slate-300">
|
||||||
|
{value.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 text-right text-xs font-mono text-slate-300">{value.toFixed(1)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -137,11 +123,26 @@ function AlertItem({ alert }: { alert: Alert }) {
|
||||||
switch (severity.toLowerCase()) {
|
switch (severity.toLowerCase()) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
case 'emergency':
|
case 'emergency':
|
||||||
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
|
return {
|
||||||
|
bg: 'bg-red-500/10',
|
||||||
|
border: 'border-red-500',
|
||||||
|
icon: AlertCircle,
|
||||||
|
iconColor: 'text-red-500',
|
||||||
|
}
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
|
return {
|
||||||
|
bg: 'bg-amber-500/10',
|
||||||
|
border: 'border-amber-500',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconColor: 'text-amber-500',
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
|
return {
|
||||||
|
bg: 'bg-green-500/10',
|
||||||
|
border: 'border-green-500',
|
||||||
|
icon: Info,
|
||||||
|
iconColor: 'text-green-500',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,11 +150,15 @@ function AlertItem({ alert }: { alert: Alert }) {
|
||||||
const Icon = styles.icon
|
const Icon = styles.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
|
<div
|
||||||
|
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
|
||||||
|
>
|
||||||
<Icon size={16} className={styles.iconColor} />
|
<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">{alert.timestamp || 'Just now'}</div>
|
<div className="text-xs text-slate-500 mt-1">
|
||||||
|
{alert.timestamp || 'Just now'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -171,13 +176,25 @@ 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">{source.node_count} nodes · {source.type}</div>
|
<div className="text-xs text-slate-500">
|
||||||
|
{source.node_count} nodes * {source.type}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subvalue,
|
||||||
|
}: {
|
||||||
|
icon: typeof Radio
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
subvalue?: string
|
||||||
|
}) {
|
||||||
return (
|
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">
|
||||||
|
|
@ -185,362 +202,100 @@ function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio;
|
||||||
<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 && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
|
{subvalue && (
|
||||||
|
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scale badge component for R/S/G
|
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
|
||||||
function ScaleBadge({ label, value }: { label: string; value: number }) {
|
if (!propagation) {
|
||||||
const getColor = () => {
|
|
||||||
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
|
|
||||||
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
|
|
||||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
|
|
||||||
{label}{value}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Large value display for SFI/Kp
|
|
||||||
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
|
|
||||||
const color = value !== undefined ? getColor(value) : 'text-slate-400'
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
|
||||||
<div className={`font-mono text-3xl font-bold ${color}`}>
|
|
||||||
{value?.toFixed(0) ?? '—'}
|
|
||||||
</div>
|
|
||||||
{unit && <div className="text-xs text-slate-500">{unit}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kp trend sparkline chart
|
|
||||||
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
if (!history || history.length === 0) return []
|
|
||||||
// Take last 16 entries (48 hours of 3-hourly data)
|
|
||||||
return history.slice(-16).map((entry, i) => ({
|
|
||||||
idx: i,
|
|
||||||
value: entry.value,
|
|
||||||
time: entry.time,
|
|
||||||
}))
|
|
||||||
}, [history])
|
|
||||||
|
|
||||||
if (chartData.length === 0) return null
|
|
||||||
|
|
||||||
const maxKp = Math.max(...chartData.map(d => d.value), 5)
|
|
||||||
const currentKp = chartData[chartData.length - 1]?.value ?? 0
|
|
||||||
|
|
||||||
// Gradient color based on max Kp
|
|
||||||
const getGradientId = () => {
|
|
||||||
if (maxKp > 5) return 'kpGradientRed'
|
|
||||||
if (maxKp > 3) return 'kpGradientAmber'
|
|
||||||
return 'kpGradientGreen'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-20 w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
|
|
||||||
<XAxis dataKey="idx" hide />
|
|
||||||
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
|
|
||||||
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
|
|
||||||
fill={`url(#${getGradientId()})`}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div className="flex justify-between text-xs text-slate-600 px-1">
|
|
||||||
<span>48h ago</span>
|
|
||||||
<span>now</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refractivity profile chart
|
|
||||||
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
if (!profile || profile.length === 0) return []
|
|
||||||
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
|
|
||||||
height: p.height_m,
|
|
||||||
M: p.M,
|
|
||||||
}))
|
|
||||||
}, [profile])
|
|
||||||
|
|
||||||
if (chartData.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-24 w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="M"
|
|
||||||
type="number"
|
|
||||||
domain={['dataMin - 20', 'dataMax + 20']}
|
|
||||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: '#334155' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
dataKey="height"
|
|
||||||
type="number"
|
|
||||||
domain={[0, 'dataMax']}
|
|
||||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: '#334155' }}
|
|
||||||
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="M"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3, fill: '#3b82f6' }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RF Propagation Card
|
|
||||||
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
|
|
||||||
const getSfiColor = (v: number) => {
|
|
||||||
if (v >= 120) return 'text-green-400'
|
|
||||||
if (v >= 80) return 'text-amber-400'
|
|
||||||
return 'text-red-400'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getKpColor = (v: number) => {
|
|
||||||
if (v <= 3) return 'text-green-400'
|
|
||||||
if (v <= 5) return 'text-amber-400'
|
|
||||||
return 'text-red-400'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDuctingBadge = (condition?: string) => {
|
|
||||||
if (!condition) return null
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
|
|
||||||
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
|
|
||||||
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
|
||||||
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
|
||||||
}
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
normal: 'Normal',
|
|
||||||
super_refraction: 'Super Refraction',
|
|
||||||
surface_duct: 'Surface Duct',
|
|
||||||
elevated_duct: 'Elevated Duct',
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
{labels[condition] || condition}
|
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||||
</span>
|
RF Propagation
|
||||||
|
</h2>
|
||||||
|
<div className="text-slate-500">
|
||||||
|
<p>Loading propagation data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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-4 flex flex-col h-full">
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
<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>
|
||||||
|
|
||||||
{/* Top row: SFI and Kp big values */}
|
{/* Solar/Geomagnetic Indices */}
|
||||||
<div className="flex justify-around mb-4">
|
<div className="mb-4">
|
||||||
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
|
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
|
||||||
<div className="w-px bg-border" />
|
{hasHF ? (
|
||||||
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
|
<div className="space-y-1">
|
||||||
</div>
|
<div className="text-sm font-mono text-slate-200">
|
||||||
|
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
|
||||||
{/* R/S/G Scale badges */}
|
</div>
|
||||||
<div className="flex justify-center gap-2 mb-4">
|
<div className="text-xs text-slate-400">
|
||||||
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
|
R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
|
||||||
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
|
</div>
|
||||||
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
|
{hf.r_scale !== undefined && hf.r_scale > 0 && (
|
||||||
</div>
|
<div className="text-xs text-amber-500">
|
||||||
|
R{hf.r_scale} Radio Blackout
|
||||||
{/* Kp Trend Chart */}
|
</div>
|
||||||
{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>
|
) : (
|
||||||
)}
|
<div className="text-sm text-slate-500">No data</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>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live Event Feed Card
|
{/* Tropospheric Ducting */}
|
||||||
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
<div>
|
||||||
const sortedEvents = useMemo(() => {
|
<div className="text-xs text-slate-500 mb-1">Tropospheric</div>
|
||||||
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
{hasDucting ? (
|
||||||
}, [events])
|
<div className="space-y-1">
|
||||||
|
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
|
||||||
// Calculate feed health summary
|
{ducting.condition === 'normal'
|
||||||
const feedSummary = useMemo(() => {
|
? 'Normal'
|
||||||
if (!envStatus?.feeds) return null
|
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
const total = envStatus.feeds.length
|
</div>
|
||||||
const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length
|
<div className="text-xs text-slate-400 font-mono">
|
||||||
const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source)
|
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
|
||||||
const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0))
|
</div>
|
||||||
const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null
|
{ducting.duct_thickness_m && (
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
return { total, active, errors, secAgo }
|
Duct: ~{ducting.duct_thickness_m}m thick
|
||||||
}, [envStatus])
|
</div>
|
||||||
|
)}
|
||||||
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>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="text-sm text-slate-500">No ducting data</div>
|
||||||
|
)}
|
||||||
{/* Feed health summary */}
|
</div>
|
||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -550,13 +305,11 @@ 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 [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
const [rfProp, setRFProp] = useState<RFPropagation | null>(null)
|
||||||
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, lastMessage } = useWebSocket()
|
const { lastHealth } = useWebSocket()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -564,25 +317,21 @@ export default function Dashboard() {
|
||||||
fetchSources(),
|
fetchSources(),
|
||||||
fetchAlerts(),
|
fetchAlerts(),
|
||||||
fetchEnvStatus(),
|
fetchEnvStatus(),
|
||||||
fetchEnvActive().catch(() => []),
|
fetchRFPropagation().catch(() => null),
|
||||||
fetchSWPC().catch(() => null),
|
|
||||||
fetchDucting().catch(() => null),
|
|
||||||
])
|
])
|
||||||
.then(([h, src, a, e, events, sw, duct]) => {
|
.then(([h, src, a, e, rf]) => {
|
||||||
setHealth(h)
|
setHealth(h)
|
||||||
setSources(src)
|
setSources(src)
|
||||||
setAlerts(a)
|
setAlerts(a)
|
||||||
setEnvStatus(e)
|
setEnvStatus(e)
|
||||||
setEnvEvents(events)
|
setRFProp(rf)
|
||||||
setSwpc(sw as ExtendedSWPCStatus)
|
setLoading(false)
|
||||||
setDucting(duct as ExtendedDuctingStatus)
|
document.title = 'Dashboard — MeshAI'
|
||||||
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'
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -593,18 +342,6 @@ 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">
|
||||||
|
|
@ -622,76 +359,114 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Top row: Health + Alerts + Stats */}
|
{/* Mesh Health */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
{/* Mesh Health */}
|
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
{health && (
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
|
<>
|
||||||
{health && (
|
<HealthGauge health={health} />
|
||||||
<>
|
<div className="mt-6 space-y-3">
|
||||||
<HealthGauge health={health} />
|
<PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
|
||||||
<div className="mt-6 space-y-3">
|
<PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
|
||||||
<PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
|
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
|
||||||
<PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
|
<PillarBar label="Power" value={health.pillars?.power ?? 0} />
|
||||||
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
|
</div>
|
||||||
<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">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Active Alerts</h2>
|
|
||||||
{alerts.length > 0 ? (
|
|
||||||
<div className="space-y-3 max-h-48 overflow-y-auto">
|
|
||||||
{alerts.map((alert, i) => (
|
|
||||||
<AlertItem key={i} alert={alert} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 text-slate-500 py-4">
|
|
||||||
<CheckCircle size={16} className="text-green-500" />
|
|
||||||
<span>No active alerts</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
|
|
||||||
<StatCard icon={Cpu} label="Infrastructure" value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} subvalue={health?.infra_online === health?.infra_total ? 'All online' : 'Some offline'} />
|
|
||||||
<StatCard icon={Activity} label="Utilization" value={`${health?.util_percent?.toFixed(1) || 0}%`} subvalue={`${health?.flagged_nodes || 0} flagged`} />
|
|
||||||
<StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle row: Sources + RF Propagation + Live Feed */}
|
{/* Alerts + Stats */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Mesh Sources */}
|
{/* 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">Mesh Sources ({sources.length})</h2>
|
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||||
{sources.length > 0 ? (
|
Active Alerts
|
||||||
<div className="space-y-2">
|
</h2>
|
||||||
{sources.map((source, i) => (
|
{alerts.length > 0 ? (
|
||||||
<SourceCard key={i} source={source} />
|
<div className="space-y-3">
|
||||||
|
{alerts.map((alert, i) => (
|
||||||
|
<AlertItem key={i} alert={alert} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-slate-500 py-4">No sources configured</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>
|
||||||
|
|
||||||
{/* RF Propagation */}
|
{/* Quick Stats */}
|
||||||
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
{/* Live Event Feed */}
|
icon={Radio}
|
||||||
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
label="Nodes Online"
|
||||||
|
value={health?.total_nodes || 0}
|
||||||
|
subvalue={`${health?.unlocated_count || 0} unlocated`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Cpu}
|
||||||
|
label="Infrastructure"
|
||||||
|
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`}
|
||||||
|
subvalue={
|
||||||
|
health?.infra_online === health?.infra_total
|
||||||
|
? 'All online'
|
||||||
|
: 'Some offline'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Utilization"
|
||||||
|
value={`${health?.util_percent?.toFixed(1) || 0}%`}
|
||||||
|
subvalue={`${health?.flagged_nodes || 0} flagged`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={MapPin}
|
||||||
|
label="Regions"
|
||||||
|
value={health?.total_regions || 0}
|
||||||
|
subvalue={`${health?.battery_warnings || 0} battery warnings`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mesh Sources */}
|
||||||
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||||
|
Mesh Sources ({sources.length})
|
||||||
|
</h2>
|
||||||
|
{sources.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sources.map((source, i) => (
|
||||||
|
<SourceCard key={i} source={source} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-500 py-4">No sources configured</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environmental Feeds */}
|
||||||
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
|
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||||
|
Environmental Feeds
|
||||||
|
</h2>
|
||||||
|
{envStatus?.enabled ? (
|
||||||
|
<div className="text-slate-400">
|
||||||
|
{envStatus.feeds.length} feeds active
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-500">
|
||||||
|
<p>Environmental feeds not enabled.</p>
|
||||||
|
<p className="text-xs mt-2">
|
||||||
|
Enable in config.yaml
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RF Propagation */}
|
||||||
|
<RFPropagationCard propagation={rfProp} />
|
||||||
</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, Moon, AlertCircle
|
Calendar, AlertTriangle, Copy
|
||||||
} 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,15 +11,20 @@ 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_frequency: 'daily' | 'twice_daily' | 'weekly'
|
// Schedule trigger
|
||||||
|
schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom'
|
||||||
schedule_time: string
|
schedule_time: string
|
||||||
schedule_time_2: string
|
schedule_time_2: string // For twice_daily
|
||||||
schedule_days: string[]
|
schedule_days: string[] // For weekly
|
||||||
|
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[]
|
||||||
|
|
@ -32,13 +37,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[]
|
||||||
|
|
@ -49,43 +54,8 @@ 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)
|
||||||
|
|
@ -217,6 +187,34 @@ 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
|
||||||
|
|
@ -309,63 +307,10 @@ 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,
|
||||||
|
|
@ -373,7 +318,6 @@ 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
|
||||||
|
|
@ -382,26 +326,35 @@ 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: '', label: '(None)', description: 'Rule matches but does not deliver' },
|
{ value: 'mesh_broadcast', label: 'Mesh Broadcast' },
|
||||||
{ value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' },
|
{ value: 'mesh_dm', label: 'Mesh DM' },
|
||||||
{ value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' },
|
{ value: 'email', label: 'Email' },
|
||||||
{ value: 'email', label: 'Email', description: 'Send via SMTP' },
|
{ value: 'webhook', label: 'Webhook' },
|
||||||
{ value: 'webhook', label: 'Webhook', description: 'POST to any URL' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const frequencyOptions = [
|
const frequencyOptions = [
|
||||||
{ value: 'daily', label: 'Daily' },
|
{ value: 'daily', label: 'Once 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', description: 'Current health score, pillar breakdown, problem nodes' },
|
{ value: 'mesh_health_summary', label: 'Mesh Health Summary' },
|
||||||
{ value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' },
|
{ value: 'rf_propagation_report', label: 'RF Propagation Report' },
|
||||||
{ value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' },
|
{ value: 'alerts_digest', label: 'Active Alerts Digest' },
|
||||||
{ value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' },
|
{ value: 'environmental_conditions', label: 'Environmental Conditions' },
|
||||||
{ value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' },
|
{ value: 'custom', label: 'Custom Message' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
|
@ -430,57 +383,42 @@ 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.filter(c => rule.categories?.includes(c.id)).map(c => c.name).slice(0, 2).join(', ') + (catCount > 2 ? ` +${catCount - 2}` : '')
|
const catText = catCount === 0 ? 'All categories' : `${catCount} categories`
|
||||||
const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
const severity = severityOptions.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
|
||||||
if (!rule.delivery_type) {
|
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
||||||
parts.push('⚠️ No delivery')
|
let target = ''
|
||||||
} else {
|
if (rule.delivery_type === 'mesh_broadcast') {
|
||||||
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
target = `Ch ${rule.broadcast_channel}`
|
||||||
let target = ''
|
} else if (rule.delivery_type === 'mesh_dm') {
|
||||||
if (rule.delivery_type === 'mesh_broadcast') {
|
target = `${rule.node_ids?.length || 0} nodes`
|
||||||
target = `Ch ${rule.broadcast_channel}`
|
} else if (rule.delivery_type === 'email') {
|
||||||
} else if (rule.delivery_type === 'mesh_dm') {
|
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
|
||||||
target = `${rule.node_ids?.length || 0} nodes`
|
} else if (rule.delivery_type === 'webhook') {
|
||||||
} else if (rule.delivery_type === 'email') {
|
try {
|
||||||
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
|
const url = new URL(rule.webhook_url)
|
||||||
} else if (rule.delivery_type === 'webhook') {
|
target = url.hostname
|
||||||
try {
|
} catch {
|
||||||
const url = new URL(rule.webhook_url)
|
target = rule.webhook_url?.slice(0, 30) || 'no 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(' → ')
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +435,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' : 'Disabled'}
|
title={rule.enabled ? 'Enabled - click to disable' : 'Disabled - click to enable'}
|
||||||
/>
|
/>
|
||||||
{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" />
|
||||||
|
|
@ -506,7 +444,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 truncate hidden sm:block ${!rule.delivery_type ? 'text-amber-400' : 'text-slate-500'}`}>
|
<span className="text-xs text-slate-500 truncate hidden sm:block">
|
||||||
{getSummary()}
|
{getSummary()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -516,21 +454,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="Test rule"
|
title="Send test"
|
||||||
>
|
>
|
||||||
<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"
|
title="Duplicate rule"
|
||||||
>
|
>
|
||||||
<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"
|
title="Delete rule"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -549,7 +487,7 @@ function NotificationRuleCard({
|
||||||
helper="A descriptive name for this rule"
|
helper="A descriptive name for this rule"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Trigger type toggle */}
|
{/* Trigger type selector */}
|
||||||
<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">
|
||||||
|
|
@ -580,8 +518,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 reports on a schedule (daily briefings, weekly digests)'
|
? 'Send messages on a schedule (daily reports, weekly digests)'
|
||||||
: 'React to alert conditions (fires, outages, weather warnings)'}
|
: 'React to alert conditions (fires, outages, warnings)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -593,9 +531,12 @@ function NotificationRuleCard({
|
||||||
WHEN (Condition)
|
WHEN (Condition)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SeveritySelector
|
<SelectInput
|
||||||
|
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">
|
||||||
|
|
@ -637,24 +578,19 @@ function NotificationRuleCard({
|
||||||
WHEN (Schedule)
|
WHEN (Schedule)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<SelectInput
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
|
label="Frequency"
|
||||||
<select
|
value={rule.schedule_frequency || 'daily'}
|
||||||
value={rule.schedule_frequency || 'daily'}
|
onChange={(v) => onChange({ ...rule, schedule_frequency: v as any })}
|
||||||
onChange={(e) => onChange({ ...rule, schedule_frequency: e.target.value as any })}
|
options={frequencyOptions}
|
||||||
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
|
||||||
|
|
@ -687,27 +623,30 @@ function NotificationRuleCard({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
{rule.schedule_frequency === 'custom' && (
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
|
<TextInput
|
||||||
<select
|
label="Cron Expression"
|
||||||
value={rule.message_type || 'mesh_health_summary'}
|
value={rule.schedule_cron || ''}
|
||||||
onChange={(e) => onChange({ ...rule, message_type: e.target.value })}
|
onChange={(v) => onChange({ ...rule, schedule_cron: v })}
|
||||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
placeholder="0 7 * * *"
|
||||||
>
|
helper="Standard cron format"
|
||||||
{messageTypeOptions.map(opt => (
|
info="Five-field cron: minute hour day-of-month month day-of-week. Example: '0 7 * * 1' = 7:00 AM every Monday."
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
/>
|
||||||
))}
|
)}
|
||||||
</select>
|
|
||||||
<p className="text-xs text-slate-600">
|
<SelectInput
|
||||||
{messageTypeOptions.find(m => m.value === rule.message_type)?.description}
|
label="Message Type"
|
||||||
</p>
|
value={rule.message_type || 'mesh_health_summary'}
|
||||||
</div>
|
onChange={(v) => onChange({ ...rule, message_type: v })}
|
||||||
|
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="Available tokens: {MESH_SCORE}, {NODE_COUNT}, {NODES_ONLINE}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
|
<InfoButton info="Use template tokens: {MESH_SCORE}, {NODE_COUNT}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={rule.custom_message || ''}
|
value={rule.custom_message || ''}
|
||||||
|
|
@ -728,34 +667,12 @@ function NotificationRuleCard({
|
||||||
SEND VIA
|
SEND VIA
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<SelectInput
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
label="Delivery Method"
|
||||||
Delivery Method
|
value={rule.delivery_type || 'mesh_broadcast'}
|
||||||
<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." />
|
onChange={(v) => onChange({ ...rule, delivery_type: v })}
|
||||||
</label>
|
options={deliveryOptions}
|
||||||
<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' && (
|
||||||
|
|
@ -822,7 +739,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="Gmail users: use an App Password from myaccount.google.com/apppasswords"
|
info="For Gmail, use an App Password from myaccount.google.com/apppasswords"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
|
@ -843,14 +760,28 @@ function NotificationRuleCard({
|
||||||
|
|
||||||
{/* Webhook fields */}
|
{/* Webhook fields */}
|
||||||
{rule.delivery_type === 'webhook' && (
|
{rule.delivery_type === 'webhook' && (
|
||||||
<TextInput
|
<div className="space-y-4">
|
||||||
label="Webhook URL"
|
<TextInput
|
||||||
value={rule.webhook_url || ''}
|
label="Webhook URL"
|
||||||
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
value={rule.webhook_url || ''}
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
||||||
helper="POST alert as JSON"
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint."
|
helper="POST endpoint for alerts"
|
||||||
/>
|
info="Works with Discord, Slack, ntfy.sh, Home Assistant, Pushover, or any HTTP POST endpoint."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<details className="group">
|
||||||
|
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
|
||||||
|
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
|
||||||
|
Custom Headers (optional)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 pl-6 border-l border-[#1e2a3a]">
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
Headers are configured in config.yaml for security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -864,28 +795,15 @@ 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."
|
||||||
/>
|
/>
|
||||||
{quietHoursEnabled && (
|
<div className="flex items-end pb-1">
|
||||||
<div className="flex items-end pb-1">
|
<Toggle
|
||||||
<Toggle
|
label="Override Quiet Hours"
|
||||||
label="Override Quiet Hours"
|
checked={rule.override_quiet ?? false}
|
||||||
checked={rule.override_quiet ?? false}
|
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
||||||
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
helper="Send during quiet hours"
|
||||||
helper="Deliver during quiet hours"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Example message */}
|
|
||||||
{rule.trigger_type !== 'schedule' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Example Message</label>
|
|
||||||
<div className="p-3 bg-[#1e2a3a]/50 rounded-lg border border-[#1e2a3a]">
|
|
||||||
<p className="text-sm text-slate-300 font-mono">{getExampleMessage()}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-600">This is an example of what this rule would send.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -984,9 +902,10 @@ 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: '', // Start with no delivery
|
delivery_type: 'mesh_broadcast',
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
node_ids: [],
|
node_ids: [],
|
||||||
smtp_host: '',
|
smtp_host: '',
|
||||||
|
|
@ -1046,11 +965,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 */}
|
{/* Header with actions */}
|
||||||
<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">
|
||||||
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
|
Configure notification rules for alerts and scheduled reports.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1106,47 +1025,31 @@ 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. Alerts still get recorded to history."
|
info="When disabled, no alerts or scheduled messages will be delivered. The alert engine still runs and records alerts to history."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<>
|
<>
|
||||||
{/* Quiet Hours Section */}
|
{/* Quiet Hours Section - at top */}
|
||||||
<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]">
|
||||||
<div className="flex items-center gap-2">
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
<Moon size={14} className="text-slate-400" />
|
Quiet Hours
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
|
<InfoButton info="Non-emergency alerts are held during these hours. Rules with 'Override Quiet Hours' enabled still deliver. Emergency and critical alerts always get through." />
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<TimeInput
|
||||||
|
label="Start Time"
|
||||||
|
value={config.quiet_hours_start || '22:00'}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
|
||||||
|
helper="When quiet hours begin"
|
||||||
|
/>
|
||||||
|
<TimeInput
|
||||||
|
label="End Time"
|
||||||
|
value={config.quiet_hours_end || '06:00'}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
|
||||||
|
helper="When quiet hours end"
|
||||||
|
/>
|
||||||
</div>
|
</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 */}
|
||||||
|
|
@ -1166,7 +1069,6 @@ 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,22 +246,18 @@ 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 = 30
|
battery_warning_threshold: int = 50
|
||||||
battery_critical_threshold: int = 15
|
battery_critical_threshold: int = 25
|
||||||
battery_emergency_threshold: int = 5
|
battery_emergency_threshold: int = 10
|
||||||
# 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 = 40.0
|
high_util_threshold: float = 20.0
|
||||||
high_util_hours: int = 6
|
high_util_hours: int = 6
|
||||||
packet_flood: bool = True
|
packet_flood: bool = True
|
||||||
packet_flood_threshold: int = 10
|
packet_flood_threshold: int = 500
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
infra_single_gateway: bool = True
|
infra_single_gateway: bool = True
|
||||||
|
|
@ -270,7 +266,7 @@ class AlertRulesConfig:
|
||||||
|
|
||||||
# Health Scores
|
# Health Scores
|
||||||
mesh_score_alert: bool = True
|
mesh_score_alert: bool = True
|
||||||
mesh_score_threshold: int = 65
|
mesh_score_threshold: int = 70
|
||||||
region_score_alert: bool = True
|
region_score_alert: bool = True
|
||||||
region_score_threshold: int = 60
|
region_score_threshold: int = 60
|
||||||
|
|
||||||
|
|
@ -452,7 +448,7 @@ class NotificationRuleConfig:
|
||||||
custom_message: str = ""
|
custom_message: str = ""
|
||||||
|
|
||||||
# Delivery type
|
# Delivery type
|
||||||
delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
|
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook
|
||||||
|
|
||||||
# Mesh broadcast fields
|
# Mesh broadcast fields
|
||||||
broadcast_channel: int = 0
|
broadcast_channel: int = 0
|
||||||
|
|
@ -486,7 +482,6 @@ 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,192 +1,163 @@
|
||||||
"""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")
|
||||||
@router.get("/env/streams")
|
async def get_streams_data(request: Request):
|
||||||
async def get_streams_data(request: Request):
|
"""Get USGS stream gauge readings."""
|
||||||
"""Get USGS stream gauge readings."""
|
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="usgs")
|
||||||
return env_store.get_active(source="usgs")
|
|
||||||
|
|
||||||
|
@router.get("/env/traffic")
|
||||||
@router.get("/env/usgs/lookup/{site_id}")
|
async def get_traffic_data(request: Request):
|
||||||
async def lookup_usgs_site(request: Request, site_id: str):
|
"""Get TomTom traffic flow data."""
|
||||||
"""Lookup USGS site metadata and NWS flood stages.
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
if not env_store:
|
||||||
Used by the config UI to auto-populate fields when adding a new gauge.
|
return []
|
||||||
"""
|
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
return env_store.get_active(source="traffic")
|
||||||
|
|
||||||
if not env_store:
|
|
||||||
return {"error": "Environmental feeds not enabled"}
|
@router.get("/env/roads")
|
||||||
|
async def get_roads_data(request: Request):
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
"""Get 511 road conditions."""
|
||||||
usgs_adapter = adapters.get("usgs")
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not usgs_adapter:
|
if not env_store:
|
||||||
# Create a temporary adapter for lookup
|
return []
|
||||||
from meshai.env.usgs import USGSStreamsAdapter
|
|
||||||
from meshai.config import USGSConfig
|
return env_store.get_active(source="511")
|
||||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
|
||||||
|
|
||||||
try:
|
@router.get("/env/hotspots")
|
||||||
result = usgs_adapter.lookup_site(site_id)
|
async def get_hotspots_data(request: Request):
|
||||||
return result
|
"""Get NASA FIRMS satellite fire hotspots."""
|
||||||
except Exception as e:
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
return {"error": str(e), "site_id": site_id}
|
|
||||||
|
if not env_store:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0}
|
||||||
@router.get("/env/traffic")
|
|
||||||
async def get_traffic_data(request: Request):
|
firms_adapter = getattr(env_store, "_firms", None)
|
||||||
"""Get TomTom traffic flow data."""
|
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
if not firms_adapter:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
||||||
if not env_store:
|
|
||||||
return []
|
hotspots = env_store.get_active(source="firms")
|
||||||
|
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||||
return env_store.get_active(source="traffic")
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
@router.get("/env/roads")
|
"hotspots": hotspots,
|
||||||
async def get_roads_data(request: Request):
|
"new_ignitions": len(new_ignitions),
|
||||||
"""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),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
425
meshai/dashboard/static/assets/index-BOJS6jme.js
Normal file
425
meshai/dashboard/static/assets/index-BOJS6jme.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
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-DG_2rmdm.css
Normal file
1
meshai/dashboard/static/assets/index-DG_2rmdm.css
Normal file
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-DARDkZhk.js"></script>
|
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CYHGOAxN.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.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,36 +140,15 @@ 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. Store full history for charting.
|
Last entry is most recent.
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Store full history (last 24-48 hours of readings)
|
# Get last entry (most recent)
|
||||||
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
|
|
||||||
|
|
||||||
self._status["kp_history"] = kp_history
|
|
||||||
|
|
||||||
# Get last entry (most recent) for current value
|
|
||||||
last_entry = data[-1]
|
last_entry = data[-1]
|
||||||
|
|
||||||
|
# Handle both dict format (new API) and list format (legacy)
|
||||||
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))
|
||||||
|
|
@ -205,26 +184,10 @@ 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 with NWS flood stage auto-lookup.
|
"""USGS Water Services stream gauge adapter.
|
||||||
|
|
||||||
# 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, Optional
|
from typing import TYPE_CHECKING
|
||||||
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,17 +21,11 @@ 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 with NWS flood stages."""
|
"""USGS instantaneous values for stream gauge readings."""
|
||||||
|
|
||||||
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 []
|
||||||
|
|
@ -43,9 +37,6 @@ 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}"
|
||||||
|
|
@ -70,192 +61,15 @@ 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(site_ids),
|
"sites": ",".join(self._sites),
|
||||||
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
||||||
"siteStatus": "active",
|
"siteStatus": "active",
|
||||||
}
|
}
|
||||||
|
|
@ -307,10 +121,6 @@ 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")
|
||||||
|
|
@ -349,37 +159,11 @@ class USGSStreamsAdapter:
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get flood stages for this site
|
# Check flood threshold
|
||||||
nwps_stages = self._lookup_nwps_stages(site_id)
|
|
||||||
|
|
||||||
# Determine severity based on flood stages (for gage height)
|
|
||||||
severity = "info"
|
severity = "info"
|
||||||
flood_status = None
|
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
|
||||||
|
if threshold and value > threshold:
|
||||||
if param_type == "height" and nwps_stages:
|
severity = "warning"
|
||||||
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":
|
||||||
|
|
@ -387,9 +171,6 @@ 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}",
|
||||||
|
|
@ -407,8 +188,6 @@ 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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,7 +210,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(site_ids)} sites")
|
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(self._sites)} sites")
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
@ -449,5 +228,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._get_site_ids()),
|
"site_count": len(self._sites),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,218 +1,139 @@
|
||||||
"""Alert category registry.
|
"""Alert category registry.
|
||||||
|
|
||||||
Defines all alertable conditions with human-readable names, descriptions,
|
Defines all alertable conditions with human-readable names and descriptions.
|
||||||
and example messages showing what users will receive.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALERT_CATEGORIES = {
|
ALERT_CATEGORIES = {
|
||||||
# Infrastructure alerts
|
# Infrastructure alerts
|
||||||
"infra_offline": {
|
"infra_offline": {
|
||||||
"name": "Infrastructure Node Offline",
|
"name": "Infrastructure Offline",
|
||||||
"description": "An infrastructure node (router/repeater) stopped responding",
|
"description": "An infrastructure node 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 you marked as critical went offline",
|
"description": "A node marked as critical went offline",
|
||||||
"default_severity": "warning",
|
"default_severity": "critical",
|
||||||
"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 offline infrastructure node came back online",
|
"description": "An 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 30% (3.60V)",
|
"description": "Infrastructure node battery below warning threshold",
|
||||||
"default_severity": "advisory",
|
"default_severity": "warning",
|
||||||
"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 15% (3.50V)",
|
"description": "Infrastructure node battery below critical threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "critical",
|
||||||
"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 below 5% (3.40V) — shutdown imminent",
|
"description": "Infrastructure node battery critically low",
|
||||||
"default_severity": "critical",
|
"default_severity": "emergency",
|
||||||
"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 — possible solar or charging issue",
|
"description": "Battery showing declining trend over 7 days",
|
||||||
"default_severity": "advisory",
|
"default_severity": "warning",
|
||||||
"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 power outage at site",
|
"description": "Node switched from USB to battery (possible outage)",
|
||||||
"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 — panel issue or obstruction",
|
"description": "Solar panel not charging during daylight hours",
|
||||||
"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": "Sustained High Utilization",
|
"name": "High Utilization",
|
||||||
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
"description": "Channel utilization elevated for extended period",
|
||||||
"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": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
"description": "Node sending excessive packets",
|
||||||
"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 — reduced redundancy",
|
"description": "Infrastructure node dropped to single gateway coverage",
|
||||||
"default_severity": "advisory",
|
"default_severity": "warning",
|
||||||
"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 — coverage gap possible",
|
"description": "A feeder gateway stopped responding",
|
||||||
"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 — complete coverage loss",
|
"description": "All infrastructure in a region is offline",
|
||||||
"default_severity": "critical",
|
"default_severity": "emergency",
|
||||||
"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 dropped below threshold — multiple issues likely",
|
"description": "Overall mesh health score below threshold",
|
||||||
"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 — localized issues",
|
"description": "A region's health score below threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Weather
|
# Environmental alerts
|
||||||
"weather_warning": {
|
"weather_warning": {
|
||||||
"name": "Severe Weather",
|
"name": "Severe Weather",
|
||||||
"description": "NWS warning or advisory affecting your mesh area",
|
"description": "NWS warning or advisory for 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 flare degrading HF propagation on sunlit side",
|
"description": "R3+ solar event degrading HF propagation",
|
||||||
"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 trapping VHF/UHF signals — extended range",
|
"description": "Atmospheric conditions extending VHF/UHF 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": "Active wildfire within alert radius of mesh infrastructure",
|
"description": "Wildfire detected within configured distance",
|
||||||
"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 detected NOT near any known fire — potential new wildfire",
|
"description": "Satellite hotspot not matching any known fire",
|
||||||
"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.",
|
|
||||||
},
|
|
||||||
|
|
||||||
# Environmental - Flood
|
|
||||||
"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": {
|
"flood_warning": {
|
||||||
"name": "Stream High Water",
|
"name": "Flood Warning",
|
||||||
"description": "River gauge approaching flood stage — monitoring recommended",
|
"description": "Stream gauge exceeds flood threshold",
|
||||||
"default_severity": "advisory",
|
"default_severity": "warning",
|
||||||
"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 a monitored corridor",
|
"description": "Full road closure on 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.",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +146,6 @@ 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,11 +29,10 @@ 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] = {} # (rule_name, category, event_key) -> last_sent_time
|
self._recent: dict[tuple, float] = {} # (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
|
||||||
|
|
@ -57,16 +56,9 @@ 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",
|
||||||
|
|
@ -95,13 +87,13 @@ class NotificationRouter:
|
||||||
"headers": rule.get("webhook_headers", {}),
|
"headers": rule.get("webhook_headers", {}),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
|
logger.warning("Unknown delivery type: %s", delivery_type)
|
||||||
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:
|
||||||
|
|
@ -114,8 +106,6 @@ 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:
|
||||||
|
|
@ -126,18 +116,15 @@ class NotificationRouter:
|
||||||
if not self._severity_meets(severity, min_severity):
|
if not self._severity_meets(severity, min_severity):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check quiet hours (only if quiet hours are enabled globally)
|
# Check quiet hours (emergencies and criticals override)
|
||||||
if self._quiet_enabled and self._in_quiet_hours():
|
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
|
||||||
# Emergencies and criticals always go through
|
if not rule.get("override_quiet", False):
|
||||||
if severity not in ("emergency", "critical"):
|
continue
|
||||||
# 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:
|
||||||
|
|
@ -146,19 +133,9 @@ 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:
|
||||||
|
|
@ -176,9 +153,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 rule '%s': %s", rule_name, category)
|
logger.info("Alert delivered via %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
|
||||||
|
|
||||||
|
|
@ -193,9 +170,6 @@ 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)
|
||||||
|
|
@ -230,69 +204,12 @@ 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