meshai/dashboard-frontend/src/pages/Notifications.tsx
K7ZVX 3fa7b9fe5e feat(dashboard): Add dynamic channel and node pickers
- Add GET /api/channels endpoint for live radio channel data
- Create ChannelPicker component (single/multi-select from live channels)
- Create NodePicker component (searchable multi-select from mesh nodes)
- Replace manual inputs in Config with data-driven pickers
- Update Notifications to use pickers for mesh broadcast/DM
- Resolve node names in Alerts subscriptions display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 07:07:05 +00:00

978 lines
37 KiB
TypeScript

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'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
// 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<string, string>
}
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 (
<div className="relative inline-block">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
className="ml-1.5 w-4 h-4 rounded-full bg-slate-700 hover:bg-slate-600 text-slate-400 hover:text-slate-200 inline-flex items-center justify-center text-xs transition-colors"
title="More info"
>
?
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed">
{info}
{link && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-1 text-accent hover:underline"
onClick={(e) => e.stopPropagation()}
>
{linkText} <ExternalLink size={10} />
</a>
)}
</div>
</>
)}
</div>
)
}
// 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 (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoButton info={info} />}
</label>
<div className="relative">
<input
type={isPassword && !showPassword ? 'password' : 'text'}
value={value}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
>
{showPassword ? <EyeOff size={16} /> : <EyeIcon size={16} />}
</button>
)}
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
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 (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoButton info={info} />}
</label>
<input
type="number"
value={value}
onChange={(e) => 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 && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
function Toggle({ label, checked, onChange, helper = '', info = '' }: {
label: string
checked: boolean
onChange: (v: boolean) => void
helper?: string
info?: string
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<span className="flex items-center text-sm text-slate-300">
{label}
{info && <InfoButton info={info} />}
</span>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative w-11 h-6 rounded-full transition-colors ${
checked ? 'bg-accent' : 'bg-[#1e2a3a]'
}`}
>
<span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
checked ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
)
}
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 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 (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoButton info={info} />}
</label>
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addItem())}
className="flex-1 px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
placeholder="Add item..."
/>
<button
type="button"
onClick={addItem}
className="px-3 py-2 bg-accent hover:bg-accent/80 rounded text-sm text-white transition-colors"
>
<Plus size={16} />
</button>
</div>
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{value.map((item, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-2 py-1 bg-[#1e2a3a] rounded text-sm text-slate-300"
>
{item}
<button
type="button"
onClick={() => removeItem(i)}
className="text-slate-500 hover:text-red-400"
>
<X size={14} />
</button>
</span>
))}
</div>
)}
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
// 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<string, string> = {
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 (
<div className="border border-[#1e2a3a] rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div className={`w-2 h-2 rounded-full ${channel.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
<span className="font-medium text-slate-200">{channel.id || 'New Channel'}</span>
<span className="text-xs text-slate-500 bg-[#1e2a3a] px-2 py-0.5 rounded">
{typeOptions.find(t => t.value === channel.type)?.label || channel.type}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !channel.id}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
title="Send test alert"
>
<Send size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 size={14} />
</button>
</div>
</div>
{expanded && (
<div className="p-4 space-y-4 border-t border-[#1e2a3a]">
<div className="grid grid-cols-2 gap-4">
<TextInput
label="Channel ID"
value={channel.id}
onChange={(v) => 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')."
/>
<SelectInput
label="Type"
value={channel.type}
onChange={(v) => onChange({ ...channel, type: v })}
options={typeOptions}
info={typeDescriptions[channel.type] || 'Select a channel type'}
/>
</div>
<Toggle
label="Enabled"
checked={channel.enabled}
onChange={(v) => onChange({ ...channel, enabled: v })}
helper="Disable to temporarily stop alerts on this channel"
/>
{channel.type === 'mesh_broadcast' && (
<ChannelPicker
label="Broadcast Channel"
value={channel.channel_index}
onChange={(v) => onChange({ ...channel, channel_index: v })}
helper="Channel for broadcast alerts"
info="The mesh channel to broadcast alerts on."
mode="single"
/>
)}
{channel.type === 'mesh_dm' && (
<NodePicker
label="Recipient Nodes"
value={channel.node_ids}
onChange={(v) => onChange({ ...channel, node_ids: v })}
helper="Nodes to receive DM alerts"
info="Nodes that receive direct message alerts."
valueType="node_id_hex"
/>
)}
{channel.type === 'email' && (
<>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="SMTP Host"
value={channel.smtp_host}
onChange={(v) => 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"
/>
<NumberInput
label="SMTP Port"
value={channel.smtp_port}
onChange={(v) => 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."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="SMTP User"
value={channel.smtp_user}
onChange={(v) => onChange({ ...channel, smtp_user: v })}
placeholder="you@gmail.com"
helper="Login username"
/>
<TextInput
label="SMTP Password"
value={channel.smtp_password}
onChange={(v) => 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"
/>
</div>
<Toggle
label="Use TLS"
checked={channel.smtp_tls}
onChange={(v) => onChange({ ...channel, smtp_tls: v })}
helper="Encrypt SMTP connection"
info="Enable TLS encryption for the SMTP connection. Required for most modern email servers."
/>
<TextInput
label="From Address"
value={channel.from_address}
onChange={(v) => 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."
/>
<ListInput
label="Recipients"
value={channel.recipients}
onChange={(v) => 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' && (
<>
<TextInput
label="Webhook URL"
value={channel.url}
onChange={(v) => 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."
/>
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Headers (optional)
<InfoButton info="Additional HTTP headers to send with the webhook request. Useful for authentication tokens or custom headers required by the receiving service." />
</label>
<div className="text-xs text-slate-600">
Custom headers can be configured in the YAML config file
</div>
</div>
</>
)}
</div>
)}
</div>
)
}
// 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 (
<div className="border border-[#1e2a3a] rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<span className="font-medium text-slate-200">{rule.name || 'New Rule'}</span>
<span className="text-xs text-slate-500">
{rule.categories?.length || 0} categories {rule.channel_ids?.length || 0} channels
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 size={14} />
</button>
</div>
{expanded && (
<div className="p-4 space-y-4 border-t border-[#1e2a3a]">
<TextInput
label="Rule Name"
value={rule.name}
onChange={(v) => 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'"
/>
<SelectInput
label="Minimum Severity"
value={rule.min_severity}
onChange={(v) => 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."
/>
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Alert Categories
<InfoButton info="Which alert types this rule applies to. Select none to match all categories. Alerts matching any selected category (AND meeting severity threshold) will trigger this rule." />
</label>
<div className="text-xs text-slate-500 mb-2">
{rule.categories?.length === 0 ? 'All categories (none selected)' : `${rule.categories?.length} selected`}
</div>
<div className="max-h-48 overflow-y-auto border border-[#1e2a3a] rounded-lg p-2 space-y-1">
{categories.map((cat) => (
<label
key={cat.id}
className="flex items-start gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<input
type="checkbox"
checked={rule.categories?.includes(cat.id) || false}
onChange={() => toggleCategory(cat.id)}
className="mt-0.5 rounded border-slate-600 bg-[#0a0e17] text-accent focus:ring-accent"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200">{cat.name}</div>
<div className="text-xs text-slate-500">{cat.description}</div>
</div>
</label>
))}
</div>
</div>
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Delivery Channels
<InfoButton info="Which channels receive alerts matching this rule. Select at least one channel." />
</label>
{channels.length === 0 ? (
<div className="text-xs text-slate-500 p-2 border border-[#1e2a3a] rounded-lg">
No channels configured. Add channels above first.
</div>
) : (
<div className="border border-[#1e2a3a] rounded-lg p-2 space-y-1">
{channels.map((ch) => (
<label
key={ch.id}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<input
type="checkbox"
checked={rule.channel_ids?.includes(ch.id) || false}
onChange={() => toggleChannel(ch.id)}
className="rounded border-slate-600 bg-[#0a0e17] text-accent focus:ring-accent"
/>
<span className="text-sm text-slate-200">{ch.id}</span>
<span className="text-xs text-slate-500">({ch.type})</span>
</label>
))}
</div>
)}
</div>
<Toggle
label="Override Quiet Hours"
checked={rule.override_quiet}
onChange={(v) => 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."
/>
</div>
)}
</div>
)
}
// Main Notifications Page Component
export default function Notifications() {
const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
const [categories, setCategories] = useState<AlertCategory[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(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 (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading notifications config...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load notifications config</div>
</div>
)
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header with actions */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Configure where alerts get delivered and which conditions trigger them.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 rounded-lg text-sm bg-red-500/10 text-red-400 border border-red-500/20">
{error}
</div>
)}
{success && (
<div className="p-3 rounded-lg text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{testResult && (
<div className={`p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}`}>
{testResult.success ? <Check size={14} className="inline mr-2" /> : <X size={14} className="inline mr-2" />}
{testResult.message}
</div>
)}
{/* Main content */}
<div className="bg-bg-card border border-border rounded-lg p-6 space-y-6">
<Toggle
label="Enable Notifications"
checked={config.enabled}
onChange={(v) => 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 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Channels
<InfoButton info="Where alerts get delivered. Add channels for each destination you want to receive alerts." />
</label>
</div>
<p className="text-sm text-slate-500 -mt-1">
Where alerts get delivered. Add channels for each destination you want to receive alerts.
</p>
{(config.channels || []).map((channel, i) => (
<NotificationChannelCard
key={i}
channel={channel}
onChange={(c) => {
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)}
/>
))}
<button
onClick={addChannel}
className="w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} /> Add Channel
</button>
</div>
{/* Rules Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Rules
<InfoButton info="Rules connect alert categories to delivery channels. When a condition matches a rule, the alert is sent to all channels in that rule." />
</label>
</div>
<p className="text-sm text-slate-500 -mt-1">
Rules connect alert categories to delivery channels. When a condition matches a rule, the alert is sent to all channels in that rule.
</p>
{(config.rules || []).map((rule, i) => (
<NotificationRuleCard
key={i}
rule={rule}
categories={categories}
channels={config.channels || []}
onChange={(r) => {
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) })
}
}}
/>
))}
<button
onClick={addRule}
className="w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} /> Add Rule
</button>
</div>
{/* Quiet Hours Section */}
<div className="space-y-3">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Quiet Hours
<InfoButton info="Suppress non-emergency alerts during sleeping hours. Emergency and critical alerts always get through. Rules with 'Override Quiet Hours' enabled will also deliver during this time." />
</label>
<p className="text-sm text-slate-500 -mt-1">
Suppress non-emergency alerts during sleeping hours. Emergency and critical alerts always get through.
</p>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="Start Time"
value={config.quiet_hours_start || '22:00'}
onChange={(v) => 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."
/>
<TextInput
label="End Time"
value={config.quiet_hours_end || '06:00'}
onChange={(v) => 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."
/>
</div>
</div>
{/* Dedup Section */}
<div className="space-y-3">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Deduplication
<InfoButton info="Prevents alert spam. If the same condition fires multiple times within this window, only the first one is delivered." />
</label>
<NumberInput
label="Dedup Window (seconds)"
value={config.dedup_seconds || 600}
onChange={(v) => 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)."
/>
</div>
</>
)}
</div>
</div>
)
}