mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 08:54:36 +02:00
Compare commits
8 commits
bb36ebb8c3
...
b4f7e24c26
| Author | SHA1 | Date | |
|---|---|---|---|
| b4f7e24c26 | |||
| 3fa7b9fe5e | |||
| 10328686e2 | |||
| c22cf47dec | |||
| 947cce514e | |||
| 3bf5e3dfbc | |||
| f8874104ad | |||
| 3d74eb92b0 |
37 changed files with 7164 additions and 1938 deletions
|
|
@ -203,6 +203,91 @@ 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,18 +5,23 @@ 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 (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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,9 +6,11 @@ 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
|
||||
|
|
@ -20,6 +22,7 @@ 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 {
|
||||
|
|
@ -39,8 +42,21 @@ function getPageTitle(pathname: string): string {
|
|||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const { connected } = useWebSocket()
|
||||
const { connected, lastAlert } = useWebSocket()
|
||||
const { addToast } = useToast()
|
||||
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(() => {
|
||||
|
|
|
|||
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
141
dashboard-frontend/src/components/ToastProvider.tsx
Normal file
141
dashboard-frontend/src/components/ToastProvider.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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,3 +47,28 @@ 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,6 +93,34 @@ 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[]
|
||||
|
|
@ -209,6 +237,24 @@ 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')
|
||||
}
|
||||
|
|
@ -330,6 +376,36 @@ 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[]
|
||||
|
|
@ -355,6 +431,10 @@ 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,15 +1,572 @@
|
|||
import { Bell } from 'lucide-react'
|
||||
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)
|
||||
|
||||
export default function Alerts() {
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<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,10 +326,12 @@ 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,6 +12,7 @@ import {
|
|||
Mountain,
|
||||
Droplets,
|
||||
Car,
|
||||
Satellite,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchEnvStatus,
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
fetchStreams,
|
||||
fetchTraffic,
|
||||
fetchRoads,
|
||||
fetchHotspots,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
|
|
@ -32,6 +34,8 @@ 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 } }) {
|
||||
|
|
@ -359,10 +363,13 @@ 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(() => []),
|
||||
|
|
@ -373,8 +380,9 @@ 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]) => {
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData, hotspotsData]) => {
|
||||
setEnvStatus(status)
|
||||
setEvents(active)
|
||||
setSWPC(swpcData)
|
||||
|
|
@ -384,6 +392,8 @@ export default function Environment() {
|
|||
setStreams(streamsData || [])
|
||||
setTraffic(trafficData || [])
|
||||
setRoads(roadsData || [])
|
||||
setHotspots(hotspotsData?.hotspots || [])
|
||||
setNewIgnitions(hotspotsData?.new_ignitions || 0)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -690,6 +700,60 @@ 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,6 +26,7 @@ export default function Mesh() {
|
|||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
document.title = 'Mesh — MeshAI'
|
||||
Promise.all([fetchNodes(), fetchEdges(), fetchRegions()])
|
||||
.then(([n, e, r]) => {
|
||||
setNodes(n)
|
||||
|
|
|
|||
1099
dashboard-frontend/src/pages/Notifications.tsx
Normal file
1099
dashboard-frontend/src/pages/Notifications.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -162,6 +162,7 @@ def create_dispatcher(
|
|||
health_engine=None,
|
||||
subscription_manager=None,
|
||||
env_store=None,
|
||||
notification_router=None,
|
||||
) -> CommandDispatcher:
|
||||
"""Create and populate command dispatcher with default commands.
|
||||
|
||||
|
|
@ -224,24 +225,24 @@ def create_dispatcher(
|
|||
dispatcher.register(alias_handler)
|
||||
|
||||
# Register subscription commands
|
||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store)
|
||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
||||
dispatcher.register(sub_cmd)
|
||||
for alias in getattr(sub_cmd, 'aliases', []):
|
||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store)
|
||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
unsub_cmd = UnsubCommand(subscription_manager)
|
||||
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
|
||||
dispatcher.register(unsub_cmd)
|
||||
for alias in getattr(unsub_cmd, 'aliases', []):
|
||||
alias_handler = UnsubCommand(subscription_manager)
|
||||
alias_handler = UnsubCommand(subscription_manager, notification_router)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
mysubs_cmd = MySubsCommand(subscription_manager)
|
||||
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
|
||||
dispatcher.register(mysubs_cmd)
|
||||
for alias in getattr(mysubs_cmd, 'aliases', []):
|
||||
alias_handler = MySubsCommand(subscription_manager)
|
||||
alias_handler = MySubsCommand(subscription_manager, notification_router)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
|
|
@ -299,6 +300,15 @@ 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():
|
||||
|
|
|
|||
100
meshai/commands/hotspots_cmd.py
Normal file
100
meshai/commands/hotspots_cmd.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""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,6 +8,7 @@ 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):
|
||||
|
|
@ -15,7 +16,7 @@ class SubCommand(CommandHandler):
|
|||
|
||||
name = "sub"
|
||||
description = "Subscribe to reports or alerts"
|
||||
usage = "!sub daily|weekly|alerts [time] [day] [scope]"
|
||||
usage = "!sub daily|weekly|alerts|<category> [time] [day] [scope]"
|
||||
aliases = ["subscribe"]
|
||||
|
||||
def __init__(
|
||||
|
|
@ -23,23 +24,35 @@ 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."""
|
||||
if not self._sub_manager:
|
||||
return "Subscriptions not available."
|
||||
|
||||
parts = args.strip().split()
|
||||
|
||||
# No args - show available alert categories
|
||||
if not parts:
|
||||
return self._usage_help()
|
||||
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 f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts"
|
||||
return self._show_categories()
|
||||
|
||||
if not self._sub_manager:
|
||||
return "Subscriptions not available."
|
||||
|
||||
try:
|
||||
if sub_type == "daily":
|
||||
|
|
@ -51,15 +64,55 @@ 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
|
||||
!sub alerts region SCID - alerts for a region"""
|
||||
!sub alerts - mesh-wide alerts (legacy)
|
||||
!sub <category> - subscribe to alert category
|
||||
!sub all - subscribe to all alerts"""
|
||||
|
||||
def _handle_daily(self, args: list, context: CommandContext) -> str:
|
||||
"""Handle daily subscription."""
|
||||
|
|
@ -68,11 +121,9 @@ 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)
|
||||
|
||||
result = self._sub_manager.add(
|
||||
self._sub_manager.add(
|
||||
user_id=self._get_user_id(context),
|
||||
sub_type="daily",
|
||||
schedule_time=schedule_time,
|
||||
|
|
@ -92,11 +143,9 @@ 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)
|
||||
|
||||
result = self._sub_manager.add(
|
||||
self._sub_manager.add(
|
||||
user_id=self._get_user_id(context),
|
||||
sub_type="weekly",
|
||||
schedule_time=schedule_time,
|
||||
|
|
@ -111,13 +160,11 @@ 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."""
|
||||
"""Handle alerts subscription (legacy)."""
|
||||
scope_type, scope_value = self._parse_scope(args)
|
||||
|
||||
# Validate scope
|
||||
scope_value = self._validate_scope(scope_type, scope_value)
|
||||
|
||||
result = self._sub_manager.add(
|
||||
self._sub_manager.add(
|
||||
user_id=self._get_user_id(context),
|
||||
sub_type="alerts",
|
||||
scope_type=scope_type,
|
||||
|
|
@ -128,15 +175,10 @@ 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.
|
||||
|
||||
Returns:
|
||||
(scope_type, scope_value) tuple
|
||||
"""
|
||||
"""Parse scope from remaining args."""
|
||||
if not args:
|
||||
return "mesh", None
|
||||
|
||||
# Look for 'region' or 'node' keyword
|
||||
scope_type = "mesh"
|
||||
scope_value = None
|
||||
|
||||
|
|
@ -144,26 +186,17 @@ 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.
|
||||
|
||||
Returns:
|
||||
Resolved scope_value (e.g., full region name)
|
||||
|
||||
Raises:
|
||||
ValueError: If scope not found
|
||||
"""
|
||||
"""Validate and resolve scope value."""
|
||||
if scope_type == "mesh":
|
||||
return None
|
||||
|
||||
|
|
@ -172,14 +205,9 @@ class SubCommand(CommandHandler):
|
|||
|
||||
if scope_type == "region" and self._reporter:
|
||||
region = self._reporter._find_region(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 region:
|
||||
return region.name
|
||||
return scope_value
|
||||
|
||||
if scope_type == "node" and self._reporter:
|
||||
node = self._reporter._find_node(scope_value)
|
||||
|
|
@ -191,7 +219,6 @@ 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))
|
||||
|
|
@ -217,26 +244,40 @@ class UnsubCommand(CommandHandler):
|
|||
|
||||
name = "unsub"
|
||||
description = "Remove subscription(s)"
|
||||
usage = "!unsub daily|weekly|alerts|all"
|
||||
usage = "!unsub daily|weekly|alerts|<category>|all"
|
||||
aliases = ["unsubscribe"]
|
||||
|
||||
def __init__(self, subscription_manager: "SubscriptionManager" = None):
|
||||
def __init__(
|
||||
self,
|
||||
subscription_manager: "SubscriptionManager" = None,
|
||||
notification_router: "NotificationRouter" = None,
|
||||
):
|
||||
self._sub_manager = subscription_manager
|
||||
self._notification_router = notification_router
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Handle unsubscribe command."""
|
||||
if not self._sub_manager:
|
||||
return "Subscriptions not available."
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None)
|
||||
|
||||
if removed == 0:
|
||||
|
|
@ -260,26 +301,44 @@ class MySubsCommand(CommandHandler):
|
|||
name = "mysubs"
|
||||
description = "List your subscriptions"
|
||||
usage = "!mysubs"
|
||||
aliases = ["subs"]
|
||||
aliases = ["subs", "subscriptions"]
|
||||
|
||||
def __init__(self, subscription_manager: "SubscriptionManager" = None):
|
||||
def __init__(
|
||||
self,
|
||||
subscription_manager: "SubscriptionManager" = None,
|
||||
notification_router: "NotificationRouter" = 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)
|
||||
subs = self._sub_manager.get_user_subs(user_id)
|
||||
lines = []
|
||||
|
||||
if not subs:
|
||||
# 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:
|
||||
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:
|
||||
|
|
@ -301,7 +360,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: # alerts
|
||||
else:
|
||||
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,6 +393,20 @@ 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."""
|
||||
|
|
@ -407,21 +421,76 @@ 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"
|
||||
|
||||
|
|
@ -447,6 +516,7 @@ 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)
|
||||
|
||||
|
|
@ -465,6 +535,69 @@ 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:
|
||||
|
|
@ -518,6 +651,18 @@ 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,6 +1,7 @@
|
|||
"""Alert API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(tags=["alerts"])
|
||||
|
||||
|
|
@ -8,22 +9,21 @@ router = APIRouter(tags=["alerts"])
|
|||
@router.get("/alerts/active")
|
||||
async def get_active_alerts(request: Request):
|
||||
"""Get currently active alerts."""
|
||||
alert_engine = request.app.state.alert_engine
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
|
||||
if not alert_engine:
|
||||
return []
|
||||
|
||||
# Get recent alerts from alert engine if it has internal state
|
||||
alerts = []
|
||||
|
||||
# Check for AlertState or similar if available
|
||||
if hasattr(alert_engine, "get_active_alerts"):
|
||||
# Try get_pending_alerts first (our method)
|
||||
if hasattr(alert_engine, "get_pending_alerts"):
|
||||
try:
|
||||
raw_alerts = alert_engine.get_active_alerts()
|
||||
raw_alerts = alert_engine.get_pending_alerts()
|
||||
for alert in raw_alerts:
|
||||
alerts.append({
|
||||
"type": alert.get("type", "unknown"),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"severity": _map_severity(alert),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": alert.get("timestamp"),
|
||||
"scope_type": alert.get("scope_type"),
|
||||
|
|
@ -31,17 +31,6 @@ 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
|
||||
|
||||
|
|
@ -49,19 +38,28 @@ async def get_active_alerts(request: Request):
|
|||
@router.get("/alerts/history")
|
||||
async def get_alert_history(
|
||||
request: Request,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
type: Optional[str] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
):
|
||||
"""Get historical alerts with pagination."""
|
||||
# Historical alert data would come from SQLite
|
||||
# For now, return empty list
|
||||
return []
|
||||
"""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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def get_subscriptions(request: Request):
|
||||
"""Get all alert subscriptions."""
|
||||
subscription_manager = request.app.state.subscription_manager
|
||||
subscription_manager = getattr(request.app.state, "subscription_manager", None)
|
||||
|
||||
if not subscription_manager:
|
||||
return []
|
||||
|
|
@ -83,3 +81,19 @@ 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,6 +27,8 @@ RESTART_REQUIRED_SECTIONS = {
|
|||
|
||||
# Valid config section names
|
||||
VALID_SECTIONS = {
|
||||
"notifications",
|
||||
"environmental",
|
||||
"bot",
|
||||
"connection",
|
||||
"response",
|
||||
|
|
|
|||
|
|
@ -138,3 +138,26 @@ 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,3 +354,50 @@ 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 []
|
||||
|
|
|
|||
35
meshai/dashboard/api/notification_routes.py
Normal file
35
meshai/dashboard/api/notification_routes.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""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,6 +52,7 @@ 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")
|
||||
|
|
@ -59,6 +60,7 @@ 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)
|
||||
|
||||
|
|
@ -110,6 +112,8 @@ 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()
|
||||
|
|
|
|||
425
meshai/dashboard/static/assets/index-BOJS6jme.js
Normal file
425
meshai/dashboard/static/assets/index-BOJS6jme.js
Normal file
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-DG_2rmdm.css
Normal file
1
meshai/dashboard/static/assets/index-DG_2rmdm.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.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-Lqo8lYVT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DvM_5H7j.css">
|
||||
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
365
meshai/env/firms.py
vendored
Normal file
365
meshai/env/firms.py
vendored
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""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,6 +54,13 @@ 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:
|
||||
|
|
@ -224,6 +231,16 @@ 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,6 +44,7 @@ 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
|
||||
|
|
@ -337,6 +338,18 @@ 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:
|
||||
|
|
@ -394,6 +407,7 @@ class MeshAI:
|
|||
health_engine=self.health_engine,
|
||||
subscription_manager=self.subscription_manager,
|
||||
env_store=self.env_store,
|
||||
notification_router=self.notification_router,
|
||||
)
|
||||
|
||||
# Message router
|
||||
|
|
@ -406,6 +420,7 @@ class MeshAI:
|
|||
health_engine=self.health_engine,
|
||||
mesh_reporter=self.mesh_reporter,
|
||||
env_store=self.env_store,
|
||||
# notification_router not used by MessageRouter
|
||||
)
|
||||
|
||||
# Responder
|
||||
|
|
@ -548,30 +563,38 @@ class MeshAI:
|
|||
message = alert["message"]
|
||||
logger.info(f"ALERT: {message}")
|
||||
|
||||
# Send to alert channel if configured
|
||||
if alert_channel >= 0 and self.connector:
|
||||
# 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:
|
||||
try:
|
||||
self.connector.send_message(
|
||||
text=message,
|
||||
destination=None, # Broadcast
|
||||
destination=None,
|
||||
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}")
|
||||
|
||||
# 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}")
|
||||
# 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}")
|
||||
|
||||
self.alert_engine.clear_pending()
|
||||
if self.alert_engine:
|
||||
self.alert_engine.clear_pending()
|
||||
|
||||
async def _check_scheduled_subs(self) -> None:
|
||||
"""Check for and deliver due scheduled reports."""
|
||||
|
|
|
|||
6
meshai/notifications/__init__.py
Normal file
6
meshai/notifications/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""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"]
|
||||
157
meshai/notifications/categories.py
Normal file
157
meshai/notifications/categories.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""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()
|
||||
]
|
||||
308
meshai/notifications/channels.py
Normal file
308
meshai/notifications/channels.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""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)
|
||||
271
meshai/notifications/router.py
Normal file
271
meshai/notifications/router.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""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
|
||||
}
|
||||
64
meshai/notifications/summarizer.py
Normal file
64
meshai/notifications/summarizer.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""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