mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
Compare commits
No commits in common. "b4f7e24c2644c9ab91e3fed97e5e7caee65393d1" and "bb36ebb8c347664bc77fbca491c9b5d2fe87c78d" have entirely different histories.
b4f7e24c26
...
bb36ebb8c3
37 changed files with 1938 additions and 7164 deletions
|
|
@ -203,91 +203,6 @@ environmental:
|
|||
endpoints: ["/get/event"]
|
||||
bbox: [] # [west, south, east, north]
|
||||
|
||||
# NASA FIRMS Satellite Fire Detection
|
||||
# Early warning via satellite hotspots, hours before official perimeters
|
||||
# Get MAP_KEY at: https://firms.modaps.eosdis.nasa.gov/api/area/
|
||||
firms:
|
||||
enabled: false
|
||||
tick_seconds: 1800 # 30 min default
|
||||
map_key: "" # Required - NASA FIRMS MAP_KEY
|
||||
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||
bbox: [] # [west, south, east, north] - Required
|
||||
day_range: 1 # 1-10 days of data
|
||||
confidence_min: "nominal" # low, nominal, high
|
||||
proximity_km: 10.0 # km to match known fire perimeters
|
||||
|
||||
|
||||
# === NOTIFICATION DELIVERY ===
|
||||
# Route alerts to channels (mesh, email, webhook) based on rules.
|
||||
# Categories match alert types from alert_engine.py.
|
||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
||||
#
|
||||
notifications:
|
||||
enabled: false
|
||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
||||
quiet_hours_end: "06:00"
|
||||
|
||||
# Notification rules - each rule is self-contained with its own delivery config
|
||||
rules:
|
||||
# All emergencies -> mesh broadcast
|
||||
- name: "Emergency Broadcast"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: [] # Empty = all categories
|
||||
min_severity: "emergency"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 5
|
||||
override_quiet: true # Send even during quiet hours
|
||||
|
||||
# Example: Fire alerts -> email
|
||||
# - name: "Fire Alerts Email"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["wildfire_proximity", "new_ignition"]
|
||||
# min_severity: "advisory"
|
||||
# delivery_type: email
|
||||
# smtp_host: "smtp.gmail.com"
|
||||
# smtp_port: 587
|
||||
# smtp_user: "you@gmail.com"
|
||||
# smtp_password: "${SMTP_PASSWORD}"
|
||||
# smtp_tls: true
|
||||
# from_address: "meshai@yourdomain.com"
|
||||
# recipients: ["admin@yourdomain.com"]
|
||||
# cooldown_minutes: 30
|
||||
|
||||
# Example: All warnings -> Discord webhook
|
||||
# - name: "Discord Alerts"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: []
|
||||
# min_severity: "warning"
|
||||
# delivery_type: webhook
|
||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||
# cooldown_minutes: 10
|
||||
|
||||
# Example: Daily health report -> mesh broadcast
|
||||
# - name: "Morning Briefing"
|
||||
# enabled: true
|
||||
# trigger_type: schedule
|
||||
# schedule_frequency: daily
|
||||
# schedule_time: "07:00"
|
||||
# message_type: mesh_health_summary
|
||||
# delivery_type: mesh_broadcast
|
||||
# broadcast_channel: 0
|
||||
|
||||
# Example: Weekly digest -> email
|
||||
# - name: "Weekly Digest"
|
||||
# enabled: true
|
||||
# trigger_type: schedule
|
||||
# schedule_frequency: weekly
|
||||
# schedule_days: ["monday"]
|
||||
# schedule_time: "08:00"
|
||||
# message_type: alerts_digest
|
||||
# delivery_type: email
|
||||
# smtp_host: "smtp.gmail.com"
|
||||
# recipients: ["admin@example.com"]
|
||||
|
||||
# === WEB DASHBOARD ===
|
||||
dashboard:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -5,23 +5,18 @@ import Mesh from './pages/Mesh'
|
|||
import Environment from './pages/Environment'
|
||||
import Config from './pages/Config'
|
||||
import Alerts from './pages/Alerts'
|
||||
import Notifications from './pages/Notifications'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/mesh" element={<Mesh />} />
|
||||
<Route path="/environment" element={<Environment />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ToastProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/mesh" element={<Mesh />} />
|
||||
<Route path="/environment" element={<Environment />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface Channel {
|
||||
index: number
|
||||
name: string
|
||||
role: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ChannelPickerSingleProps {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'single'
|
||||
includeDisabled?: boolean // Include a "Disabled (-1)" option
|
||||
}
|
||||
|
||||
interface ChannelPickerMultiProps {
|
||||
label: string
|
||||
value: number[]
|
||||
onChange: (value: number[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'multi'
|
||||
}
|
||||
|
||||
type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps
|
||||
|
||||
export default function ChannelPicker(props: ChannelPickerProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/channels')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setChannels(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setChannels([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const formatChannel = (ch: Channel): string => {
|
||||
const roleLabel = ch.role === 'PRIMARY' ? 'Primary' :
|
||||
ch.role === 'SECONDARY' ? 'Secondary' : ''
|
||||
return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}`
|
||||
}
|
||||
|
||||
// Fallback to number input if no channels loaded
|
||||
if (!loading && channels.length === 0) {
|
||||
if (props.mode === 'single') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(Number(e.target.value))}
|
||||
min={props.includeDisabled ? -1 : 0}
|
||||
max={7}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.value.join(', ')}
|
||||
onChange={(e) => {
|
||||
const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
|
||||
props.onChange(nums)
|
||||
}}
|
||||
placeholder="Enter channel numbers separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single select mode - dropdown
|
||||
if (props.mode === 'single') {
|
||||
const { value, onChange, label, helper, includeDisabled } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(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"
|
||||
>
|
||||
{includeDisabled && (
|
||||
<option value={-1}>Disabled</option>
|
||||
)}
|
||||
{enabledChannels.map((ch) => (
|
||||
<option key={ch.index} value={ch.index}>
|
||||
{formatChannel(ch)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi select mode - checkboxes
|
||||
const { value, onChange, label, helper } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
const toggleChannel = (index: number) => {
|
||||
if (value.includes(index)) {
|
||||
onChange(value.filter(v => v !== index))
|
||||
} else {
|
||||
onChange([...value, index].sort((a, b) => a - b))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<div className="border border-[#1e2a3a] rounded-lg p-2 space-y-1">
|
||||
{enabledChannels.map((ch) => (
|
||||
<label
|
||||
key={ch.index}
|
||||
onClick={() => toggleChannel(ch.index)}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
value.includes(ch.index) ? 'bg-accent border-accent' : 'border-slate-600'
|
||||
}`}>
|
||||
{value.includes(ch.index) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-slate-200">{formatChannel(ch)}</span>
|
||||
</label>
|
||||
))}
|
||||
{enabledChannels.length === 0 && (
|
||||
<div className="text-sm text-slate-500 p-2">No channels available</div>
|
||||
)}
|
||||
</div>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,11 +6,9 @@ import {
|
|||
Cloud,
|
||||
Settings,
|
||||
Bell,
|
||||
BellRing,
|
||||
} from 'lucide-react'
|
||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import { useToast } from './ToastProvider'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
|
|
@ -22,7 +20,6 @@ const navItems = [
|
|||
{ path: '/environment', label: 'Environment', icon: Cloud },
|
||||
{ path: '/config', label: 'Config', icon: Settings },
|
||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
||||
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
||||
]
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
|
|
@ -42,21 +39,8 @@ function getPageTitle(pathname: string): string {
|
|||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const { connected, lastAlert } = useWebSocket()
|
||||
const { addToast } = useToast()
|
||||
const { connected } = useWebSocket()
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [lastAlertId, setLastAlertId] = useState<string | null>(null)
|
||||
|
||||
// Trigger toast on new alerts
|
||||
useEffect(() => {
|
||||
if (lastAlert) {
|
||||
const alertId = `${lastAlert.type}-${lastAlert.message}-${lastAlert.timestamp}`
|
||||
if (alertId !== lastAlertId) {
|
||||
setLastAlertId(alertId)
|
||||
addToast(lastAlert)
|
||||
}
|
||||
}
|
||||
}, [lastAlert, lastAlertId, addToast])
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Search, X, Check } from 'lucide-react'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
is_infrastructure?: boolean
|
||||
}
|
||||
|
||||
interface NodePickerProps {
|
||||
label: string
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
|
||||
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
|
||||
}
|
||||
|
||||
export default function NodePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helper,
|
||||
info: _info,
|
||||
roleFilter,
|
||||
valueType = 'short_name',
|
||||
}: NodePickerProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/nodes')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setNodes(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setNodes([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
let result = nodes
|
||||
|
||||
// Filter by role if specified
|
||||
if (roleFilter) {
|
||||
result = result.filter(n => {
|
||||
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
|
||||
return n.is_infrastructure ||
|
||||
n.role === 'ROUTER' ||
|
||||
n.role === 'ROUTER_CLIENT' ||
|
||||
n.role === 'REPEATER'
|
||||
}
|
||||
return n.role === roleFilter
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase()
|
||||
result = result.filter(n =>
|
||||
n.short_name?.toLowerCase().includes(s) ||
|
||||
n.long_name?.toLowerCase().includes(s) ||
|
||||
n.role?.toLowerCase().includes(s) ||
|
||||
n.node_id_hex?.toLowerCase().includes(s)
|
||||
)
|
||||
}
|
||||
|
||||
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
|
||||
}, [nodes, search, roleFilter])
|
||||
|
||||
const getNodeValue = (node: Node): string => {
|
||||
switch (valueType) {
|
||||
case 'node_num':
|
||||
return String(node.node_num)
|
||||
case 'node_id_hex':
|
||||
return node.node_id_hex
|
||||
default:
|
||||
return node.short_name || String(node.node_num)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (node: Node): boolean => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
return value.includes(nodeVal)
|
||||
}
|
||||
|
||||
const toggleNode = (node: Node) => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
if (value.includes(nodeVal)) {
|
||||
onChange(value.filter(v => v !== nodeVal))
|
||||
} else {
|
||||
onChange([...value, nodeVal])
|
||||
}
|
||||
}
|
||||
|
||||
const formatNodeDisplay = (node: Node): string => {
|
||||
const parts = [node.short_name]
|
||||
if (node.long_name && node.long_name !== node.short_name) {
|
||||
parts.push(`— ${node.long_name}`)
|
||||
}
|
||||
if (node.role) {
|
||||
parts.push(`(${node.role})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// Fallback to text input if no nodes loaded
|
||||
if (!loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.join(', ')}
|
||||
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||
placeholder="Enter node IDs separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
|
||||
{/* Selected nodes display */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{value.map((v) => {
|
||||
const node = nodes.find(n => getNodeValue(n) === v)
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
|
||||
>
|
||||
{node ? node.short_name : v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter(val => val !== v))}
|
||||
className="hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
|
||||
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !loading && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl">
|
||||
{filteredNodes.length === 0 ? (
|
||||
<div className="p-3 text-sm text-slate-500 text-center">
|
||||
No nodes found
|
||||
</div>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<button
|
||||
key={node.node_num}
|
||||
type="button"
|
||||
onClick={() => toggleNode(node)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[#1e2a3a] ${
|
||||
isSelected(node) ? 'bg-accent/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isSelected(node) ? 'bg-accent border-accent' : 'border-slate-600'
|
||||
}`}>
|
||||
{isSelected(node) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-slate-200">{formatNodeDisplay(node)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react'
|
||||
import type { Alert } from '@/lib/api'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
alert: Alert
|
||||
dismissedAt?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (alert: Alert) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
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',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500',
|
||||
icon: Info,
|
||||
iconColor: 'text-blue-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
toast,
|
||||
onDismiss,
|
||||
onNavigate,
|
||||
}: {
|
||||
toast: Toast
|
||||
onDismiss: () => void
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
const styles = getSeverityStyles(toast.alert.severity)
|
||||
const Icon = styles.icon
|
||||
|
||||
// Auto-dismiss after 8 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 8000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onDismiss])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.bg} border ${styles.border} rounded-lg shadow-lg overflow-hidden animate-slide-in cursor-pointer`}
|
||||
onClick={onNavigate}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
{/* Severity bar */}
|
||||
<div className={`w-1 self-stretch -ml-4 -my-4 ${styles.border.replace('border', 'bg')}`} />
|
||||
|
||||
<Icon size={18} className={styles.iconColor} />
|
||||
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<div className="text-sm font-medium text-slate-200 mb-0.5">
|
||||
{toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</div>
|
||||
<div className="text-sm text-slate-300 line-clamp-2">
|
||||
{toast.alert.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
const addToast = useCallback((alert: Alert) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setToasts((prev) => [...prev, { id, alert }])
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
navigate('/alerts')
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast container - fixed bottom right */}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem
|
||||
toast={toast}
|
||||
onDismiss={() => dismissToast(toast.id)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -47,28 +47,3 @@ body {
|
|||
.animate-pulse-slow {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
/* Toast slide-in animation */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,34 +93,6 @@ export interface Alert {
|
|||
scope_value?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryItem {
|
||||
id?: number
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
duration?: number
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
resolved_at?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryResponse {
|
||||
items: AlertHistoryItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
user_id: string
|
||||
sub_type: string
|
||||
schedule_time?: string
|
||||
schedule_day?: string
|
||||
scope_type: string
|
||||
scope_value?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EnvStatus {
|
||||
enabled: boolean
|
||||
feeds: EnvFeedHealth[]
|
||||
|
|
@ -237,24 +209,6 @@ export async function fetchAlerts(): Promise<Alert[]> {
|
|||
return fetchJson<Alert[]>('/api/alerts/active')
|
||||
}
|
||||
|
||||
export async function fetchAlertHistory(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
type?: string,
|
||||
severity?: string
|
||||
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', limit.toString())
|
||||
params.set('offset', offset.toString())
|
||||
if (type && type !== 'all') params.set('type', type)
|
||||
if (severity && severity !== 'all') params.set('severity', severity)
|
||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
||||
}
|
||||
|
||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||
return fetchJson<EnvStatus>('/api/env/status')
|
||||
}
|
||||
|
|
@ -376,36 +330,6 @@ export interface RoadEvent {
|
|||
}
|
||||
}
|
||||
|
||||
export interface HotspotEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
new_ignition: boolean
|
||||
confidence: string
|
||||
frp?: number
|
||||
brightness?: number
|
||||
acq_date: string
|
||||
acq_time: string
|
||||
near_fire?: string
|
||||
distance_to_fire_km?: number
|
||||
distance_km?: number
|
||||
nearest_anchor?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HotspotsResponse {
|
||||
enabled: boolean
|
||||
hotspots: HotspotEvent[]
|
||||
new_ignitions: number
|
||||
}
|
||||
|
||||
export interface AvalancheResponse {
|
||||
off_season: boolean
|
||||
advisories: AvalancheEvent[]
|
||||
|
|
@ -431,10 +355,6 @@ export async function fetchRoads(): Promise<RoadEvent[]> {
|
|||
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||
}
|
||||
|
||||
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
||||
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||
}
|
||||
|
||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,572 +1,15 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Radio,
|
||||
Zap,
|
||||
|
||||
Cloud,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Battery,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchAlerts,
|
||||
fetchAlertHistory,
|
||||
fetchSubscriptions,
|
||||
type Alert,
|
||||
type AlertHistoryItem,
|
||||
type Subscription,
|
||||
} from '@/lib/api'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
}
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
|
||||
// Alert type icons mapping
|
||||
const alertTypeIcons: Record<string, typeof Bell> = {
|
||||
infra_offline: WifiOff,
|
||||
infra_recovery: Wifi,
|
||||
battery_warning: Battery,
|
||||
battery_critical: Battery,
|
||||
battery_emergency: Battery,
|
||||
hf_blackout: Zap,
|
||||
uhf_ducting: Radio,
|
||||
weather_warning: Cloud,
|
||||
weather_watch: Cloud,
|
||||
new_router: Radio,
|
||||
packet_flood: AlertTriangle,
|
||||
sustained_high_util: AlertTriangle,
|
||||
region_blackout: AlertCircle,
|
||||
default: Bell,
|
||||
}
|
||||
|
||||
function getAlertIcon(type: string) {
|
||||
return alertTypeIcons[type] || alertTypeIcons.default
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
badge: 'bg-red-500/20 text-red-400',
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
badge: 'bg-amber-500/20 text-amber-400',
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
case 'watch':
|
||||
return {
|
||||
bg: 'bg-yellow-500/10',
|
||||
border: 'border-yellow-500',
|
||||
badge: 'bg-yellow-500/20 text-yellow-400',
|
||||
iconColor: 'text-yellow-500',
|
||||
}
|
||||
case 'advisory':
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500',
|
||||
badge: 'bg-blue-500/20 text-blue-400',
|
||||
iconColor: 'text-blue-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: string | number): string {
|
||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffSec < 60) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHour < 24) return `${diffHour}h ago`
|
||||
return `${diffDay}d ago`
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp: string | number): string {
|
||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
return `${Math.floor(seconds / 86400)}d`
|
||||
}
|
||||
|
||||
// Active Alert Card Component
|
||||
function ActiveAlertCard({
|
||||
alert,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
alert: Alert
|
||||
onAcknowledge: (alert: Alert) => void
|
||||
}) {
|
||||
const styles = getSeverityStyles(alert.severity)
|
||||
const Icon = getAlertIcon(alert.type)
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg ${styles.bg} border-l-4 ${styles.border}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon size={20} className={styles.iconColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{alert.severity?.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{alert.type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
|
||||
</span>
|
||||
{alert.scope_value && (
|
||||
<span>{alert.scope_type}: {alert.scope_value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAcknowledge(alert)}
|
||||
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Alert History Table Component
|
||||
function AlertHistoryTable({
|
||||
history,
|
||||
typeFilter,
|
||||
severityFilter,
|
||||
onTypeFilterChange,
|
||||
onSeverityFilterChange,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
history: AlertHistoryItem[]
|
||||
typeFilter: string
|
||||
severityFilter: string
|
||||
onTypeFilterChange: (v: string) => void
|
||||
onSeverityFilterChange: (v: string) => void
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (p: number) => void
|
||||
}) {
|
||||
const alertTypes = [
|
||||
'all',
|
||||
'infra_offline',
|
||||
'infra_recovery',
|
||||
'battery_warning',
|
||||
'battery_critical',
|
||||
'hf_blackout',
|
||||
'uhf_ducting',
|
||||
'weather_warning',
|
||||
'new_router',
|
||||
'packet_flood',
|
||||
]
|
||||
|
||||
const severities = ['all', 'critical', 'warning', 'watch', 'info']
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg">
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-border flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={14} className="text-slate-400" />
|
||||
<span className="text-sm text-slate-400">Filter:</span>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => onTypeFilterChange(e.target.value)}
|
||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{alertTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => onSeverityFilterChange(e.target.value)}
|
||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{severities.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length > 0 ? (
|
||||
history.map((item, i) => {
|
||||
const styles = getSeverityStyles(item.severity)
|
||||
return (
|
||||
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
|
||||
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
|
||||
{formatDateTime(item.timestamp)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-300">
|
||||
{item.type.replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{item.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
|
||||
{item.message}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-400 font-mono">
|
||||
{item.duration ? formatDuration(item.duration) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
||||
No alert history available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Subscription Card Component
|
||||
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
|
||||
const resolveNodeName = (userId: string): string => {
|
||||
const node = nodes.find(n =>
|
||||
n.node_id_hex === userId ||
|
||||
String(n.node_num) === userId ||
|
||||
n.short_name === userId
|
||||
)
|
||||
if (node) {
|
||||
return node.long_name && node.long_name !== node.short_name
|
||||
? `${node.short_name} (${node.long_name})`
|
||||
: node.short_name
|
||||
}
|
||||
return userId
|
||||
}
|
||||
const formatSchedule = () => {
|
||||
if (subscription.sub_type === 'alerts') {
|
||||
return 'Real-time'
|
||||
}
|
||||
const time = subscription.schedule_time || '0000'
|
||||
const hours = parseInt(time.slice(0, 2))
|
||||
const minutes = time.slice(2)
|
||||
const period = hours >= 12 ? 'PM' : 'AM'
|
||||
const displayHour = hours % 12 || 12
|
||||
let schedule = `${displayHour}:${minutes} ${period}`
|
||||
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
|
||||
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
const getTypeIcon = () => {
|
||||
switch (subscription.sub_type) {
|
||||
case 'alerts':
|
||||
return Bell
|
||||
case 'daily':
|
||||
return Clock
|
||||
case 'weekly':
|
||||
return Clock
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = getTypeIcon()
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-bg-hover border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Icon size={18} className="text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-slate-200 font-medium">
|
||||
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
|
||||
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
|
||||
<span className="text-slate-400 font-normal ml-2">
|
||||
({subscription.scope_type}: {subscription.scope_value})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{formatSchedule()} • {resolveNodeName(subscription.user_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { Bell } from 'lucide-react'
|
||||
|
||||
export default function Alerts() {
|
||||
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
|
||||
const [history, setHistory] = useState<AlertHistoryItem[]>([])
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters and pagination
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [severityFilter, setSeverityFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
// Acknowledged alerts (local state only)
|
||||
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set())
|
||||
|
||||
const { lastAlert } = useWebSocket()
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = 'Alerts — MeshAI'
|
||||
}, [])
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchAlerts().catch(() => []),
|
||||
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
|
||||
fetchSubscriptions().catch(() => []),
|
||||
fetch('/api/nodes').then(r => r.json()).catch(() => []),
|
||||
])
|
||||
.then(([alerts, historyData, subs, nodeData]) => {
|
||||
setActiveAlerts(alerts)
|
||||
if (Array.isArray(historyData)) {
|
||||
setHistory(historyData)
|
||||
setTotalPages(1)
|
||||
} else {
|
||||
setHistory(historyData.items || [])
|
||||
setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
|
||||
}
|
||||
setSubscriptions(subs)
|
||||
setNodes(nodeData)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle new alerts from WebSocket
|
||||
useEffect(() => {
|
||||
if (lastAlert) {
|
||||
setActiveAlerts((prev) => {
|
||||
// Avoid duplicates
|
||||
const exists = prev.some(
|
||||
(a) => a.type === lastAlert.type && a.message === lastAlert.message
|
||||
)
|
||||
if (exists) return prev
|
||||
return [lastAlert, ...prev]
|
||||
})
|
||||
}
|
||||
}, [lastAlert])
|
||||
|
||||
// Reload history when filters or page change
|
||||
useEffect(() => {
|
||||
const offset = (page - 1) * pageSize
|
||||
fetchAlertHistory(pageSize, offset, typeFilter, severityFilter)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setHistory(data)
|
||||
setTotalPages(1)
|
||||
} else {
|
||||
setHistory(data.items || [])
|
||||
setTotalPages(Math.ceil((data.total || 0) / pageSize))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep current data on error
|
||||
})
|
||||
}, [page, typeFilter, severityFilter])
|
||||
|
||||
const handleAcknowledge = useCallback((alert: Alert) => {
|
||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
||||
setAcknowledged((prev) => new Set([...prev, key]))
|
||||
}, [])
|
||||
|
||||
// Filter out acknowledged alerts
|
||||
const visibleAlerts = activeAlerts.filter((alert) => {
|
||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
||||
return !acknowledged.has(key)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-slate-400">Loading alerts...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-400">Error: {error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="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 flex items-center gap-2">
|
||||
<AlertTriangle size={14} />
|
||||
Active Alerts ({visibleAlerts.length})
|
||||
</h2>
|
||||
{visibleAlerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{visibleAlerts.map((alert, i) => (
|
||||
<ActiveAlertCard
|
||||
key={`${alert.type}-${alert.timestamp}-${i}`}
|
||||
alert={alert}
|
||||
onAcknowledge={handleAcknowledge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-500 py-8">
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<span>No active alerts — all systems nominal</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Clock size={14} />
|
||||
Alert History
|
||||
</h2>
|
||||
<AlertHistoryTable
|
||||
history={history}
|
||||
typeFilter={typeFilter}
|
||||
severityFilter={severityFilter}
|
||||
onTypeFilterChange={(v) => {
|
||||
setTypeFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
onSeverityFilterChange={(v) => {
|
||||
setSeverityFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subscriptions */}
|
||||
<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">
|
||||
<Users size={14} />
|
||||
Mesh Subscriptions ({subscriptions.length})
|
||||
</h2>
|
||||
{subscriptions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{subscriptions.map((sub) => (
|
||||
<SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500 py-4">
|
||||
<p>No active subscriptions.</p>
|
||||
<p className="text-xs mt-2">
|
||||
Manage subscriptions via <code className="text-blue-400">!subscribe</code> on mesh
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
||||
<Bell size={32} className="text-slate-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Alerts</h2>
|
||||
<p className="text-slate-500 max-w-md">
|
||||
Alert history and subscriptions coming in Phase 11
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -326,12 +326,10 @@ export default function Dashboard() {
|
|||
setEnvStatus(e)
|
||||
setRFProp(rf)
|
||||
setLoading(false)
|
||||
document.title = 'Dashboard — MeshAI'
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
document.title = 'Dashboard — MeshAI'
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
Mountain,
|
||||
Droplets,
|
||||
Car,
|
||||
Satellite,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchEnvStatus,
|
||||
|
|
@ -24,7 +23,6 @@ import {
|
|||
fetchStreams,
|
||||
fetchTraffic,
|
||||
fetchRoads,
|
||||
fetchHotspots,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
|
|
@ -34,8 +32,6 @@ import {
|
|||
type StreamGaugeEvent,
|
||||
type TrafficEvent,
|
||||
type RoadEvent,
|
||||
type HotspotEvent,
|
||||
|
||||
} from '@/lib/api'
|
||||
|
||||
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
|
||||
|
|
@ -363,13 +359,10 @@ export default function Environment() {
|
|||
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
|
||||
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
|
||||
const [roads, setRoads] = useState<RoadEvent[]>([])
|
||||
const [hotspots, setHotspots] = useState<HotspotEvent[]>([])
|
||||
const [newIgnitions, setNewIgnitions] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Environment — MeshAI'
|
||||
Promise.all([
|
||||
fetchEnvStatus().catch(() => null),
|
||||
fetchEnvActive().catch(() => []),
|
||||
|
|
@ -380,9 +373,8 @@ export default function Environment() {
|
|||
fetchStreams().catch(() => []),
|
||||
fetchTraffic().catch(() => []),
|
||||
fetchRoads().catch(() => []),
|
||||
fetchHotspots().catch(() => ({ hotspots: [], new_ignitions: 0 })),
|
||||
])
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData, hotspotsData]) => {
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData]) => {
|
||||
setEnvStatus(status)
|
||||
setEvents(active)
|
||||
setSWPC(swpcData)
|
||||
|
|
@ -392,8 +384,6 @@ export default function Environment() {
|
|||
setStreams(streamsData || [])
|
||||
setTraffic(trafficData || [])
|
||||
setRoads(roadsData || [])
|
||||
setHotspots(hotspotsData?.hotspots || [])
|
||||
setNewIgnitions(hotspotsData?.new_ignitions || 0)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -700,60 +690,6 @@ export default function Environment() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Satellite Hotspots */}
|
||||
{hotspots.length > 0 && (
|
||||
<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">
|
||||
<Satellite size={14} />
|
||||
Satellite Hotspots ({hotspots.length})
|
||||
{newIgnitions > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-400 animate-pulse">
|
||||
{newIgnitions} NEW
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{hotspots.map((h) => (
|
||||
<div
|
||||
key={h.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
h.properties?.new_ignition
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: h.severity === 'watch'
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: 'bg-orange-500/10 border-l-2 border-orange-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{h.properties?.new_ignition && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-slate-200">
|
||||
{h.headline}
|
||||
</span>
|
||||
</div>
|
||||
{h.properties?.frp && (
|
||||
<span className="text-sm font-mono text-orange-400">
|
||||
{Math.round(h.properties.frp)} MW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 flex items-center gap-3">
|
||||
<span>Conf: {h.properties?.confidence || 'N/A'}</span>
|
||||
{h.properties?.acq_time && <span>@{h.properties.acq_time}Z</span>}
|
||||
{h.properties?.near_fire && (
|
||||
<span>Near: {h.properties.near_fire}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Events */}
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export default function Mesh() {
|
|||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
document.title = 'Mesh — MeshAI'
|
||||
Promise.all([fetchNodes(), fetchEdges(), fetchRegions()])
|
||||
.then(([n, e, r]) => {
|
||||
setNodes(n)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -162,7 +162,6 @@ def create_dispatcher(
|
|||
health_engine=None,
|
||||
subscription_manager=None,
|
||||
env_store=None,
|
||||
notification_router=None,
|
||||
) -> CommandDispatcher:
|
||||
"""Create and populate command dispatcher with default commands.
|
||||
|
||||
|
|
@ -225,24 +224,24 @@ def create_dispatcher(
|
|||
dispatcher.register(alias_handler)
|
||||
|
||||
# Register subscription commands
|
||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store)
|
||||
dispatcher.register(sub_cmd)
|
||||
for alias in getattr(sub_cmd, 'aliases', []):
|
||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
|
||||
unsub_cmd = UnsubCommand(subscription_manager)
|
||||
dispatcher.register(unsub_cmd)
|
||||
for alias in getattr(unsub_cmd, 'aliases', []):
|
||||
alias_handler = UnsubCommand(subscription_manager, notification_router)
|
||||
alias_handler = UnsubCommand(subscription_manager)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
|
||||
mysubs_cmd = MySubsCommand(subscription_manager)
|
||||
dispatcher.register(mysubs_cmd)
|
||||
for alias in getattr(mysubs_cmd, 'aliases', []):
|
||||
alias_handler = MySubsCommand(subscription_manager, notification_router)
|
||||
alias_handler = MySubsCommand(subscription_manager)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
|
|
@ -300,15 +299,6 @@ def create_dispatcher(
|
|||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
# Register hotspots command (NASA FIRMS satellite fire detection)
|
||||
from .hotspots_cmd import HotspotsCommand
|
||||
hotspots_cmd = HotspotsCommand(env_store)
|
||||
dispatcher.register(hotspots_cmd)
|
||||
for alias in getattr(hotspots_cmd, 'aliases', []):
|
||||
alias_handler = HotspotsCommand(env_store)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
# Register custom commands
|
||||
if custom_commands:
|
||||
for name, response in custom_commands.items():
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
"""Satellite fire hotspot command."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class HotspotsCommand(CommandHandler):
|
||||
"""Show NASA FIRMS satellite fire hotspot data."""
|
||||
|
||||
aliases = ["satellite", "ignitions"]
|
||||
|
||||
def __init__(self, env_store):
|
||||
self._env_store = env_store
|
||||
self._name = "hotspots"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._name = value
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Show satellite fire hotspots"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "!hotspots [--new]"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
if not self._env_store:
|
||||
return "Environmental feeds not configured."
|
||||
|
||||
# Check for --new flag
|
||||
new_only = "--new" in args.lower() or "new" in args.lower().split()
|
||||
|
||||
# Get FIRMS adapter
|
||||
firms_adapter = getattr(self._env_store, "_firms", None)
|
||||
|
||||
if not firms_adapter:
|
||||
return "Satellite hotspot monitoring not configured."
|
||||
|
||||
if not firms_adapter._is_loaded:
|
||||
return "Satellite data not yet loaded. Try again shortly."
|
||||
|
||||
if firms_adapter._consecutive_errors >= 999:
|
||||
return "Satellite monitoring disabled (invalid API key)."
|
||||
|
||||
# Get events
|
||||
if new_only:
|
||||
events = firms_adapter.get_new_ignitions()
|
||||
title = "NEW IGNITIONS"
|
||||
else:
|
||||
events = firms_adapter.get_events()
|
||||
title = "FIRE HOTSPOTS"
|
||||
|
||||
if not events:
|
||||
if new_only:
|
||||
return "No new ignitions detected. All hotspots near known fires."
|
||||
return "No satellite fire hotspots detected in monitored area."
|
||||
|
||||
# Build response
|
||||
lines = [f"{title} ({len(events)}):"]
|
||||
|
||||
# Sort by severity (warning > watch > advisory) then by FRP
|
||||
severity_order = {"warning": 0, "watch": 1, "advisory": 2}
|
||||
sorted_events = sorted(
|
||||
events,
|
||||
key=lambda e: (
|
||||
severity_order.get(e.get("severity", "advisory"), 3),
|
||||
-(e.get("properties", {}).get("frp") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
for event in sorted_events[:8]: # Limit for mesh
|
||||
props = event.get("properties", {})
|
||||
severity = event.get("severity", "advisory").upper()[:1] # W/A
|
||||
|
||||
# Format line
|
||||
line = f"[{severity}] {event.get('headline', 'Unknown')}"
|
||||
|
||||
# Add confidence and FRP if available
|
||||
details = []
|
||||
if props.get("confidence"):
|
||||
details.append(f"conf:{props['confidence']}")
|
||||
if props.get("frp"):
|
||||
details.append(f"{int(props['frp'])}MW")
|
||||
if props.get("acq_time"):
|
||||
details.append(f"@{props['acq_time']}Z")
|
||||
|
||||
if details:
|
||||
line += f" ({', '.join(details)})"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
if len(events) > 8:
|
||||
lines.append(f"...and {len(events) - 8} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
|
@ -8,7 +8,6 @@ if TYPE_CHECKING:
|
|||
from ..mesh_data_store import MeshDataStore
|
||||
from ..mesh_reporter import MeshReporter
|
||||
from ..subscriptions import SubscriptionManager
|
||||
from ..notifications.router import NotificationRouter
|
||||
|
||||
|
||||
class SubCommand(CommandHandler):
|
||||
|
|
@ -16,7 +15,7 @@ class SubCommand(CommandHandler):
|
|||
|
||||
name = "sub"
|
||||
description = "Subscribe to reports or alerts"
|
||||
usage = "!sub daily|weekly|alerts|<category> [time] [day] [scope]"
|
||||
usage = "!sub daily|weekly|alerts [time] [day] [scope]"
|
||||
aliases = ["subscribe"]
|
||||
|
||||
def __init__(
|
||||
|
|
@ -24,36 +23,24 @@ class SubCommand(CommandHandler):
|
|||
subscription_manager: "SubscriptionManager" = None,
|
||||
mesh_reporter: "MeshReporter" = None,
|
||||
data_store: "MeshDataStore" = None,
|
||||
notification_router: "NotificationRouter" = None,
|
||||
):
|
||||
self._sub_manager = subscription_manager
|
||||
self._reporter = mesh_reporter
|
||||
self._data_store = data_store
|
||||
self._notification_router = notification_router
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Handle subscription command."""
|
||||
parts = args.strip().split()
|
||||
|
||||
# No args - show available alert categories
|
||||
if not parts:
|
||||
return self._show_categories()
|
||||
|
||||
sub_type = parts[0].lower()
|
||||
|
||||
# Check if it's a category subscription
|
||||
if self._notification_router:
|
||||
from ..notifications.categories import ALERT_CATEGORIES
|
||||
if sub_type in ALERT_CATEGORIES or sub_type == "all":
|
||||
return self._handle_category_subscription(sub_type, context)
|
||||
|
||||
# Legacy subscription types
|
||||
if sub_type not in ("daily", "weekly", "alerts"):
|
||||
return self._show_categories()
|
||||
|
||||
if not self._sub_manager:
|
||||
return "Subscriptions not available."
|
||||
|
||||
parts = args.strip().split()
|
||||
if not parts:
|
||||
return self._usage_help()
|
||||
|
||||
sub_type = parts[0].lower()
|
||||
if sub_type not in ("daily", "weekly", "alerts"):
|
||||
return f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts"
|
||||
|
||||
try:
|
||||
if sub_type == "daily":
|
||||
return self._handle_daily(parts[1:], context)
|
||||
|
|
@ -64,55 +51,15 @@ class SubCommand(CommandHandler):
|
|||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def _show_categories(self) -> str:
|
||||
"""Show available alert categories."""
|
||||
try:
|
||||
from ..notifications.categories import ALERT_CATEGORIES
|
||||
except ImportError:
|
||||
return self._usage_help()
|
||||
|
||||
lines = ["Available alert categories:"]
|
||||
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
||||
lines.append(f" {cat_id} - {cat_info['description']}")
|
||||
lines.append("")
|
||||
lines.append("Usage:")
|
||||
lines.append(" !sub <category> - subscribe to a category")
|
||||
lines.append(" !sub all - subscribe to all alerts")
|
||||
lines.append(" !sub alerts - legacy mesh-wide alerts")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _handle_category_subscription(self, category: str, context: CommandContext) -> str:
|
||||
"""Handle category-based alert subscription."""
|
||||
node_id = self._get_user_id(context)
|
||||
|
||||
if category == "all":
|
||||
categories = [] # Empty = all categories
|
||||
else:
|
||||
categories = [category]
|
||||
|
||||
# Add subscription via notification router
|
||||
rule_name = self._notification_router.add_mesh_subscription(
|
||||
node_id=node_id,
|
||||
categories=categories,
|
||||
)
|
||||
|
||||
if category == "all":
|
||||
return "Subscribed to all alert categories. Use !unsub to remove."
|
||||
else:
|
||||
from ..notifications.categories import get_category
|
||||
cat_info = get_category(category)
|
||||
return f"Subscribed to {cat_info['name']} alerts. Use !unsub {category} to remove."
|
||||
|
||||
def _usage_help(self) -> str:
|
||||
"""Return usage help."""
|
||||
return """Usage:
|
||||
!sub daily 1830 - daily mesh report at 6:30 PM
|
||||
!sub daily 1830 region SCID - daily region report
|
||||
!sub daily 1830 node MHR - daily node report
|
||||
!sub weekly 0800 sun - weekly digest Sunday 8 AM
|
||||
!sub alerts - mesh-wide alerts (legacy)
|
||||
!sub <category> - subscribe to alert category
|
||||
!sub all - subscribe to all alerts"""
|
||||
!sub alerts - mesh-wide alerts
|
||||
!sub alerts region SCID - alerts for a region"""
|
||||
|
||||
def _handle_daily(self, args: list, context: CommandContext) -> str:
|
||||
"""Handle daily subscription."""
|
||||
|
|
@ -121,9 +68,11 @@ class SubCommand(CommandHandler):
|
|||
|
||||
schedule_time = args[0]
|
||||
scope_type, scope_value = self._parse_scope(args[1:])
|
||||
|
||||
# Validate scope
|
||||
scope_value = self._validate_scope(scope_type, scope_value)
|
||||
|
||||
self._sub_manager.add(
|
||||
result = self._sub_manager.add(
|
||||
user_id=self._get_user_id(context),
|
||||
sub_type="daily",
|
||||
schedule_time=schedule_time,
|
||||
|
|
@ -143,9 +92,11 @@ class SubCommand(CommandHandler):
|
|||
schedule_time = args[0]
|
||||
schedule_day = args[1].lower()
|
||||
scope_type, scope_value = self._parse_scope(args[2:])
|
||||
|
||||
# Validate scope
|
||||
scope_value = self._validate_scope(scope_type, scope_value)
|
||||
|
||||
self._sub_manager.add(
|
||||
result = self._sub_manager.add(
|
||||
user_id=self._get_user_id(context),
|
||||
sub_type="weekly",
|
||||
schedule_time=schedule_time,
|
||||
|
|
@ -160,11 +111,13 @@ class SubCommand(CommandHandler):
|
|||
return f"Subscribed: weekly {scope_desc}report at {time_fmt} {day_fmt}"
|
||||
|
||||
def _handle_alerts(self, args: list, context: CommandContext) -> str:
|
||||
"""Handle alerts subscription (legacy)."""
|
||||
"""Handle alerts subscription."""
|
||||
scope_type, scope_value = self._parse_scope(args)
|
||||
|
||||
# Validate scope
|
||||
scope_value = self._validate_scope(scope_type, scope_value)
|
||||
|
||||
self._sub_manager.add(
|
||||
result = self._sub_manager.add(
|
||||
user_id=self._get_user_id(context),
|
||||
sub_type="alerts",
|
||||
scope_type=scope_type,
|
||||
|
|
@ -175,10 +128,15 @@ class SubCommand(CommandHandler):
|
|||
return f"Subscribed: alerts for {scope_desc.strip() or 'mesh'}"
|
||||
|
||||
def _parse_scope(self, args: list) -> tuple[str, str]:
|
||||
"""Parse scope from remaining args."""
|
||||
"""Parse scope from remaining args.
|
||||
|
||||
Returns:
|
||||
(scope_type, scope_value) tuple
|
||||
"""
|
||||
if not args:
|
||||
return "mesh", None
|
||||
|
||||
# Look for 'region' or 'node' keyword
|
||||
scope_type = "mesh"
|
||||
scope_value = None
|
||||
|
||||
|
|
@ -186,17 +144,26 @@ class SubCommand(CommandHandler):
|
|||
arg_lower = arg.lower()
|
||||
if arg_lower == "region":
|
||||
scope_type = "region"
|
||||
# Everything after 'region' is the region name
|
||||
scope_value = " ".join(args[i + 1:]) if i + 1 < len(args) else None
|
||||
break
|
||||
elif arg_lower == "node":
|
||||
scope_type = "node"
|
||||
# Next arg is the node identifier
|
||||
scope_value = args[i + 1] if i + 1 < len(args) else None
|
||||
break
|
||||
|
||||
return scope_type, scope_value
|
||||
|
||||
def _validate_scope(self, scope_type: str, scope_value: str) -> str:
|
||||
"""Validate and resolve scope value."""
|
||||
"""Validate and resolve scope value.
|
||||
|
||||
Returns:
|
||||
Resolved scope_value (e.g., full region name)
|
||||
|
||||
Raises:
|
||||
ValueError: If scope not found
|
||||
"""
|
||||
if scope_type == "mesh":
|
||||
return None
|
||||
|
||||
|
|
@ -205,9 +172,14 @@ class SubCommand(CommandHandler):
|
|||
|
||||
if scope_type == "region" and self._reporter:
|
||||
region = self._reporter._find_region(scope_value)
|
||||
if region:
|
||||
return region.name
|
||||
return scope_value
|
||||
if not region:
|
||||
# List available regions
|
||||
health = self._reporter.health_engine.mesh_health
|
||||
if health:
|
||||
available = [r.name for r in health.regions if r.node_ids]
|
||||
return scope_value # Use as-is, will fail at delivery if invalid
|
||||
raise ValueError(f"Region '{scope_value}' not found")
|
||||
return region.name # Return canonical name
|
||||
|
||||
if scope_type == "node" and self._reporter:
|
||||
node = self._reporter._find_node(scope_value)
|
||||
|
|
@ -219,6 +191,7 @@ class SubCommand(CommandHandler):
|
|||
|
||||
def _get_user_id(self, context: CommandContext) -> str:
|
||||
"""Extract user ID from context."""
|
||||
# sender_id is like "!abcd1234" - convert to node_num
|
||||
sender_id = context.sender_id
|
||||
if sender_id.startswith("!"):
|
||||
return str(int(sender_id[1:], 16))
|
||||
|
|
@ -244,40 +217,26 @@ class UnsubCommand(CommandHandler):
|
|||
|
||||
name = "unsub"
|
||||
description = "Remove subscription(s)"
|
||||
usage = "!unsub daily|weekly|alerts|<category>|all"
|
||||
usage = "!unsub daily|weekly|alerts|all"
|
||||
aliases = ["unsubscribe"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_manager: "SubscriptionManager" = None,
|
||||
notification_router: "NotificationRouter" = None,
|
||||
):
|
||||
def __init__(self, subscription_manager: "SubscriptionManager" = None):
|
||||
self._sub_manager = subscription_manager
|
||||
self._notification_router = notification_router
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Handle unsubscribe command."""
|
||||
sub_type = args.strip().lower() if args else None
|
||||
|
||||
if not sub_type:
|
||||
return "Usage: !unsub daily|weekly|alerts|<category>|all"
|
||||
|
||||
user_id = self._get_user_id(context)
|
||||
|
||||
# Check if it's a category unsubscription
|
||||
if self._notification_router:
|
||||
from ..notifications.categories import ALERT_CATEGORIES
|
||||
if sub_type in ALERT_CATEGORIES or sub_type == "all":
|
||||
self._notification_router.remove_mesh_subscription(user_id)
|
||||
return "Removed alert subscriptions"
|
||||
|
||||
# Legacy subscription types
|
||||
if not self._sub_manager:
|
||||
return "Subscriptions not available."
|
||||
|
||||
if sub_type not in ("daily", "weekly", "alerts", "all"):
|
||||
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, <category>, or all"
|
||||
sub_type = args.strip().lower() if args else None
|
||||
|
||||
if not sub_type:
|
||||
return "Usage: !unsub daily|weekly|alerts|all"
|
||||
|
||||
if sub_type not in ("daily", "weekly", "alerts", "all"):
|
||||
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, or all"
|
||||
|
||||
user_id = self._get_user_id(context)
|
||||
removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None)
|
||||
|
||||
if removed == 0:
|
||||
|
|
@ -301,44 +260,26 @@ class MySubsCommand(CommandHandler):
|
|||
name = "mysubs"
|
||||
description = "List your subscriptions"
|
||||
usage = "!mysubs"
|
||||
aliases = ["subs", "subscriptions"]
|
||||
aliases = ["subs"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_manager: "SubscriptionManager" = None,
|
||||
notification_router: "NotificationRouter" = None,
|
||||
):
|
||||
def __init__(self, subscription_manager: "SubscriptionManager" = None):
|
||||
self._sub_manager = subscription_manager
|
||||
self._notification_router = notification_router
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""List user's subscriptions."""
|
||||
if not self._sub_manager:
|
||||
return "Subscriptions not available."
|
||||
|
||||
user_id = self._get_user_id(context)
|
||||
lines = []
|
||||
subs = self._sub_manager.get_user_subs(user_id)
|
||||
|
||||
# Check notification router subscriptions
|
||||
if self._notification_router:
|
||||
categories = self._notification_router.get_node_subscriptions(user_id)
|
||||
if categories:
|
||||
if categories == ["all"]:
|
||||
lines.append("Alert subscriptions: all categories")
|
||||
else:
|
||||
lines.append(f"Alert subscriptions: {', '.join(categories)}")
|
||||
|
||||
# Check legacy subscriptions
|
||||
if self._sub_manager:
|
||||
subs = self._sub_manager.get_user_subs(user_id)
|
||||
if subs:
|
||||
if not lines:
|
||||
lines.append("Your subscriptions:")
|
||||
else:
|
||||
lines.append("\nScheduled reports:")
|
||||
for i, sub in enumerate(subs, 1):
|
||||
lines.append(f" {i}. {self._format_sub(sub)}")
|
||||
|
||||
if not lines:
|
||||
if not subs:
|
||||
return "No active subscriptions. Use !sub to subscribe."
|
||||
|
||||
lines = ["Your subscriptions:"]
|
||||
for i, sub in enumerate(subs, 1):
|
||||
lines.append(f" {i}. {self._format_sub(sub)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_sub(self, sub: dict) -> str:
|
||||
|
|
@ -360,7 +301,7 @@ class MySubsCommand(CommandHandler):
|
|||
time_str = self._format_time(sub.get("schedule_time", "0000"))
|
||||
day_str = (sub.get("schedule_day") or "").capitalize()
|
||||
return f"Weekly {scope_desc}report at {time_str} {day_str}"
|
||||
else:
|
||||
else: # alerts
|
||||
return f"Alerts for {scope_desc.strip() or 'mesh'}"
|
||||
|
||||
def _format_time(self, hhmm: str) -> str:
|
||||
|
|
|
|||
161
meshai/config.py
161
meshai/config.py
|
|
@ -393,20 +393,6 @@ class Roads511Config:
|
|||
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FIRMSConfig:
|
||||
"""NASA FIRMS satellite fire hotspot settings."""
|
||||
|
||||
enabled: bool = False
|
||||
tick_seconds: int = 1800 # 30 min default
|
||||
map_key: str = "" # NASA FIRMS MAP_KEY, get at https://firms.modaps.eosdis.nasa.gov/api/area/
|
||||
source: str = "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||
day_range: int = 1 # 1-10 days of data
|
||||
confidence_min: str = "nominal" # low, nominal, high
|
||||
proximity_km: float = 10.0 # km to match known fire
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvironmentalConfig:
|
||||
"""Environmental feeds settings."""
|
||||
|
|
@ -421,76 +407,21 @@ class EnvironmentalConfig:
|
|||
usgs: USGSConfig = field(default_factory=USGSConfig)
|
||||
traffic: TomTomConfig = field(default_factory=TomTomConfig)
|
||||
roads511: Roads511Config = field(default_factory=Roads511Config)
|
||||
firms: FIRMSConfig = field(default_factory=FIRMSConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationRuleConfig:
|
||||
"""Self-contained notification rule with inline delivery config."""
|
||||
|
||||
name: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
# Trigger type
|
||||
trigger_type: str = "condition" # "condition" or "schedule"
|
||||
|
||||
# Condition trigger fields
|
||||
categories: list = field(default_factory=list) # Empty = all categories
|
||||
min_severity: str = "warning"
|
||||
|
||||
# Schedule trigger fields
|
||||
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
|
||||
schedule_time: str = "07:00"
|
||||
schedule_time_2: str = "19:00" # For twice_daily
|
||||
schedule_days: list = field(default_factory=list) # For weekly
|
||||
schedule_cron: str = "" # For custom
|
||||
message_type: str = "mesh_health_summary"
|
||||
custom_message: str = ""
|
||||
|
||||
# Delivery type
|
||||
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook
|
||||
|
||||
# Mesh broadcast fields
|
||||
broadcast_channel: int = 0
|
||||
|
||||
# Mesh DM fields
|
||||
node_ids: list = field(default_factory=list)
|
||||
|
||||
# Email fields
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_tls: bool = True
|
||||
from_address: str = ""
|
||||
recipients: list = field(default_factory=list)
|
||||
|
||||
# Webhook fields
|
||||
webhook_url: str = ""
|
||||
webhook_headers: dict = field(default_factory=dict)
|
||||
|
||||
# Behavior
|
||||
cooldown_minutes: int = 10
|
||||
override_quiet: bool = False
|
||||
|
||||
# Legacy field for migration (ignored in new format)
|
||||
channel_ids: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationsConfig:
|
||||
"""Notification system settings."""
|
||||
|
||||
enabled: bool = False
|
||||
quiet_hours_start: str = "22:00"
|
||||
quiet_hours_end: str = "06:00"
|
||||
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
||||
|
||||
@dataclass
|
||||
class DashboardConfig:
|
||||
"""Web dashboard settings."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
port: int = 8080
|
||||
host: str = "0.0.0.0"
|
||||
|
||||
|
|
@ -516,7 +447,6 @@ class Config:
|
|||
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
|
||||
environmental: EnvironmentalConfig = field(default_factory=EnvironmentalConfig)
|
||||
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
||||
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
|
||||
|
||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||
|
||||
|
|
@ -535,69 +465,6 @@ class Config:
|
|||
return ""
|
||||
|
||||
|
||||
def _migrate_legacy_channels(notifications, data: dict):
|
||||
"""Migrate legacy channels+rules format to self-contained rules."""
|
||||
old_channels = data.get("channels", [])
|
||||
old_rules = data.get("rules", [])
|
||||
|
||||
if not old_channels:
|
||||
return
|
||||
|
||||
_config_logger.info("Migrating %d legacy notification channels to inline rules", len(old_channels))
|
||||
|
||||
# Build channel lookup
|
||||
channel_map = {}
|
||||
for ch in old_channels:
|
||||
if isinstance(ch, dict):
|
||||
channel_map[ch.get("id", "")] = ch
|
||||
|
||||
# Convert each old rule + referenced channels to new format
|
||||
migrated_rules = []
|
||||
for old_rule in old_rules:
|
||||
if not isinstance(old_rule, dict):
|
||||
continue
|
||||
|
||||
channel_ids = old_rule.get("channel_ids", [])
|
||||
if not channel_ids:
|
||||
continue
|
||||
|
||||
for ch_id in channel_ids:
|
||||
ch = channel_map.get(ch_id)
|
||||
if not ch:
|
||||
continue
|
||||
|
||||
# Create new rule with inline delivery config
|
||||
new_rule = NotificationRuleConfig(
|
||||
name=old_rule.get("name", "") or ch_id,
|
||||
enabled=ch.get("enabled", True),
|
||||
trigger_type="condition",
|
||||
categories=old_rule.get("categories", []),
|
||||
min_severity=old_rule.get("min_severity", "warning"),
|
||||
delivery_type=ch.get("type", "mesh_broadcast"),
|
||||
broadcast_channel=ch.get("channel_index", 0),
|
||||
node_ids=ch.get("node_ids", []),
|
||||
smtp_host=ch.get("smtp_host", ""),
|
||||
smtp_port=ch.get("smtp_port", 587),
|
||||
smtp_user=ch.get("smtp_user", ""),
|
||||
smtp_password=ch.get("smtp_password", ""),
|
||||
smtp_tls=ch.get("smtp_tls", True),
|
||||
from_address=ch.get("from_address", ""),
|
||||
recipients=ch.get("recipients", []),
|
||||
webhook_url=ch.get("url", ""),
|
||||
webhook_headers=ch.get("headers", {}),
|
||||
cooldown_minutes=10,
|
||||
override_quiet=old_rule.get("override_quiet", False),
|
||||
)
|
||||
migrated_rules.append(new_rule)
|
||||
|
||||
# Replace rules with migrated ones (migrated rules come first, then any new-format rules)
|
||||
if migrated_rules:
|
||||
# Keep only non-migrated rules (those without channel_ids)
|
||||
existing_new_rules = [r for r in notifications.rules if not getattr(r, 'channel_ids', [])]
|
||||
notifications.rules = migrated_rules + existing_new_rules
|
||||
_config_logger.info("Migrated to %d self-contained rules", len(notifications.rules))
|
||||
|
||||
|
||||
def _dict_to_dataclass(cls, data: dict):
|
||||
"""Recursively convert dict to dataclass, handling nested structures."""
|
||||
if data is None:
|
||||
|
|
@ -651,18 +518,6 @@ def _dict_to_dataclass(cls, data: dict):
|
|||
kwargs[key] = _dict_to_dataclass(TomTomConfig, value)
|
||||
elif key == "roads511" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(Roads511Config, value)
|
||||
elif key == "firms" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(FIRMSConfig, value)
|
||||
elif key == "dashboard" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
|
||||
elif key == "notifications" and isinstance(value, dict):
|
||||
notifications = _dict_to_dataclass(NotificationsConfig, value)
|
||||
if "rules" in value and isinstance(value["rules"], list):
|
||||
notifications.rules = [_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r for r in value["rules"]]
|
||||
# Migrate old channels+rules format if present
|
||||
if "channels" in value and isinstance(value["channels"], list) and value["channels"]:
|
||||
_migrate_legacy_channels(notifications, value)
|
||||
kwargs[key] = notifications
|
||||
else:
|
||||
kwargs[key] = value
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Alert API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["alerts"])
|
||||
|
||||
|
|
@ -9,21 +8,22 @@ router = APIRouter(tags=["alerts"])
|
|||
@router.get("/alerts/active")
|
||||
async def get_active_alerts(request: Request):
|
||||
"""Get currently active alerts."""
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
alert_engine = request.app.state.alert_engine
|
||||
|
||||
if not alert_engine:
|
||||
return []
|
||||
|
||||
# Get recent alerts from alert engine if it has internal state
|
||||
alerts = []
|
||||
|
||||
# Try get_pending_alerts first (our method)
|
||||
if hasattr(alert_engine, "get_pending_alerts"):
|
||||
# Check for AlertState or similar if available
|
||||
if hasattr(alert_engine, "get_active_alerts"):
|
||||
try:
|
||||
raw_alerts = alert_engine.get_pending_alerts()
|
||||
raw_alerts = alert_engine.get_active_alerts()
|
||||
for alert in raw_alerts:
|
||||
alerts.append({
|
||||
"type": alert.get("type", "unknown"),
|
||||
"severity": _map_severity(alert),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": alert.get("timestamp"),
|
||||
"scope_type": alert.get("scope_type"),
|
||||
|
|
@ -31,6 +31,17 @@ async def get_active_alerts(request: Request):
|
|||
})
|
||||
except Exception:
|
||||
pass
|
||||
elif hasattr(alert_engine, "_recent_alerts"):
|
||||
try:
|
||||
for alert in alert_engine._recent_alerts:
|
||||
alerts.append({
|
||||
"type": alert.get("type", "unknown"),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": alert.get("timestamp"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return alerts
|
||||
|
||||
|
|
@ -38,28 +49,19 @@ async def get_active_alerts(request: Request):
|
|||
@router.get("/alerts/history")
|
||||
async def get_alert_history(
|
||||
request: Request,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
type: Optional[str] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""Get historical alerts with pagination and filtering.
|
||||
|
||||
Note: Alert history persistence is not yet implemented.
|
||||
Returns empty array for now.
|
||||
"""
|
||||
# Future: Query SQLite for historical alerts
|
||||
# For now, return empty with proper structure
|
||||
return {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
}
|
||||
"""Get historical alerts with pagination."""
|
||||
# Historical alert data would come from SQLite
|
||||
# For now, return empty list
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def get_subscriptions(request: Request):
|
||||
"""Get all alert subscriptions."""
|
||||
subscription_manager = getattr(request.app.state, "subscription_manager", None)
|
||||
subscription_manager = request.app.state.subscription_manager
|
||||
|
||||
if not subscription_manager:
|
||||
return []
|
||||
|
|
@ -81,19 +83,3 @@ async def get_subscriptions(request: Request):
|
|||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _map_severity(alert: dict) -> str:
|
||||
"""Map alert properties to severity level."""
|
||||
if alert.get("is_critical"):
|
||||
return "critical"
|
||||
alert_type = alert.get("type", "")
|
||||
if "emergency" in alert_type:
|
||||
return "emergency"
|
||||
if "critical" in alert_type:
|
||||
return "critical"
|
||||
if "warning" in alert_type:
|
||||
return "warning"
|
||||
if "watch" in alert_type:
|
||||
return "watch"
|
||||
return "info"
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ RESTART_REQUIRED_SECTIONS = {
|
|||
|
||||
# Valid config section names
|
||||
VALID_SECTIONS = {
|
||||
"notifications",
|
||||
"environmental",
|
||||
"bot",
|
||||
"connection",
|
||||
"response",
|
||||
|
|
|
|||
|
|
@ -138,26 +138,3 @@ async def get_roads_data(request: Request):
|
|||
return []
|
||||
|
||||
return env_store.get_active(source="511")
|
||||
|
||||
|
||||
@router.get("/env/hotspots")
|
||||
async def get_hotspots_data(request: Request):
|
||||
"""Get NASA FIRMS satellite fire hotspots."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"hotspots": [], "new_ignitions": 0}
|
||||
|
||||
firms_adapter = getattr(env_store, "_firms", None)
|
||||
|
||||
if not firms_adapter:
|
||||
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
||||
|
||||
hotspots = env_store.get_active(source="firms")
|
||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"hotspots": hotspots,
|
||||
"new_ignitions": len(new_ignitions),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,50 +354,3 @@ async def get_edges(request: Request):
|
|||
})
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
async def get_channels(request: Request):
|
||||
"""Get radio channels from the connected Meshtastic interface."""
|
||||
connector = getattr(request.app.state, "connector", None)
|
||||
|
||||
if not connector or not connector.connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
interface = connector._interface
|
||||
if not interface or not hasattr(interface, "localNode"):
|
||||
return []
|
||||
|
||||
local_node = interface.localNode
|
||||
if not local_node or not hasattr(local_node, "channels"):
|
||||
return []
|
||||
|
||||
channels = []
|
||||
for ch in local_node.channels:
|
||||
if ch is None:
|
||||
continue
|
||||
|
||||
# Get channel settings
|
||||
settings = getattr(ch, "settings", None)
|
||||
name = getattr(settings, "name", "") if settings else ""
|
||||
role_val = getattr(ch, "role", 0)
|
||||
|
||||
# Map role enum to string
|
||||
role_map = {0: "DISABLED", 1: "PRIMARY", 2: "SECONDARY"}
|
||||
role = role_map.get(role_val, "UNKNOWN")
|
||||
|
||||
channels.append({
|
||||
"index": ch.index,
|
||||
"name": name or f"Channel {ch.index}",
|
||||
"role": role,
|
||||
"enabled": role_val != 0,
|
||||
})
|
||||
|
||||
return channels
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to get channels: {e}")
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
"""Notification API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_categories():
|
||||
"""Get all alert categories with descriptions."""
|
||||
try:
|
||||
from ...notifications.categories import list_categories
|
||||
return list_categories()
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
return []
|
||||
return notification_router.get_rules()
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/test")
|
||||
async def test_rule(request: Request, rule_index: int):
|
||||
"""Send a test alert through a specific rule."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
success, message = await notification_router.test_rule(rule_index)
|
||||
return {"success": success, "message": message}
|
||||
|
|
@ -52,7 +52,6 @@ def create_app() -> FastAPI:
|
|||
from .api.mesh_routes import router as mesh_router
|
||||
from .api.env_routes import router as env_router
|
||||
from .api.alert_routes import router as alert_router
|
||||
from .api.notification_routes import router as notification_router
|
||||
|
||||
app.include_router(system_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
|
|
@ -60,7 +59,6 @@ def create_app() -> FastAPI:
|
|||
app.include_router(env_router, prefix="/api")
|
||||
app.include_router(alert_router, prefix="/api")
|
||||
|
||||
app.include_router(notification_router, prefix="/api")
|
||||
# WebSocket router (no prefix, path is /ws/live)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
|
@ -112,8 +110,6 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
|
|||
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
||||
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
||||
app.state.subscription_manager = meshai_instance.subscription_manager
|
||||
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
||||
app.state.connector = meshai_instance.connector
|
||||
|
||||
# Create broadcaster and attach to app state
|
||||
broadcaster = DashboardBroadcaster()
|
||||
|
|
|
|||
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-DvM_5H7j.css
Normal file
1
meshai/dashboard/static/assets/index-DvM_5H7j.css
Normal file
File diff suppressed because one or more lines are too long
375
meshai/dashboard/static/assets/index-Lqo8lYVT.js
Normal file
375
meshai/dashboard/static/assets/index-Lqo8lYVT.js
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-BOJS6jme.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
||||
<script type="module" crossorigin src="/assets/index-Lqo8lYVT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DvM_5H7j.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
365
meshai/env/firms.py
vendored
365
meshai/env/firms.py
vendored
|
|
@ -1,365 +0,0 @@
|
|||
"""NASA FIRMS satellite fire hotspot adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import FIRMSConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FIRMSAdapter:
|
||||
"""NASA FIRMS satellite fire hotspot polling.
|
||||
|
||||
Detects fire hotspots from satellite data (MODIS, VIIRS) typically
|
||||
hours before NIFC publishes official perimeters. Early warning.
|
||||
|
||||
API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE}
|
||||
"""
|
||||
|
||||
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
|
||||
|
||||
def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None):
|
||||
self._map_key = config.map_key
|
||||
self._source = config.source or "VIIRS_SNPP_NRT"
|
||||
self._bbox = config.bbox # [west, south, east, north]
|
||||
self._day_range = config.day_range or 1
|
||||
self._tick_interval = config.tick_seconds or 1800
|
||||
self._confidence_min = config.confidence_min or "nominal"
|
||||
self._proximity_km = config.proximity_km or 10.0 # km to match known fire
|
||||
|
||||
self._last_tick = 0.0
|
||||
self._events = []
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
|
||||
# For cross-referencing
|
||||
self._region_anchors = region_anchors or []
|
||||
self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
if now - self._last_tick < self._tick_interval:
|
||||
return False
|
||||
|
||||
self._last_tick = now
|
||||
|
||||
if not self._map_key:
|
||||
if not self._last_error:
|
||||
logger.warning("FIRMS: No MAP_KEY configured, skipping")
|
||||
self._last_error = "No MAP_KEY configured"
|
||||
return False
|
||||
|
||||
if not self._bbox or len(self._bbox) != 4:
|
||||
if not self._last_error:
|
||||
logger.warning("FIRMS: No valid bbox configured, skipping")
|
||||
self._last_error = "No valid bbox configured"
|
||||
return False
|
||||
|
||||
return self._fetch()
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch fire hotspots from NASA FIRMS.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
# Format bbox as west,south,east,north
|
||||
bbox_str = ",".join(str(c) for c in self._bbox)
|
||||
|
||||
url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}"
|
||||
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0",
|
||||
"Accept": "text/csv",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
csv_data = resp.read().decode("utf-8")
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 401:
|
||||
logger.error("FIRMS: Invalid MAP_KEY, disabling adapter")
|
||||
self._last_error = "Invalid MAP_KEY"
|
||||
self._consecutive_errors = 999 # Disable
|
||||
return False
|
||||
logger.warning(f"FIRMS HTTP error: {e.code}")
|
||||
self._last_error = f"HTTP {e.code}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"FIRMS connection error: {e.reason}")
|
||||
self._last_error = str(e.reason)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"FIRMS fetch error: {e}")
|
||||
self._last_error = str(e)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
# Parse CSV response
|
||||
new_events = self._parse_csv(csv_data)
|
||||
|
||||
# Check if data changed
|
||||
old_ids = {e["event_id"] for e in self._events}
|
||||
new_ids = {e["event_id"] for e in new_events}
|
||||
changed = old_ids != new_ids
|
||||
|
||||
self._events = new_events
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = True
|
||||
|
||||
if changed:
|
||||
new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition"))
|
||||
logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions")
|
||||
|
||||
return changed
|
||||
|
||||
def _parse_csv(self, csv_data: str) -> list:
|
||||
"""Parse FIRMS CSV response into events."""
|
||||
lines = csv_data.strip().split("\n")
|
||||
if len(lines) < 2:
|
||||
return []
|
||||
|
||||
# Parse header
|
||||
header = lines[0].split(",")
|
||||
header_map = {col.strip().lower(): i for i, col in enumerate(header)}
|
||||
|
||||
# Required columns
|
||||
lat_idx = header_map.get("latitude")
|
||||
lon_idx = header_map.get("longitude")
|
||||
conf_idx = header_map.get("confidence")
|
||||
frp_idx = header_map.get("frp") # Fire Radiative Power
|
||||
acq_date_idx = header_map.get("acq_date")
|
||||
acq_time_idx = header_map.get("acq_time")
|
||||
bright_idx = header_map.get("bright_ti4") or header_map.get("brightness")
|
||||
|
||||
if lat_idx is None or lon_idx is None:
|
||||
logger.warning("FIRMS CSV missing required columns")
|
||||
return []
|
||||
|
||||
events = []
|
||||
now = time.time()
|
||||
|
||||
# Confidence mapping
|
||||
conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3}
|
||||
min_conf = conf_values.get(self._confidence_min.lower(), 2)
|
||||
|
||||
# Get known fire locations for cross-referencing
|
||||
known_fires = self._get_known_fires()
|
||||
|
||||
for line in lines[1:]:
|
||||
cols = line.split(",")
|
||||
if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1:
|
||||
continue
|
||||
|
||||
try:
|
||||
lat = float(cols[lat_idx])
|
||||
lon = float(cols[lon_idx])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# Parse confidence
|
||||
conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n"
|
||||
conf_value = conf_values.get(conf_raw.lower(), 2)
|
||||
|
||||
# Filter by confidence
|
||||
if conf_value < min_conf:
|
||||
continue
|
||||
|
||||
# Parse FRP (fire radiative power in MW)
|
||||
frp = None
|
||||
if frp_idx is not None and frp_idx < len(cols):
|
||||
try:
|
||||
frp = float(cols[frp_idx])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Parse brightness temperature
|
||||
brightness = None
|
||||
if bright_idx is not None and bright_idx < len(cols):
|
||||
try:
|
||||
brightness = float(cols[bright_idx])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Parse acquisition datetime
|
||||
acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else ""
|
||||
acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else ""
|
||||
|
||||
# Create unique ID from position and time
|
||||
event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}"
|
||||
|
||||
# Check if near known fire
|
||||
near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires)
|
||||
|
||||
# Determine severity
|
||||
if not near_fire:
|
||||
# Potential new ignition
|
||||
severity = "watch"
|
||||
new_ignition = True
|
||||
headline = f"NEW HOTSPOT detected"
|
||||
else:
|
||||
# Near known fire
|
||||
severity = "advisory"
|
||||
new_ignition = False
|
||||
headline = f"Hotspot near {fire_name}"
|
||||
|
||||
# Bump severity for high FRP
|
||||
if frp is not None and frp > 100:
|
||||
if severity == "advisory":
|
||||
severity = "watch"
|
||||
elif severity == "watch":
|
||||
severity = "warning"
|
||||
headline += f" ({int(frp)} MW)"
|
||||
|
||||
# Compute proximity to region anchors
|
||||
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
||||
|
||||
if distance_km is not None and nearest_anchor:
|
||||
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
||||
|
||||
event = {
|
||||
"source": "firms",
|
||||
"event_id": event_id,
|
||||
"event_type": "Fire Hotspot",
|
||||
"severity": severity,
|
||||
"headline": headline,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"expires": now + 21600, # 6 hour TTL
|
||||
"fetched_at": now,
|
||||
"properties": {
|
||||
"new_ignition": new_ignition,
|
||||
"confidence": conf_raw,
|
||||
"frp": frp,
|
||||
"brightness": brightness,
|
||||
"acq_date": acq_date,
|
||||
"acq_time": acq_time,
|
||||
"near_fire": fire_name if near_fire else None,
|
||||
"distance_to_fire_km": distance_to_fire,
|
||||
"distance_km": distance_km,
|
||||
"nearest_anchor": nearest_anchor,
|
||||
},
|
||||
}
|
||||
|
||||
events.append(event)
|
||||
|
||||
return events
|
||||
|
||||
def _get_known_fires(self) -> list:
|
||||
"""Get known fire locations from NIFC adapter."""
|
||||
if not self._fires_adapter:
|
||||
return []
|
||||
|
||||
fires = self._fires_adapter.get_events()
|
||||
return [
|
||||
{
|
||||
"name": f.get("name", "Unknown"),
|
||||
"lat": f.get("lat"),
|
||||
"lon": f.get("lon"),
|
||||
}
|
||||
for f in fires
|
||||
if f.get("lat") is not None and f.get("lon") is not None
|
||||
]
|
||||
|
||||
def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple:
|
||||
"""Check if hotspot is near a known fire.
|
||||
|
||||
Returns:
|
||||
(is_near, fire_name, distance_km)
|
||||
"""
|
||||
if not known_fires:
|
||||
return (False, None, None)
|
||||
|
||||
from ..geo import haversine_distance
|
||||
|
||||
for fire in known_fires:
|
||||
fire_lat = fire.get("lat")
|
||||
fire_lon = fire.get("lon")
|
||||
if fire_lat is None or fire_lon is None:
|
||||
continue
|
||||
|
||||
# haversine_distance returns miles, convert to km
|
||||
dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon)
|
||||
dist_km = dist_miles * 1.60934
|
||||
|
||||
if dist_km <= self._proximity_km:
|
||||
return (True, fire.get("name"), dist_km)
|
||||
|
||||
return (False, None, None)
|
||||
|
||||
def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple:
|
||||
"""Find distance to nearest region anchor.
|
||||
|
||||
Returns:
|
||||
(distance_km, anchor_name) or (None, None)
|
||||
"""
|
||||
if not self._region_anchors:
|
||||
return (None, None)
|
||||
|
||||
from ..geo import haversine_distance
|
||||
|
||||
min_dist = float("inf")
|
||||
nearest_name = None
|
||||
|
||||
for anchor in self._region_anchors:
|
||||
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
|
||||
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
|
||||
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
||||
|
||||
if anchor_lat is None or anchor_lon is None:
|
||||
continue
|
||||
|
||||
# haversine_distance returns miles, convert to km
|
||||
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
||||
dist_km = dist_miles * 1.60934
|
||||
|
||||
if dist_km < min_dist:
|
||||
min_dist = dist_km
|
||||
nearest_name = anchor_name
|
||||
|
||||
if min_dist < float("inf"):
|
||||
return (min_dist, nearest_name)
|
||||
|
||||
return (None, None)
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current hotspot events."""
|
||||
return self._events
|
||||
|
||||
def get_new_ignitions(self) -> list:
|
||||
"""Get only potential new ignitions (not near known fires)."""
|
||||
return [e for e in self._events if e.get("properties", {}).get("new_ignition")]
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
new_ignitions = len(self.get_new_ignitions())
|
||||
return {
|
||||
"source": "firms",
|
||||
"is_loaded": self._is_loaded,
|
||||
"last_error": str(self._last_error) if self._last_error else None,
|
||||
"consecutive_errors": self._consecutive_errors,
|
||||
"event_count": len(self._events),
|
||||
"new_ignitions": new_ignitions,
|
||||
"last_fetch": self._last_tick,
|
||||
}
|
||||
17
meshai/env/store.py
vendored
17
meshai/env/store.py
vendored
|
|
@ -54,13 +54,6 @@ class EnvironmentalStore:
|
|||
from .roads511 import Roads511Adapter
|
||||
self._adapters["roads511"] = Roads511Adapter(config.roads511)
|
||||
|
||||
# FIRMS needs reference to NIFC adapter for cross-referencing
|
||||
if config.firms.enabled:
|
||||
from .firms import FIRMSAdapter
|
||||
fires_adapter = self._adapters.get("nifc")
|
||||
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter)
|
||||
self._adapters["firms"] = self._firms
|
||||
|
||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
|
|
@ -231,16 +224,6 @@ class EnvironmentalStore:
|
|||
for r in roads[:2]:
|
||||
lines.append(f" - {r['headline'][:60]}")
|
||||
|
||||
# Satellite hotspots
|
||||
hotspots = self.get_active(source="firms")
|
||||
if hotspots:
|
||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||
lines.append(f"Satellite Hotspots: {len(hotspots)} detected")
|
||||
if new_ignitions:
|
||||
lines.append(f" *** {len(new_ignitions)} POTENTIAL NEW IGNITION(S) ***")
|
||||
for h in hotspots[:2]:
|
||||
lines.append(f" - {h['headline']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_source_health(self) -> list:
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ class MeshAI:
|
|||
self.mesh_reporter = None
|
||||
self.subscription_manager = None
|
||||
self.alert_engine = None
|
||||
self.notification_router = None
|
||||
self.env_store = None # Environmental feeds store
|
||||
self._last_sub_check: float = 0.0
|
||||
self.router: Optional[MessageRouter] = None
|
||||
|
|
@ -338,18 +337,6 @@ class MeshAI:
|
|||
)
|
||||
logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})")
|
||||
|
||||
|
||||
# Notification router
|
||||
if self.config.notifications.enabled:
|
||||
from .notifications.router import NotificationRouter
|
||||
self.notification_router = NotificationRouter(
|
||||
config=self.config.notifications,
|
||||
connector=self.connector,
|
||||
llm_backend=self.llm,
|
||||
timezone=self.config.timezone,
|
||||
)
|
||||
logger.info("Notification router initialized")
|
||||
|
||||
# Environmental feeds
|
||||
env_cfg = self.config.environmental
|
||||
if env_cfg.enabled:
|
||||
|
|
@ -407,7 +394,6 @@ class MeshAI:
|
|||
health_engine=self.health_engine,
|
||||
subscription_manager=self.subscription_manager,
|
||||
env_store=self.env_store,
|
||||
notification_router=self.notification_router,
|
||||
)
|
||||
|
||||
# Message router
|
||||
|
|
@ -420,7 +406,6 @@ class MeshAI:
|
|||
health_engine=self.health_engine,
|
||||
mesh_reporter=self.mesh_reporter,
|
||||
env_store=self.env_store,
|
||||
# notification_router not used by MessageRouter
|
||||
)
|
||||
|
||||
# Responder
|
||||
|
|
@ -563,38 +548,30 @@ class MeshAI:
|
|||
message = alert["message"]
|
||||
logger.info(f"ALERT: {message}")
|
||||
|
||||
# Route through notification router if enabled
|
||||
if self.notification_router:
|
||||
try:
|
||||
await self.notification_router.process_alert(alert)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification router error: {e}")
|
||||
|
||||
# Fallback: Send to alert channel if no notification router
|
||||
elif alert_channel >= 0 and self.connector:
|
||||
# Send to alert channel if configured
|
||||
if alert_channel >= 0 and self.connector:
|
||||
try:
|
||||
self.connector.send_message(
|
||||
text=message,
|
||||
destination=None,
|
||||
destination=None, # Broadcast
|
||||
channel=alert_channel,
|
||||
)
|
||||
logger.info(f"Alert sent to channel {alert_channel}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel alert: {e}")
|
||||
|
||||
# Fallback: Send DMs to matching subscribers
|
||||
if self.alert_engine and self.subscription_manager:
|
||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
||||
for sub in subscribers:
|
||||
user_id = sub["user_id"]
|
||||
try:
|
||||
await self._send_sub_dm(user_id, message)
|
||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
||||
# Send DMs to matching subscribers
|
||||
if self.alert_engine and self.subscription_manager:
|
||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
||||
for sub in subscribers:
|
||||
user_id = sub["user_id"]
|
||||
try:
|
||||
await self._send_sub_dm(user_id, message)
|
||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
||||
|
||||
if self.alert_engine:
|
||||
self.alert_engine.clear_pending()
|
||||
self.alert_engine.clear_pending()
|
||||
|
||||
async def _check_scheduled_subs(self) -> None:
|
||||
"""Check for and deliver due scheduled reports."""
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
"""Notification system for MeshAI alerts."""
|
||||
|
||||
from .categories import ALERT_CATEGORIES, get_category, list_categories
|
||||
from .router import NotificationRouter
|
||||
|
||||
__all__ = ["ALERT_CATEGORIES", "get_category", "list_categories", "NotificationRouter"]
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
"""Alert category registry.
|
||||
|
||||
Defines all alertable conditions with human-readable names and descriptions.
|
||||
"""
|
||||
|
||||
ALERT_CATEGORIES = {
|
||||
# Infrastructure alerts
|
||||
"infra_offline": {
|
||||
"name": "Infrastructure Offline",
|
||||
"description": "An infrastructure node stopped responding",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"critical_node_down": {
|
||||
"name": "Critical Node Down",
|
||||
"description": "A node marked as critical went offline",
|
||||
"default_severity": "critical",
|
||||
},
|
||||
"infra_recovery": {
|
||||
"name": "Infrastructure Recovery",
|
||||
"description": "An infrastructure node came back online",
|
||||
"default_severity": "info",
|
||||
},
|
||||
"new_router": {
|
||||
"name": "New Router",
|
||||
"description": "A new router appeared on the mesh",
|
||||
"default_severity": "info",
|
||||
},
|
||||
|
||||
# Power alerts
|
||||
"battery_warning": {
|
||||
"name": "Battery Warning",
|
||||
"description": "Infrastructure node battery below warning threshold",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"battery_critical": {
|
||||
"name": "Battery Critical",
|
||||
"description": "Infrastructure node battery below critical threshold",
|
||||
"default_severity": "critical",
|
||||
},
|
||||
"battery_emergency": {
|
||||
"name": "Battery Emergency",
|
||||
"description": "Infrastructure node battery critically low",
|
||||
"default_severity": "emergency",
|
||||
},
|
||||
"battery_trend": {
|
||||
"name": "Battery Declining",
|
||||
"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 outage)",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"solar_not_charging": {
|
||||
"name": "Solar Not Charging",
|
||||
"description": "Solar panel not charging during daylight hours",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
|
||||
# Utilization alerts
|
||||
"sustained_high_util": {
|
||||
"name": "High Utilization",
|
||||
"description": "Channel utilization elevated for extended period",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"packet_flood": {
|
||||
"name": "Packet Flood",
|
||||
"description": "Node sending excessive packets",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
|
||||
# Coverage alerts
|
||||
"infra_single_gateway": {
|
||||
"name": "Single Gateway",
|
||||
"description": "Infrastructure node dropped to single gateway coverage",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"feeder_offline": {
|
||||
"name": "Feeder Offline",
|
||||
"description": "A feeder gateway stopped responding",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"region_total_blackout": {
|
||||
"name": "Region Blackout",
|
||||
"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 below threshold",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"region_score_low": {
|
||||
"name": "Region Health Low",
|
||||
"description": "A region's health score below threshold",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
|
||||
# Environmental alerts
|
||||
"weather_warning": {
|
||||
"name": "Severe Weather",
|
||||
"description": "NWS warning or advisory for mesh area",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"hf_blackout": {
|
||||
"name": "HF Radio Blackout",
|
||||
"description": "R3+ solar event degrading HF propagation",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"tropospheric_ducting": {
|
||||
"name": "Tropospheric Ducting",
|
||||
"description": "Atmospheric conditions extending VHF/UHF range",
|
||||
"default_severity": "info",
|
||||
},
|
||||
"wildfire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Wildfire detected within configured distance",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"new_ignition": {
|
||||
"name": "New Fire Ignition",
|
||||
"description": "Satellite hotspot not matching any known fire",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"flood_warning": {
|
||||
"name": "Flood Warning",
|
||||
"description": "Stream gauge exceeds flood threshold",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
"road_closure": {
|
||||
"name": "Road Closure",
|
||||
"description": "Full road closure on monitored corridor",
|
||||
"default_severity": "warning",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_category(category_id: str) -> dict:
|
||||
"""Get category info by ID, with fallback for unknown categories."""
|
||||
if category_id in ALERT_CATEGORIES:
|
||||
return ALERT_CATEGORIES[category_id]
|
||||
return {
|
||||
"name": category_id.replace("_", " ").title(),
|
||||
"description": f"Alert type: {category_id}",
|
||||
"default_severity": "info",
|
||||
}
|
||||
|
||||
|
||||
def list_categories() -> list[dict]:
|
||||
"""List all categories with their IDs."""
|
||||
return [
|
||||
{"id": cat_id, **cat_info}
|
||||
for cat_id, cat_info in ALERT_CATEGORIES.items()
|
||||
]
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
"""Notification channel implementations."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..connector import MeshConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationChannel(ABC):
|
||||
"""Base class for notification delivery channels."""
|
||||
|
||||
channel_type: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
||||
"""Send alert. Returns True on success."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
"""Send test message. Returns (success, message)."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MeshBroadcastChannel(NotificationChannel):
|
||||
"""Post alert to mesh channel."""
|
||||
|
||||
channel_type = "mesh_broadcast"
|
||||
|
||||
def __init__(self, connector: "MeshConnector", channel_index: int = 0):
|
||||
self._connector = connector
|
||||
self._channel = channel_index
|
||||
|
||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
||||
"""Send alert to mesh channel."""
|
||||
if not self._connector:
|
||||
logger.warning("No mesh connector available")
|
||||
return False
|
||||
|
||||
try:
|
||||
message = alert.get("message", "")
|
||||
self._connector.send_message(
|
||||
text=message,
|
||||
destination=None,
|
||||
channel=self._channel,
|
||||
)
|
||||
logger.info("Broadcast alert to channel %d", self._channel)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to broadcast alert: %s", e)
|
||||
return False
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
"""Send test broadcast."""
|
||||
try:
|
||||
self._connector.send_message(
|
||||
text="[TEST] MeshAI notification system test",
|
||||
destination=None,
|
||||
channel=self._channel,
|
||||
)
|
||||
return True, "Test message sent to channel %d" % self._channel
|
||||
except Exception as e:
|
||||
return False, "Failed to send test: %s" % e
|
||||
|
||||
|
||||
class MeshDMChannel(NotificationChannel):
|
||||
"""DM alert to specific node IDs."""
|
||||
|
||||
channel_type = "mesh_dm"
|
||||
|
||||
def __init__(self, connector: "MeshConnector", node_ids: list[str]):
|
||||
self._connector = connector
|
||||
self._node_ids = node_ids
|
||||
|
||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
||||
"""Send alert via DM to configured nodes."""
|
||||
if not self._connector:
|
||||
return False
|
||||
|
||||
message = alert.get("message", "")
|
||||
success = True
|
||||
|
||||
for node_id in self._node_ids:
|
||||
try:
|
||||
dest = int(node_id) if node_id.isdigit() else node_id
|
||||
self._connector.send_message(text=message, destination=dest, channel=0)
|
||||
except Exception as e:
|
||||
logger.error("Failed to DM %s: %s", node_id, e)
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
"""Send test DM to all configured nodes."""
|
||||
if not self._node_ids:
|
||||
return False, "No node IDs configured"
|
||||
try:
|
||||
for node_id in self._node_ids:
|
||||
dest = int(node_id) if node_id.isdigit() else node_id
|
||||
self._connector.send_message(
|
||||
text="[TEST] MeshAI notification test",
|
||||
destination=dest,
|
||||
channel=0,
|
||||
)
|
||||
return True, "Test DMs sent to %d nodes" % len(self._node_ids)
|
||||
except Exception as e:
|
||||
return False, "Failed to send test DMs: %s" % e
|
||||
|
||||
|
||||
class EmailChannel(NotificationChannel):
|
||||
"""Send alert via SMTP email."""
|
||||
|
||||
channel_type = "email"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_user: str,
|
||||
smtp_password: str,
|
||||
smtp_tls: bool,
|
||||
from_address: str,
|
||||
recipients: list[str],
|
||||
):
|
||||
self._host = smtp_host
|
||||
self._port = smtp_port
|
||||
self._user = smtp_user
|
||||
self._password = smtp_password
|
||||
self._tls = smtp_tls
|
||||
self._from = from_address
|
||||
self._recipients = recipients
|
||||
|
||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
||||
"""Send alert via email."""
|
||||
if not self._recipients:
|
||||
return False
|
||||
|
||||
alert_type = alert.get("type", "alert")
|
||||
severity = alert.get("severity", "info").upper()
|
||||
message = alert.get("message", "")
|
||||
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
||||
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
||||
alert_type, severity, time.strftime("%Y-%m-%d %H:%M:%S"), message
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._send_email, subject, body)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to send email: %s", e)
|
||||
return False
|
||||
|
||||
def _send_email(self, subject: str, body: str):
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = self._from
|
||||
msg["To"] = ", ".join(self._recipients)
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
|
||||
if self._tls:
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP(self._host, self._port) as server:
|
||||
server.starttls(context=context)
|
||||
if self._user and self._password:
|
||||
server.login(self._user, self._password)
|
||||
server.sendmail(self._from, self._recipients, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(self._host, self._port) as server:
|
||||
if self._user and self._password:
|
||||
server.login(self._user, self._password)
|
||||
server.sendmail(self._from, self._recipients, msg.as_string())
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._send_email,
|
||||
"[MeshAI TEST] Notification Test",
|
||||
"Test message from MeshAI.",
|
||||
)
|
||||
return True, "Test email sent to %d recipients" % len(self._recipients)
|
||||
except Exception as e:
|
||||
return False, "Failed to send test email: %s" % e
|
||||
|
||||
|
||||
class WebhookChannel(NotificationChannel):
|
||||
"""POST alert JSON to a URL."""
|
||||
|
||||
channel_type = "webhook"
|
||||
|
||||
def __init__(self, url: str, headers: Optional[dict] = None):
|
||||
self._url = url
|
||||
self._headers = headers or {}
|
||||
|
||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
||||
"""POST alert to webhook URL."""
|
||||
payload = {
|
||||
"type": alert.get("type"),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": time.time(),
|
||||
"node_name": alert.get("node_name"),
|
||||
"region": alert.get("region"),
|
||||
}
|
||||
|
||||
# Discord/Slack format
|
||||
if "discord.com" in self._url or "slack.com" in self._url:
|
||||
severity = alert.get("severity", "info")
|
||||
color = {
|
||||
"emergency": 0xFF0000,
|
||||
"critical": 0xFF4444,
|
||||
"warning": 0xFFAA00,
|
||||
"info": 0x0099FF,
|
||||
}.get(severity, 0x888888)
|
||||
payload = {
|
||||
"embeds": [{
|
||||
"title": "MeshAI: %s" % alert.get("type", "unknown"),
|
||||
"description": alert.get("message", ""),
|
||||
"color": color,
|
||||
}]
|
||||
}
|
||||
|
||||
# ntfy format
|
||||
elif "ntfy" in self._url:
|
||||
headers = {
|
||||
**self._headers,
|
||||
"Title": "MeshAI: %s" % alert.get("type", "alert"),
|
||||
"Priority": "3",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
self._url,
|
||||
content=alert.get("message", ""),
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
return resp.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error("Webhook failed: %s", e)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
self._url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", **self._headers},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error("Webhook failed: %s", e)
|
||||
return False
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
test_alert = {"type": "test", "severity": "info", "message": "MeshAI test message"}
|
||||
success = await self.deliver(test_alert, {})
|
||||
if success:
|
||||
return True, "Test sent to %s" % self._url
|
||||
return False, "Webhook failed"
|
||||
|
||||
|
||||
def create_channel(config: dict, connector=None) -> NotificationChannel:
|
||||
"""Create a channel instance from config."""
|
||||
channel_type = config.get("type", "")
|
||||
|
||||
if channel_type == "mesh_broadcast":
|
||||
return MeshBroadcastChannel(
|
||||
connector=connector,
|
||||
channel_index=config.get("channel_index", 0),
|
||||
)
|
||||
elif channel_type == "mesh_dm":
|
||||
return MeshDMChannel(
|
||||
connector=connector,
|
||||
node_ids=config.get("node_ids", []),
|
||||
)
|
||||
elif channel_type == "email":
|
||||
return EmailChannel(
|
||||
smtp_host=config.get("smtp_host", ""),
|
||||
smtp_port=config.get("smtp_port", 587),
|
||||
smtp_user=config.get("smtp_user", ""),
|
||||
smtp_password=config.get("smtp_password", ""),
|
||||
smtp_tls=config.get("smtp_tls", True),
|
||||
from_address=config.get("from_address", ""),
|
||||
recipients=config.get("recipients", []),
|
||||
)
|
||||
elif channel_type == "webhook":
|
||||
return WebhookChannel(
|
||||
url=config.get("url", ""),
|
||||
headers=config.get("headers", {}),
|
||||
)
|
||||
else:
|
||||
raise ValueError("Unknown channel type: %s" % channel_type)
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
"""Notification router - matches alerts to rules and delivers via channels."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from .channels import create_channel, NotificationChannel
|
||||
from .summarizer import MessageSummarizer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..connector import MeshConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Severity levels in order
|
||||
SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"]
|
||||
|
||||
|
||||
class NotificationRouter:
|
||||
"""Routes alerts through matching rules to notification channels."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
connector: Optional["MeshConnector"] = None,
|
||||
llm_backend=None,
|
||||
timezone: str = "America/Boise",
|
||||
):
|
||||
self._rules: list[dict] = []
|
||||
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
||||
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
||||
self._timezone = timezone
|
||||
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
|
||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||
self._connector = connector
|
||||
self._config = config
|
||||
|
||||
# Load rules from config
|
||||
rules_config = getattr(config, "rules", [])
|
||||
for rule in rules_config:
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = dict(rule) if isinstance(rule, dict) else {}
|
||||
|
||||
# Skip disabled rules
|
||||
if not rule_dict.get("enabled", True):
|
||||
continue
|
||||
|
||||
# Only load condition-triggered rules (scheduled rules handled by scheduler)
|
||||
if rule_dict.get("trigger_type", "condition") == "condition":
|
||||
self._rules.append(rule_dict)
|
||||
|
||||
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."""
|
||||
delivery_type = rule.get("delivery_type", "")
|
||||
|
||||
if delivery_type == "mesh_broadcast":
|
||||
config = {
|
||||
"type": "mesh_broadcast",
|
||||
"channel_index": rule.get("broadcast_channel", 0),
|
||||
}
|
||||
elif delivery_type == "mesh_dm":
|
||||
config = {
|
||||
"type": "mesh_dm",
|
||||
"node_ids": rule.get("node_ids", []),
|
||||
}
|
||||
elif delivery_type == "email":
|
||||
config = {
|
||||
"type": "email",
|
||||
"smtp_host": rule.get("smtp_host", ""),
|
||||
"smtp_port": rule.get("smtp_port", 587),
|
||||
"smtp_user": rule.get("smtp_user", ""),
|
||||
"smtp_password": rule.get("smtp_password", ""),
|
||||
"smtp_tls": rule.get("smtp_tls", True),
|
||||
"from_address": rule.get("from_address", ""),
|
||||
"recipients": rule.get("recipients", []),
|
||||
}
|
||||
elif delivery_type == "webhook":
|
||||
config = {
|
||||
"type": "webhook",
|
||||
"url": rule.get("webhook_url", ""),
|
||||
"headers": rule.get("webhook_headers", {}),
|
||||
}
|
||||
else:
|
||||
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)
|
||||
return None
|
||||
|
||||
async def process_alert(self, alert: dict) -> bool:
|
||||
"""Route an alert through matching rules.
|
||||
|
||||
Returns True if alert was delivered to at least one channel.
|
||||
"""
|
||||
category = alert.get("type", "")
|
||||
severity = alert.get("severity", "info")
|
||||
delivered = False
|
||||
|
||||
for rule in self._rules:
|
||||
# Check category match
|
||||
rule_categories = rule.get("categories", [])
|
||||
if rule_categories and category not in rule_categories:
|
||||
continue
|
||||
|
||||
# Check severity threshold
|
||||
min_severity = rule.get("min_severity", "info")
|
||||
if not self._severity_meets(severity, min_severity):
|
||||
continue
|
||||
|
||||
# Check quiet hours (emergencies and criticals override)
|
||||
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
|
||||
if not rule.get("override_quiet", False):
|
||||
continue
|
||||
|
||||
# Check 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:
|
||||
if now - self._recent[dedup_key] < cooldown:
|
||||
logger.debug("Skipping alert (cooldown): %s via %s", category, rule_name)
|
||||
continue
|
||||
self._recent[dedup_key] = now
|
||||
|
||||
# Create channel and deliver
|
||||
channel = self._create_channel_for_rule(rule)
|
||||
if not channel:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Summarize for mesh channels if over 200 chars
|
||||
delivery_alert = alert
|
||||
message = alert.get("message", "")
|
||||
if channel.channel_type in ("mesh_broadcast", "mesh_dm"):
|
||||
if len(message) > 200:
|
||||
if self._summarizer:
|
||||
summary = await self._summarizer.summarize(message, max_chars=195)
|
||||
delivery_alert = {**alert, "message": summary}
|
||||
else:
|
||||
delivery_alert = {**alert, "message": message[:195] + "..."}
|
||||
|
||||
success = await channel.deliver(delivery_alert, rule)
|
||||
if success:
|
||||
delivered = True
|
||||
logger.info("Alert delivered via %s: %s", rule_name, category)
|
||||
except Exception as e:
|
||||
logger.warning("Rule %s delivery failed: %s", rule_name, e)
|
||||
|
||||
return delivered
|
||||
|
||||
def _severity_meets(self, actual: str, required: str) -> bool:
|
||||
"""Check if actual severity meets or exceeds required severity."""
|
||||
try:
|
||||
actual_idx = SEVERITY_ORDER.index(actual.lower())
|
||||
required_idx = SEVERITY_ORDER.index(required.lower())
|
||||
return actual_idx >= required_idx
|
||||
except ValueError:
|
||||
return True # Unknown severity, allow through
|
||||
|
||||
def _in_quiet_hours(self) -> bool:
|
||||
"""Check if current time is within quiet hours."""
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo(self._timezone)
|
||||
now = datetime.now(tz)
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
start = self._quiet_start
|
||||
end = self._quiet_end
|
||||
|
||||
if start <= end:
|
||||
# Simple range (e.g., 01:00 to 06:00)
|
||||
return start <= current_time <= end
|
||||
else:
|
||||
# Crosses midnight (e.g., 22:00 to 06:00)
|
||||
return current_time >= start or current_time <= end
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_rules(self) -> list[dict]:
|
||||
"""Get list of configured rules."""
|
||||
return self._rules
|
||||
|
||||
async def test_rule(self, rule_index: int) -> tuple[bool, str]:
|
||||
"""Send a test alert through a specific rule."""
|
||||
rules_config = getattr(self._config, "rules", [])
|
||||
if rule_index < 0 or rule_index >= len(rules_config):
|
||||
return False, "Rule index out of range"
|
||||
|
||||
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)
|
||||
|
||||
channel = self._create_channel_for_rule(rule_dict)
|
||||
if not channel:
|
||||
return False, "Failed to create delivery channel"
|
||||
|
||||
return await channel.test()
|
||||
|
||||
def add_mesh_subscription(
|
||||
self,
|
||||
node_id: str,
|
||||
categories: list[str],
|
||||
rule_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Add a mesh DM subscription for a node.
|
||||
|
||||
Creates a rule for the node to receive alerts.
|
||||
Returns the rule name.
|
||||
"""
|
||||
if not rule_name:
|
||||
rule_name = "sub_%s" % node_id
|
||||
|
||||
# Check if rule already exists
|
||||
for rule in self._rules:
|
||||
if rule.get("name") == rule_name:
|
||||
# Update existing rule
|
||||
rule["categories"] = categories if categories else []
|
||||
rule["node_ids"] = [node_id]
|
||||
return rule_name
|
||||
|
||||
# Add new rule
|
||||
self._rules.append({
|
||||
"name": rule_name,
|
||||
"enabled": True,
|
||||
"trigger_type": "condition",
|
||||
"categories": categories if categories else [], # Empty = all
|
||||
"min_severity": "warning",
|
||||
"delivery_type": "mesh_dm",
|
||||
"node_ids": [node_id],
|
||||
"cooldown_minutes": 10,
|
||||
"override_quiet": False,
|
||||
})
|
||||
|
||||
return rule_name
|
||||
|
||||
def remove_mesh_subscription(self, node_id: str) -> bool:
|
||||
"""Remove a mesh subscription for a node."""
|
||||
rule_name = "sub_%s" % node_id
|
||||
self._rules = [r for r in self._rules if r.get("name") != rule_name]
|
||||
return True
|
||||
|
||||
def get_node_subscriptions(self, node_id: str) -> list[str]:
|
||||
"""Get categories a node is subscribed to."""
|
||||
rule_name = "sub_%s" % node_id
|
||||
for rule in self._rules:
|
||||
if rule.get("name") == rule_name:
|
||||
categories = rule.get("categories", [])
|
||||
return categories if categories else ["all"]
|
||||
return []
|
||||
|
||||
def cleanup_recent(self, max_age: int = 3600):
|
||||
"""Clean up old entries from recent alerts cache."""
|
||||
now = time.time()
|
||||
self._recent = {
|
||||
k: v for k, v in self._recent.items()
|
||||
if now - v < max_age
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""Message summarizer for mesh delivery."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..backends import LLMBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageSummarizer:
|
||||
"""Summarizes long messages for mesh delivery.
|
||||
|
||||
Only used when:
|
||||
- Delivering to mesh channels (broadcast or DM)
|
||||
- Message exceeds max_chars (default 200)
|
||||
- LLM backend is available
|
||||
|
||||
Email and webhook channels receive full messages.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_backend: Optional["LLMBackend"] = None):
|
||||
self._llm = llm_backend
|
||||
|
||||
async def summarize(self, message: str, max_chars: int = 195) -> str:
|
||||
"""Summarize a message to fit within max_chars.
|
||||
|
||||
Args:
|
||||
message: Original message text
|
||||
max_chars: Maximum characters for summary
|
||||
|
||||
Returns:
|
||||
Summarized message, or truncated original if LLM unavailable
|
||||
"""
|
||||
if len(message) <= max_chars:
|
||||
return message
|
||||
|
||||
if not self._llm:
|
||||
return message[:max_chars - 3] + "..."
|
||||
|
||||
prompt = (
|
||||
"Summarize this alert in under %d characters. "
|
||||
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
|
||||
% (max_chars, message)
|
||||
)
|
||||
|
||||
try:
|
||||
# Use the LLM to generate a summary
|
||||
response = await self._llm.generate(
|
||||
prompt,
|
||||
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
|
||||
max_tokens=100,
|
||||
)
|
||||
summary = response.strip()
|
||||
|
||||
# Ensure it fits
|
||||
if len(summary) <= max_chars:
|
||||
return summary
|
||||
return summary[:max_chars - 3] + "..."
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("LLM summarization failed: %s", e)
|
||||
return message[:max_chars - 3] + "..."
|
||||
Loading…
Add table
Add a link
Reference in a new issue