import { useState, useEffect, useCallback } from 'react' import { Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, Check, X, Eye as EyeIcon, EyeOff, ExternalLink, Send } from 'lucide-react' // Types interface NotificationChannelConfig { id: string type: string enabled: boolean channel_index: number node_ids: string[] smtp_host: string smtp_port: number smtp_user: string smtp_password: string smtp_tls: boolean from_address: string recipients: string[] url: string headers: Record } interface NotificationRuleConfig { name: string categories: string[] min_severity: string channel_ids: string[] override_quiet: boolean } interface NotificationsConfig { enabled: boolean quiet_hours_start: string quiet_hours_end: string dedup_seconds: number channels: NotificationChannelConfig[] rules: NotificationRuleConfig[] } interface AlertCategory { id: string name: string description: string default_severity: string } // InfoButton component function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; link?: string; linkText?: string }) { const [open, setOpen] = useState(false) return (
{open && ( <>
setOpen(false)} />
{info} {link && ( e.stopPropagation()} > {linkText} )}
)}
) } // 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 SelectInput({ label, value, onChange, options, helper = '', info = '' }: { label: string value: string onChange: (v: string) => void options: { value: string; label: string }[] helper?: string info?: string }) { return (
{helper &&

{helper}

}
) } function ListInput({ label, value, onChange, helper = '', info = '' }: { label: string value: string[] onChange: (v: string[]) => void 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="Add item..." />
{value.length > 0 && (
{value.map((item, i) => ( {item} ))}
)} {helper &&

{helper}

}
) } // Notification Channel Card Component function NotificationChannelCard({ channel, onChange, onDelete, onTest, }: { channel: NotificationChannelConfig onChange: (c: NotificationChannelConfig) => void onDelete: () => void onTest: () => void }) { const [expanded, setExpanded] = useState(false) const [testing, setTesting] = useState(false) const typeOptions = [ { value: 'mesh_broadcast', label: 'Mesh Broadcast' }, { value: 'mesh_dm', label: 'Mesh DM' }, { value: 'email', label: 'Email' }, { value: 'webhook', label: 'Webhook' }, ] const typeDescriptions: Record = { mesh_broadcast: 'Broadcast alerts to a mesh channel. All nodes on that channel receive the alert.', mesh_dm: 'Send alerts as direct messages to specific nodes.', email: 'Send alert emails via SMTP. Works with Gmail, Outlook, and any SMTP server.', webhook: 'POST alert JSON to any URL. Works with Discord webhooks, ntfy.sh, Pushover, Slack, Home Assistant, or any service that accepts HTTP POST.', } const handleTest = async () => { setTesting(true) await onTest() setTesting(false) } return (
setExpanded(!expanded)} >
{expanded ? : }
{channel.id || 'New Channel'} {typeOptions.find(t => t.value === channel.type)?.label || channel.type}
{expanded && (
onChange({ ...channel, id: v })} helper="Unique identifier for this channel" info="Used to reference this channel in notification rules. Use lowercase with hyphens (e.g., 'mesh-main', 'email-admin')." /> onChange({ ...channel, type: v })} options={typeOptions} info={typeDescriptions[channel.type] || 'Select a channel type'} />
onChange({ ...channel, enabled: v })} helper="Disable to temporarily stop alerts on this channel" /> {channel.type === 'mesh_broadcast' && ( onChange({ ...channel, channel_index: v })} min={0} max={7} helper="Mesh channel number (0-7)" info="The mesh channel to broadcast alerts on. Channel 0 is typically the default channel." /> )} {channel.type === 'mesh_dm' && ( onChange({ ...channel, node_ids: v })} helper="Node IDs to receive DM alerts" info="Node IDs that receive direct message alerts. Enter the full node ID (e.g., '!a1b2c3d4') for each recipient." /> )} {channel.type === 'email' && ( <>
onChange({ ...channel, smtp_host: v })} placeholder="smtp.gmail.com" helper="SMTP server hostname" info="The SMTP server for sending emails. Gmail: smtp.gmail.com, Outlook: smtp.office365.com" /> onChange({ ...channel, smtp_port: v })} min={1} max={65535} helper="587 (TLS) or 465 (SSL)" info="SMTP port. Use 587 for TLS (recommended) or 465 for SSL." />
onChange({ ...channel, smtp_user: v })} placeholder="you@gmail.com" helper="Login username" /> onChange({ ...channel, smtp_password: v })} type="password" helper="App password recommended" info="Gmail users: use an App Password, not your regular password. Generate one at myaccount.google.com/apppasswords" />
onChange({ ...channel, smtp_tls: v })} helper="Encrypt SMTP connection" info="Enable TLS encryption for the SMTP connection. Required for most modern email servers." /> onChange({ ...channel, from_address: v })} placeholder="alerts@yourdomain.com" helper="Sender email address" info="The email address that appears as the sender. Some servers require this to match your login." /> onChange({ ...channel, recipients: v })} helper="Email addresses to receive alerts" info="List of email addresses that will receive alerts from this channel." /> )} {channel.type === 'webhook' && ( <> onChange({ ...channel, url: v })} placeholder="https://discord.com/api/webhooks/..." helper="POST endpoint for alerts" info="POST alert JSON to any URL. Works with Discord webhooks, ntfy.sh, Pushover, Slack, Home Assistant, or any service that accepts HTTP POST." />
Custom headers can be configured in the YAML config file
)}
)}
) } // Notification Rule Card Component function NotificationRuleCard({ rule, categories, channels, onChange, onDelete, }: { rule: NotificationRuleConfig categories: AlertCategory[] channels: NotificationChannelConfig[] onChange: (r: NotificationRuleConfig) => void onDelete: () => void }) { const [expanded, setExpanded] = 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 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 toggleChannel = (channelId: string) => { const current = rule.channel_ids || [] if (current.includes(channelId)) { onChange({ ...rule, channel_ids: current.filter(c => c !== channelId) }) } else { onChange({ ...rule, channel_ids: [...current, channelId] }) } } return (
setExpanded(!expanded)} >
{expanded ? : } {rule.name || 'New Rule'} {rule.categories?.length || 0} categories → {rule.channel_ids?.length || 0} channels
{expanded && (
onChange({ ...rule, name: v })} helper="Human-readable name for this rule" info="A descriptive name to identify this rule. Example: 'Emergency Alerts', 'Fire Notifications', 'Infrastructure Warnings'" /> onChange({ ...rule, min_severity: v })} options={severityOptions} helper="Only alerts at or above this severity" info="Only alerts at this severity or above will trigger this rule. 'warning' is recommended for most channels. Use 'info' to receive all alerts." />
{rule.categories?.length === 0 ? 'All categories (none selected)' : `${rule.categories?.length} selected`}
{categories.map((cat) => ( ))}
{channels.length === 0 ? (
No channels configured. Add channels above first.
) : (
{channels.map((ch) => ( ))}
)}
onChange({ ...rule, override_quiet: v })} helper="Send alerts even during quiet hours" info="When enabled, this rule sends alerts even during quiet hours. Use for critical conditions like fires or infrastructure failures." />
)}
) } // Main Notifications Page Component export default function Notifications() { const [config, setConfig] = useState(null) const [originalConfig, setOriginalConfig] = useState(null) const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) const [hasChanges, setHasChanges] = useState(false) const fetchConfig = useCallback(async () => { try { const [configRes, categoriesRes] = await Promise.all([ fetch('/api/config/notifications'), fetch('/api/notifications/categories'), ]) if (!configRes.ok) throw new Error('Failed to fetch notifications config') const configData = await configRes.json() const categoriesData = await categoriesRes.json() setConfig(configData) setOriginalConfig(JSON.parse(JSON.stringify(configData))) setCategories(categoriesData) setHasChanges(false) setError(null) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setLoading(false) } }, []) useEffect(() => { document.title = 'Notifications — MeshAI' fetchConfig() }, [fetchConfig]) useEffect(() => { if (config && originalConfig) { setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) } }, [config, originalConfig]) const saveConfig = async () => { if (!config) return setSaving(true) setError(null) setSuccess(null) try { const res = await fetch('/api/config/notifications', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }) const result = await res.json() if (!res.ok) { throw new Error(result.detail || 'Save failed') } setSuccess('Notifications config saved successfully') setOriginalConfig(JSON.parse(JSON.stringify(config))) setHasChanges(false) setTimeout(() => setSuccess(null), 3000) } catch (err) { setError(err instanceof Error ? err.message : 'Save failed') } finally { setSaving(false) } } const discardChanges = () => { if (originalConfig) { setConfig(JSON.parse(JSON.stringify(originalConfig))) setHasChanges(false) } } const addChannel = () => { if (!config) return const newChannel: NotificationChannelConfig = { id: '', type: 'mesh_broadcast', enabled: true, channel_index: 0, node_ids: [], smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', smtp_tls: true, from_address: '', recipients: [], url: '', headers: {}, } setConfig({ ...config, channels: [...(config.channels || []), newChannel] }) } const addRule = () => { if (!config) return const newRule: NotificationRuleConfig = { name: '', categories: [], min_severity: 'warning', channel_ids: [], override_quiet: false, } setConfig({ ...config, rules: [...(config.rules || []), newRule] }) } const testChannel = async (channelId: string) => { try { const res = await fetch(`/api/notifications/channels/${channelId}/test`, { method: 'POST' }) const result = await res.json() setTestResult(result) setTimeout(() => setTestResult(null), 5000) } catch { setTestResult({ success: false, message: 'Test failed' }) setTimeout(() => setTestResult(null), 5000) } } if (loading) { return (
Loading notifications config...
) } if (!config) { return (
Failed to load notifications config
) } return (
{/* Header with actions */}

Configure where alerts get delivered and which conditions trigger them.

{/* Status messages */} {error && (
{error}
)} {success && (
{success}
)} {testResult && (
{testResult.success ? : } {testResult.message}
)} {/* Main content */}
setConfig({ ...config, enabled: v })} helper="Master switch for all notification delivery" info="When disabled, no alerts will be delivered through any channel. The alert engine still runs and records alerts to history." /> {config.enabled && ( <> {/* Channels Section */}

Where alerts get delivered. Add channels for each destination you want to receive alerts.

{(config.channels || []).map((channel, i) => ( { const newChannels = [...(config.channels || [])] newChannels[i] = c setConfig({ ...config, channels: newChannels }) }} onDelete={() => { if (confirm(`Delete channel "${channel.id || 'New Channel'}"?`)) { setConfig({ ...config, channels: (config.channels || []).filter((_, j) => j !== i) }) } }} onTest={() => testChannel(channel.id)} /> ))}
{/* Rules Section */}

Rules connect alert categories to delivery channels. When a condition matches a rule, the alert is sent to all channels in that rule.

{(config.rules || []).map((rule, i) => ( { const newRules = [...(config.rules || [])] newRules[i] = r setConfig({ ...config, rules: newRules }) }} onDelete={() => { if (confirm(`Delete rule "${rule.name || 'New Rule'}"?`)) { setConfig({ ...config, rules: (config.rules || []).filter((_, j) => j !== i) }) } }} /> ))}
{/* Quiet Hours Section */}

Suppress non-emergency alerts during sleeping hours. Emergency and critical alerts always get through.

setConfig({ ...config, quiet_hours_start: v })} placeholder="22:00" helper="When quiet hours begin" info="Time in 24-hour format (HH:MM) when quiet hours start. Alerts below emergency severity will be held until quiet hours end." /> setConfig({ ...config, quiet_hours_end: v })} placeholder="06:00" helper="When quiet hours end" info="Time in 24-hour format (HH:MM) when quiet hours end. Held alerts will be delivered at this time." />
{/* Dedup Section */}
setConfig({ ...config, dedup_seconds: v })} min={0} max={86400} helper="Don't re-send the same alert within this window" info="Prevents alert spam. If the same condition fires multiple times within this window, only the first one is delivered. Default is 600 seconds (10 minutes)." />
)}
) }