From e35c0f5553447d4688eb2c1f93dc5740200d51b9 Mon Sep 17 00:00:00 2001 From: zvx-echo6 Date: Wed, 13 May 2026 18:40:18 -0600 Subject: [PATCH] feat(notifications): end-to-end verification system - Channel connectivity test: SMTP, webhook, mesh with real errors - Rule test shows live data from feeds, not canned examples - Near-miss detection: shows events filtered by threshold - Three send actions: current conditions, example alert, live alert - Rule status indicators: last fired, data source health - All errors show actual error messages - Disabled feed detection with clear warnings Co-Authored-By: Claude Opus 4.5 --- .../src/pages/Notifications.tsx | 1882 +++++++++++++++++ meshai/dashboard/api/notification_routes.py | 332 ++- meshai/notifications/channels.py | 533 ++++- meshai/notifications/router.py | 778 +++++-- 4 files changed, 3293 insertions(+), 232 deletions(-) create mode 100644 dashboard-frontend/src/pages/Notifications.tsx diff --git a/dashboard-frontend/src/pages/Notifications.tsx b/dashboard-frontend/src/pages/Notifications.tsx new file mode 100644 index 0000000..256e670 --- /dev/null +++ b/dashboard-frontend/src/pages/Notifications.tsx @@ -0,0 +1,1882 @@ +import { useState, useEffect, useCallback } from 'react' +import { + Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, + Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap, + Calendar, AlertTriangle, Copy, Moon, AlertCircle, Layers, + Wifi, WifiOff, Mail, Globe, Radio, MessageSquare +} from 'lucide-react' +import ChannelPicker from '@/components/ChannelPicker' +import NodePicker from '@/components/NodePicker' + +// Types +interface NotificationRuleConfig { + name: string + enabled: boolean + trigger_type: 'condition' | 'schedule' + categories: string[] + min_severity: string + schedule_frequency: 'daily' | 'twice_daily' | 'weekly' + schedule_time: string + schedule_time_2: string + schedule_days: string[] + message_type: string + custom_message: string + delivery_type: string + broadcast_channel: number + node_ids: string[] + smtp_host: string + smtp_port: number + smtp_user: string + smtp_password: string + smtp_tls: boolean + from_address: string + recipients: string[] + webhook_url: string + webhook_headers: Record + cooldown_minutes: number + override_quiet: boolean +} + +interface NotificationsConfig { + enabled: boolean + quiet_hours_enabled: boolean + quiet_hours_start: string + quiet_hours_end: string + rules: NotificationRuleConfig[] +} + +interface AlertCategory { + id: string + name: string + description: string + default_severity: string + example_message: string +} + +interface RuleStats { + last_fired: number | null + last_test: number | null + fire_count: number +} + +interface SourceHealth { + [category: string]: { + enabled: boolean + active_events: number + source: string + status: 'ok' | 'disabled' | 'no_data' + } +} + +interface TestResult { + live_data_summary?: string[] + conditions_matched?: number + preview_messages?: string[] + is_example?: boolean + conditions_below_threshold?: number + below_threshold_summary?: string + below_threshold_events?: { headline: string; severity: string }[] + suggestion?: string + delivered?: boolean + delivery_method?: string + delivery_result?: string + delivery_error?: string + can_send_live?: boolean + source_health?: SourceHealth + rule_stats?: RuleStats + // Legacy + success?: boolean + message?: string +} + +interface ChannelTestResult { + success: boolean + message: string + error: string + details: Record +} + +// Severity levels with descriptions +const SEVERITY_OPTIONS = [ + { value: 'info', label: 'Info', description: 'Routine updates (ducting detected, new router appeared)' }, + { value: 'advisory', label: 'Advisory', description: 'Worth knowing (weather advisory, traffic slow, battery declining)' }, + { value: 'watch', label: 'Watch', description: 'Pay attention (fire within 50km, weather watch, stream rising)' }, + { value: 'warning', label: 'Warning', description: 'Act now (fire within 25km, severe weather, critical battery)' }, + { value: 'critical', label: 'Critical', description: 'Serious issue (critical node down, battery emergency)' }, + { value: 'emergency', label: 'Emergency', description: 'Life safety (extreme weather, fire at infrastructure, total blackout)' }, +] + +// Notification rule templates +const RULE_TEMPLATES = [ + { + id: "mesh_health", + name: "Mesh Health Monitoring", + description: "Infrastructure problems - offline nodes, low battery, channel congestion", + rule: { + name: "Mesh Health Monitoring", + enabled: true, + trigger_type: "condition" as const, + categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"], + min_severity: "advisory", + delivery_type: "mesh_broadcast", + broadcast_channel: 0, + cooldown_minutes: 30, + override_quiet: false, + schedule_frequency: "daily" as const, + schedule_time: "07:00", + schedule_time_2: "", + schedule_days: [] as string[], + message_type: "", + custom_message: "", + node_ids: [] as string[], + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + smtp_tls: true, + from_address: "", + recipients: [] as string[], + webhook_url: "", + webhook_headers: {} as Record, + } + }, + { + id: "weather_fire", + name: "Weather & Fire Alerts", + description: "Environmental threats - severe weather, nearby wildfires, new ignitions, flooding", + rule: { + name: "Weather & Fire Alerts", + enabled: true, + trigger_type: "condition" as const, + categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"], + min_severity: "warning", + delivery_type: "mesh_broadcast", + broadcast_channel: 0, + cooldown_minutes: 15, + override_quiet: false, + schedule_frequency: "daily" as const, + schedule_time: "07:00", + schedule_time_2: "", + schedule_days: [] as string[], + message_type: "", + custom_message: "", + node_ids: [] as string[], + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + smtp_tls: true, + from_address: "", + recipients: [] as string[], + webhook_url: "", + webhook_headers: {} as Record, + } + }, + { + id: "rf_conditions", + name: "RF Conditions", + description: "Propagation changes - solar events, HF blackouts, tropospheric ducting", + rule: { + name: "RF Conditions", + enabled: true, + trigger_type: "condition" as const, + categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"], + min_severity: "info", + delivery_type: "mesh_broadcast", + broadcast_channel: 0, + cooldown_minutes: 60, + override_quiet: false, + schedule_frequency: "daily" as const, + schedule_time: "07:00", + schedule_time_2: "", + schedule_days: [] as string[], + message_type: "", + custom_message: "", + node_ids: [] as string[], + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + smtp_tls: true, + from_address: "", + recipients: [] as string[], + webhook_url: "", + webhook_headers: {} as Record, + } + }, + { + id: "road_traffic", + name: "Road & Traffic", + description: "Road closures and severe congestion", + rule: { + name: "Road & Traffic", + enabled: true, + trigger_type: "condition" as const, + categories: ["road_closure", "traffic_congestion"], + min_severity: "warning", + delivery_type: "mesh_broadcast", + broadcast_channel: 0, + cooldown_minutes: 30, + override_quiet: false, + schedule_frequency: "daily" as const, + schedule_time: "07:00", + schedule_time_2: "", + schedule_days: [] as string[], + message_type: "", + custom_message: "", + node_ids: [] as string[], + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + smtp_tls: true, + from_address: "", + recipients: [] as string[], + webhook_url: "", + webhook_headers: {} as Record, + } + }, + { + id: "everything_critical", + name: "Everything Critical", + description: "All emergency-level events regardless of type", + rule: { + name: "Everything Critical", + enabled: true, + trigger_type: "condition" as const, + categories: [] as string[], + min_severity: "emergency", + delivery_type: "mesh_broadcast", + broadcast_channel: 0, + cooldown_minutes: 5, + override_quiet: true, + schedule_frequency: "daily" as const, + schedule_time: "07:00", + schedule_time_2: "", + schedule_days: [] as string[], + message_type: "", + custom_message: "", + node_ids: [] as string[], + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + smtp_tls: true, + from_address: "", + recipients: [] as string[], + webhook_url: "", + webhook_headers: {} as Record, + } + }, + { + id: "morning_briefing", + name: "Morning Briefing", + description: "Daily health and conditions summary at 7am", + rule: { + name: "Morning Briefing", + enabled: true, + trigger_type: "schedule" as const, + categories: [] as string[], + min_severity: "info", + schedule_frequency: "daily" as const, + schedule_time: "07:00", + schedule_time_2: "", + schedule_days: [] as string[], + message_type: "mesh_health_summary", + custom_message: "", + delivery_type: "mesh_broadcast", + broadcast_channel: 0, + cooldown_minutes: 0, + override_quiet: false, + node_ids: [] as string[], + smtp_host: "", + smtp_port: 587, + smtp_user: "", + smtp_password: "", + smtp_tls: true, + from_address: "", + recipients: [] as string[], + webhook_url: "", + webhook_headers: {} as Record, + } + }, +] + +// Helper to format relative time +function formatRelativeTime(timestamp: number | null): string { + if (!timestamp) return 'Never' + const now = Date.now() / 1000 + const diff = now - timestamp + if (diff < 60) return 'Just now' + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` + return new Date(timestamp * 1000).toLocaleDateString() +} + +// InfoButton component +function InfoButton({ info }: { info: string }) { + const [open, setOpen] = useState(false) + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {info} +
+ + )} +
+ ) +} + +// Form components +function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: { + label: string + value: string + onChange: (v: string) => void + type?: string + placeholder?: string + helper?: string + info?: string +}) { + const [showPassword, setShowPassword] = useState(false) + const isPassword = type === 'password' + + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + 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 placeholder-slate-600" + /> + {isPassword && ( + + )} +
+ {helper &&

{helper}

} +
+ ) +} + +function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '' }: { + label: string + value: number + onChange: (v: number) => void + min?: number + max?: number + step?: number + helper?: string + info?: string +}) { + return ( +
+ + onChange(Number(e.target.value))} + min={min} + max={max} + step={step} + 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 &&

{helper}

} +
+ ) +} + +function Toggle({ label, checked, onChange, helper = '', info = '' }: { + label: string + checked: boolean + onChange: (v: boolean) => void + helper?: string + info?: string +}) { + return ( +
+
+ + {label} + {info && } + + {helper &&

{helper}

} +
+ +
+ ) +} + +function TimeInput({ label, value, onChange, helper = '', info = '' }: { + label: string + value: string + onChange: (v: string) => void + helper?: string + info?: string +}) { + return ( +
+ + onChange(e.target.value)} + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent" + /> + {helper &&

{helper}

} +
+ ) +} + +function ListInput({ label, value, onChange, placeholder = 'Add item...', helper = '', info = '' }: { + label: string + value: string[] + onChange: (v: string[]) => void + placeholder?: string + helper?: string + info?: string +}) { + const [inputValue, setInputValue] = useState('') + + const addItem = () => { + if (inputValue.trim() && !value.includes(inputValue.trim())) { + onChange([...value, inputValue.trim()]) + setInputValue('') + } + } + + const removeItem = (index: number) => { + onChange(value.filter((_, i) => i !== index)) + } + + return ( +
+ +
+ setInputValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addItem())} + className="flex-1 px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + placeholder={placeholder} + /> + +
+ {value.length > 0 && ( +
+ {value.map((item, i) => ( + + {item} + + + ))} +
+ )} + {helper &&

{helper}

} +
+ ) +} + +// Severity selector with descriptions +function SeveritySelector({ value, onChange }: { + value: string + onChange: (v: string) => void +}) { + const [isOpen, setIsOpen] = useState(false) + const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[3] + + return ( +
+ +
+ + {isOpen && ( + <> +
setIsOpen(false)} /> +
+ {SEVERITY_OPTIONS.map((opt) => ( + + ))} +
+ + )} +
+

Lower = more notifications. "Warning" recommended for most rules.

+
+ ) +} + +// Channel Test Button Component +function ChannelTestButton({ rule }: { + rule: NotificationRuleConfig +}) { + const [testing, setTesting] = useState(false) + const [result, setResult] = useState(null) + + const handleTest = async () => { + setTesting(true) + setResult(null) + + try { + // Build channel config from rule + let channelConfig: Record = { type: rule.delivery_type } + + if (rule.delivery_type === 'mesh_broadcast') { + channelConfig.channel_index = rule.broadcast_channel + } else if (rule.delivery_type === 'mesh_dm') { + channelConfig.node_ids = rule.node_ids + } else if (rule.delivery_type === 'email') { + channelConfig = { + type: 'email', + smtp_host: rule.smtp_host, + smtp_port: rule.smtp_port, + smtp_user: rule.smtp_user, + smtp_password: rule.smtp_password, + smtp_tls: rule.smtp_tls, + from_address: rule.from_address, + recipients: rule.recipients, + } + } else if (rule.delivery_type === 'webhook') { + channelConfig = { + type: 'webhook', + url: rule.webhook_url, + headers: rule.webhook_headers, + } + } + + const res = await fetch('/api/notifications/channels/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(channelConfig), + }) + const data = await res.json() + setResult(data) + } catch (err) { + setResult({ + success: false, + message: 'Test failed', + error: err instanceof Error ? err.message : 'Unknown error', + details: {} + }) + } finally { + setTesting(false) + } + } + + if (!rule.delivery_type) return null + + const icon = { + mesh_broadcast: , + mesh_dm: , + email: , + webhook: , + }[rule.delivery_type] || + + return ( +
+ + + {result && ( +
+
+ {result.success ? : } +
+
{result.message}
+ {result.error &&
{result.error}
} +
+
+
+ )} +
+ ) +} + +// Notification Rule Card Component +function NotificationRuleCard({ + rule, + ruleIndex, + categories, + quietHoursEnabled, + onChange, + onDelete, + onDuplicate, + onTest, +}: { + rule: NotificationRuleConfig + ruleIndex: number + categories: AlertCategory[] + quietHoursEnabled: boolean + onChange: (r: NotificationRuleConfig) => void + onDelete: () => void + onDuplicate: () => void + onTest: () => void +}) { + const [expanded, setExpanded] = useState(!rule.name) + const [testing, setTesting] = useState(false) + const [ruleStats, setRuleStats] = useState(null) + const [sourceHealth, setSourceHealth] = useState(null) + + // Fetch rule stats on mount + useEffect(() => { + if (rule.name && ruleIndex >= 0) { + fetch(`/api/notifications/rules/${ruleIndex}/stats`) + .then(res => res.json()) + .then(data => setRuleStats(data)) + .catch(() => {}) + + if (rule.categories?.length) { + fetch('/api/notifications/rules/sources', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ categories: rule.categories }) + }) + .then(res => res.json()) + .then(data => setSourceHealth(data)) + .catch(() => {}) + } + } + }, [rule.name, ruleIndex, rule.categories]) + + const deliveryOptions = [ + { value: '', label: '(None)', description: 'Rule matches but does not deliver' }, + { value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' }, + { value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' }, + { value: 'email', label: 'Email', description: 'Send via SMTP' }, + { value: 'webhook', label: 'Webhook', description: 'POST to any URL' }, + ] + + const frequencyOptions = [ + { value: 'daily', label: 'Daily' }, + { value: 'twice_daily', label: 'Twice Daily' }, + { value: 'weekly', label: 'Weekly' }, + ] + + const messageTypeOptions = [ + { value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' }, + { value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' }, + { value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' }, + { value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' }, + { value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' }, + ] + + const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + + const toggleCategory = (catId: string) => { + const current = rule.categories || [] + if (current.includes(catId)) { + onChange({ ...rule, categories: current.filter(c => c !== catId) }) + } else { + onChange({ ...rule, categories: [...current, catId] }) + } + } + + const toggleDay = (day: string) => { + const current = rule.schedule_days || [] + if (current.includes(day)) { + onChange({ ...rule, schedule_days: current.filter(d => d !== day) }) + } else { + onChange({ ...rule, schedule_days: [...current, day] }) + } + } + + const handleTest = async () => { + setTesting(true) + await onTest() + setTesting(false) + } + + // Get example message for display + const getExampleMessage = (): string => { + if (rule.trigger_type === 'schedule') { + return '[Scheduled report preview would appear here]' + } + const ruleCats = rule.categories || [] + if (ruleCats.length === 0 && categories.length > 0) { + return categories[0].example_message || 'Alert notification' + } + const firstCat = categories.find(c => ruleCats.includes(c.id)) + return firstCat?.example_message || 'Alert notification' + } + + // Generate summary for collapsed view + const getSummary = () => { + const parts: string[] = [] + + if (rule.trigger_type === 'schedule') { + const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency + const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type + parts.push(`${freq} at ${rule.schedule_time || '??:??'}`) + parts.push(msgType) + } else { + const catCount = rule.categories?.length || 0 + const catText = catCount === 0 ? 'All' : categories.filter(c => rule.categories?.includes(c.id)).map(c => c.name).slice(0, 2).join(', ') + (catCount > 2 ? ` +${catCount - 2}` : '') + const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity + parts.push(`${catText} at ${severity}+`) + } + + // Delivery summary + if (!rule.delivery_type) { + parts.push('No delivery') + } else { + const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type + let target = '' + if (rule.delivery_type === 'mesh_broadcast') { + target = `Ch ${rule.broadcast_channel}` + } else if (rule.delivery_type === 'mesh_dm') { + target = `${rule.node_ids?.length || 0} nodes` + } else if (rule.delivery_type === 'email') { + target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients' + } else if (rule.delivery_type === 'webhook') { + try { + const url = new URL(rule.webhook_url) + target = url.hostname + } catch { + target = rule.webhook_url?.slice(0, 20) || 'no URL' + } + } + parts.push(`${delivery}${target ? ` (${target})` : ''}`) + } + + return parts.join(' -> ') + } + + // Get source status indicators + const getSourceIndicators = () => { + if (!sourceHealth || !rule.categories?.length) return null + + const sources = new Map() + for (const [, health] of Object.entries(sourceHealth)) { + const existing = sources.get(health.source) + if (existing) { + existing.events += health.active_events + existing.enabled = existing.enabled && health.enabled + } else { + sources.set(health.source, { enabled: health.enabled, events: health.active_events }) + } + } + + return Array.from(sources.entries()).map(([source, { enabled, events }]) => ( + + {enabled ? : } + {source.toUpperCase()} + {enabled && events > 0 && ` (${events})`} + + )) + } + + return ( +
+ {/* Header */} +
setExpanded(!expanded)} + > +
+ {expanded ? : } +
+
+ {/* Stats badge */} + {ruleStats && !expanded && ( + + {ruleStats.last_fired ? formatRelativeTime(ruleStats.last_fired) : 'Never fired'} + + )} + {/* Source indicators */} + {!expanded && ( +
+ {getSourceIndicators()} +
+ )} + + + +
+
+ + {/* Status line (collapsed view) */} + {!expanded && rule.name && ( +
+ {!rule.delivery_type && ( + + + No delivery method + + )} + {ruleStats?.fire_count !== undefined && ruleStats.fire_count > 0 && ( + + Fired {ruleStats.fire_count}x + + )} +
+ )} + + {/* Expanded content */} + {expanded && ( +
+ {/* Rule name */} + onChange({ ...rule, name: v })} + placeholder="e.g., Emergency Broadcast, Daily Health Report" + helper="A descriptive name for this rule" + /> + + {/* Trigger type toggle */} +
+ +
+ + +
+

+ {rule.trigger_type === 'schedule' + ? 'Send reports on a schedule (daily briefings, weekly digests)' + : 'React to alert conditions (fires, outages, weather warnings)'} +

+
+ + {/* WHEN section - Condition trigger */} + {rule.trigger_type !== 'schedule' && ( +
+
+ + WHEN (Condition) +
+ + onChange({ ...rule, min_severity: v })} + /> + +
+ +
+ {(rule.categories?.length || 0) === 0 ? 'All categories (none selected)' : `${rule.categories?.length} selected`} +
+
+ {categories.map((cat) => ( + + ))} +
+
+ + {/* Source health display */} + {sourceHealth && Object.keys(sourceHealth).length > 0 && ( +
+ +
+ {getSourceIndicators()} +
+
+ )} +
+ )} + + {/* WHEN section - Schedule trigger */} + {rule.trigger_type === 'schedule' && ( +
+
+ + WHEN (Schedule) +
+ +
+ + +
+ +
+ onChange({ ...rule, schedule_time: v })} + /> + {rule.schedule_frequency === 'twice_daily' && ( + onChange({ ...rule, schedule_time_2: v })} + /> + )} +
+ + {rule.schedule_frequency === 'weekly' && ( +
+ +
+ {dayOptions.map((day) => ( + + ))} +
+
+ )} + +
+ + +

+ {messageTypeOptions.find(m => m.value === rule.message_type)?.description} +

+
+ + {rule.message_type === 'custom' && ( +
+ +