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