diff --git a/dashboard-frontend/src/App.tsx b/dashboard-frontend/src/App.tsx index 6c69cf8..c6665ea 100644 --- a/dashboard-frontend/src/App.tsx +++ b/dashboard-frontend/src/App.tsx @@ -6,6 +6,7 @@ import Environment from './pages/Environment' import Config from './pages/Config' import Alerts from './pages/Alerts' import Notifications from './pages/Notifications' +import Reference from './pages/Reference' import { ToastProvider } from './components/ToastProvider' function App() { @@ -19,6 +20,7 @@ function App() { } /> } /> } /> + } /> diff --git a/dashboard-frontend/src/components/Layout.tsx b/dashboard-frontend/src/components/Layout.tsx index 07b2fb6..c5640a1 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -7,6 +7,7 @@ import { Settings, Bell, BellRing, + BookOpen, } from 'lucide-react' import { fetchStatus, type SystemStatus } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' @@ -23,6 +24,7 @@ const navItems = [ { path: '/config', label: 'Config', icon: Settings }, { path: '/alerts', label: 'Alerts', icon: Bell }, { path: '/notifications', label: 'Notifications', icon: BellRing }, + { path: '/reference', label: 'Reference', icon: BookOpen }, ] function formatUptime(seconds: number): string { diff --git a/dashboard-frontend/src/pages/Notifications.tsx b/dashboard-frontend/src/pages/Notifications.tsx index 37d3a65..0b01cd8 100644 --- a/dashboard-frontend/src/pages/Notifications.tsx +++ b/dashboard-frontend/src/pages/Notifications.tsx @@ -1,1197 +1,1431 @@ -import { useState, useEffect, useCallback } from 'react' -import { - Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, - Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap, - Calendar, AlertTriangle, Copy, Moon, AlertCircle -} from 'lucide-react' -import ChannelPicker from '@/components/ChannelPicker' -import NodePicker from '@/components/NodePicker' - -// Types -interface NotificationRuleConfig { - name: string - enabled: boolean - trigger_type: 'condition' | 'schedule' - categories: string[] - min_severity: string - schedule_frequency: 'daily' | 'twice_daily' | 'weekly' - schedule_time: string - schedule_time_2: string - schedule_days: string[] - message_type: string - custom_message: string - delivery_type: string - broadcast_channel: number - node_ids: string[] - smtp_host: string - smtp_port: number - smtp_user: string - smtp_password: string - smtp_tls: boolean - from_address: string - recipients: string[] - webhook_url: string - webhook_headers: Record - cooldown_minutes: number - override_quiet: boolean -} - -interface NotificationsConfig { - enabled: boolean - quiet_hours_enabled: boolean - quiet_hours_start: string - quiet_hours_end: string - rules: NotificationRuleConfig[] -} - -interface AlertCategory { - id: string - name: string - description: string - default_severity: string - example_message: string -} - -// 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) - - return ( -
- - {open && ( - <> -
setOpen(false)} /> -
- {info} -
- - )} -
- ) -} - -// Form components -function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: { - label: string - value: string - onChange: (v: string) => void - type?: string - placeholder?: string - helper?: string - info?: string -}) { - const [showPassword, setShowPassword] = useState(false) - const isPassword = type === 'password' - - return ( -
- -
- onChange(e.target.value)} - placeholder={placeholder} - className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600" - /> - {isPassword && ( - - )} -
- {helper &&

{helper}

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

{helper}

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

{helper}

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

{helper}

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

{helper}

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

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

-
- ) -} - -// Notification Rule Card Component -function NotificationRuleCard({ - rule, - categories, - quietHoursEnabled, - onChange, - onDelete, - onDuplicate, - onTest, -}: { - rule: NotificationRuleConfig - categories: AlertCategory[] - quietHoursEnabled: boolean - onChange: (r: NotificationRuleConfig) => void - onDelete: () => void - onDuplicate: () => void - onTest: () => void -}) { - const [expanded, setExpanded] = useState(!rule.name) - const [testing, setTesting] = useState(false) - - const deliveryOptions = [ - { value: '', label: '(None)', description: 'Rule matches but does not deliver' }, - { value: 'mesh_broadcast', label: 'Mesh Broadcast', description: 'Send to a mesh radio channel' }, - { value: 'mesh_dm', label: 'Mesh DM', description: 'Direct message to specific nodes' }, - { value: 'email', label: 'Email', description: 'Send via SMTP' }, - { value: 'webhook', label: 'Webhook', description: 'POST to any URL' }, - ] - - const frequencyOptions = [ - { value: 'daily', label: 'Daily' }, - { value: 'twice_daily', label: 'Twice Daily' }, - { value: 'weekly', label: 'Weekly' }, - ] - - const messageTypeOptions = [ - { value: 'mesh_health_summary', label: 'Mesh Health Summary', description: 'Current health score, pillar breakdown, problem nodes' }, - { value: 'rf_propagation_report', label: 'RF Propagation Report', description: 'Solar indices, Kp, ducting conditions' }, - { value: 'alerts_digest', label: 'Active Alerts Digest', description: 'Summary of all active environmental alerts' }, - { value: 'environmental_conditions', label: 'Environmental Conditions', description: 'Full conditions: weather, fire, streams, roads' }, - { value: 'custom', label: 'Custom Message', description: 'Write your own with template tokens' }, - ] - - const dayOptions = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] - - const toggleCategory = (catId: string) => { - const current = rule.categories || [] - if (current.includes(catId)) { - onChange({ ...rule, categories: current.filter(c => c !== catId) }) - } else { - onChange({ ...rule, categories: [...current, catId] }) - } - } - - const toggleDay = (day: string) => { - const current = rule.schedule_days || [] - if (current.includes(day)) { - onChange({ ...rule, schedule_days: current.filter(d => d !== day) }) - } else { - onChange({ ...rule, schedule_days: [...current, day] }) - } - } - - const handleTest = async () => { - setTesting(true) - await onTest() - setTesting(false) - } - - // Get example message for display - const getExampleMessage = (): string => { - if (rule.trigger_type === 'schedule') { - return '[Scheduled report preview would appear here]' - } - const ruleCats = rule.categories || [] - if (ruleCats.length === 0 && categories.length > 0) { - return categories[0].example_message || 'Alert notification' - } - const firstCat = categories.find(c => ruleCats.includes(c.id)) - return firstCat?.example_message || 'Alert notification' - } - - // Generate summary for collapsed view - const getSummary = () => { - const parts: string[] = [] - - if (rule.trigger_type === 'schedule') { - const freq = frequencyOptions.find(f => f.value === rule.schedule_frequency)?.label || rule.schedule_frequency - const msgType = messageTypeOptions.find(m => m.value === rule.message_type)?.label || rule.message_type - parts.push(`${freq} at ${rule.schedule_time || '??:??'}`) - parts.push(msgType) - } else { - const catCount = rule.categories?.length || 0 - const catText = catCount === 0 ? 'All' : categories.filter(c => rule.categories?.includes(c.id)).map(c => c.name).slice(0, 2).join(', ') + (catCount > 2 ? ` +${catCount - 2}` : '') - const severity = SEVERITY_OPTIONS.find(s => s.value === rule.min_severity)?.label || rule.min_severity - parts.push(`${catText} at ${severity}+`) - } - - // Delivery summary - if (!rule.delivery_type) { - parts.push('⚠️ No delivery') - } else { - const delivery = deliveryOptions.find(d => d.value === rule.delivery_type)?.label || rule.delivery_type - let target = '' - if (rule.delivery_type === 'mesh_broadcast') { - target = `Ch ${rule.broadcast_channel}` - } else if (rule.delivery_type === 'mesh_dm') { - target = `${rule.node_ids?.length || 0} nodes` - } else if (rule.delivery_type === 'email') { - target = rule.recipients?.length ? rule.recipients[0] + (rule.recipients.length > 1 ? ` +${rule.recipients.length - 1}` : '') : 'no recipients' - } else if (rule.delivery_type === 'webhook') { - try { - const url = new URL(rule.webhook_url) - target = url.hostname - } catch { - target = rule.webhook_url?.slice(0, 20) || 'no URL' - } - } - parts.push(`${delivery}${target ? ` (${target})` : ''}`) - } - - return parts.join(' → ') - } - - return ( -
- {/* Header */} -
setExpanded(!expanded)} - > -
- {expanded ? : } -
-
- - - -
-
- - {/* Expanded content */} - {expanded && ( -
- {/* Rule name */} - onChange({ ...rule, name: v })} - placeholder="e.g., Emergency Broadcast, Daily Health Report" - helper="A descriptive name for this rule" - /> - - {/* Trigger type toggle */} -
- -
- - -
-

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

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

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

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