import { useState, useEffect, useCallback } from 'react' import { Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap, Calendar, AlertTriangle, Copy, Moon, AlertCircle, Layers, Wifi, WifiOff, Mail, Globe, Radio, MessageSquare } from 'lucide-react' import ChannelPicker from '@/components/ChannelPicker' import NodePicker from '@/components/NodePicker' // Types interface NotificationRuleConfig { name: string enabled: boolean trigger_type: 'condition' | 'schedule' categories: string[] min_severity: string schedule_frequency: 'daily' | 'twice_daily' | 'weekly' schedule_time: string schedule_time_2: string schedule_days: string[] message_type: string custom_message: string delivery_type: string broadcast_channel: number node_ids: string[] smtp_host: string smtp_port: number smtp_user: string smtp_password: string smtp_tls: boolean from_address: string recipients: string[] webhook_url: string webhook_headers: Record cooldown_minutes: number override_quiet: boolean } interface NotificationsConfig { enabled: boolean quiet_hours_enabled: boolean quiet_hours_start: string quiet_hours_end: string rules: NotificationRuleConfig[] } interface AlertCategory { id: string name: string description: string default_severity: string example_message: string } interface RuleStats { last_fired: number | null last_test: number | null fire_count: number } interface SourceHealth { [category: string]: { enabled: boolean active_events: number source: string status: 'ok' | 'disabled' | 'no_data' } } interface TestResult { live_data_summary?: string[] conditions_matched?: number preview_messages?: string[] is_example?: boolean conditions_below_threshold?: number below_threshold_summary?: string below_threshold_events?: { headline: string; severity: string }[] suggestion?: string delivered?: boolean delivery_method?: string delivery_result?: string delivery_error?: string can_send_live?: boolean source_health?: SourceHealth rule_stats?: RuleStats // Legacy success?: boolean message?: string } interface ChannelTestResult { success: boolean message: string error: string details: Record } // Severity levels with descriptions const SEVERITY_OPTIONS = [ { value: 'routine', label: 'Routine', description: 'Informational, no time pressure (ducting, new node, weather advisory, battery declining)' }, { value: 'priority', label: 'Priority', description: 'Needs attention soon (severe weather, fire nearby, node offline, HF blackout)' }, { value: 'immediate', label: 'Immediate', description: 'Act now, drop everything (fire at infrastructure, extreme weather, region blackout)' }, ] // Notification rule templates const RULE_TEMPLATES = [ { id: "mesh_health", name: "Mesh Health Monitoring", description: "Infrastructure problems - offline nodes, low battery, channel congestion", rule: { name: "Mesh Health Monitoring", enabled: true, trigger_type: "condition" as const, categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"], min_severity: "routine", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 30, override_quiet: false, schedule_frequency: "daily" as const, schedule_time: "07:00", schedule_time_2: "", schedule_days: [] as string[], message_type: "", custom_message: "", node_ids: [] as string[], smtp_host: "", smtp_port: 587, smtp_user: "", smtp_password: "", smtp_tls: true, from_address: "", recipients: [] as string[], webhook_url: "", webhook_headers: {} as Record, } }, { id: "weather_fire", name: "Weather & Fire Alerts", description: "Environmental threats - severe weather, nearby wildfires, new ignitions, flooding", rule: { name: "Weather & Fire Alerts", enabled: true, trigger_type: "condition" as const, categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"], min_severity: "priority", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 15, override_quiet: false, schedule_frequency: "daily" as const, schedule_time: "07:00", schedule_time_2: "", schedule_days: [] as string[], message_type: "", custom_message: "", node_ids: [] as string[], smtp_host: "", smtp_port: 587, smtp_user: "", smtp_password: "", smtp_tls: true, from_address: "", recipients: [] as string[], webhook_url: "", webhook_headers: {} as Record, } }, { id: "rf_conditions", name: "RF Conditions", description: "Propagation changes - solar events, HF blackouts, tropospheric ducting", rule: { name: "RF Conditions", enabled: true, trigger_type: "condition" as const, categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"], min_severity: "routine", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 60, override_quiet: false, schedule_frequency: "daily" as const, schedule_time: "07:00", schedule_time_2: "", schedule_days: [] as string[], message_type: "", custom_message: "", node_ids: [] as string[], smtp_host: "", smtp_port: 587, smtp_user: "", smtp_password: "", smtp_tls: true, from_address: "", recipients: [] as string[], webhook_url: "", webhook_headers: {} as Record, } }, { id: "road_traffic", name: "Road & Traffic", description: "Road closures and severe congestion", rule: { name: "Road & Traffic", enabled: true, trigger_type: "condition" as const, categories: ["road_closure", "traffic_congestion"], min_severity: "routine", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 30, override_quiet: false, schedule_frequency: "daily" as const, schedule_time: "07:00", schedule_time_2: "", schedule_days: [] as string[], message_type: "", custom_message: "", node_ids: [] as string[], smtp_host: "", smtp_port: 587, smtp_user: "", smtp_password: "", smtp_tls: true, from_address: "", recipients: [] as string[], webhook_url: "", webhook_headers: {} as Record, } }, { id: "everything_critical", name: "Everything Critical", description: "All emergency-level events regardless of type", rule: { name: "Everything Critical", enabled: true, trigger_type: "condition" as const, categories: [] as string[], min_severity: "immediate", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 5, override_quiet: true, schedule_frequency: "daily" as const, schedule_time: "07:00", schedule_time_2: "", schedule_days: [] as string[], message_type: "", custom_message: "", node_ids: [] as string[], smtp_host: "", smtp_port: 587, smtp_user: "", smtp_password: "", smtp_tls: true, from_address: "", recipients: [] as string[], webhook_url: "", webhook_headers: {} as Record, } }, { id: "morning_briefing", name: "Morning Briefing", description: "Daily health and conditions summary at 7am", rule: { name: "Morning Briefing", enabled: true, trigger_type: "schedule" as const, categories: [] as string[], min_severity: "routine", schedule_frequency: "daily" as const, schedule_time: "07:00", schedule_time_2: "", schedule_days: [] as string[], message_type: "mesh_health_summary", custom_message: "", delivery_type: "mesh_broadcast", broadcast_channel: 0, cooldown_minutes: 0, override_quiet: false, node_ids: [] as string[], smtp_host: "", smtp_port: 587, smtp_user: "", smtp_password: "", smtp_tls: true, from_address: "", recipients: [] as string[], webhook_url: "", webhook_headers: {} as Record, } }, ] // Helper to format relative time function formatRelativeTime(timestamp: number | null): string { if (!timestamp) return 'Never' const now = Date.now() / 1000 const diff = now - timestamp if (diff < 60) return 'Just now' if (diff < 3600) return `${Math.floor(diff / 60)}m ago` if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` return new Date(timestamp * 1000).toLocaleDateString() } // InfoButton component function InfoButton({ info }: { info: string }) { const [open, setOpen] = useState(false) return (
{open && ( <>
setOpen(false)} />
{info}
)}
) } // Form components function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: { label: string value: string onChange: (v: string) => void type?: string placeholder?: string helper?: string info?: string }) { const [showPassword, setShowPassword] = useState(false) const isPassword = type === 'password' return (
onChange(e.target.value)} placeholder={placeholder} className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600" /> {isPassword && ( )}
{helper &&

{helper}

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

{helper}

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

{helper}

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

{helper}

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

{helper}

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

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

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

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

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

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

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