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
|
|
@ -224,14 +224,12 @@ environmental:
|
|||
#
|
||||
notifications:
|
||||
enabled: false
|
||||
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
||||
quiet_hours_end: "06:00"
|
||||
|
||||
# Notification rules - each rule is self-contained with its own delivery config
|
||||
# Default baseline rules are created on fresh install
|
||||
rules:
|
||||
# Emergency Broadcast - all emergencies go out immediately
|
||||
# All emergencies -> mesh broadcast
|
||||
- name: "Emergency Broadcast"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
|
|
@ -242,39 +240,6 @@ notifications:
|
|||
cooldown_minutes: 5
|
||||
override_quiet: true # Send even during quiet hours
|
||||
|
||||
# Infrastructure Down - critical node and infrastructure offline alerts
|
||||
- name: "Infrastructure Down"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["infra_offline", "critical_node_down"]
|
||||
min_severity: "warning"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 30
|
||||
override_quiet: false
|
||||
|
||||
# Fire Alert - wildfire proximity and new ignition
|
||||
- name: "Fire Alert"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["wildfire_proximity", "new_ignition"]
|
||||
min_severity: "advisory"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 60
|
||||
override_quiet: false
|
||||
|
||||
# Severe Weather - weather warnings
|
||||
- name: "Severe Weather"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["weather_warning"]
|
||||
min_severity: "warning"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 30
|
||||
override_quiet: false
|
||||
|
||||
# Example: Fire alerts -> email
|
||||
# - name: "Fire Alerts Email"
|
||||
# enabled: true
|
||||
|
|
@ -311,13 +276,17 @@ notifications:
|
|||
# delivery_type: mesh_broadcast
|
||||
# broadcast_channel: 0
|
||||
|
||||
# Example: Rule with no delivery (matches and logs, but doesn't send)
|
||||
# - name: "Monitor Only"
|
||||
# Example: Weekly digest -> email
|
||||
# - name: "Weekly Digest"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["battery_warning"]
|
||||
# min_severity: "warning"
|
||||
# delivery_type: "" # Empty = no delivery, just tracks matches
|
||||
# trigger_type: schedule
|
||||
# schedule_frequency: weekly
|
||||
# schedule_days: ["monday"]
|
||||
# schedule_time: "08:00"
|
||||
# message_type: alerts_digest
|
||||
# delivery_type: email
|
||||
# smtp_host: "smtp.gmail.com"
|
||||
# recipients: ["admin@example.com"]
|
||||
|
||||
# === WEB DASHBOARD ===
|
||||
dashboard:
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
|
||||
import type { MeshHealth, Alert } from '@/lib/api'
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string
|
||||
data?: unknown
|
||||
event?: EnvEvent
|
||||
data: unknown
|
||||
}
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
connected: boolean
|
||||
lastHealth: MeshHealth | null
|
||||
lastAlert: Alert | null
|
||||
lastMessage: WebSocketMessage | null
|
||||
}
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
||||
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectDelayRef = useRef(1000)
|
||||
|
|
@ -44,9 +41,6 @@ export function useWebSocket(): UseWebSocketReturn {
|
|||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data)
|
||||
|
||||
// Store all messages for generic handling
|
||||
setLastMessage(message)
|
||||
|
||||
switch (message.type) {
|
||||
case 'health_update':
|
||||
setLastHealth(message.data as MeshHealth)
|
||||
|
|
@ -54,7 +48,6 @@ export function useWebSocket(): UseWebSocketReturn {
|
|||
case 'alert_fired':
|
||||
setLastAlert(message.data as Alert)
|
||||
break
|
||||
// env_update messages are handled via lastMessage
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e)
|
||||
|
|
@ -105,5 +98,5 @@ export function useWebSocket(): UseWebSocketReturn {
|
|||
}
|
||||
}, [connect])
|
||||
|
||||
return { connected, lastHealth, lastAlert, lastMessage }
|
||||
return { connected, lastHealth, lastAlert }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,37 +147,6 @@ export interface EnvEvent {
|
|||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Kp history entry for charting
|
||||
export interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// SFI history entry for charting
|
||||
export interface SfiHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// Refractivity profile entry
|
||||
export interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
// Gradient entry
|
||||
export interface GradientEntry {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
|
|
@ -187,8 +156,6 @@ export interface SWPCStatus {
|
|||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: SfiHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
|
|
@ -198,10 +165,6 @@ export interface DuctingStatus {
|
|||
duct_thickness_m?: number | null
|
||||
duct_base_m?: number | null
|
||||
last_update?: string
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: GradientEntry[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
export interface RFPropagation {
|
||||
|
|
@ -212,13 +175,11 @@ export interface RFPropagation {
|
|||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
}
|
||||
uhf_ducting: {
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
profile?: ProfileEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import NodePicker from '@/components/NodePicker'
|
||||
import ChannelPicker from '@/components/ChannelPicker'
|
||||
import {
|
||||
|
|
@ -309,28 +309,12 @@ const US_STATES = [
|
|||
{ value: 'US-WI', label: 'Wisconsin' }, { value: 'US-WY', label: 'Wyoming' },
|
||||
]
|
||||
|
||||
// InfoButton component with click-outside dismiss and X close button
|
||||
// InfoButton component
|
||||
function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; link?: string; linkText?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={popoverRef}>
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
|
|
@ -340,16 +324,10 @@ function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; lin
|
|||
?
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 inline-flex items-center justify-center transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<div className="pr-4">{info}</div>
|
||||
{info}
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
|
|
@ -362,6 +340,7 @@ function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; lin
|
|||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -2160,8 +2139,7 @@ function EnvironmentalSection({ data, onChange }: { data: EnvironmentalConfig; o
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardSection({ data, onChange }: { data: DashboardConfig; onChange: (d: DashboardConfig) => void }) {
|
||||
function DashboardSection({ data, onChange }: { data: DashboardConfig; onChange: (d: DashboardConfig) => void }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionDescription text={SECTION_DESCRIPTIONS.dashboard} />
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
fetchHealth,
|
||||
fetchSources,
|
||||
fetchAlerts,
|
||||
fetchEnvStatus,
|
||||
fetchEnvActive,
|
||||
fetchSWPC,
|
||||
fetchDucting,
|
||||
fetchRFPropagation,
|
||||
type MeshHealth,
|
||||
type SourceHealth,
|
||||
type Alert,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
type RFPropagation,
|
||||
} from '@/lib/api'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import {
|
||||
|
|
@ -26,63 +22,13 @@ import {
|
|||
Activity,
|
||||
MapPin,
|
||||
Zap,
|
||||
Cloud,
|
||||
Flame,
|
||||
Mountain,
|
||||
Droplets,
|
||||
Car,
|
||||
Construction,
|
||||
Satellite,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts'
|
||||
|
||||
// Extended types for history data
|
||||
interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
interface ExtendedSWPCStatus extends SWPCStatus {
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: { time: string; value: number }[]
|
||||
}
|
||||
|
||||
interface ExtendedDuctingStatus extends DuctingStatus {
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
function HealthGauge({ health }: { health: MeshHealth }) {
|
||||
const score = health.score
|
||||
const tier = health.tier
|
||||
|
||||
// Color based on score
|
||||
const getColor = (s: number) => {
|
||||
if (s >= 80) return '#22c55e'
|
||||
if (s >= 60) return '#f59e0b'
|
||||
|
|
@ -96,17 +42,46 @@ function HealthGauge({ health }: { health: MeshHealth }) {
|
|||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<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
|
||||
cx="50" cy="50" r="45" fill="none" stroke={color} strokeWidth="8"
|
||||
strokeLinecap="round" strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="#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"
|
||||
/>
|
||||
<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)}
|
||||
</text>
|
||||
<text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
|
||||
<text
|
||||
x="50"
|
||||
y="62"
|
||||
textAnchor="middle"
|
||||
className="fill-slate-400 text-xs"
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
{tier}
|
||||
</text>
|
||||
</svg>
|
||||
|
|
@ -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) => {
|
||||
if (v >= 80) return 'bg-green-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="w-24 text-xs text-slate-400 truncate">{label}</div>
|
||||
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
|
||||
<div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
|
||||
<div
|
||||
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 className="w-12 text-right text-xs font-mono text-slate-300">{value.toFixed(1)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -137,11 +123,26 @@ function AlertItem({ alert }: { alert: Alert }) {
|
|||
switch (severity.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
case 'warning':
|
||||
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
default:
|
||||
return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
|
||||
return {
|
||||
bg: 'bg-green-500/10',
|
||||
border: 'border-green-500',
|
||||
icon: Info,
|
||||
iconColor: 'text-green-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,11 +150,15 @@ function AlertItem({ alert }: { alert: Alert }) {
|
|||
const Icon = styles.icon
|
||||
|
||||
return (
|
||||
<div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
|
||||
<div
|
||||
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
|
||||
>
|
||||
<Icon size={16} className={styles.iconColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{alert.timestamp || 'Just now'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -171,13 +176,25 @@ function SourceCard({ source }: { source: SourceHealth }) {
|
|||
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-slate-200 truncate">{source.name}</div>
|
||||
<div className="text-xs text-slate-500">{source.node_count} nodes · {source.type}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{source.node_count} nodes * {source.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subvalue,
|
||||
}: {
|
||||
icon: typeof Radio
|
||||
label: string
|
||||
value: string | number
|
||||
subvalue?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-slate-400 mb-2">
|
||||
|
|
@ -185,363 +202,101 @@ function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio;
|
|||
<span className="text-xs">{label}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xl text-slate-100">{value}</div>
|
||||
{subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
|
||||
{subvalue && (
|
||||
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Scale badge component for R/S/G
|
||||
function ScaleBadge({ label, value }: { label: string; value: number }) {
|
||||
const getColor = () => {
|
||||
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
}
|
||||
|
||||
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
|
||||
if (!propagation) {
|
||||
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 className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||
RF Propagation
|
||||
</h2>
|
||||
<div className="text-slate-500">
|
||||
<p>Loading propagation data...</p>
|
||||
</div>
|
||||
</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])
|
||||
const hf = propagation.hf
|
||||
const ducting = propagation.uhf_ducting
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
const getDuctingColor = (condition?: string) => {
|
||||
if (!condition) return 'text-slate-400'
|
||||
switch (condition) {
|
||||
case 'normal':
|
||||
return 'text-green-500'
|
||||
case 'super_refraction':
|
||||
return 'text-amber-500'
|
||||
case 'surface_duct':
|
||||
case 'elevated_duct':
|
||||
return 'text-blue-400'
|
||||
default:
|
||||
return 'text-slate-400'
|
||||
}
|
||||
}
|
||||
|
||||
const hasHF = hf && (hf.sfi || hf.kp_current !== undefined)
|
||||
const hasDucting = ducting && ducting.condition
|
||||
|
||||
return (
|
||||
<div className="h-24 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
||||
<XAxis
|
||||
dataKey="M"
|
||||
type="number"
|
||||
domain={['dataMin - 20', 'dataMax + 20']}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="height"
|
||||
type="number"
|
||||
domain={[0, 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="M"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#3b82f6' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RF Propagation Card
|
||||
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
|
||||
const getSfiColor = (v: number) => {
|
||||
if (v >= 120) return 'text-green-400'
|
||||
if (v >= 80) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getKpColor = (v: number) => {
|
||||
if (v <= 3) return 'text-green-400'
|
||||
if (v <= 5) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getDuctingBadge = (condition?: string) => {
|
||||
if (!condition) return null
|
||||
const styles: Record<string, string> = {
|
||||
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
|
||||
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
normal: 'Normal',
|
||||
super_refraction: 'Super Refraction',
|
||||
surface_duct: 'Surface Duct',
|
||||
elevated_duct: 'Elevated Duct',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
|
||||
{labels[condition] || condition}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<Zap size={14} />
|
||||
RF Propagation
|
||||
</h2>
|
||||
|
||||
{/* Top row: SFI and Kp big values */}
|
||||
<div className="flex justify-around mb-4">
|
||||
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
|
||||
<div className="w-px bg-border" />
|
||||
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
|
||||
</div>
|
||||
|
||||
{/* R/S/G Scale badges */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
|
||||
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
|
||||
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
|
||||
</div>
|
||||
|
||||
{/* Kp Trend Chart */}
|
||||
{swpc?.kp_history && swpc.kp_history.length > 0 && (
|
||||
{/* Solar/Geomagnetic Indices */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
|
||||
<KpTrendChart history={swpc.kp_history} />
|
||||
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
|
||||
{hasHF ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-mono text-slate-200">
|
||||
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
|
||||
</div>
|
||||
{hf.r_scale !== undefined && hf.r_scale > 0 && (
|
||||
<div className="text-xs text-amber-500">
|
||||
R{hf.r_scale} Radio Blackout
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-3" />
|
||||
|
||||
{/* Tropospheric section */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cloud size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-500">Tropospheric</span>
|
||||
{getDuctingBadge(ducting?.condition)}
|
||||
</div>
|
||||
|
||||
{ducting?.min_gradient !== undefined && (
|
||||
<div className="text-xs text-slate-400 font-mono mb-2">
|
||||
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refractivity profile chart */}
|
||||
{ducting?.profile && ducting.profile.length > 0 && (
|
||||
<RefractivityChart profile={ducting.profile} />
|
||||
)}
|
||||
|
||||
{/* SWPC Warnings */}
|
||||
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
|
||||
<div className="mt-auto pt-3 border-t border-border">
|
||||
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{swpc.active_warnings.slice(0, 3).map((w, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
|
||||
{w.replace('Space Weather Message Code: ', '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Source icon mapping
|
||||
const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: string }> = {
|
||||
nws: { icon: Cloud, color: 'text-blue-400', label: 'NWS' },
|
||||
swpc: { icon: Sun, color: 'text-yellow-400', label: 'SWPC' },
|
||||
ducting: { icon: Radio, color: 'text-cyan-400', label: 'Tropo' },
|
||||
nifc: { icon: Flame, color: 'text-orange-400', label: 'NIFC' },
|
||||
firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' },
|
||||
avalanche: { icon: Mountain, color: 'text-slate-300', label: 'Avy' },
|
||||
usgs: { icon: Droplets, color: 'text-blue-300', label: 'USGS' },
|
||||
traffic: { icon: Car, color: 'text-purple-400', label: 'Traffic' },
|
||||
roads: { icon: Construction, color: 'text-amber-400', label: '511' },
|
||||
}
|
||||
|
||||
// Severity badge colors
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
warning: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
critical: 'bg-red-600/20 text-red-300 border-red-600/30',
|
||||
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
||||
}
|
||||
|
||||
function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
||||
const Icon = sourceConfig.icon
|
||||
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (ts: number) => {
|
||||
const date = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
||||
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||
{event.severity || 'info'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
||||
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Live Event Feed Card
|
||||
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
||||
}, [events])
|
||||
|
||||
// Calculate feed health summary
|
||||
const feedSummary = useMemo(() => {
|
||||
if (!envStatus?.feeds) return null
|
||||
const total = envStatus.feeds.length
|
||||
const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length
|
||||
const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source)
|
||||
const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0))
|
||||
const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null
|
||||
|
||||
return { total, active, errors, secAgo }
|
||||
}, [envStatus])
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
||||
<Activity size={14} />
|
||||
Live Event Feed
|
||||
</h2>
|
||||
|
||||
{sortedEvents.length > 0 ? (
|
||||
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||
{sortedEvents.map((event, i) => (
|
||||
<EventFeedItem key={event.event_id || i} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle size={24} className="text-green-500 mx-auto mb-2" />
|
||||
<div className="text-slate-400">No active events</div>
|
||||
<div className="text-xs text-slate-500">All clear</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">No data</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feed health summary */}
|
||||
{feedSummary && (
|
||||
<div className={`text-xs mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||
{feedSummary.active} of {feedSummary.total} feeds active
|
||||
{feedSummary.secAgo !== null && ` · Last update ${feedSummary.secAgo}s ago`}
|
||||
{feedSummary.errors.length > 0 && (
|
||||
<span className="text-amber-400"> · {feedSummary.errors.join(', ')}: error</span>
|
||||
)}
|
||||
{/* Tropospheric Ducting */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-1">Tropospheric</div>
|
||||
{hasDucting ? (
|
||||
<div className="space-y-1">
|
||||
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
|
||||
{ducting.condition === 'normal'
|
||||
? 'Normal'
|
||||
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-mono">
|
||||
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
|
||||
</div>
|
||||
{ducting.duct_thickness_m && (
|
||||
<div className="text-xs text-slate-400">
|
||||
Duct: ~{ducting.duct_thickness_m}m thick
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">No ducting data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -550,13 +305,11 @@ export default function Dashboard() {
|
|||
const [sources, setSources] = useState<SourceHealth[]>([])
|
||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
||||
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
||||
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
|
||||
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
|
||||
const [rfProp, setRFProp] = useState<RFPropagation | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { lastHealth, lastMessage } = useWebSocket()
|
||||
const { lastHealth } = useWebSocket()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
|
|
@ -564,18 +317,14 @@ export default function Dashboard() {
|
|||
fetchSources(),
|
||||
fetchAlerts(),
|
||||
fetchEnvStatus(),
|
||||
fetchEnvActive().catch(() => []),
|
||||
fetchSWPC().catch(() => null),
|
||||
fetchDucting().catch(() => null),
|
||||
fetchRFPropagation().catch(() => null),
|
||||
])
|
||||
.then(([h, src, a, e, events, sw, duct]) => {
|
||||
.then(([h, src, a, e, rf]) => {
|
||||
setHealth(h)
|
||||
setSources(src)
|
||||
setAlerts(a)
|
||||
setEnvStatus(e)
|
||||
setEnvEvents(events)
|
||||
setSwpc(sw as ExtendedSWPCStatus)
|
||||
setDucting(duct as ExtendedDuctingStatus)
|
||||
setRFProp(rf)
|
||||
setLoading(false)
|
||||
document.title = 'Dashboard — MeshAI'
|
||||
})
|
||||
|
|
@ -593,18 +342,6 @@ export default function Dashboard() {
|
|||
}
|
||||
}, [lastHealth])
|
||||
|
||||
// Handle WebSocket env_update messages
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type === 'env_update' && lastMessage.event) {
|
||||
setEnvEvents(prev => {
|
||||
// Add new event, dedupe by event_id
|
||||
const newEvent = lastMessage.event as EnvEvent
|
||||
const filtered = prev.filter(e => e.event_id !== newEvent.event_id)
|
||||
return [newEvent, ...filtered].slice(0, 100) // Keep last 100
|
||||
})
|
||||
}
|
||||
}, [lastMessage])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -622,8 +359,6 @@ export default function Dashboard() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top row: Health + Alerts + Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Mesh Health */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
|
|
@ -645,9 +380,11 @@ export default function Dashboard() {
|
|||
<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>
|
||||
<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">
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert, i) => (
|
||||
<AlertItem key={i} alert={alert} />
|
||||
))}
|
||||
|
|
@ -662,19 +399,42 @@ export default function Dashboard() {
|
|||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
{/* Middle row: Sources + RF Propagation + Live Feed */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Mesh Sources */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Sources ({sources.length})</h2>
|
||||
<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) => (
|
||||
|
|
@ -686,12 +446,27 @@ export default function Dashboard() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* RF Propagation */}
|
||||
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
||||
|
||||
{/* Live Event Feed */}
|
||||
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||
import {
|
||||
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
|
||||
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
|
||||
Calendar, AlertTriangle, Copy, Moon, AlertCircle
|
||||
Calendar, AlertTriangle, Copy
|
||||
} from 'lucide-react'
|
||||
import ChannelPicker from '@/components/ChannelPicker'
|
||||
import NodePicker from '@/components/NodePicker'
|
||||
|
|
@ -11,15 +11,20 @@ import NodePicker from '@/components/NodePicker'
|
|||
interface NotificationRuleConfig {
|
||||
name: string
|
||||
enabled: boolean
|
||||
// Trigger
|
||||
trigger_type: 'condition' | 'schedule'
|
||||
// Condition trigger
|
||||
categories: string[]
|
||||
min_severity: string
|
||||
schedule_frequency: 'daily' | 'twice_daily' | 'weekly'
|
||||
// Schedule trigger
|
||||
schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom'
|
||||
schedule_time: string
|
||||
schedule_time_2: string
|
||||
schedule_days: string[]
|
||||
schedule_time_2: string // For twice_daily
|
||||
schedule_days: string[] // For weekly
|
||||
schedule_cron: string // For custom
|
||||
message_type: string
|
||||
custom_message: string
|
||||
// Delivery
|
||||
delivery_type: string
|
||||
broadcast_channel: number
|
||||
node_ids: string[]
|
||||
|
|
@ -32,13 +37,13 @@ interface NotificationRuleConfig {
|
|||
recipients: string[]
|
||||
webhook_url: string
|
||||
webhook_headers: Record<string, string>
|
||||
// Behavior
|
||||
cooldown_minutes: number
|
||||
override_quiet: boolean
|
||||
}
|
||||
|
||||
interface NotificationsConfig {
|
||||
enabled: boolean
|
||||
quiet_hours_enabled: boolean
|
||||
quiet_hours_start: string
|
||||
quiet_hours_end: string
|
||||
rules: NotificationRuleConfig[]
|
||||
|
|
@ -49,43 +54,8 @@ interface AlertCategory {
|
|||
name: string
|
||||
description: string
|
||||
default_severity: string
|
||||
example_message: string
|
||||
}
|
||||
|
||||
// Severity levels with descriptions
|
||||
const SEVERITY_OPTIONS = [
|
||||
{
|
||||
value: 'info',
|
||||
label: 'Info',
|
||||
description: 'Routine updates (ducting detected, new router appeared)',
|
||||
},
|
||||
{
|
||||
value: 'advisory',
|
||||
label: 'Advisory',
|
||||
description: 'Worth knowing (weather advisory, traffic slow, battery declining)',
|
||||
},
|
||||
{
|
||||
value: 'watch',
|
||||
label: 'Watch',
|
||||
description: 'Pay attention (fire within 50km, weather watch, stream rising)',
|
||||
},
|
||||
{
|
||||
value: 'warning',
|
||||
label: 'Warning',
|
||||
description: 'Act now (fire within 25km, severe weather, critical battery)',
|
||||
},
|
||||
{
|
||||
value: 'critical',
|
||||
label: 'Critical',
|
||||
description: 'Serious issue (critical node down, battery emergency)',
|
||||
},
|
||||
{
|
||||
value: 'emergency',
|
||||
label: 'Emergency',
|
||||
description: 'Life safety (extreme weather, fire at infrastructure, total blackout)',
|
||||
},
|
||||
]
|
||||
|
||||
// InfoButton component
|
||||
function InfoButton({ info }: { info: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -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 = '' }: {
|
||||
label: 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
|
||||
function NotificationRuleCard({
|
||||
rule,
|
||||
categories,
|
||||
quietHoursEnabled,
|
||||
onChange,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
|
|
@ -373,7 +318,6 @@ function NotificationRuleCard({
|
|||
}: {
|
||||
rule: NotificationRuleConfig
|
||||
categories: AlertCategory[]
|
||||
quietHoursEnabled: boolean
|
||||
onChange: (r: NotificationRuleConfig) => void
|
||||
onDelete: () => void
|
||||
onDuplicate: () => void
|
||||
|
|
@ -382,26 +326,35 @@ function NotificationRuleCard({
|
|||
const [expanded, setExpanded] = useState(!rule.name)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const severityOptions = [
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'advisory', label: 'Advisory' },
|
||||
{ value: 'watch', label: 'Watch' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'emergency', label: 'Emergency' },
|
||||
]
|
||||
|
||||
const deliveryOptions = [
|
||||
{ value: '', label: '(None)', description: 'Rule matches but does not deliver' },
|
||||
{ value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' },
|
||||
{ value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' },
|
||||
{ value: 'email', label: 'Email', description: 'Send via SMTP' },
|
||||
{ value: 'webhook', label: 'Webhook', description: 'POST to any URL' },
|
||||
{ value: 'mesh_broadcast', label: 'Mesh Broadcast' },
|
||||
{ value: 'mesh_dm', label: 'Mesh DM' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
]
|
||||
|
||||
const frequencyOptions = [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'daily', label: 'Once Daily' },
|
||||
{ value: 'twice_daily', label: 'Twice Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'custom', label: 'Custom Cron' },
|
||||
]
|
||||
|
||||
const messageTypeOptions = [
|
||||
{ value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' },
|
||||
{ value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' },
|
||||
{ value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' },
|
||||
{ value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' },
|
||||
{ value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' },
|
||||
{ value: 'mesh_health_summary', label: 'Mesh Health Summary' },
|
||||
{ value: 'rf_propagation_report', label: 'RF Propagation Report' },
|
||||
{ value: 'alerts_digest', label: 'Active Alerts Digest' },
|
||||
{ value: 'environmental_conditions', label: 'Environmental Conditions' },
|
||||
{ value: 'custom', label: 'Custom Message' },
|
||||
]
|
||||
|
||||
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
|
|
@ -430,39 +383,25 @@ function NotificationRuleCard({
|
|||
setTesting(false)
|
||||
}
|
||||
|
||||
// Get example message for display
|
||||
const getExampleMessage = (): string => {
|
||||
if (rule.trigger_type === 'schedule') {
|
||||
return '[Scheduled report preview would appear here]'
|
||||
}
|
||||
const ruleCats = rule.categories || []
|
||||
if (ruleCats.length === 0 && categories.length > 0) {
|
||||
return categories[0].example_message || 'Alert notification'
|
||||
}
|
||||
const firstCat = categories.find(c => ruleCats.includes(c.id))
|
||||
return firstCat?.example_message || 'Alert notification'
|
||||
}
|
||||
|
||||
// Generate summary for collapsed view
|
||||
const getSummary = () => {
|
||||
const parts: string[] = []
|
||||
|
||||
if (rule.trigger_type === 'schedule') {
|
||||
// Schedule summary
|
||||
const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency
|
||||
const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type
|
||||
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
|
||||
parts.push(msgType)
|
||||
} else {
|
||||
// Condition summary
|
||||
const catCount = rule.categories?.length || 0
|
||||
const catText = catCount === 0 ? 'All' : categories.filter(c => rule.categories?.includes(c.id)).map(c => c.name).slice(0, 2).join(', ') + (catCount > 2 ? ` +${catCount - 2}` : '')
|
||||
const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
||||
const catText = catCount === 0 ? 'All categories' : `${catCount} categories`
|
||||
const severity = severityOptions.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
||||
parts.push(`${catText} at ${severity}+`)
|
||||
}
|
||||
|
||||
// Delivery summary
|
||||
if (!rule.delivery_type) {
|
||||
parts.push('⚠️ No delivery')
|
||||
} else {
|
||||
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
||||
let target = ''
|
||||
if (rule.delivery_type === 'mesh_broadcast') {
|
||||
|
|
@ -476,11 +415,10 @@ function NotificationRuleCard({
|
|||
const url = new URL(rule.webhook_url)
|
||||
target = url.hostname
|
||||
} catch {
|
||||
target = rule.webhook_url?.slice(0, 20) || 'no URL'
|
||||
target = rule.webhook_url?.slice(0, 30) || 'no URL'
|
||||
}
|
||||
}
|
||||
parts.push(`${delivery}${target ? ` (${target})` : ''}`)
|
||||
}
|
||||
|
||||
return parts.join(' → ')
|
||||
}
|
||||
|
|
@ -497,7 +435,7 @@ function NotificationRuleCard({
|
|||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }}
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`}
|
||||
title={rule.enabled ? 'Enabled' : 'Disabled'}
|
||||
title={rule.enabled ? 'Enabled - click to disable' : 'Disabled - click to enable'}
|
||||
/>
|
||||
{rule.trigger_type === 'schedule' ? (
|
||||
<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>
|
||||
{!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()}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -516,21 +454,21 @@ function NotificationRuleCard({
|
|||
onClick={(e) => { e.stopPropagation(); handleTest() }}
|
||||
disabled={testing || !rule.name}
|
||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
|
||||
title="Test rule"
|
||||
title="Send test"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
|
||||
title="Duplicate"
|
||||
title="Duplicate rule"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||
title="Delete"
|
||||
title="Delete rule"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
|
@ -549,7 +487,7 @@ function NotificationRuleCard({
|
|||
helper="A descriptive name for this rule"
|
||||
/>
|
||||
|
||||
{/* Trigger type toggle */}
|
||||
{/* Trigger type selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -580,8 +518,8 @@ function NotificationRuleCard({
|
|||
</div>
|
||||
<p className="text-xs text-slate-600">
|
||||
{rule.trigger_type === 'schedule'
|
||||
? 'Send reports on a schedule (daily briefings, weekly digests)'
|
||||
: 'React to alert conditions (fires, outages, weather warnings)'}
|
||||
? 'Send messages on a schedule (daily reports, weekly digests)'
|
||||
: 'React to alert conditions (fires, outages, warnings)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -593,9 +531,12 @@ function NotificationRuleCard({
|
|||
WHEN (Condition)
|
||||
</div>
|
||||
|
||||
<SeveritySelector
|
||||
<SelectInput
|
||||
label="Minimum Severity"
|
||||
value={rule.min_severity}
|
||||
onChange={(v) => onChange({ ...rule, min_severity: v })}
|
||||
options={severityOptions}
|
||||
helper="Only alerts at or above this level"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -637,24 +578,19 @@ function NotificationRuleCard({
|
|||
WHEN (Schedule)
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
|
||||
<select
|
||||
<SelectInput
|
||||
label="Frequency"
|
||||
value={rule.schedule_frequency || 'daily'}
|
||||
onChange={(e) => onChange({ ...rule, schedule_frequency: e.target.value as any })}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{frequencyOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
onChange={(v) => onChange({ ...rule, schedule_frequency: v as any })}
|
||||
options={frequencyOptions}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TimeInput
|
||||
label="Time"
|
||||
value={rule.schedule_time || '07:00'}
|
||||
onChange={(v) => onChange({ ...rule, schedule_time: v })}
|
||||
helper="24-hour format"
|
||||
/>
|
||||
{rule.schedule_frequency === 'twice_daily' && (
|
||||
<TimeInput
|
||||
|
|
@ -687,27 +623,30 @@ function NotificationRuleCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
|
||||
<select
|
||||
{rule.schedule_frequency === 'custom' && (
|
||||
<TextInput
|
||||
label="Cron Expression"
|
||||
value={rule.schedule_cron || ''}
|
||||
onChange={(v) => onChange({ ...rule, schedule_cron: v })}
|
||||
placeholder="0 7 * * *"
|
||||
helper="Standard cron format"
|
||||
info="Five-field cron: minute hour day-of-month month day-of-week. Example: '0 7 * * 1' = 7:00 AM every Monday."
|
||||
/>
|
||||
)}
|
||||
|
||||
<SelectInput
|
||||
label="Message Type"
|
||||
value={rule.message_type || 'mesh_health_summary'}
|
||||
onChange={(e) => onChange({ ...rule, message_type: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{messageTypeOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-600">
|
||||
{messageTypeOptions.find(m => m.value === rule.message_type)?.description}
|
||||
</p>
|
||||
</div>
|
||||
onChange={(v) => onChange({ ...rule, message_type: v })}
|
||||
options={messageTypeOptions}
|
||||
info="The type of report or message to send."
|
||||
/>
|
||||
|
||||
{rule.message_type === 'custom' && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Custom Message
|
||||
<InfoButton info="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>
|
||||
<textarea
|
||||
value={rule.custom_message || ''}
|
||||
|
|
@ -728,34 +667,12 @@ function NotificationRuleCard({
|
|||
SEND VIA
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Delivery Method
|
||||
<InfoButton info="Where this notification gets delivered. Select (None) to save the rule without delivery — it will match conditions but won't send until you configure a delivery method." />
|
||||
</label>
|
||||
<select
|
||||
value={rule.delivery_type || ''}
|
||||
onChange={(e) => onChange({ ...rule, delivery_type: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{deliveryOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-600">
|
||||
{deliveryOptions.find(d => d.value === (rule.delivery_type || ''))?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* No delivery warning */}
|
||||
{!rule.delivery_type && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
||||
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-300">
|
||||
Rule will log matches but not deliver until a delivery method is configured.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SelectInput
|
||||
label="Delivery Method"
|
||||
value={rule.delivery_type || 'mesh_broadcast'}
|
||||
onChange={(v) => onChange({ ...rule, delivery_type: v })}
|
||||
options={deliveryOptions}
|
||||
/>
|
||||
|
||||
{/* Mesh Broadcast fields */}
|
||||
{rule.delivery_type === 'mesh_broadcast' && (
|
||||
|
|
@ -822,7 +739,7 @@ function NotificationRuleCard({
|
|||
value={rule.smtp_password || ''}
|
||||
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
||||
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>
|
||||
<Toggle
|
||||
|
|
@ -843,14 +760,28 @@ function NotificationRuleCard({
|
|||
|
||||
{/* Webhook fields */}
|
||||
{rule.delivery_type === 'webhook' && (
|
||||
<div className="space-y-4">
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
value={rule.webhook_url || ''}
|
||||
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
helper="POST alert as JSON"
|
||||
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>
|
||||
|
||||
|
|
@ -864,28 +795,15 @@ function NotificationRuleCard({
|
|||
helper="Min time between repeat sends"
|
||||
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
|
||||
/>
|
||||
{quietHoursEnabled && (
|
||||
<div className="flex items-end pb-1">
|
||||
<Toggle
|
||||
label="Override Quiet Hours"
|
||||
checked={rule.override_quiet ?? false}
|
||||
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
||||
helper="Deliver during quiet hours"
|
||||
helper="Send 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>
|
||||
|
|
@ -984,9 +902,10 @@ export default function Notifications() {
|
|||
schedule_time: '07:00',
|
||||
schedule_time_2: '19:00',
|
||||
schedule_days: ['monday'],
|
||||
schedule_cron: '',
|
||||
message_type: 'mesh_health_summary',
|
||||
custom_message: '',
|
||||
delivery_type: '', // Start with no delivery
|
||||
delivery_type: 'mesh_broadcast',
|
||||
broadcast_channel: 0,
|
||||
node_ids: [],
|
||||
smtp_host: '',
|
||||
|
|
@ -1046,11 +965,11 @@ export default function Notifications() {
|
|||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -1106,28 +1025,17 @@ export default function Notifications() {
|
|||
checked={config.enabled}
|
||||
onChange={(v) => setConfig({ ...config, enabled: v })}
|
||||
helper="Master switch for all notification delivery"
|
||||
info="When disabled, no alerts or scheduled messages will be delivered. 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 && (
|
||||
<>
|
||||
{/* Quiet Hours Section */}
|
||||
{/* Quiet Hours Section - at top */}
|
||||
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon size={14} className="text-slate-400" />
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Enable Quiet Hours"
|
||||
checked={config.quiet_hours_enabled ?? true}
|
||||
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
|
||||
helper="Suppress non-emergency alerts during sleeping hours"
|
||||
info="When enabled, alerts below emergency severity are held during quiet hours. When disabled, all alerts deliver anytime."
|
||||
/>
|
||||
|
||||
{config.quiet_hours_enabled && (
|
||||
<>
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Quiet Hours
|
||||
<InfoButton info="Non-emergency alerts are held during these hours. Rules with 'Override Quiet Hours' enabled still deliver. Emergency and critical alerts always get through." />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TimeInput
|
||||
label="Start Time"
|
||||
|
|
@ -1142,11 +1050,6 @@ export default function Notifications() {
|
|||
helper="When quiet hours end"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">
|
||||
Emergency alerts and rules with "Override Quiet Hours" enabled always deliver.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rules Section */}
|
||||
|
|
@ -1166,7 +1069,6 @@ export default function Notifications() {
|
|||
key={i}
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
quietHoursEnabled={config.quiet_hours_enabled ?? true}
|
||||
onChange={(r) => {
|
||||
const newRules = [...(config.rules || [])]
|
||||
newRules[i] = r
|
||||
|
|
|
|||
|
|
@ -246,22 +246,18 @@ class AlertRulesConfig:
|
|||
battery_warning: bool = True
|
||||
battery_critical: bool = True
|
||||
battery_emergency: bool = True
|
||||
battery_warning_threshold: int = 30
|
||||
battery_critical_threshold: int = 15
|
||||
battery_emergency_threshold: int = 5
|
||||
# Voltage-based thresholds (more accurate than percentage)
|
||||
battery_warning_voltage: float = 3.60
|
||||
battery_critical_voltage: float = 3.50
|
||||
battery_emergency_voltage: float = 3.40
|
||||
battery_warning_threshold: int = 50
|
||||
battery_critical_threshold: int = 25
|
||||
battery_emergency_threshold: int = 10
|
||||
power_source_change: bool = True
|
||||
solar_not_charging: bool = True
|
||||
|
||||
# Utilization
|
||||
sustained_high_util: bool = True
|
||||
high_util_threshold: float = 40.0
|
||||
high_util_threshold: float = 20.0
|
||||
high_util_hours: int = 6
|
||||
packet_flood: bool = True
|
||||
packet_flood_threshold: int = 10
|
||||
packet_flood_threshold: int = 500
|
||||
|
||||
# Coverage
|
||||
infra_single_gateway: bool = True
|
||||
|
|
@ -270,7 +266,7 @@ class AlertRulesConfig:
|
|||
|
||||
# Health Scores
|
||||
mesh_score_alert: bool = True
|
||||
mesh_score_threshold: int = 65
|
||||
mesh_score_threshold: int = 70
|
||||
region_score_alert: bool = True
|
||||
region_score_threshold: int = 60
|
||||
|
||||
|
|
@ -452,7 +448,7 @@ class NotificationRuleConfig:
|
|||
custom_message: str = ""
|
||||
|
||||
# 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
|
||||
broadcast_channel: int = 0
|
||||
|
|
@ -486,7 +482,6 @@ class NotificationsConfig:
|
|||
"""Notification system settings."""
|
||||
|
||||
enabled: bool = False
|
||||
quiet_hours_enabled: bool = True # Master toggle for quiet hours
|
||||
quiet_hours_start: str = "22:00"
|
||||
quiet_hours_end: str = "06:00"
|
||||
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ async def get_avalanche_data(request: Request):
|
|||
"advisories": env_store.get_active(source="avalanche"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/streams")
|
||||
async def get_streams_data(request: Request):
|
||||
"""Get USGS stream gauge readings."""
|
||||
|
|
@ -119,34 +118,6 @@ async def get_streams_data(request: Request):
|
|||
return env_store.get_active(source="usgs")
|
||||
|
||||
|
||||
@router.get("/env/usgs/lookup/{site_id}")
|
||||
async def lookup_usgs_site(request: Request, site_id: str):
|
||||
"""Lookup USGS site metadata and NWS flood stages.
|
||||
|
||||
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
||||
Used by the config UI to auto-populate fields when adding a new gauge.
|
||||
"""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"error": "Environmental feeds not enabled"}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
usgs_adapter = adapters.get("usgs")
|
||||
|
||||
if not usgs_adapter:
|
||||
# Create a temporary adapter for lookup
|
||||
from meshai.env.usgs import USGSStreamsAdapter
|
||||
from meshai.config import USGSConfig
|
||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||
|
||||
try:
|
||||
result = usgs_adapter.lookup_site(site_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e), "site_id": site_id}
|
||||
|
||||
|
||||
@router.get("/env/traffic")
|
||||
async def get_traffic_data(request: Request):
|
||||
"""Get TomTom traffic flow data."""
|
||||
|
|
|
|||
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.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-DARDkZhk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CYHGOAxN.css">
|
||||
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
||||
</head>
|
||||
<body>
|
||||
<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.
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
# Store full history (last 24-48 hours of readings)
|
||||
kp_history = []
|
||||
for entry in data:
|
||||
if isinstance(entry, dict):
|
||||
try:
|
||||
kp_history.append({
|
||||
"time": entry.get("time_tag", ""),
|
||||
"value": float(entry.get("Kp", 0)),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
elif isinstance(entry, list) and len(entry) > 1:
|
||||
# Legacy array format fallback
|
||||
try:
|
||||
kp_history.append({
|
||||
"time": entry[0] if len(entry) > 0 else "",
|
||||
"value": float(entry[1]),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._status["kp_history"] = kp_history
|
||||
|
||||
# Get last entry (most recent) for current value
|
||||
# Get last entry (most recent)
|
||||
last_entry = data[-1]
|
||||
|
||||
# Handle both dict format (new API) and list format (legacy)
|
||||
if isinstance(last_entry, dict):
|
||||
try:
|
||||
self._status["kp_current"] = float(last_entry.get("Kp", 0))
|
||||
|
|
@ -205,26 +184,10 @@ class SWPCAdapter:
|
|||
"""Parse f107_cm_flux.json.
|
||||
|
||||
Data format: array of objects with time_tag, flux
|
||||
Store history for potential charting.
|
||||
"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Store SFI history (last 30 days of readings)
|
||||
sfi_history = []
|
||||
if isinstance(data, list):
|
||||
for entry in data[-30:]: # Last 30 entries
|
||||
if isinstance(entry, dict):
|
||||
try:
|
||||
sfi_history.append({
|
||||
"time": entry.get("time_tag", ""),
|
||||
"value": float(entry.get("flux", 0)),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._status["sfi_history"] = sfi_history
|
||||
|
||||
# Get most recent entry (last in list)
|
||||
if isinstance(data, list) and data:
|
||||
last = data[-1]
|
||||
|
|
|
|||
235
meshai/env/usgs.py
vendored
235
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
|
||||
# Legacy waterservices.usgs.gov will be decommissioned.
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
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)
|
||||
MIN_TICK_SECONDS = 900 # 15 minutes
|
||||
|
||||
# Cache for NWS flood stages (rarely change)
|
||||
_nwps_cache: dict[str, dict] = {}
|
||||
_nwps_cache_time: dict[str, float] = {}
|
||||
NWPS_CACHE_TTL = 86400 * 7 # 7 days
|
||||
|
||||
|
||||
class USGSStreamsAdapter:
|
||||
"""USGS instantaneous values for stream gauge readings with NWS flood stages."""
|
||||
"""USGS instantaneous values for stream gauge readings."""
|
||||
|
||||
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
|
||||
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
|
||||
|
||||
def __init__(self, config: "USGSConfig"):
|
||||
self._sites = config.sites or []
|
||||
|
|
@ -43,9 +37,6 @@ class USGSStreamsAdapter:
|
|||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
|
||||
# Site metadata cache (name, flood stages from NWPS)
|
||||
self._site_metadata: dict[str, dict] = {}
|
||||
|
||||
if self._tick_interval < MIN_TICK_SECONDS:
|
||||
logger.warning(
|
||||
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
|
||||
|
|
@ -70,192 +61,15 @@ class USGSStreamsAdapter:
|
|||
self._last_tick = now
|
||||
return self._fetch()
|
||||
|
||||
def _get_site_ids(self) -> list[str]:
|
||||
"""Extract site IDs from config (handles both string and dict formats)."""
|
||||
site_ids = []
|
||||
for site in self._sites:
|
||||
if isinstance(site, str):
|
||||
site_ids.append(site)
|
||||
elif isinstance(site, dict):
|
||||
site_ids.append(site.get("id", ""))
|
||||
elif hasattr(site, "id"):
|
||||
site_ids.append(site.id)
|
||||
return [s for s in site_ids if s]
|
||||
|
||||
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
|
||||
"""Lookup flood stages from NWS National Water Prediction Service.
|
||||
|
||||
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
|
||||
We try a mapping lookup first, then fall back to direct lookup.
|
||||
|
||||
Returns:
|
||||
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
|
||||
or None if not available
|
||||
"""
|
||||
global _nwps_cache, _nwps_cache_time
|
||||
|
||||
# Check cache
|
||||
now = time.time()
|
||||
if usgs_site_id in _nwps_cache:
|
||||
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
|
||||
return _nwps_cache[usgs_site_id]
|
||||
|
||||
# Try to find NWS gauge ID from USGS site ID
|
||||
# First, query USGS site info to get the NWS ID crosswalk
|
||||
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
|
||||
if not nws_gauge_id:
|
||||
# Fall back to using USGS ID directly (sometimes they match)
|
||||
nws_gauge_id = usgs_site_id
|
||||
|
||||
# Query NWPS for flood stages
|
||||
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
# Extract flood stages
|
||||
stages = {}
|
||||
flood_info = data.get("flood", {})
|
||||
|
||||
if "action" in flood_info:
|
||||
stages["action_stage"] = flood_info["action"].get("stage")
|
||||
if "minor" in flood_info:
|
||||
stages["flood_stage"] = flood_info["minor"].get("stage")
|
||||
if "moderate" in flood_info:
|
||||
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
|
||||
if "major" in flood_info:
|
||||
stages["major_flood_stage"] = flood_info["major"].get("stage")
|
||||
|
||||
# Also grab the official name if available
|
||||
stages["nws_name"] = data.get("name", "")
|
||||
stages["nws_gauge_id"] = nws_gauge_id
|
||||
|
||||
# Cache result
|
||||
_nwps_cache[usgs_site_id] = stages
|
||||
_nwps_cache_time[usgs_site_id] = now
|
||||
|
||||
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
|
||||
return stages
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
# No NWPS data for this gauge - cache the miss
|
||||
_nwps_cache[usgs_site_id] = {}
|
||||
_nwps_cache_time[usgs_site_id] = now
|
||||
logger.debug(f"No NWPS data for gauge {usgs_site_id}")
|
||||
else:
|
||||
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
|
||||
return None
|
||||
|
||||
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
|
||||
"""Try to find NWS gauge ID from USGS site ID.
|
||||
|
||||
The USGS provides a crosswalk in their site metadata, but it's not
|
||||
always populated. This is a best-effort lookup.
|
||||
"""
|
||||
# Try USGS site service for metadata including NWS ID
|
||||
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
|
||||
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
content = resp.read().decode("utf-8")
|
||||
|
||||
# Parse RDB format - look for NWS ID in the data
|
||||
# This is a simplified parser; full implementation would be more robust
|
||||
for line in content.split("\n"):
|
||||
if line.startswith(usgs_site_id):
|
||||
# NWS station ID is typically in column ~30ish
|
||||
# This varies by USGS response format
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def lookup_site(self, site_id: str) -> dict:
|
||||
"""Lookup site metadata for config UI auto-populate.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"site_id": "13090500",
|
||||
"name": "Snake River nr Twin Falls ID",
|
||||
"lat": 42.xxx,
|
||||
"lon": -114.xxx,
|
||||
"flood_stages": {
|
||||
"action_stage": 9.0,
|
||||
"flood_stage": 10.5,
|
||||
"moderate_flood_stage": 12.0,
|
||||
"major_flood_stage": 14.0,
|
||||
} or None
|
||||
}
|
||||
"""
|
||||
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
|
||||
|
||||
# Get USGS site info
|
||||
params = {
|
||||
"format": "json",
|
||||
"sites": site_id,
|
||||
"siteOutput": "expanded",
|
||||
}
|
||||
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
sites = data.get("value", {}).get("timeSeries", [])
|
||||
if not sites:
|
||||
# Try alternate format
|
||||
sites_list = data.get("value", {}).get("sites", [])
|
||||
if sites_list:
|
||||
site_info = sites_list[0]
|
||||
result["name"] = site_info.get("siteName", "")
|
||||
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
|
||||
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"USGS site lookup failed for {site_id}: {e}")
|
||||
|
||||
# Get NWS flood stages
|
||||
stages = self._lookup_nwps_stages(site_id)
|
||||
if stages:
|
||||
result["flood_stages"] = {
|
||||
"action_stage": stages.get("action_stage"),
|
||||
"flood_stage": stages.get("flood_stage"),
|
||||
"moderate_flood_stage": stages.get("moderate_flood_stage"),
|
||||
"major_flood_stage": stages.get("major_flood_stage"),
|
||||
}
|
||||
if stages.get("nws_name") and not result["name"]:
|
||||
result["name"] = stages["nws_name"]
|
||||
|
||||
return result
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch instantaneous values from USGS Water Services.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
site_ids = self._get_site_ids()
|
||||
if not site_ids:
|
||||
return False
|
||||
|
||||
params = {
|
||||
"format": "json",
|
||||
"sites": ",".join(site_ids),
|
||||
"sites": ",".join(self._sites),
|
||||
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
|
||||
"siteStatus": "active",
|
||||
}
|
||||
|
|
@ -307,10 +121,6 @@ class USGSStreamsAdapter:
|
|||
site_codes = source_info.get("siteCode", [])
|
||||
site_id = site_codes[0].get("value", "") if site_codes else ""
|
||||
|
||||
# Cache site name
|
||||
if site_id and site_id not in self._site_metadata:
|
||||
self._site_metadata[site_id] = {"name": site_name}
|
||||
|
||||
# Extract location
|
||||
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
|
||||
lat = geo_loc.get("latitude")
|
||||
|
|
@ -349,34 +159,8 @@ class USGSStreamsAdapter:
|
|||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Get flood stages for this site
|
||||
nwps_stages = self._lookup_nwps_stages(site_id)
|
||||
|
||||
# Determine severity based on flood stages (for gage height)
|
||||
# Check flood threshold
|
||||
severity = "info"
|
||||
flood_status = None
|
||||
|
||||
if param_type == "height" and nwps_stages:
|
||||
major = nwps_stages.get("major_flood_stage")
|
||||
moderate = nwps_stages.get("moderate_flood_stage")
|
||||
minor = nwps_stages.get("flood_stage")
|
||||
action = nwps_stages.get("action_stage")
|
||||
|
||||
if major and value >= major:
|
||||
severity = "critical"
|
||||
flood_status = "Major Flood"
|
||||
elif moderate and value >= moderate:
|
||||
severity = "warning"
|
||||
flood_status = "Moderate Flood"
|
||||
elif minor and value >= minor:
|
||||
severity = "warning"
|
||||
flood_status = "Minor Flood"
|
||||
elif action and value >= action:
|
||||
severity = "advisory"
|
||||
flood_status = "Action Stage"
|
||||
|
||||
# Fall back to legacy manual thresholds
|
||||
if severity == "info":
|
||||
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
|
||||
if threshold and value > threshold:
|
||||
severity = "warning"
|
||||
|
|
@ -387,9 +171,6 @@ class USGSStreamsAdapter:
|
|||
else:
|
||||
headline = f"{site_name}: {value:.1f} {unit_code}"
|
||||
|
||||
if flood_status:
|
||||
headline += f" — {flood_status}"
|
||||
|
||||
event = {
|
||||
"source": "usgs",
|
||||
"event_id": f"{site_id}_{param_type}",
|
||||
|
|
@ -407,8 +188,6 @@ class USGSStreamsAdapter:
|
|||
"value": value,
|
||||
"unit": unit_code,
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -449,5 +228,5 @@ class USGSStreamsAdapter:
|
|||
"consecutive_errors": self._consecutive_errors,
|
||||
"event_count": len(self._events),
|
||||
"last_fetch": self._last_tick,
|
||||
"site_count": len(self._get_site_ids()),
|
||||
"site_count": len(self._sites),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,218 +1,139 @@
|
|||
"""Alert category registry.
|
||||
|
||||
Defines all alertable conditions with human-readable names, descriptions,
|
||||
and example messages showing what users will receive.
|
||||
Defines all alertable conditions with human-readable names and descriptions.
|
||||
"""
|
||||
|
||||
ALERT_CATEGORIES = {
|
||||
# Infrastructure alerts
|
||||
"infra_offline": {
|
||||
"name": "Infrastructure Node Offline",
|
||||
"description": "An infrastructure node (router/repeater) stopped responding",
|
||||
"name": "Infrastructure Offline",
|
||||
"description": "An infrastructure node stopped responding",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
||||
},
|
||||
"critical_node_down": {
|
||||
"name": "Critical Node Down",
|
||||
"description": "A node you marked as critical went offline",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
||||
"description": "A node marked as critical went offline",
|
||||
"default_severity": "critical",
|
||||
},
|
||||
"infra_recovery": {
|
||||
"name": "Infrastructure Recovery",
|
||||
"description": "An offline infrastructure node came back online",
|
||||
"description": "An infrastructure node came back online",
|
||||
"default_severity": "info",
|
||||
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
||||
},
|
||||
"new_router": {
|
||||
"name": "New Router",
|
||||
"description": "A new router appeared on the mesh",
|
||||
"default_severity": "info",
|
||||
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
||||
},
|
||||
|
||||
# Power alerts
|
||||
"battery_warning": {
|
||||
"name": "Battery Warning",
|
||||
"description": "Infrastructure node battery below 30% (3.60V)",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
||||
"description": "Infrastructure node battery below warning threshold",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"battery_critical": {
|
||||
"name": "Battery Critical",
|
||||
"description": "Infrastructure node battery below 15% (3.50V)",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
||||
"description": "Infrastructure node battery below critical threshold",
|
||||
"default_severity": "critical",
|
||||
},
|
||||
"battery_emergency": {
|
||||
"name": "Battery Emergency",
|
||||
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
||||
"default_severity": "critical",
|
||||
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
||||
"description": "Infrastructure node battery critically low",
|
||||
"default_severity": "emergency",
|
||||
},
|
||||
"battery_trend": {
|
||||
"name": "Battery Declining",
|
||||
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
||||
"description": "Battery showing declining trend over 7 days",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"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",
|
||||
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
||||
},
|
||||
"solar_not_charging": {
|
||||
"name": "Solar Not Charging",
|
||||
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
||||
"description": "Solar panel not charging during daylight hours",
|
||||
"default_severity": "warning",
|
||||
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
||||
},
|
||||
|
||||
# Utilization alerts
|
||||
"high_utilization": {
|
||||
"name": "Channel Airtime High",
|
||||
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
||||
},
|
||||
"sustained_high_util": {
|
||||
"name": "Sustained High Utilization",
|
||||
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
||||
"name": "High Utilization",
|
||||
"description": "Channel utilization elevated for extended period",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
||||
},
|
||||
"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",
|
||||
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
||||
},
|
||||
|
||||
# Coverage alerts
|
||||
"infra_single_gateway": {
|
||||
"name": "Single Gateway",
|
||||
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
||||
"description": "Infrastructure node dropped to single gateway coverage",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"feeder_offline": {
|
||||
"name": "Feeder Offline",
|
||||
"description": "A feeder gateway stopped responding — coverage gap possible",
|
||||
"description": "A feeder gateway stopped responding",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
||||
},
|
||||
"region_total_blackout": {
|
||||
"name": "Region Blackout",
|
||||
"description": "All infrastructure in a region is offline — complete coverage loss",
|
||||
"default_severity": "critical",
|
||||
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||
"description": "All infrastructure in a region is offline",
|
||||
"default_severity": "emergency",
|
||||
},
|
||||
|
||||
# Health score alerts
|
||||
"mesh_score_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",
|
||||
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
||||
},
|
||||
"region_score_low": {
|
||||
"name": "Region Health Low",
|
||||
"description": "A region's health score below threshold — localized issues",
|
||||
"description": "A region's health score below threshold",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
||||
},
|
||||
|
||||
# Environmental - Weather
|
||||
# Environmental alerts
|
||||
"weather_warning": {
|
||||
"name": "Severe Weather",
|
||||
"description": "NWS warning or advisory affecting your mesh area",
|
||||
"description": "NWS warning or advisory for mesh area",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
||||
},
|
||||
|
||||
# Environmental - Space Weather
|
||||
"hf_blackout": {
|
||||
"name": "HF Radio Blackout",
|
||||
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
||||
"description": "R3+ solar event degrading HF propagation",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
||||
},
|
||||
"geomagnetic_storm": {
|
||||
"name": "Geomagnetic Storm",
|
||||
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
||||
},
|
||||
|
||||
# Environmental - Tropospheric
|
||||
"tropospheric_ducting": {
|
||||
"name": "Tropospheric Ducting",
|
||||
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
||||
"description": "Atmospheric conditions extending VHF/UHF range",
|
||||
"default_severity": "info",
|
||||
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
||||
},
|
||||
|
||||
# Environmental - Fire
|
||||
"fire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
||||
},
|
||||
"wildfire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"description": "Wildfire detected within configured distance",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
||||
},
|
||||
"new_ignition": {
|
||||
"name": "New Fire Ignition",
|
||||
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
||||
"default_severity": "watch",
|
||||
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
|
||||
},
|
||||
|
||||
# Environmental - Flood
|
||||
"stream_flood_warning": {
|
||||
"name": "Stream Flood Warning",
|
||||
"description": "River gauge exceeds NWS flood stage threshold",
|
||||
"description": "Satellite hotspot not matching any known fire",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
||||
},
|
||||
"stream_high_water": {
|
||||
"name": "Stream High Water",
|
||||
"description": "River gauge approaching flood stage — monitoring recommended",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
||||
"flood_warning": {
|
||||
"name": "Flood Warning",
|
||||
"description": "Stream gauge exceeds flood threshold",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
|
||||
# Environmental - Roads
|
||||
"road_closure": {
|
||||
"name": "Road Closure",
|
||||
"description": "Full road closure on a monitored corridor",
|
||||
"description": "Full road closure on monitored corridor",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
||||
},
|
||||
"traffic_congestion": {
|
||||
"name": "Traffic Congestion",
|
||||
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
||||
},
|
||||
|
||||
# Environmental - Avalanche
|
||||
"avalanche_warning": {
|
||||
"name": "Avalanche Danger High",
|
||||
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
||||
},
|
||||
"avalanche_considerable": {
|
||||
"name": "Avalanche Danger Considerable",
|
||||
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
||||
"default_severity": "watch",
|
||||
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +146,6 @@ def get_category(category_id: str) -> dict:
|
|||
"name": category_id.replace("_", " ").title(),
|
||||
"description": f"Alert type: {category_id}",
|
||||
"default_severity": "info",
|
||||
"example_message": f"Alert: {category_id}",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,11 +29,10 @@ class NotificationRouter:
|
|||
timezone: str = "America/Boise",
|
||||
):
|
||||
self._rules: list[dict] = []
|
||||
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
|
||||
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
||||
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
||||
self._timezone = timezone
|
||||
self._recent: dict[tuple, float] = {} # (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._connector = connector
|
||||
self._config = config
|
||||
|
|
@ -57,16 +56,9 @@ class NotificationRouter:
|
|||
logger.info("Notification router initialized: %d condition rules", len(self._rules))
|
||||
|
||||
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
|
||||
"""Create a channel instance from a rule's inline delivery config.
|
||||
|
||||
Returns None if delivery_type is empty or invalid.
|
||||
"""
|
||||
"""Create a channel instance from a rule's inline delivery config."""
|
||||
delivery_type = rule.get("delivery_type", "")
|
||||
|
||||
# Empty delivery type is valid - rule exists but doesn't deliver
|
||||
if not delivery_type:
|
||||
return None
|
||||
|
||||
if delivery_type == "mesh_broadcast":
|
||||
config = {
|
||||
"type": "mesh_broadcast",
|
||||
|
|
@ -95,13 +87,13 @@ class NotificationRouter:
|
|||
"headers": rule.get("webhook_headers", {}),
|
||||
}
|
||||
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
|
||||
|
||||
try:
|
||||
return create_channel(config, self._connector)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
|
||||
logger.warning("Failed to create channel for rule %s: %s", rule.get("name"), e)
|
||||
return None
|
||||
|
||||
async def process_alert(self, alert: dict) -> bool:
|
||||
|
|
@ -114,8 +106,6 @@ class NotificationRouter:
|
|||
delivered = False
|
||||
|
||||
for rule in self._rules:
|
||||
rule_name = rule.get("name", "unnamed")
|
||||
|
||||
# Check category match
|
||||
rule_categories = rule.get("categories", [])
|
||||
if rule_categories and category not in rule_categories:
|
||||
|
|
@ -126,18 +116,15 @@ class NotificationRouter:
|
|||
if not self._severity_meets(severity, min_severity):
|
||||
continue
|
||||
|
||||
# Check quiet hours (only if quiet hours are enabled globally)
|
||||
if self._quiet_enabled and self._in_quiet_hours():
|
||||
# Emergencies and criticals always go through
|
||||
if severity not in ("emergency", "critical"):
|
||||
# Check if rule overrides quiet hours
|
||||
# Check quiet hours (emergencies and criticals override)
|
||||
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
|
||||
if not rule.get("override_quiet", False):
|
||||
logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name)
|
||||
continue
|
||||
|
||||
# Check cooldown
|
||||
cooldown = rule.get("cooldown_minutes", 10) * 60
|
||||
event_id = alert.get("event_id", alert.get("message", "")[:50])
|
||||
rule_name = rule.get("name", "unknown")
|
||||
dedup_key = (rule_name, category, event_id)
|
||||
now = time.time()
|
||||
if dedup_key in self._recent:
|
||||
|
|
@ -146,19 +133,9 @@ class NotificationRouter:
|
|||
continue
|
||||
self._recent[dedup_key] = now
|
||||
|
||||
# Log rule match
|
||||
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
|
||||
|
||||
# Check if rule has delivery configured
|
||||
delivery_type = rule.get("delivery_type", "")
|
||||
if not delivery_type:
|
||||
logger.info("Rule '%s' matched but has no delivery configured", rule_name)
|
||||
continue
|
||||
|
||||
# Create channel and deliver
|
||||
channel = self._create_channel_for_rule(rule)
|
||||
if not channel:
|
||||
logger.warning("Rule '%s' failed to create delivery channel", rule_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -176,9 +153,9 @@ class NotificationRouter:
|
|||
success = await channel.deliver(delivery_alert, rule)
|
||||
if success:
|
||||
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:
|
||||
logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
|
||||
logger.warning("Rule %s delivery failed: %s", rule_name, e)
|
||||
|
||||
return delivered
|
||||
|
||||
|
|
@ -193,9 +170,6 @@ class NotificationRouter:
|
|||
|
||||
def _in_quiet_hours(self) -> bool:
|
||||
"""Check if current time is within quiet hours."""
|
||||
if not self._quiet_enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo(self._timezone)
|
||||
|
|
@ -230,69 +204,12 @@ class NotificationRouter:
|
|||
else:
|
||||
rule_dict = dict(rule)
|
||||
|
||||
# Check if delivery is configured
|
||||
if not rule_dict.get("delivery_type"):
|
||||
return False, "No delivery method configured for this rule"
|
||||
|
||||
channel = self._create_channel_for_rule(rule_dict)
|
||||
if not channel:
|
||||
return False, "Failed to create delivery channel"
|
||||
|
||||
return await channel.test()
|
||||
|
||||
async def preview_rule(self, rule_index: int) -> dict:
|
||||
"""Preview what a rule would match right now.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"matches": bool,
|
||||
"conditions": [...], # Current conditions that match
|
||||
"preview": str, # Example message
|
||||
}
|
||||
"""
|
||||
rules_config = getattr(self._config, "rules", [])
|
||||
if rule_index < 0 or rule_index >= len(rules_config):
|
||||
return {"matches": False, "conditions": [], "preview": "Invalid rule index"}
|
||||
|
||||
rule = rules_config[rule_index]
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = dict(rule)
|
||||
|
||||
# For condition rules, show example based on categories
|
||||
if rule_dict.get("trigger_type", "condition") == "condition":
|
||||
from .categories import get_category
|
||||
categories = rule_dict.get("categories", [])
|
||||
|
||||
if not categories:
|
||||
# All categories - show first example
|
||||
example = get_category("infra_offline")
|
||||
return {
|
||||
"matches": True,
|
||||
"conditions": ["All alert categories"],
|
||||
"preview": example.get("example_message", "Alert notification"),
|
||||
}
|
||||
else:
|
||||
# Show example from first category
|
||||
cat_info = get_category(categories[0])
|
||||
return {
|
||||
"matches": True,
|
||||
"conditions": [get_category(c)["name"] for c in categories],
|
||||
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
|
||||
}
|
||||
|
||||
# For schedule rules, generate preview report
|
||||
elif rule_dict.get("trigger_type") == "schedule":
|
||||
message_type = rule_dict.get("message_type", "mesh_health_summary")
|
||||
return {
|
||||
"matches": True,
|
||||
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
|
||||
"preview": f"[{message_type}] Report content would appear here",
|
||||
}
|
||||
|
||||
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
||||
|
||||
def add_mesh_subscription(
|
||||
self,
|
||||
node_id: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue