mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
b4f7e24c26
commit
d90b787c12
8 changed files with 614 additions and 370 deletions
|
|
@ -216,80 +216,111 @@ environmental:
|
||||||
confidence_min: "nominal" # low, nominal, high
|
confidence_min: "nominal" # low, nominal, high
|
||||||
proximity_km: 10.0 # km to match known fire perimeters
|
proximity_km: 10.0 # km to match known fire perimeters
|
||||||
|
|
||||||
|
|
||||||
# === NOTIFICATION DELIVERY ===
|
# === NOTIFICATION DELIVERY ===
|
||||||
# Route alerts to channels (mesh, email, webhook) based on rules.
|
# Route alerts to channels (mesh, email, webhook) based on rules.
|
||||||
# Categories match alert types from alert_engine.py.
|
# Categories match alert types from alert_engine.py.
|
||||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
# Severity levels: info, advisory, watch, warning, critical, emergency
|
||||||
#
|
#
|
||||||
notifications:
|
notifications:
|
||||||
enabled: false
|
enabled: false
|
||||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||||
quiet_hours_end: "06:00"
|
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:
|
# Notification rules - each rule is self-contained with its own delivery config
|
||||||
# All emergencies -> mesh broadcast
|
# Default baseline rules are created on fresh install
|
||||||
- name: "Emergency Broadcast"
|
rules:
|
||||||
enabled: true
|
# Emergency Broadcast - all emergencies go out immediately
|
||||||
trigger_type: condition
|
- name: "Emergency Broadcast"
|
||||||
categories: [] # Empty = all categories
|
enabled: true
|
||||||
min_severity: "emergency"
|
trigger_type: condition
|
||||||
delivery_type: mesh_broadcast
|
categories: [] # Empty = all categories
|
||||||
broadcast_channel: 0
|
min_severity: "emergency"
|
||||||
cooldown_minutes: 5
|
delivery_type: mesh_broadcast
|
||||||
override_quiet: true # Send even during quiet hours
|
broadcast_channel: 0
|
||||||
|
cooldown_minutes: 5
|
||||||
# Example: Fire alerts -> email
|
override_quiet: true # Send even during quiet hours
|
||||||
# - name: "Fire Alerts Email"
|
|
||||||
# enabled: true
|
# Infrastructure Down - critical node and infrastructure offline alerts
|
||||||
# trigger_type: condition
|
- name: "Infrastructure Down"
|
||||||
# categories: ["wildfire_proximity", "new_ignition"]
|
enabled: true
|
||||||
# min_severity: "advisory"
|
trigger_type: condition
|
||||||
# delivery_type: email
|
categories: ["infra_offline", "critical_node_down"]
|
||||||
# smtp_host: "smtp.gmail.com"
|
min_severity: "warning"
|
||||||
# smtp_port: 587
|
delivery_type: mesh_broadcast
|
||||||
# smtp_user: "you@gmail.com"
|
broadcast_channel: 0
|
||||||
# smtp_password: "${SMTP_PASSWORD}"
|
cooldown_minutes: 30
|
||||||
# smtp_tls: true
|
override_quiet: false
|
||||||
# from_address: "meshai@yourdomain.com"
|
|
||||||
# recipients: ["admin@yourdomain.com"]
|
# Fire Alert - wildfire proximity and new ignition
|
||||||
# cooldown_minutes: 30
|
- name: "Fire Alert"
|
||||||
|
enabled: true
|
||||||
# Example: All warnings -> Discord webhook
|
trigger_type: condition
|
||||||
# - name: "Discord Alerts"
|
categories: ["wildfire_proximity", "new_ignition"]
|
||||||
# enabled: true
|
min_severity: "advisory"
|
||||||
# trigger_type: condition
|
delivery_type: mesh_broadcast
|
||||||
# categories: []
|
broadcast_channel: 0
|
||||||
# min_severity: "warning"
|
cooldown_minutes: 60
|
||||||
# delivery_type: webhook
|
override_quiet: false
|
||||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
|
||||||
# cooldown_minutes: 10
|
# Severe Weather - weather warnings
|
||||||
|
- name: "Severe Weather"
|
||||||
# Example: Daily health report -> mesh broadcast
|
enabled: true
|
||||||
# - name: "Morning Briefing"
|
trigger_type: condition
|
||||||
# enabled: true
|
categories: ["weather_warning"]
|
||||||
# trigger_type: schedule
|
min_severity: "warning"
|
||||||
# schedule_frequency: daily
|
delivery_type: mesh_broadcast
|
||||||
# schedule_time: "07:00"
|
broadcast_channel: 0
|
||||||
# message_type: mesh_health_summary
|
cooldown_minutes: 30
|
||||||
# delivery_type: mesh_broadcast
|
override_quiet: false
|
||||||
# broadcast_channel: 0
|
|
||||||
|
# Example: Fire alerts -> email
|
||||||
# Example: Weekly digest -> email
|
# - name: "Fire Alerts Email"
|
||||||
# - name: "Weekly Digest"
|
# enabled: true
|
||||||
# enabled: true
|
# trigger_type: condition
|
||||||
# trigger_type: schedule
|
# categories: ["wildfire_proximity", "new_ignition"]
|
||||||
# schedule_frequency: weekly
|
# min_severity: "advisory"
|
||||||
# schedule_days: ["monday"]
|
# delivery_type: email
|
||||||
# schedule_time: "08:00"
|
# smtp_host: "smtp.gmail.com"
|
||||||
# message_type: alerts_digest
|
# smtp_port: 587
|
||||||
# delivery_type: email
|
# smtp_user: "you@gmail.com"
|
||||||
# smtp_host: "smtp.gmail.com"
|
# smtp_password: "${SMTP_PASSWORD}"
|
||||||
# recipients: ["admin@example.com"]
|
# smtp_tls: true
|
||||||
|
# from_address: "meshai@yourdomain.com"
|
||||||
# === WEB DASHBOARD ===
|
# recipients: ["admin@yourdomain.com"]
|
||||||
dashboard:
|
# cooldown_minutes: 30
|
||||||
enabled: true
|
|
||||||
port: 8080
|
# Example: All warnings -> Discord webhook
|
||||||
host: "0.0.0.0"
|
# - 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"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
|
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
|
||||||
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
|
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
|
||||||
Calendar, AlertTriangle, Copy
|
Calendar, AlertTriangle, Copy, Moon, AlertCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
|
|
@ -11,20 +11,15 @@ import NodePicker from '@/components/NodePicker'
|
||||||
interface NotificationRuleConfig {
|
interface NotificationRuleConfig {
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
// Trigger
|
|
||||||
trigger_type: 'condition' | 'schedule'
|
trigger_type: 'condition' | 'schedule'
|
||||||
// Condition trigger
|
|
||||||
categories: string[]
|
categories: string[]
|
||||||
min_severity: string
|
min_severity: string
|
||||||
// Schedule trigger
|
schedule_frequency: 'daily' | 'twice_daily' | 'weekly'
|
||||||
schedule_frequency: 'daily' | 'twice_daily' | 'weekly' | 'custom'
|
|
||||||
schedule_time: string
|
schedule_time: string
|
||||||
schedule_time_2: string // For twice_daily
|
schedule_time_2: string
|
||||||
schedule_days: string[] // For weekly
|
schedule_days: string[]
|
||||||
schedule_cron: string // For custom
|
|
||||||
message_type: string
|
message_type: string
|
||||||
custom_message: string
|
custom_message: string
|
||||||
// Delivery
|
|
||||||
delivery_type: string
|
delivery_type: string
|
||||||
broadcast_channel: number
|
broadcast_channel: number
|
||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
|
|
@ -37,13 +32,13 @@ interface NotificationRuleConfig {
|
||||||
recipients: string[]
|
recipients: string[]
|
||||||
webhook_url: string
|
webhook_url: string
|
||||||
webhook_headers: Record<string, string>
|
webhook_headers: Record<string, string>
|
||||||
// Behavior
|
|
||||||
cooldown_minutes: number
|
cooldown_minutes: number
|
||||||
override_quiet: boolean
|
override_quiet: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationsConfig {
|
interface NotificationsConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
quiet_hours_enabled: boolean
|
||||||
quiet_hours_start: string
|
quiet_hours_start: string
|
||||||
quiet_hours_end: string
|
quiet_hours_end: string
|
||||||
rules: NotificationRuleConfig[]
|
rules: NotificationRuleConfig[]
|
||||||
|
|
@ -54,8 +49,43 @@ interface AlertCategory {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
default_severity: 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
|
// InfoButton component
|
||||||
function InfoButton({ info }: { info: string }) {
|
function InfoButton({ info }: { info: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
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 (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
|
||||||
{label}
|
|
||||||
{info && <InfoButton info={info} />}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimeInput({ label, value, onChange, helper = '', info = '' }: {
|
function TimeInput({ label, value, onChange, helper = '', info = '' }: {
|
||||||
label: string
|
label: string
|
||||||
value: 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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Severity Threshold
|
||||||
|
<InfoButton info="Only alerts at or above this severity trigger this rule. Lower threshold = more notifications. 'Warning' is recommended for most rules." />
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-left flex items-center justify-between hover:border-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-200">{selected.label}</span>
|
||||||
|
<span className="text-slate-500 ml-2">— {selected.description}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} className={`text-slate-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||||
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl overflow-hidden">
|
||||||
|
{SEVERITY_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(opt.value); setIsOpen(false) }}
|
||||||
|
className={`w-full px-3 py-2.5 text-left text-sm hover:bg-[#1e2a3a] transition-colors ${
|
||||||
|
value === opt.value ? 'bg-accent/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-slate-200">{opt.label}</div>
|
||||||
|
<div className="text-xs text-slate-500">{opt.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">Lower = more notifications. "Warning" recommended for most rules.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Notification Rule Card Component
|
// Notification Rule Card Component
|
||||||
function NotificationRuleCard({
|
function NotificationRuleCard({
|
||||||
rule,
|
rule,
|
||||||
categories,
|
categories,
|
||||||
|
quietHoursEnabled,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
|
@ -318,6 +373,7 @@ function NotificationRuleCard({
|
||||||
}: {
|
}: {
|
||||||
rule: NotificationRuleConfig
|
rule: NotificationRuleConfig
|
||||||
categories: AlertCategory[]
|
categories: AlertCategory[]
|
||||||
|
quietHoursEnabled: boolean
|
||||||
onChange: (r: NotificationRuleConfig) => void
|
onChange: (r: NotificationRuleConfig) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
|
|
@ -326,35 +382,26 @@ function NotificationRuleCard({
|
||||||
const [expanded, setExpanded] = useState(!rule.name)
|
const [expanded, setExpanded] = useState(!rule.name)
|
||||||
const [testing, setTesting] = useState(false)
|
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 = [
|
const deliveryOptions = [
|
||||||
{ value: 'mesh_broadcast', label: 'Mesh Broadcast' },
|
{ value: '', label: '(None)', description: 'Rule matches but does not deliver' },
|
||||||
{ value: 'mesh_dm', label: 'Mesh DM' },
|
{ value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' },
|
||||||
{ value: 'email', label: 'Email' },
|
{ value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' },
|
||||||
{ value: 'webhook', label: 'Webhook' },
|
{ value: 'email', label: 'Email', description: 'Send via SMTP' },
|
||||||
|
{ value: 'webhook', label: 'Webhook', description: 'POST to any URL' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const frequencyOptions = [
|
const frequencyOptions = [
|
||||||
{ value: 'daily', label: 'Once Daily' },
|
{ value: 'daily', label: 'Daily' },
|
||||||
{ value: 'twice_daily', label: 'Twice Daily' },
|
{ value: 'twice_daily', label: 'Twice Daily' },
|
||||||
{ value: 'weekly', label: 'Weekly' },
|
{ value: 'weekly', label: 'Weekly' },
|
||||||
{ value: 'custom', label: 'Custom Cron' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const messageTypeOptions = [
|
const messageTypeOptions = [
|
||||||
{ value: 'mesh_health_summary', label: 'Mesh Health Summary' },
|
{ value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' },
|
||||||
{ value: 'rf_propagation_report', label: 'RF Propagation Report' },
|
{ value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' },
|
||||||
{ value: 'alerts_digest', label: 'Active Alerts Digest' },
|
{ value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' },
|
||||||
{ value: 'environmental_conditions', label: 'Environmental Conditions' },
|
{ value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' },
|
||||||
{ value: 'custom', label: 'Custom Message' },
|
{ value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
|
@ -383,42 +430,57 @@ function NotificationRuleCard({
|
||||||
setTesting(false)
|
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
|
// Generate summary for collapsed view
|
||||||
const getSummary = () => {
|
const getSummary = () => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (rule.trigger_type === 'schedule') {
|
if (rule.trigger_type === 'schedule') {
|
||||||
// Schedule summary
|
|
||||||
const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency
|
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
|
const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type
|
||||||
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
|
parts.push(`${freq} at ${rule.schedule_time || '??:??'}`)
|
||||||
parts.push(msgType)
|
parts.push(msgType)
|
||||||
} else {
|
} else {
|
||||||
// Condition summary
|
|
||||||
const catCount = rule.categories?.length || 0
|
const catCount = rule.categories?.length || 0
|
||||||
const catText = catCount === 0 ? 'All categories' : `${catCount} categories`
|
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 = severityOptions.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity
|
||||||
parts.push(`${catText} at ${severity}+`)
|
parts.push(`${catText} at ${severity}+`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery summary
|
// Delivery summary
|
||||||
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
if (!rule.delivery_type) {
|
||||||
let target = ''
|
parts.push('⚠️ No delivery')
|
||||||
if (rule.delivery_type === 'mesh_broadcast') {
|
} else {
|
||||||
target = `Ch ${rule.broadcast_channel}`
|
const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type
|
||||||
} else if (rule.delivery_type === 'mesh_dm') {
|
let target = ''
|
||||||
target = `${rule.node_ids?.length || 0} nodes`
|
if (rule.delivery_type === 'mesh_broadcast') {
|
||||||
} else if (rule.delivery_type === 'email') {
|
target = `Ch ${rule.broadcast_channel}`
|
||||||
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
|
} else if (rule.delivery_type === 'mesh_dm') {
|
||||||
} else if (rule.delivery_type === 'webhook') {
|
target = `${rule.node_ids?.length || 0} nodes`
|
||||||
try {
|
} else if (rule.delivery_type === 'email') {
|
||||||
const url = new URL(rule.webhook_url)
|
target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients'
|
||||||
target = url.hostname
|
} else if (rule.delivery_type === 'webhook') {
|
||||||
} catch {
|
try {
|
||||||
target = rule.webhook_url?.slice(0, 30) || 'no URL'
|
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(' → ')
|
return parts.join(' → ')
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +497,7 @@ function NotificationRuleCard({
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }}
|
onClick={(e) => { e.stopPropagation(); onChange({ ...rule, enabled: !rule.enabled }) }}
|
||||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`}
|
className={`w-2 h-2 rounded-full flex-shrink-0 ${rule.enabled ? 'bg-green-500' : 'bg-slate-500'}`}
|
||||||
title={rule.enabled ? 'Enabled - click to disable' : 'Disabled - click to enable'}
|
title={rule.enabled ? 'Enabled' : 'Disabled'}
|
||||||
/>
|
/>
|
||||||
{rule.trigger_type === 'schedule' ? (
|
{rule.trigger_type === 'schedule' ? (
|
||||||
<Clock size={14} className="text-blue-400 flex-shrink-0" />
|
<Clock size={14} className="text-blue-400 flex-shrink-0" />
|
||||||
|
|
@ -444,7 +506,7 @@ function NotificationRuleCard({
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
|
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
|
||||||
{!expanded && (
|
{!expanded && (
|
||||||
<span className="text-xs text-slate-500 truncate hidden sm:block">
|
<span className={`text-xs truncate hidden sm:block ${!rule.delivery_type ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||||
{getSummary()}
|
{getSummary()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -454,21 +516,21 @@ function NotificationRuleCard({
|
||||||
onClick={(e) => { e.stopPropagation(); handleTest() }}
|
onClick={(e) => { e.stopPropagation(); handleTest() }}
|
||||||
disabled={testing || !rule.name}
|
disabled={testing || !rule.name}
|
||||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
|
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
|
||||||
title="Send test"
|
title="Test rule"
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
|
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
|
||||||
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
|
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
|
||||||
title="Duplicate rule"
|
title="Duplicate"
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
title="Delete rule"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -487,7 +549,7 @@ function NotificationRuleCard({
|
||||||
helper="A descriptive name for this rule"
|
helper="A descriptive name for this rule"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Trigger type selector */}
|
{/* Trigger type toggle */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -518,8 +580,8 @@ function NotificationRuleCard({
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-600">
|
<p className="text-xs text-slate-600">
|
||||||
{rule.trigger_type === 'schedule'
|
{rule.trigger_type === 'schedule'
|
||||||
? 'Send messages on a schedule (daily reports, weekly digests)'
|
? 'Send reports on a schedule (daily briefings, weekly digests)'
|
||||||
: 'React to alert conditions (fires, outages, warnings)'}
|
: 'React to alert conditions (fires, outages, weather warnings)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -531,12 +593,9 @@ function NotificationRuleCard({
|
||||||
WHEN (Condition)
|
WHEN (Condition)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInput
|
<SeveritySelector
|
||||||
label="Minimum Severity"
|
|
||||||
value={rule.min_severity}
|
value={rule.min_severity}
|
||||||
onChange={(v) => onChange({ ...rule, min_severity: v })}
|
onChange={(v) => onChange({ ...rule, min_severity: v })}
|
||||||
options={severityOptions}
|
|
||||||
helper="Only alerts at or above this level"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -578,19 +637,24 @@ function NotificationRuleCard({
|
||||||
WHEN (Schedule)
|
WHEN (Schedule)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInput
|
<div className="space-y-1">
|
||||||
label="Frequency"
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
|
||||||
value={rule.schedule_frequency || 'daily'}
|
<select
|
||||||
onChange={(v) => onChange({ ...rule, schedule_frequency: v as any })}
|
value={rule.schedule_frequency || 'daily'}
|
||||||
options={frequencyOptions}
|
onChange={(e) => onChange({ ...rule, schedule_frequency: e.target.value as any })}
|
||||||
/>
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{frequencyOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<TimeInput
|
<TimeInput
|
||||||
label="Time"
|
label="Time"
|
||||||
value={rule.schedule_time || '07:00'}
|
value={rule.schedule_time || '07:00'}
|
||||||
onChange={(v) => onChange({ ...rule, schedule_time: v })}
|
onChange={(v) => onChange({ ...rule, schedule_time: v })}
|
||||||
helper="24-hour format"
|
|
||||||
/>
|
/>
|
||||||
{rule.schedule_frequency === 'twice_daily' && (
|
{rule.schedule_frequency === 'twice_daily' && (
|
||||||
<TimeInput
|
<TimeInput
|
||||||
|
|
@ -623,30 +687,27 @@ function NotificationRuleCard({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rule.schedule_frequency === 'custom' && (
|
<div className="space-y-1">
|
||||||
<TextInput
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
|
||||||
label="Cron Expression"
|
<select
|
||||||
value={rule.schedule_cron || ''}
|
value={rule.message_type || 'mesh_health_summary'}
|
||||||
onChange={(v) => onChange({ ...rule, schedule_cron: v })}
|
onChange={(e) => onChange({ ...rule, message_type: e.target.value })}
|
||||||
placeholder="0 7 * * *"
|
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="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."
|
{messageTypeOptions.map(opt => (
|
||||||
/>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
)}
|
))}
|
||||||
|
</select>
|
||||||
<SelectInput
|
<p className="text-xs text-slate-600">
|
||||||
label="Message Type"
|
{messageTypeOptions.find(m => m.value === rule.message_type)?.description}
|
||||||
value={rule.message_type || 'mesh_health_summary'}
|
</p>
|
||||||
onChange={(v) => onChange({ ...rule, message_type: v })}
|
</div>
|
||||||
options={messageTypeOptions}
|
|
||||||
info="The type of report or message to send."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{rule.message_type === 'custom' && (
|
{rule.message_type === 'custom' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
Custom Message
|
Custom Message
|
||||||
<InfoButton info="Use template tokens: {MESH_SCORE}, {NODE_COUNT}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
|
<InfoButton info="Available tokens: {MESH_SCORE}, {NODE_COUNT}, {NODES_ONLINE}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={rule.custom_message || ''}
|
value={rule.custom_message || ''}
|
||||||
|
|
@ -667,12 +728,34 @@ function NotificationRuleCard({
|
||||||
SEND VIA
|
SEND VIA
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectInput
|
<div className="space-y-1">
|
||||||
label="Delivery Method"
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
value={rule.delivery_type || 'mesh_broadcast'}
|
Delivery Method
|
||||||
onChange={(v) => onChange({ ...rule, delivery_type: v })}
|
<InfoButton info="Where this notification gets delivered. Select (None) to save the rule without delivery — it will match conditions but won't send until you configure a delivery method." />
|
||||||
options={deliveryOptions}
|
</label>
|
||||||
/>
|
<select
|
||||||
|
value={rule.delivery_type || ''}
|
||||||
|
onChange={(e) => onChange({ ...rule, delivery_type: 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"
|
||||||
|
>
|
||||||
|
{deliveryOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
{deliveryOptions.find(d => d.value === (rule.delivery_type || ''))?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No delivery warning */}
|
||||||
|
{!rule.delivery_type && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
||||||
|
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-amber-300">
|
||||||
|
Rule will log matches but not deliver until a delivery method is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mesh Broadcast fields */}
|
{/* Mesh Broadcast fields */}
|
||||||
{rule.delivery_type === 'mesh_broadcast' && (
|
{rule.delivery_type === 'mesh_broadcast' && (
|
||||||
|
|
@ -739,7 +822,7 @@ function NotificationRuleCard({
|
||||||
value={rule.smtp_password || ''}
|
value={rule.smtp_password || ''}
|
||||||
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
||||||
type="password"
|
type="password"
|
||||||
info="For Gmail, use an App Password from myaccount.google.com/apppasswords"
|
info="Gmail users: use an App Password from myaccount.google.com/apppasswords"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
|
@ -760,28 +843,14 @@ function NotificationRuleCard({
|
||||||
|
|
||||||
{/* Webhook fields */}
|
{/* Webhook fields */}
|
||||||
{rule.delivery_type === 'webhook' && (
|
{rule.delivery_type === 'webhook' && (
|
||||||
<div className="space-y-4">
|
<TextInput
|
||||||
<TextInput
|
label="Webhook URL"
|
||||||
label="Webhook URL"
|
value={rule.webhook_url || ''}
|
||||||
value={rule.webhook_url || ''}
|
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
||||||
onChange={(v) => onChange({ ...rule, webhook_url: v })}
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
helper="POST alert as JSON"
|
||||||
helper="POST endpoint for alerts"
|
info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint."
|
||||||
info="Works with Discord, Slack, ntfy.sh, Home Assistant, Pushover, or any HTTP POST endpoint."
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<details className="group">
|
|
||||||
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
|
|
||||||
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
|
|
||||||
Custom Headers (optional)
|
|
||||||
</summary>
|
|
||||||
<div className="mt-4 pl-6 border-l border-[#1e2a3a]">
|
|
||||||
<p className="text-xs text-slate-500 mb-2">
|
|
||||||
Headers are configured in config.yaml for security.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -795,15 +864,28 @@ function NotificationRuleCard({
|
||||||
helper="Min time between repeat sends"
|
helper="Min time between repeat sends"
|
||||||
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
|
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end pb-1">
|
{quietHoursEnabled && (
|
||||||
<Toggle
|
<div className="flex items-end pb-1">
|
||||||
label="Override Quiet Hours"
|
<Toggle
|
||||||
checked={rule.override_quiet ?? false}
|
label="Override Quiet Hours"
|
||||||
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
checked={rule.override_quiet ?? false}
|
||||||
helper="Send during quiet hours"
|
onChange={(v) => onChange({ ...rule, override_quiet: v })}
|
||||||
/>
|
helper="Deliver during quiet hours"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Example message */}
|
||||||
|
{rule.trigger_type !== 'schedule' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Example Message</label>
|
||||||
|
<div className="p-3 bg-[#1e2a3a]/50 rounded-lg border border-[#1e2a3a]">
|
||||||
|
<p className="text-sm text-slate-300 font-mono">{getExampleMessage()}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">This is an example of what this rule would send.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -902,10 +984,9 @@ export default function Notifications() {
|
||||||
schedule_time: '07:00',
|
schedule_time: '07:00',
|
||||||
schedule_time_2: '19:00',
|
schedule_time_2: '19:00',
|
||||||
schedule_days: ['monday'],
|
schedule_days: ['monday'],
|
||||||
schedule_cron: '',
|
|
||||||
message_type: 'mesh_health_summary',
|
message_type: 'mesh_health_summary',
|
||||||
custom_message: '',
|
custom_message: '',
|
||||||
delivery_type: 'mesh_broadcast',
|
delivery_type: '', // Start with no delivery
|
||||||
broadcast_channel: 0,
|
broadcast_channel: 0,
|
||||||
node_ids: [],
|
node_ids: [],
|
||||||
smtp_host: '',
|
smtp_host: '',
|
||||||
|
|
@ -965,11 +1046,11 @@ export default function Notifications() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* Header with actions */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Configure notification rules for alerts and scheduled reports.
|
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1025,31 +1106,47 @@ export default function Notifications() {
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onChange={(v) => setConfig({ ...config, enabled: v })}
|
onChange={(v) => setConfig({ ...config, enabled: v })}
|
||||||
helper="Master switch for all notification delivery"
|
helper="Master switch for all notification delivery"
|
||||||
info="When disabled, no alerts or scheduled messages will be delivered. The alert engine still runs and records alerts to history."
|
info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<>
|
<>
|
||||||
{/* Quiet Hours Section - at top */}
|
{/* Quiet Hours Section */}
|
||||||
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
|
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
<div className="flex items-center gap-2">
|
||||||
Quiet Hours
|
<Moon size={14} className="text-slate-400" />
|
||||||
<InfoButton info="Non-emergency alerts are held during these hours. Rules with 'Override Quiet Hours' enabled still deliver. Emergency and critical alerts always get through." />
|
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<TimeInput
|
|
||||||
label="Start Time"
|
|
||||||
value={config.quiet_hours_start || '22:00'}
|
|
||||||
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
|
|
||||||
helper="When quiet hours begin"
|
|
||||||
/>
|
|
||||||
<TimeInput
|
|
||||||
label="End Time"
|
|
||||||
value={config.quiet_hours_end || '06:00'}
|
|
||||||
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
|
|
||||||
helper="When quiet hours end"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
label="Enable Quiet Hours"
|
||||||
|
checked={config.quiet_hours_enabled ?? true}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
|
||||||
|
helper="Suppress non-emergency alerts during sleeping hours"
|
||||||
|
info="When enabled, alerts below emergency severity are held during quiet hours. When disabled, all alerts deliver anytime."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{config.quiet_hours_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<TimeInput
|
||||||
|
label="Start Time"
|
||||||
|
value={config.quiet_hours_start || '22:00'}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
|
||||||
|
helper="When quiet hours begin"
|
||||||
|
/>
|
||||||
|
<TimeInput
|
||||||
|
label="End Time"
|
||||||
|
value={config.quiet_hours_end || '06:00'}
|
||||||
|
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
|
||||||
|
helper="When quiet hours end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Emergency alerts and rules with "Override Quiet Hours" enabled always deliver.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rules Section */}
|
{/* Rules Section */}
|
||||||
|
|
@ -1069,6 +1166,7 @@ export default function Notifications() {
|
||||||
key={i}
|
key={i}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
quietHoursEnabled={config.quiet_hours_enabled ?? true}
|
||||||
onChange={(r) => {
|
onChange={(r) => {
|
||||||
const newRules = [...(config.rules || [])]
|
const newRules = [...(config.rules || [])]
|
||||||
newRules[i] = r
|
newRules[i] = r
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,7 @@ class NotificationRuleConfig:
|
||||||
custom_message: str = ""
|
custom_message: str = ""
|
||||||
|
|
||||||
# Delivery type
|
# Delivery type
|
||||||
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook
|
delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
|
||||||
|
|
||||||
# Mesh broadcast fields
|
# Mesh broadcast fields
|
||||||
broadcast_channel: int = 0
|
broadcast_channel: int = 0
|
||||||
|
|
@ -482,6 +482,7 @@ class NotificationsConfig:
|
||||||
"""Notification system settings."""
|
"""Notification system settings."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
quiet_hours_enabled: bool = True # Master toggle for quiet hours
|
||||||
quiet_hours_start: str = "22:00"
|
quiet_hours_start: str = "22:00"
|
||||||
quiet_hours_end: str = "06:00"
|
quiet_hours_end: str = "06:00"
|
||||||
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
<script type="module" crossorigin src="/assets/index-utMF5PG3.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D0mCSizv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Alert category registry.
|
"""Alert category registry.
|
||||||
|
|
||||||
Defines all alertable conditions with human-readable names and descriptions.
|
Defines all alertable conditions with human-readable names, descriptions,
|
||||||
|
and example messages showing what users will receive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALERT_CATEGORIES = {
|
ALERT_CATEGORIES = {
|
||||||
|
|
@ -9,21 +10,25 @@ ALERT_CATEGORIES = {
|
||||||
"name": "Infrastructure Offline",
|
"name": "Infrastructure Offline",
|
||||||
"description": "An infrastructure node stopped responding",
|
"description": "An infrastructure node stopped responding",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "❌ Mountain Harrison Rptr went offline in Magic Valley.",
|
||||||
},
|
},
|
||||||
"critical_node_down": {
|
"critical_node_down": {
|
||||||
"name": "Critical Node Down",
|
"name": "Critical Node Down",
|
||||||
"description": "A node marked as critical went offline",
|
"description": "A node marked as critical went offline",
|
||||||
"default_severity": "critical",
|
"default_severity": "critical",
|
||||||
|
"example_message": "🚨 MHR went offline in Magic Valley. (alert 1/4)",
|
||||||
},
|
},
|
||||||
"infra_recovery": {
|
"infra_recovery": {
|
||||||
"name": "Infrastructure Recovery",
|
"name": "Infrastructure Recovery",
|
||||||
"description": "An infrastructure node came back online",
|
"description": "An infrastructure node came back online",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": "✅ Mountain Harrison Rptr is back online in Magic Valley.",
|
||||||
},
|
},
|
||||||
"new_router": {
|
"new_router": {
|
||||||
"name": "New Router",
|
"name": "New Router",
|
||||||
"description": "A new router appeared on the mesh",
|
"description": "A new router appeared on the mesh",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": "📡 New router appeared: Snake River Relay in Wood River Valley.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Power alerts
|
# Power alerts
|
||||||
|
|
@ -31,43 +36,51 @@ ALERT_CATEGORIES = {
|
||||||
"name": "Battery Warning",
|
"name": "Battery Warning",
|
||||||
"description": "Infrastructure node battery below warning threshold",
|
"description": "Infrastructure node battery below warning threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔋 BLD-MTN battery low at 35% in Boise Foothills.",
|
||||||
},
|
},
|
||||||
"battery_critical": {
|
"battery_critical": {
|
||||||
"name": "Battery Critical",
|
"name": "Battery Critical",
|
||||||
"description": "Infrastructure node battery below critical threshold",
|
"description": "Infrastructure node battery below critical threshold",
|
||||||
"default_severity": "critical",
|
"default_severity": "critical",
|
||||||
|
"example_message": "🔋 MHR battery critical at 18% in Magic Valley.",
|
||||||
},
|
},
|
||||||
"battery_emergency": {
|
"battery_emergency": {
|
||||||
"name": "Battery Emergency",
|
"name": "Battery Emergency",
|
||||||
"description": "Infrastructure node battery critically low",
|
"description": "Infrastructure node battery critically low",
|
||||||
"default_severity": "emergency",
|
"default_severity": "emergency",
|
||||||
|
"example_message": "🚨 BLD-MTN battery EMERGENCY at 8% in Boise Foothills.",
|
||||||
},
|
},
|
||||||
"battery_trend": {
|
"battery_trend": {
|
||||||
"name": "Battery Declining",
|
"name": "Battery Declining",
|
||||||
"description": "Battery showing declining trend over 7 days",
|
"description": "Battery showing declining trend over 7 days",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔋 HPR battery declining: 85% → 62% over 7 days (-3.3%/day) in Hagerman.",
|
||||||
},
|
},
|
||||||
"power_source_change": {
|
"power_source_change": {
|
||||||
"name": "Power Source Change",
|
"name": "Power Source Change",
|
||||||
"description": "Node switched from USB to battery (possible outage)",
|
"description": "Node switched from USB to battery (possible outage)",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "⚡ MHR switched from USB to battery in Magic Valley. Possible power outage.",
|
||||||
},
|
},
|
||||||
"solar_not_charging": {
|
"solar_not_charging": {
|
||||||
"name": "Solar Not Charging",
|
"name": "Solar Not Charging",
|
||||||
"description": "Solar panel not charging during daylight hours",
|
"description": "Solar panel not charging during daylight hours",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "☀️ BLD-MTN solar not charging in Boise Foothills.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Utilization alerts
|
# Utilization alerts
|
||||||
"sustained_high_util": {
|
"sustained_high_util": {
|
||||||
"name": "High Utilization",
|
"name": "High Channel Utilization",
|
||||||
"description": "Channel utilization elevated for extended period",
|
"description": "Channel airtime elevated for extended period",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔥 MHR at 32% channel utilization for 6+ hours in Magic Valley.",
|
||||||
},
|
},
|
||||||
"packet_flood": {
|
"packet_flood": {
|
||||||
"name": "Packet Flood",
|
"name": "Packet Flood",
|
||||||
"description": "Node sending excessive packets",
|
"description": "Node sending excessive packets (possible firmware bug)",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📡 BRKN-NODE sent 847 packets in 24h (threshold: 500) in Boise.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Coverage alerts
|
# Coverage alerts
|
||||||
|
|
@ -75,16 +88,19 @@ ALERT_CATEGORIES = {
|
||||||
"name": "Single Gateway",
|
"name": "Single Gateway",
|
||||||
"description": "Infrastructure node dropped to single gateway coverage",
|
"description": "Infrastructure node dropped to single gateway coverage",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📶 HPR dropped to single gateway coverage in Hagerman.",
|
||||||
},
|
},
|
||||||
"feeder_offline": {
|
"feeder_offline": {
|
||||||
"name": "Feeder Offline",
|
"name": "Feeder Offline",
|
||||||
"description": "A feeder gateway stopped responding",
|
"description": "A feeder gateway stopped responding",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📡 Feeder gateway AIDA-N2 went offline.",
|
||||||
},
|
},
|
||||||
"region_total_blackout": {
|
"region_total_blackout": {
|
||||||
"name": "Region Blackout",
|
"name": "Region Blackout",
|
||||||
"description": "All infrastructure in a region is offline",
|
"description": "All infrastructure in a region is offline",
|
||||||
"default_severity": "emergency",
|
"default_severity": "emergency",
|
||||||
|
"example_message": "🚨 TOTAL BLACKOUT: All infrastructure in Magic Valley is offline!",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Health score alerts
|
# Health score alerts
|
||||||
|
|
@ -92,11 +108,13 @@ ALERT_CATEGORIES = {
|
||||||
"name": "Mesh Health Low",
|
"name": "Mesh Health Low",
|
||||||
"description": "Overall mesh health score below threshold",
|
"description": "Overall mesh health score below threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📉 Mesh Health: Score dropped to 62 (Warning threshold: 70).",
|
||||||
},
|
},
|
||||||
"region_score_low": {
|
"region_score_low": {
|
||||||
"name": "Region Health Low",
|
"name": "Region Health Low",
|
||||||
"description": "A region's health score below threshold",
|
"description": "A region's health score below threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📉 Magic Valley health score dropped to 55 (threshold: 60).",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental alerts
|
# Environmental alerts
|
||||||
|
|
@ -104,36 +122,43 @@ ALERT_CATEGORIES = {
|
||||||
"name": "Severe Weather",
|
"name": "Severe Weather",
|
||||||
"description": "NWS warning or advisory for mesh area",
|
"description": "NWS warning or advisory for mesh area",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "⚠️ Red Flag Warning — Twin Falls, Jerome, Cassia counties until May 14 04:00 MDT.",
|
||||||
},
|
},
|
||||||
"hf_blackout": {
|
"hf_blackout": {
|
||||||
"name": "HF Radio Blackout",
|
"name": "HF Radio Blackout",
|
||||||
"description": "R3+ solar event degrading HF propagation",
|
"description": "R3+ solar event degrading HF propagation",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "📻 R3 HF Radio Blackout — HF propagation degraded for several hours.",
|
||||||
},
|
},
|
||||||
"tropospheric_ducting": {
|
"tropospheric_ducting": {
|
||||||
"name": "Tropospheric Ducting",
|
"name": "Tropospheric Ducting",
|
||||||
"description": "Atmospheric conditions extending VHF/UHF range",
|
"description": "Atmospheric conditions extending VHF/UHF range",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km. Extended VHF/UHF range possible.",
|
||||||
},
|
},
|
||||||
"wildfire_proximity": {
|
"wildfire_proximity": {
|
||||||
"name": "Fire Near Mesh",
|
"name": "Fire Near Mesh",
|
||||||
"description": "Wildfire detected within configured distance",
|
"description": "Wildfire detected within configured distance of mesh infrastructure",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🔥 Rock Creek Fire — 1,240 ac, 15% contained, 24 km SSW of MHR.",
|
||||||
},
|
},
|
||||||
"new_ignition": {
|
"new_ignition": {
|
||||||
"name": "New Fire Ignition",
|
"name": "New Fire Ignition",
|
||||||
"description": "Satellite hotspot not matching any known fire",
|
"description": "Satellite hotspot not matching any known fire perimeter",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🛰️ New Ignition: Satellite fire detection at 42.32°N, 114.30°W — high confidence, not near any known fire.",
|
||||||
},
|
},
|
||||||
"flood_warning": {
|
"stream_flood_warning": {
|
||||||
"name": "Flood Warning",
|
"name": "Stream Flood Warning",
|
||||||
"description": "Stream gauge exceeds flood threshold",
|
"description": "River gauge exceeds flood stage threshold",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🌊 Snake River nr Twin Falls at 12.8 ft (flood stage: 13.0 ft).",
|
||||||
},
|
},
|
||||||
"road_closure": {
|
"road_closure": {
|
||||||
"name": "Road Closure",
|
"name": "Road Closure",
|
||||||
"description": "Full road closure on monitored corridor",
|
"description": "Full road closure on monitored corridor",
|
||||||
"default_severity": "warning",
|
"default_severity": "warning",
|
||||||
|
"example_message": "🚧 I-84 EB closed at MP 173 — full closure due to wildfire smoke.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +171,7 @@ def get_category(category_id: str) -> dict:
|
||||||
"name": category_id.replace("_", " ").title(),
|
"name": category_id.replace("_", " ").title(),
|
||||||
"description": f"Alert type: {category_id}",
|
"description": f"Alert type: {category_id}",
|
||||||
"default_severity": "info",
|
"default_severity": "info",
|
||||||
|
"example_message": f"Alert: {category_id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,11 @@ class NotificationRouter:
|
||||||
timezone: str = "America/Boise",
|
timezone: str = "America/Boise",
|
||||||
):
|
):
|
||||||
self._rules: list[dict] = []
|
self._rules: list[dict] = []
|
||||||
|
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
|
||||||
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
||||||
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
||||||
self._timezone = timezone
|
self._timezone = timezone
|
||||||
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
|
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
||||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
@ -56,9 +57,16 @@ class NotificationRouter:
|
||||||
logger.info("Notification router initialized: %d condition rules", len(self._rules))
|
logger.info("Notification router initialized: %d condition rules", len(self._rules))
|
||||||
|
|
||||||
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
|
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
|
||||||
"""Create a channel instance from a rule's inline delivery config."""
|
"""Create a channel instance from a rule's inline delivery config.
|
||||||
|
|
||||||
|
Returns None if delivery_type is empty or invalid.
|
||||||
|
"""
|
||||||
delivery_type = rule.get("delivery_type", "")
|
delivery_type = rule.get("delivery_type", "")
|
||||||
|
|
||||||
|
# Empty delivery type is valid - rule exists but doesn't deliver
|
||||||
|
if not delivery_type:
|
||||||
|
return None
|
||||||
|
|
||||||
if delivery_type == "mesh_broadcast":
|
if delivery_type == "mesh_broadcast":
|
||||||
config = {
|
config = {
|
||||||
"type": "mesh_broadcast",
|
"type": "mesh_broadcast",
|
||||||
|
|
@ -87,13 +95,13 @@ class NotificationRouter:
|
||||||
"headers": rule.get("webhook_headers", {}),
|
"headers": rule.get("webhook_headers", {}),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logger.warning("Unknown delivery type: %s", delivery_type)
|
logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return create_channel(config, self._connector)
|
return create_channel(config, self._connector)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to create channel for rule %s: %s", rule.get("name"), e)
|
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def process_alert(self, alert: dict) -> bool:
|
async def process_alert(self, alert: dict) -> bool:
|
||||||
|
|
@ -106,6 +114,8 @@ class NotificationRouter:
|
||||||
delivered = False
|
delivered = False
|
||||||
|
|
||||||
for rule in self._rules:
|
for rule in self._rules:
|
||||||
|
rule_name = rule.get("name", "unnamed")
|
||||||
|
|
||||||
# Check category match
|
# Check category match
|
||||||
rule_categories = rule.get("categories", [])
|
rule_categories = rule.get("categories", [])
|
||||||
if rule_categories and category not in rule_categories:
|
if rule_categories and category not in rule_categories:
|
||||||
|
|
@ -116,15 +126,18 @@ class NotificationRouter:
|
||||||
if not self._severity_meets(severity, min_severity):
|
if not self._severity_meets(severity, min_severity):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check quiet hours (emergencies and criticals override)
|
# Check quiet hours (only if quiet hours are enabled globally)
|
||||||
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
|
if self._quiet_enabled and self._in_quiet_hours():
|
||||||
if not rule.get("override_quiet", False):
|
# Emergencies and criticals always go through
|
||||||
continue
|
if severity not in ("emergency", "critical"):
|
||||||
|
# Check if rule overrides quiet hours
|
||||||
|
if not rule.get("override_quiet", False):
|
||||||
|
logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name)
|
||||||
|
continue
|
||||||
|
|
||||||
# Check cooldown
|
# Check cooldown
|
||||||
cooldown = rule.get("cooldown_minutes", 10) * 60
|
cooldown = rule.get("cooldown_minutes", 10) * 60
|
||||||
event_id = alert.get("event_id", alert.get("message", "")[:50])
|
event_id = alert.get("event_id", alert.get("message", "")[:50])
|
||||||
rule_name = rule.get("name", "unknown")
|
|
||||||
dedup_key = (rule_name, category, event_id)
|
dedup_key = (rule_name, category, event_id)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if dedup_key in self._recent:
|
if dedup_key in self._recent:
|
||||||
|
|
@ -133,9 +146,19 @@ class NotificationRouter:
|
||||||
continue
|
continue
|
||||||
self._recent[dedup_key] = now
|
self._recent[dedup_key] = now
|
||||||
|
|
||||||
|
# Log rule match
|
||||||
|
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
|
||||||
|
|
||||||
|
# Check if rule has delivery configured
|
||||||
|
delivery_type = rule.get("delivery_type", "")
|
||||||
|
if not delivery_type:
|
||||||
|
logger.info("Rule '%s' matched but has no delivery configured", rule_name)
|
||||||
|
continue
|
||||||
|
|
||||||
# Create channel and deliver
|
# Create channel and deliver
|
||||||
channel = self._create_channel_for_rule(rule)
|
channel = self._create_channel_for_rule(rule)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
logger.warning("Rule '%s' failed to create delivery channel", rule_name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -153,9 +176,9 @@ class NotificationRouter:
|
||||||
success = await channel.deliver(delivery_alert, rule)
|
success = await channel.deliver(delivery_alert, rule)
|
||||||
if success:
|
if success:
|
||||||
delivered = True
|
delivered = True
|
||||||
logger.info("Alert delivered via %s: %s", rule_name, category)
|
logger.info("Alert delivered via rule '%s': %s", rule_name, category)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Rule %s delivery failed: %s", rule_name, e)
|
logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
|
||||||
|
|
||||||
return delivered
|
return delivered
|
||||||
|
|
||||||
|
|
@ -170,6 +193,9 @@ class NotificationRouter:
|
||||||
|
|
||||||
def _in_quiet_hours(self) -> bool:
|
def _in_quiet_hours(self) -> bool:
|
||||||
"""Check if current time is within quiet hours."""
|
"""Check if current time is within quiet hours."""
|
||||||
|
if not self._quiet_enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
tz = ZoneInfo(self._timezone)
|
tz = ZoneInfo(self._timezone)
|
||||||
|
|
@ -204,12 +230,69 @@ class NotificationRouter:
|
||||||
else:
|
else:
|
||||||
rule_dict = dict(rule)
|
rule_dict = dict(rule)
|
||||||
|
|
||||||
|
# Check if delivery is configured
|
||||||
|
if not rule_dict.get("delivery_type"):
|
||||||
|
return False, "No delivery method configured for this rule"
|
||||||
|
|
||||||
channel = self._create_channel_for_rule(rule_dict)
|
channel = self._create_channel_for_rule(rule_dict)
|
||||||
if not channel:
|
if not channel:
|
||||||
return False, "Failed to create delivery channel"
|
return False, "Failed to create delivery channel"
|
||||||
|
|
||||||
return await channel.test()
|
return await channel.test()
|
||||||
|
|
||||||
|
async def preview_rule(self, rule_index: int) -> dict:
|
||||||
|
"""Preview what a rule would match right now.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"matches": bool,
|
||||||
|
"conditions": [...], # Current conditions that match
|
||||||
|
"preview": str, # Example message
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
rules_config = getattr(self._config, "rules", [])
|
||||||
|
if rule_index < 0 or rule_index >= len(rules_config):
|
||||||
|
return {"matches": False, "conditions": [], "preview": "Invalid rule index"}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# For condition rules, show example based on categories
|
||||||
|
if rule_dict.get("trigger_type", "condition") == "condition":
|
||||||
|
from .categories import get_category
|
||||||
|
categories = rule_dict.get("categories", [])
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
# All categories - show first example
|
||||||
|
example = get_category("infra_offline")
|
||||||
|
return {
|
||||||
|
"matches": True,
|
||||||
|
"conditions": ["All alert categories"],
|
||||||
|
"preview": example.get("example_message", "Alert notification"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Show example from first category
|
||||||
|
cat_info = get_category(categories[0])
|
||||||
|
return {
|
||||||
|
"matches": True,
|
||||||
|
"conditions": [get_category(c)["name"] for c in categories],
|
||||||
|
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# For schedule rules, generate preview report
|
||||||
|
elif rule_dict.get("trigger_type") == "schedule":
|
||||||
|
message_type = rule_dict.get("message_type", "mesh_health_summary")
|
||||||
|
return {
|
||||||
|
"matches": True,
|
||||||
|
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
|
||||||
|
"preview": f"[{message_type}] Report content would appear here",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
||||||
|
|
||||||
def add_mesh_subscription(
|
def add_mesh_subscription(
|
||||||
self,
|
self,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue