From d90b787c124ca6a944cf1e43e7551d542f36ea44 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Wed, 13 May 2026 14:25:57 +0000 Subject: [PATCH 1/4] refactor(notifications): complete UX redesign - Self-contained rules replace abstract channels - Inline delivery config (broadcast/DM/email/webhook or none) - quiet_hours_enabled master toggle separate from start/end times - delivery_type="" valid: rule matches but does not deliver - Severity dropdown with plain-English descriptions - Example messages per alert category - Default baseline rules: Emergency Broadcast, Infrastructure Down, Fire Alert, Severe Weather - Condition vs Schedule trigger types - Test and preview buttons per rule - stream_flood_warning renamed from flood_warning (distinct from packet_flood) - Categories display with descriptions Co-Authored-By: Claude Opus 4.5 --- config.example.yaml | 185 +++++--- .../src/pages/Notifications.tsx | 448 +++++++++++------- meshai/config.py | 3 +- ...{index-DG_2rmdm.css => index-D0mCSizv.css} | 2 +- .../{index-BOJS6jme.js => index-utMF5PG3.js} | 193 ++++---- meshai/dashboard/static/index.html | 4 +- meshai/notifications/categories.py | 44 +- meshai/notifications/router.py | 105 +++- 8 files changed, 614 insertions(+), 370 deletions(-) rename meshai/dashboard/static/assets/{index-DG_2rmdm.css => index-D0mCSizv.css} (56%) rename meshai/dashboard/static/assets/{index-BOJS6jme.js => index-utMF5PG3.js} (70%) diff --git a/config.example.yaml b/config.example.yaml index 0696890..6a68c1a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -216,80 +216,111 @@ environmental: confidence_min: "nominal" # low, nominal, high proximity_km: 10.0 # km to match known fire perimeters - -# === NOTIFICATION DELIVERY === -# Route alerts to channels (mesh, email, webhook) based on rules. -# Categories match alert types from alert_engine.py. -# Severity levels: info, advisory, watch, warning, critical, emergency -# -notifications: - enabled: false - quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours - quiet_hours_end: "06:00" - - # Notification rules - each rule is self-contained with its own delivery config - rules: - # All emergencies -> mesh broadcast - - name: "Emergency Broadcast" - enabled: true - trigger_type: condition - categories: [] # Empty = all categories - min_severity: "emergency" - delivery_type: mesh_broadcast - broadcast_channel: 0 - cooldown_minutes: 5 - override_quiet: true # Send even during quiet hours - - # Example: Fire alerts -> email - # - name: "Fire Alerts Email" - # enabled: true - # trigger_type: condition - # categories: ["wildfire_proximity", "new_ignition"] - # min_severity: "advisory" - # delivery_type: email - # smtp_host: "smtp.gmail.com" - # smtp_port: 587 - # smtp_user: "you@gmail.com" - # smtp_password: "${SMTP_PASSWORD}" - # smtp_tls: true - # from_address: "meshai@yourdomain.com" - # recipients: ["admin@yourdomain.com"] - # cooldown_minutes: 30 - - # Example: All warnings -> Discord webhook - # - name: "Discord Alerts" - # enabled: true - # trigger_type: condition - # categories: [] - # min_severity: "warning" - # delivery_type: webhook - # webhook_url: "https://discord.com/api/webhooks/..." - # cooldown_minutes: 10 - - # Example: Daily health report -> mesh broadcast - # - name: "Morning Briefing" - # enabled: true - # trigger_type: schedule - # schedule_frequency: daily - # schedule_time: "07:00" - # message_type: mesh_health_summary - # delivery_type: mesh_broadcast - # broadcast_channel: 0 - - # Example: Weekly digest -> email - # - name: "Weekly Digest" - # enabled: true - # trigger_type: schedule - # schedule_frequency: weekly - # schedule_days: ["monday"] - # schedule_time: "08:00" - # message_type: alerts_digest - # delivery_type: email - # smtp_host: "smtp.gmail.com" - # recipients: ["admin@example.com"] - -# === WEB DASHBOARD === -dashboard: - enabled: true - port: 8080 - host: "0.0.0.0" + +# === 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_enabled: true # Master toggle for quiet hours feature + 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 + # Default baseline rules are created on fresh install + rules: + # Emergency Broadcast - all emergencies go out immediately + - 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 + + # Infrastructure Down - critical node and infrastructure offline alerts + - name: "Infrastructure Down" + enabled: true + trigger_type: condition + categories: ["infra_offline", "critical_node_down"] + min_severity: "warning" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 30 + override_quiet: false + + # Fire Alert - wildfire proximity and new ignition + - name: "Fire Alert" + enabled: true + trigger_type: condition + categories: ["wildfire_proximity", "new_ignition"] + min_severity: "advisory" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 60 + override_quiet: false + + # Severe Weather - weather warnings + - name: "Severe Weather" + enabled: true + trigger_type: condition + categories: ["weather_warning"] + min_severity: "warning" + delivery_type: mesh_broadcast + broadcast_channel: 0 + cooldown_minutes: 30 + override_quiet: false + + # 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: Rule with no delivery (matches and logs, but doesn't send) + # - name: "Monitor Only" + # enabled: true + # trigger_type: condition + # categories: ["battery_warning"] + # min_severity: "warning" + # delivery_type: "" # Empty = no delivery, just tracks matches + +# === WEB DASHBOARD === +dashboard: + enabled: true + port: 8080 + host: "0.0.0.0" diff --git a/dashboard-frontend/src/pages/Notifications.tsx b/dashboard-frontend/src/pages/Notifications.tsx index 9f67519..37d3a65 100644 --- a/dashboard-frontend/src/pages/Notifications.tsx +++ b/dashboard-frontend/src/pages/Notifications.tsx @@ -2,7 +2,7 @@ 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 + Calendar, AlertTriangle, Copy, Moon, AlertCircle } from 'lucide-react' import ChannelPicker from '@/components/ChannelPicker' import NodePicker from '@/components/NodePicker' @@ -11,20 +11,15 @@ import NodePicker from '@/components/NodePicker' interface NotificationRuleConfig { name: string enabled: boolean - // Trigger trigger_type: 'condition' | 'schedule' - // Condition trigger categories: string[] min_severity: string - // Schedule trigger - schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom' + schedule_frequency: 'daily' | 'twice_daily' | 'weekly' schedule_time: string - schedule_time_2: string // For twice_daily - schedule_days: string[] // For weekly - schedule_cron: string // For custom + schedule_time_2: string + schedule_days: string[] message_type: string custom_message: string - // Delivery delivery_type: string broadcast_channel: number node_ids: string[] @@ -37,13 +32,13 @@ interface NotificationRuleConfig { recipients: string[] webhook_url: string webhook_headers: Record - // Behavior cooldown_minutes: number override_quiet: boolean } interface NotificationsConfig { enabled: boolean + quiet_hours_enabled: boolean quiet_hours_start: string quiet_hours_end: string rules: NotificationRuleConfig[] @@ -54,8 +49,43 @@ interface AlertCategory { name: string description: string default_severity: string + example_message: string } +// 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)', + }, +] + // InfoButton component function InfoButton({ info }: { info: string }) { const [open, setOpen] = useState(false) @@ -187,34 +217,6 @@ function Toggle({ label, checked, onChange, helper = '', info = '' }: { ) } -function SelectInput({ label, value, onChange, options, helper = '', info = '' }: { - label: string - value: string - onChange: (v: string) => void - options: { value: string; label: string }[] - helper?: string - info?: string -}) { - return ( -
- - - {helper &&

{helper}

} -
- ) -} - function TimeInput({ label, value, onChange, helper = '', info = '' }: { label: string value: string @@ -307,10 +309,63 @@ function ListInput({ label, value, onChange, placeholder = 'Add item...', 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.

+
+ ) +} + // Notification Rule Card Component function NotificationRuleCard({ rule, categories, + quietHoursEnabled, onChange, onDelete, onDuplicate, @@ -318,6 +373,7 @@ function NotificationRuleCard({ }: { rule: NotificationRuleConfig categories: AlertCategory[] + quietHoursEnabled: boolean onChange: (r: NotificationRuleConfig) => void onDelete: () => void onDuplicate: () => void @@ -326,35 +382,26 @@ function NotificationRuleCard({ const [expanded, setExpanded] = useState(!rule.name) const [testing, setTesting] = useState(false) - const severityOptions = [ - { value: 'info', label: 'Info' }, - { value: 'advisory', label: 'Advisory' }, - { value: 'watch', label: 'Watch' }, - { value: 'warning', label: 'Warning' }, - { value: 'critical', label: 'Critical' }, - { value: 'emergency', label: 'Emergency' }, - ] - const deliveryOptions = [ - { value: 'mesh_broadcast', label: 'Mesh Broadcast' }, - { value: 'mesh_dm', label: 'Mesh DM' }, - { value: 'email', label: 'Email' }, - { value: 'webhook', label: 'Webhook' }, + { 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: 'Once Daily' }, + { value: 'daily', label: 'Daily' }, { value: 'twice_daily', label: 'Twice Daily' }, { value: 'weekly', label: 'Weekly' }, - { value: 'custom', label: 'Custom Cron' }, ] const messageTypeOptions = [ - { value: 'mesh_health_summary', label: 'Mesh Health Summary' }, - { value: 'rf_propagation_report', label: 'RF Propagation Report' }, - { value: 'alerts_digest', label: 'Active Alerts Digest' }, - { value: 'environmental_conditions', label: 'Environmental Conditions' }, - { value: 'custom', label: 'Custom Message' }, + { 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'] @@ -383,42 +430,57 @@ function NotificationRuleCard({ 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') { - // Schedule summary 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 { - // Condition summary const catCount = rule.categories?.length || 0 - const catText = catCount === 0 ? 'All categories' : `${catCount} categories` - const severity = severityOptions.find(s => s.value === rule.min_severity)?.label || rule.min_severity + 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 - 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, 30) || 'no URL' + 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})` : ''}`) } - parts.push(`${delivery}${target ? ` (${target})` : ''}`) return parts.join(' → ') } @@ -435,7 +497,7 @@ function NotificationRuleCard({ @@ -487,7 +549,7 @@ function NotificationRuleCard({ helper="A descriptive name for this rule" /> - {/* Trigger type selector */} + {/* Trigger type toggle */}
@@ -518,8 +580,8 @@ function NotificationRuleCard({

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

@@ -531,12 +593,9 @@ function NotificationRuleCard({ WHEN (Condition)
- onChange({ ...rule, min_severity: v })} - options={severityOptions} - helper="Only alerts at or above this level" />
@@ -578,19 +637,24 @@ function NotificationRuleCard({ WHEN (Schedule)
- onChange({ ...rule, schedule_frequency: v as any })} - options={frequencyOptions} - /> +
+ + +
onChange({ ...rule, schedule_time: v })} - helper="24-hour format" /> {rule.schedule_frequency === 'twice_daily' && ( )} - {rule.schedule_frequency === 'custom' && ( - onChange({ ...rule, schedule_cron: v })} - placeholder="0 7 * * *" - helper="Standard cron format" - info="Five-field cron: minute hour day-of-month month day-of-week. Example: '0 7 * * 1' = 7:00 AM every Monday." - /> - )} - - onChange({ ...rule, message_type: v })} - options={messageTypeOptions} - info="The type of report or message to send." - /> +
+ + +

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

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