diff --git a/config.example.yaml b/config.example.yaml index d6e3b02..0696890 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -226,59 +226,70 @@ notifications: enabled: false quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours quiet_hours_end: "06:00" - dedup_seconds: 600 # Don't resend same alert within this window - # Notification channels - channels: - # Mesh broadcast - posts to mesh channel - - id: "mesh-channel" - type: mesh_broadcast + # Notification rules - each rule is self-contained with its own delivery config + rules: + # All emergencies -> mesh broadcast + - name: "Emergency Broadcast" enabled: true - channel_index: 0 # Mesh channel to broadcast on + 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: Email channel (uncomment to enable) - # - id: "email-admin" - # type: email + # 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}" # Use env var + # smtp_password: "${SMTP_PASSWORD}" # smtp_tls: true # from_address: "meshai@yourdomain.com" # recipients: ["admin@yourdomain.com"] + # cooldown_minutes: 30 - # Example: Webhook (Discord, Slack, ntfy, Home Assistant) - # - id: "discord-alerts" - # type: webhook - # url: "https://discord.com/api/webhooks/..." - - # - id: "ntfy-alerts" - # type: webhook - # url: "https://ntfy.sh/your-topic" - - # Notification rules - match alerts to channels - rules: - # All emergencies -> mesh broadcast - - name: "emergencies" - categories: [] # Empty = all categories - min_severity: "emergency" - channel_ids: ["mesh-channel"] - override_quiet: true # Send even during quiet hours - - # Example: Fire alerts at any severity - # - name: "fire-alerts" - # categories: ["wildfire_proximity", "new_ignition"] - # min_severity: "advisory" - # channel_ids: ["mesh-channel", "email-admin"] - - # Example: Infrastructure alerts - # - name: "infra-alerts" - # categories: ["infra_offline", "critical_node_down", "battery_emergency"] + # Example: All warnings -> Discord webhook + # - name: "Discord Alerts" + # enabled: true + # trigger_type: condition + # categories: [] # min_severity: "warning" - # channel_ids: ["mesh-channel"] + # delivery_type: webhook + # webhook_url: "https://discord.com/api/webhooks/..." + # cooldown_minutes: 10 -# === WEB DASHBOARD === -dashboard: - enabled: true - port: 8080 - host: "0.0.0.0" + # 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" diff --git a/dashboard-frontend/src/pages/Notifications.tsx b/dashboard-frontend/src/pages/Notifications.tsx index e878b4a..9f67519 100644 --- a/dashboard-frontend/src/pages/Notifications.tsx +++ b/dashboard-frontend/src/pages/Notifications.tsx @@ -1,17 +1,32 @@ import { useState, useEffect, useCallback } from 'react' import { Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, - Check, X, Eye as EyeIcon, EyeOff, ExternalLink, Send + Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap, + Calendar, AlertTriangle, Copy } from 'lucide-react' import ChannelPicker from '@/components/ChannelPicker' import NodePicker from '@/components/NodePicker' // Types -interface NotificationChannelConfig { - id: string - type: string +interface NotificationRuleConfig { + name: string enabled: boolean - channel_index: number + // Trigger + trigger_type: 'condition' | 'schedule' + // Condition trigger + categories: string[] + min_severity: string + // Schedule trigger + schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom' + schedule_time: string + schedule_time_2: string // For twice_daily + schedule_days: string[] // For weekly + schedule_cron: string // For custom + message_type: string + custom_message: string + // Delivery + delivery_type: string + broadcast_channel: number node_ids: string[] smtp_host: string smtp_port: number @@ -20,15 +35,10 @@ interface NotificationChannelConfig { smtp_tls: boolean from_address: string recipients: string[] - url: string - headers: Record -} - -interface NotificationRuleConfig { - name: string - categories: string[] - min_severity: string - channel_ids: string[] + webhook_url: string + webhook_headers: Record + // Behavior + cooldown_minutes: number override_quiet: boolean } @@ -36,8 +46,6 @@ interface NotificationsConfig { enabled: boolean quiet_hours_start: string quiet_hours_end: string - dedup_seconds: number - channels: NotificationChannelConfig[] rules: NotificationRuleConfig[] } @@ -49,7 +57,7 @@ interface AlertCategory { } // InfoButton component -function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; link?: string; linkText?: string }) { +function InfoButton({ info }: { info: string }) { const [open, setOpen] = useState(false) return ( @@ -67,17 +75,6 @@ function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; lin
setOpen(false)} />
{info} - {link && ( - e.stopPropagation()} - > - {linkText} - - )}
)} @@ -218,10 +215,35 @@ function SelectInput({ label, value, onChange, options, helper = '', info = '' } ) } -function ListInput({ label, value, onChange, helper = '', info = '' }: { +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 }) { @@ -251,7 +273,7 @@ function ListInput({ label, value, onChange, helper = '', info = '' }: { onChange={(e) => 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="Add item..." + placeholder={placeholder} /> - -
- - {expanded && ( -
-
- onChange({ ...channel, id: v })} - helper="Unique identifier for this channel" - info="Used to reference this channel in notification rules. Use lowercase with hyphens (e.g., 'mesh-main', 'email-admin')." - /> - onChange({ ...channel, type: v })} - options={typeOptions} - info={typeDescriptions[channel.type] || 'Select a channel type'} - /> -
- onChange({ ...channel, enabled: v })} - helper="Disable to temporarily stop alerts on this channel" - /> - - {channel.type === 'mesh_broadcast' && ( - onChange({ ...channel, channel_index: v })} - helper="Channel for broadcast alerts" - info="The mesh channel to broadcast alerts on." - mode="single" - /> - )} - - {channel.type === 'mesh_dm' && ( - onChange({ ...channel, node_ids: v })} - helper="Nodes to receive DM alerts" - info="Nodes that receive direct message alerts." - valueType="node_id_hex" - /> - )} - - {channel.type === 'email' && ( - <> -
- onChange({ ...channel, smtp_host: v })} - placeholder="smtp.gmail.com" - helper="SMTP server hostname" - info="The SMTP server for sending emails. Gmail: smtp.gmail.com, Outlook: smtp.office365.com" - /> - onChange({ ...channel, smtp_port: v })} - min={1} - max={65535} - helper="587 (TLS) or 465 (SSL)" - info="SMTP port. Use 587 for TLS (recommended) or 465 for SSL." - /> -
-
- onChange({ ...channel, smtp_user: v })} - placeholder="you@gmail.com" - helper="Login username" - /> - onChange({ ...channel, smtp_password: v })} - type="password" - helper="App password recommended" - info="Gmail users: use an App Password, not your regular password. Generate one at myaccount.google.com/apppasswords" - /> -
- onChange({ ...channel, smtp_tls: v })} - helper="Encrypt SMTP connection" - info="Enable TLS encryption for the SMTP connection. Required for most modern email servers." - /> - onChange({ ...channel, from_address: v })} - placeholder="alerts@yourdomain.com" - helper="Sender email address" - info="The email address that appears as the sender. Some servers require this to match your login." - /> - onChange({ ...channel, recipients: v })} - helper="Email addresses to receive alerts" - info="List of email addresses that will receive alerts from this channel." - /> - - )} - - {channel.type === 'webhook' && ( - <> - onChange({ ...channel, url: v })} - placeholder="https://discord.com/api/webhooks/..." - helper="POST endpoint for alerts" - info="POST alert JSON to any URL. Works with Discord webhooks, ntfy.sh, Pushover, Slack, Home Assistant, or any service that accepts HTTP POST." - /> -
- -
- Custom headers can be configured in the YAML config file -
-
- - )} -
- )} - - ) -} - // Notification Rule Card Component function NotificationRuleCard({ rule, categories, - channels, onChange, onDelete, + onDuplicate, + onTest, }: { rule: NotificationRuleConfig categories: AlertCategory[] - channels: NotificationChannelConfig[] onChange: (r: NotificationRuleConfig) => void onDelete: () => void + onDuplicate: () => void + onTest: () => void }) { - const [expanded, setExpanded] = useState(false) + const [expanded, setExpanded] = useState(!rule.name) + const [testing, setTesting] = useState(false) const severityOptions = [ { value: 'info', label: 'Info' }, @@ -513,6 +335,30 @@ function NotificationRuleCard({ { 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' }, + ] + + const frequencyOptions = [ + { value: 'daily', label: 'Once 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' }, + ] + + const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + const toggleCategory = (catId: string) => { const current = rule.categories || [] if (current.includes(catId)) { @@ -522,120 +368,442 @@ function NotificationRuleCard({ } } - const toggleChannel = (channelId: string) => { - const current = rule.channel_ids || [] - if (current.includes(channelId)) { - onChange({ ...rule, channel_ids: current.filter(c => c !== channelId) }) + 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, channel_ids: [...current, channelId] }) + onChange({ ...rule, schedule_days: [...current, day] }) } } + const handleTest = async () => { + setTesting(true) + await onTest() + setTesting(false) + } + + // 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 + 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' + } + } + parts.push(`${delivery}${target ? ` (${target})` : ''}`) + + return parts.join(' → ') + } + return ( -
+
+ {/* Header */}
setExpanded(!expanded)} > -
- {expanded ? : } - {rule.name || 'New Rule'} - - {rule.categories?.length || 0} categories → {rule.channel_ids?.length || 0} channels - +
+ {expanded ? : } +
+
+ + +
-
+ + {/* Expanded content */} {expanded && ( -
+
+ {/* Rule name */} onChange({ ...rule, name: v })} - helper="Human-readable name for this rule" - info="A descriptive name to identify this rule. Example: 'Emergency Alerts', 'Fire Notifications', 'Infrastructure Warnings'" - /> - - onChange({ ...rule, min_severity: v })} - options={severityOptions} - helper="Only alerts at or above this severity" - info="Only alerts at this severity or above will trigger this rule. 'warning' is recommended for most channels. Use 'info' to receive all alerts." + placeholder="e.g., Emergency Broadcast, Daily Health Report" + helper="A descriptive name for this rule" /> + {/* Trigger type selector */}
- -
- {rule.categories?.length === 0 ? 'All categories (none selected)' : `${rule.categories?.length} selected`} -
-
- {categories.map((cat) => ( - - ))} + +
+ +
+

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

-
- - {channels.length === 0 ? ( -
- No channels configured. Add channels above first. + {/* WHEN section - Condition trigger */} + {rule.trigger_type !== 'schedule' && ( +
+
+ + WHEN (Condition)
- ) : ( -
- {channels.map((ch) => ( -
+ )} + + {/* WHEN section - Schedule trigger */} + {rule.trigger_type === 'schedule' && ( +
+
+ + 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' && ( + onChange({ ...rule, schedule_time_2: v })} + /> + )} +
+ + {rule.schedule_frequency === 'weekly' && ( +
+ +
+ {dayOptions.map((day) => ( + + ))} +
+
+ )} + + {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." + /> + + {rule.message_type === 'custom' && ( +
+ - ))} +