Compare commits

..

No commits in common. "b4f7e24c2644c9ab91e3fed97e5e7caee65393d1" and "bb36ebb8c347664bc77fbca491c9b5d2fe87c78d" have entirely different histories.

37 changed files with 1938 additions and 7164 deletions

View file

@ -203,91 +203,6 @@ environmental:
endpoints: ["/get/event"] endpoints: ["/get/event"]
bbox: [] # [west, south, east, north] bbox: [] # [west, south, east, north]
# NASA FIRMS Satellite Fire Detection
# Early warning via satellite hotspots, hours before official perimeters
# Get MAP_KEY at: https://firms.modaps.eosdis.nasa.gov/api/area/
firms:
enabled: false
tick_seconds: 1800 # 30 min default
map_key: "" # Required - NASA FIRMS MAP_KEY
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
bbox: [] # [west, south, east, north] - Required
day_range: 1 # 1-10 days of data
confidence_min: "nominal" # low, nominal, high
proximity_km: 10.0 # km to match known fire perimeters
# === NOTIFICATION DELIVERY ===
# Route alerts to channels (mesh, email, webhook) based on rules.
# Categories match alert types from alert_engine.py.
# Severity levels: info, advisory, watch, warning, critical, emergency
#
notifications:
enabled: false
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
quiet_hours_end: "06:00"
# Notification rules - each rule is self-contained with its own delivery config
rules:
# All emergencies -> mesh broadcast
- name: "Emergency Broadcast"
enabled: true
trigger_type: condition
categories: [] # Empty = all categories
min_severity: "emergency"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 5
override_quiet: true # Send even during quiet hours
# Example: Fire alerts -> email
# - name: "Fire Alerts Email"
# enabled: true
# trigger_type: condition
# categories: ["wildfire_proximity", "new_ignition"]
# min_severity: "advisory"
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# smtp_port: 587
# smtp_user: "you@gmail.com"
# smtp_password: "${SMTP_PASSWORD}"
# smtp_tls: true
# from_address: "meshai@yourdomain.com"
# recipients: ["admin@yourdomain.com"]
# cooldown_minutes: 30
# Example: All warnings -> Discord webhook
# - name: "Discord Alerts"
# enabled: true
# trigger_type: condition
# categories: []
# min_severity: "warning"
# delivery_type: webhook
# webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10
# Example: Daily health report -> mesh broadcast
# - name: "Morning Briefing"
# enabled: true
# trigger_type: schedule
# schedule_frequency: daily
# schedule_time: "07:00"
# message_type: mesh_health_summary
# delivery_type: mesh_broadcast
# broadcast_channel: 0
# Example: Weekly digest -> email
# - name: "Weekly Digest"
# enabled: true
# trigger_type: schedule
# schedule_frequency: weekly
# schedule_days: ["monday"]
# schedule_time: "08:00"
# message_type: alerts_digest
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# recipients: ["admin@example.com"]
# === WEB DASHBOARD === # === WEB DASHBOARD ===
dashboard: dashboard:
enabled: true enabled: true

View file

@ -5,23 +5,18 @@ 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() { function App() {
return ( return (
<ToastProvider> <Layout>
<Layout> <Routes>
<Routes> <Route path="/" element={<Dashboard />} />
<Route path="/" element={<Dashboard />} /> <Route path="/mesh" element={<Mesh />} />
<Route path="/mesh" element={<Mesh />} /> <Route path="/environment" element={<Environment />} />
<Route path="/environment" element={<Environment />} /> <Route path="/config" element={<Config />} />
<Route path="/config" element={<Config />} /> <Route path="/alerts" element={<Alerts />} />
<Route path="/alerts" element={<Alerts />} /> </Routes>
<Route path="/notifications" element={<Notifications />} /> </Layout>
</Routes>
</Layout>
</ToastProvider>
) )
} }

View file

@ -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>
)
}

View file

@ -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(() => {

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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')
} }

View file

@ -1,572 +1,15 @@
import { useEffect, useState, useCallback } from 'react' import { Bell } from 'lucide-react'
import {
Bell,
AlertTriangle,
AlertCircle,
CheckCircle,
Clock,
Filter,
ChevronLeft,
ChevronRight,
Radio,
Zap,
Cloud,
Wifi,
WifiOff,
Battery,
Users,
} from 'lucide-react'
import {
fetchAlerts,
fetchAlertHistory,
fetchSubscriptions,
type Alert,
type AlertHistoryItem,
type Subscription,
} from '@/lib/api'
interface Node {
node_num: number
node_id_hex: string
short_name: string
long_name: string
}
import { useWebSocket } from '@/hooks/useWebSocket'
// Alert type icons mapping
const alertTypeIcons: Record<string, typeof Bell> = {
infra_offline: WifiOff,
infra_recovery: Wifi,
battery_warning: Battery,
battery_critical: Battery,
battery_emergency: Battery,
hf_blackout: Zap,
uhf_ducting: Radio,
weather_warning: Cloud,
weather_watch: Cloud,
new_router: Radio,
packet_flood: AlertTriangle,
sustained_high_util: AlertTriangle,
region_blackout: AlertCircle,
default: Bell,
}
function getAlertIcon(type: string) {
return alertTypeIcons[type] || alertTypeIcons.default
}
function getSeverityStyles(severity: string) {
switch (severity?.toLowerCase()) {
case 'critical':
case 'emergency':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
badge: 'bg-red-500/20 text-red-400',
iconColor: 'text-red-500',
}
case 'warning':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
badge: 'bg-amber-500/20 text-amber-400',
iconColor: 'text-amber-500',
}
case 'watch':
return {
bg: 'bg-yellow-500/10',
border: 'border-yellow-500',
badge: 'bg-yellow-500/20 text-yellow-400',
iconColor: 'text-yellow-500',
}
case 'advisory':
case 'info':
default:
return {
bg: 'bg-blue-500/10',
border: 'border-blue-500',
badge: 'bg-blue-500/20 text-blue-400',
iconColor: 'text-blue-500',
}
}
}
function formatTimeAgo(timestamp: string | number): string {
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHour < 24) return `${diffHour}h ago`
return `${diffDay}d ago`
}
function formatDateTime(timestamp: string | number): string {
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
return `${Math.floor(seconds / 86400)}d`
}
// Active Alert Card Component
function ActiveAlertCard({
alert,
onAcknowledge,
}: {
alert: Alert
onAcknowledge: (alert: Alert) => void
}) {
const styles = getSeverityStyles(alert.severity)
const Icon = getAlertIcon(alert.type)
return (
<div className={`p-4 rounded-lg ${styles.bg} border-l-4 ${styles.border}`}>
<div className="flex items-start gap-3">
<Icon size={20} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
{alert.severity?.toUpperCase()}
</span>
<span className="text-xs text-slate-500">{alert.type}</span>
</div>
<div className="text-sm text-slate-200">{alert.message}</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Clock size={12} />
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
</span>
{alert.scope_value && (
<span>{alert.scope_type}: {alert.scope_value}</span>
)}
</div>
</div>
<button
onClick={() => onAcknowledge(alert)}
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
>
Acknowledge
</button>
</div>
</div>
)
}
// Alert History Table Component
function AlertHistoryTable({
history,
typeFilter,
severityFilter,
onTypeFilterChange,
onSeverityFilterChange,
page,
totalPages,
onPageChange,
}: {
history: AlertHistoryItem[]
typeFilter: string
severityFilter: string
onTypeFilterChange: (v: string) => void
onSeverityFilterChange: (v: string) => void
page: number
totalPages: number
onPageChange: (p: number) => void
}) {
const alertTypes = [
'all',
'infra_offline',
'infra_recovery',
'battery_warning',
'battery_critical',
'hf_blackout',
'uhf_ducting',
'weather_warning',
'new_router',
'packet_flood',
]
const severities = ['all', 'critical', 'warning', 'watch', 'info']
return (
<div className="bg-bg-card border border-border rounded-lg">
{/* Filters */}
<div className="p-4 border-b border-border flex items-center gap-4">
<div className="flex items-center gap-2">
<Filter size={14} className="text-slate-400" />
<span className="text-sm text-slate-400">Filter:</span>
</div>
<select
value={typeFilter}
onChange={(e) => onTypeFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
>
{alertTypes.map((t) => (
<option key={t} value={t}>
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
</option>
))}
</select>
<select
value={severityFilter}
onChange={(e) => onSeverityFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
>
{severities.map((s) => (
<option key={s} value={s}>
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
</tr>
</thead>
<tbody>
{history.length > 0 ? (
history.map((item, i) => {
const styles = getSeverityStyles(item.severity)
return (
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
{formatDateTime(item.timestamp)}
</td>
<td className="p-4 text-sm text-slate-300">
{item.type.replace(/_/g, ' ')}
</td>
<td className="p-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
{item.severity}
</span>
</td>
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
{item.message}
</td>
<td className="p-4 text-sm text-slate-400 font-mono">
{item.duration ? formatDuration(item.duration) : '-'}
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
No alert history available
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between">
<span className="text-sm text-slate-400">
Page {page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
)
}
// Subscription Card Component
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
const resolveNodeName = (userId: string): string => {
const node = nodes.find(n =>
n.node_id_hex === userId ||
String(n.node_num) === userId ||
n.short_name === userId
)
if (node) {
return node.long_name && node.long_name !== node.short_name
? `${node.short_name} (${node.long_name})`
: node.short_name
}
return userId
}
const formatSchedule = () => {
if (subscription.sub_type === 'alerts') {
return 'Real-time'
}
const time = subscription.schedule_time || '0000'
const hours = parseInt(time.slice(0, 2))
const minutes = time.slice(2)
const period = hours >= 12 ? 'PM' : 'AM'
const displayHour = hours % 12 || 12
let schedule = `${displayHour}:${minutes} ${period}`
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
}
return schedule
}
const getTypeIcon = () => {
switch (subscription.sub_type) {
case 'alerts':
return Bell
case 'daily':
return Clock
case 'weekly':
return Clock
default:
return Bell
}
}
const Icon = getTypeIcon()
return (
<div className="p-4 rounded-lg bg-bg-hover border border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Icon size={18} className="text-blue-400" />
</div>
<div className="flex-1">
<div className="text-sm text-slate-200 font-medium">
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
<span className="text-slate-400 font-normal ml-2">
({subscription.scope_type}: {subscription.scope_value})
</span>
)}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{formatSchedule()} {resolveNodeName(subscription.user_id)}
</div>
</div>
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
</div>
</div>
)
}
export default function Alerts() { 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 ( return (
<div className="space-y-6"> <div className="flex flex-col items-center justify-center h-[60vh] text-center">
{/* Active Alerts */} <div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
<div className="bg-bg-card border border-border rounded-lg p-6"> <Bell size={32} className="text-slate-500" />
<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>
<h2 className="text-xl font-semibold text-slate-300 mb-2">Alerts</h2>
<p className="text-slate-500 max-w-md">
Alert history and subscriptions coming in Phase 11
</p>
</div> </div>
) )
} }

File diff suppressed because it is too large Load diff

View file

@ -326,12 +326,10 @@ export default function Dashboard() {
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'
}) })
}, []) }, [])

View file

@ -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">

View file

@ -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

View file

@ -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():

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -1,7 +1,6 @@
"""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"])
@ -9,21 +8,22 @@ 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 = getattr(request.app.state, "alert_engine", None) alert_engine = request.app.state.alert_engine
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"),
@ -31,6 +31,17 @@ async def get_active_alerts(request: Request):
}) })
except Exception: except Exception:
pass pass
elif hasattr(alert_engine, "_recent_alerts"):
try:
for alert in alert_engine._recent_alerts:
alerts.append({
"type": alert.get("type", "unknown"),
"severity": alert.get("severity", "info"),
"message": alert.get("message", ""),
"timestamp": alert.get("timestamp"),
})
except Exception:
pass
return alerts return alerts
@ -38,28 +49,19 @@ async def get_active_alerts(request: Request):
@router.get("/alerts/history") @router.get("/alerts/history")
async def get_alert_history( async def get_alert_history(
request: Request, request: Request,
limit: int = Query(50, ge=1, le=200), limit: int = 50,
offset: int = Query(0, ge=0), offset: int = 0,
type: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
): ):
"""Get historical alerts with pagination and filtering. """Get historical alerts with pagination."""
# Historical alert data would come from SQLite
Note: Alert history persistence is not yet implemented. # For now, return empty list
Returns empty array for now. return []
"""
# Future: Query SQLite for historical alerts
# For now, return empty with proper structure
return {
"items": [],
"total": 0,
}
@router.get("/subscriptions") @router.get("/subscriptions")
async def get_subscriptions(request: Request): async def get_subscriptions(request: Request):
"""Get all alert subscriptions.""" """Get all alert subscriptions."""
subscription_manager = getattr(request.app.state, "subscription_manager", None) subscription_manager = request.app.state.subscription_manager
if not subscription_manager: if not subscription_manager:
return [] return []
@ -81,19 +83,3 @@ async def get_subscriptions(request: Request):
] ]
except Exception: except Exception:
return [] 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"

View file

@ -27,8 +27,6 @@ RESTART_REQUIRED_SECTIONS = {
# Valid config section names # Valid config section names
VALID_SECTIONS = { VALID_SECTIONS = {
"notifications",
"environmental",
"bot", "bot",
"connection", "connection",
"response", "response",

View file

@ -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),
}

View file

@ -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 []

View file

@ -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}

View file

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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
View file

@ -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
View file

@ -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:

View file

@ -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
@ -563,38 +548,30 @@ class MeshAI:
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:
await self.notification_router.process_alert(alert)
except Exception as e:
logger.error(f"Notification router error: {e}")
# Fallback: Send to alert channel if no notification router
elif alert_channel >= 0 and self.connector:
try: try:
self.connector.send_message( self.connector.send_message(
text=message, text=message,
destination=None, destination=None, # Broadcast
channel=alert_channel, channel=alert_channel,
) )
logger.info(f"Alert sent to channel {alert_channel}") logger.info(f"Alert sent to channel {alert_channel}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send channel alert: {e}") logger.error(f"Failed to send channel alert: {e}")
# Fallback: Send DMs to matching subscribers # Send DMs to matching subscribers
if self.alert_engine and self.subscription_manager: if self.alert_engine and self.subscription_manager:
subscribers = self.alert_engine.get_subscribers_for_alert(alert) subscribers = self.alert_engine.get_subscribers_for_alert(alert)
for sub in subscribers: for sub in subscribers:
user_id = sub["user_id"] user_id = sub["user_id"]
try: try:
await self._send_sub_dm(user_id, message) await self._send_sub_dm(user_id, message)
logger.info(f"Alert DM sent to {user_id}: {alert['type']}") logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send alert DM to {user_id}: {e}") logger.error(f"Failed to send alert DM to {user_id}: {e}")
if self.alert_engine: self.alert_engine.clear_pending()
self.alert_engine.clear_pending()
async def _check_scheduled_subs(self) -> None: async def _check_scheduled_subs(self) -> None:
"""Check for and deliver due scheduled reports.""" """Check for and deliver due scheduled reports."""

View file

@ -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"]

View file

@ -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()
]

View file

@ -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)

View file

@ -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
}

View file

@ -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] + "..."