meshai/dashboard-frontend/src/pages/Notifications.tsx

1879 lines
71 KiB
TypeScript

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<string, string>
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<string, unknown>
}
// 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<string, string>,
}
},
{
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<string, string>,
}
},
{
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<string, string>,
}
},
{
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<string, string>,
}
},
{
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<string, string>,
}
},
{
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<string, string>,
}
},
]
// 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 (
<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}
</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 TimeInput({ label, value, onChange, helper = '', info = '' }: {
label: string
value: string
onChange: (v: string) => void
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="time"
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"
/>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
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 (
<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={placeholder}
/>
<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>
)
}
// 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 (
<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. ROUTINE = informational, PRIORITY = needs attention, IMMEDIATE = act now." />
</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>
)
}
// Channel Test Button Component
function ChannelTestButton({ rule }: {
rule: NotificationRuleConfig
}) {
const [testing, setTesting] = useState(false)
const [result, setResult] = useState<ChannelTestResult | null>(null)
const handleTest = async () => {
setTesting(true)
setResult(null)
try {
// Build channel config from rule
let channelConfig: Record<string, unknown> = { 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: <Radio size={14} />,
mesh_dm: <MessageSquare size={14} />,
email: <Mail size={14} />,
webhook: <Globe size={14} />,
}[rule.delivery_type] || <Wifi size={14} />
return (
<div className="space-y-2">
<button
type="button"
onClick={handleTest}
disabled={testing}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded text-sm disabled:opacity-50"
>
{testing ? (
<>
<RefreshCw size={14} className="animate-spin" />
Testing...
</>
) : (
<>
{icon}
Test Channel
</>
)}
</button>
{result && (
<div className={`p-2 rounded text-xs ${
result.success
? 'bg-green-500/10 border border-green-500/30 text-green-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}>
<div className="flex items-start gap-2">
{result.success ? <Check size={14} className="mt-0.5 flex-shrink-0" /> : <X size={14} className="mt-0.5 flex-shrink-0" />}
<div>
<div className="font-medium">{result.message}</div>
{result.error && <div className="mt-1 text-red-300">{result.error}</div>}
</div>
</div>
</div>
)}
</div>
)
}
// 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<RuleStats | null>(null)
const [sourceHealth, setSourceHealth] = useState<SourceHealth | null>(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<string, { enabled: boolean; events: number }>()
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 }]) => (
<span
key={source}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${
enabled
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'
}`}
title={enabled ? `${events} active` : 'Not enabled'}
>
{enabled ? <Wifi size={10} /> : <WifiOff size={10} />}
{source.toUpperCase()}
{enabled && events > 0 && ` (${events})`}
</span>
))
}
return (
<div className={`border rounded-lg overflow-hidden ${rule.enabled ? 'border-[#1e2a3a]' : 'border-slate-700 opacity-60'}`}>
{/* Header */}
<div
className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{expanded ? <ChevronDown size={16} className="text-slate-500 flex-shrink-0" /> : <ChevronRight size={16} className="text-slate-500 flex-shrink-0" />}
<button
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'}`}
title={rule.enabled ? 'Enabled' : 'Disabled'}
/>
{rule.trigger_type === 'schedule' ? (
<Clock size={14} className="text-blue-400 flex-shrink-0" />
) : (
<Zap size={14} className="text-yellow-400 flex-shrink-0" />
)}
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
{!expanded && (
<span className={`text-xs truncate hidden sm:block ${!rule.delivery_type ? 'text-amber-400' : 'text-slate-500'}`}>
{getSummary()}
</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Stats badge */}
{ruleStats && !expanded && (
<span className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 bg-slate-800 rounded text-xs text-slate-400 mr-2">
{ruleStats.last_fired ? formatRelativeTime(ruleStats.last_fired) : 'Never fired'}
</span>
)}
{/* Source indicators */}
{!expanded && (
<div className="hidden md:flex items-center gap-1 mr-2">
{getSourceIndicators()}
</div>
)}
<button
onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !rule.name}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
title="Test rule"
>
<Send size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicate() }}
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-500/10 rounded"
title="Duplicate"
>
<Copy size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Status line (collapsed view) */}
{!expanded && rule.name && (
<div className="px-3 pb-2 pt-0 bg-[#0a0e17] flex items-center gap-2 flex-wrap text-xs">
{!rule.delivery_type && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-amber-500/10 text-amber-400 rounded">
<AlertCircle size={10} />
No delivery method
</span>
)}
{ruleStats?.fire_count !== undefined && ruleStats.fire_count > 0 && (
<span className="text-slate-500">
Fired {ruleStats.fire_count}x
</span>
)}
</div>
)}
{/* Expanded content */}
{expanded && (
<div className="p-4 space-y-6 border-t border-[#1e2a3a]">
{/* Rule name */}
<TextInput
label="Rule Name"
value={rule.name}
onChange={(v) => onChange({ ...rule, name: v })}
placeholder="e.g., Emergency Broadcast, Daily Health Report"
helper="A descriptive name for this rule"
/>
{/* Trigger type toggle */}
<div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Trigger Type</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onChange({ ...rule, trigger_type: 'condition' })}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${
rule.trigger_type !== 'schedule'
? 'bg-accent/10 border-accent text-accent'
: 'bg-[#0a0e17] border-[#1e2a3a] text-slate-400 hover:text-slate-200'
}`}
>
<Zap size={16} />
<span>Condition</span>
</button>
<button
type="button"
onClick={() => onChange({ ...rule, trigger_type: 'schedule' })}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${
rule.trigger_type === 'schedule'
? 'bg-accent/10 border-accent text-accent'
: 'bg-[#0a0e17] border-[#1e2a3a] text-slate-400 hover:text-slate-200'
}`}
>
<Clock size={16} />
<span>Schedule</span>
</button>
</div>
<p className="text-xs text-slate-600">
{rule.trigger_type === 'schedule'
? 'Send reports on a schedule (daily briefings, weekly digests)'
: 'React to alert conditions (fires, outages, weather warnings)'}
</p>
</div>
{/* WHEN section - Condition trigger */}
{rule.trigger_type !== 'schedule' && (
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<AlertTriangle size={14} />
WHEN (Condition)
</div>
<SeveritySelector
value={rule.min_severity}
onChange={(v) => onChange({ ...rule, min_severity: v })}
/>
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Alert Categories
<InfoButton info="Select which types of alerts trigger this rule. Leave all unchecked to match ALL categories." />
</label>
<div className="text-xs text-slate-500 mb-2">
{(rule.categories?.length || 0) === 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}
onClick={() => toggleCategory(cat.id)}
className="flex items-start gap-2 p-2 rounded hover:bg-[#1e2a3a]/50 cursor-pointer"
>
<div className={`w-4 h-4 mt-0.5 rounded border flex items-center justify-center flex-shrink-0 ${
rule.categories?.includes(cat.id) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{rule.categories?.includes(cat.id) && <Check size={12} className="text-white" />}
</div>
<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>
{/* Source health display */}
{sourceHealth && Object.keys(sourceHealth).length > 0 && (
<div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Data Sources</label>
<div className="flex flex-wrap gap-2">
{getSourceIndicators()}
</div>
</div>
)}
</div>
)}
{/* WHEN section - Schedule trigger */}
{rule.trigger_type === 'schedule' && (
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<Calendar size={14} />
WHEN (Schedule)
</div>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Frequency</label>
<select
value={rule.schedule_frequency || 'daily'}
onChange={(e) => onChange({ ...rule, schedule_frequency: e.target.value as 'daily' | 'twice_daily' | 'weekly' })}
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">
<TimeInput
label="Time"
value={rule.schedule_time || '07:00'}
onChange={(v) => onChange({ ...rule, schedule_time: v })}
/>
{rule.schedule_frequency === 'twice_daily' && (
<TimeInput
label="Second Time"
value={rule.schedule_time_2 || '19:00'}
onChange={(v) => onChange({ ...rule, schedule_time_2: v })}
/>
)}
</div>
{rule.schedule_frequency === 'weekly' && (
<div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Days</label>
<div className="flex flex-wrap gap-2">
{dayOptions.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleDay(day)}
className={`px-3 py-1.5 rounded text-sm capitalize transition-colors ${
rule.schedule_days?.includes(day)
? 'bg-accent text-white'
: 'bg-[#1e2a3a] text-slate-400 hover:text-slate-200'
}`}
>
{day.slice(0, 3)}
</button>
))}
</div>
</div>
)}
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Report Type</label>
<select
value={rule.message_type || 'mesh_health_summary'}
onChange={(e) => onChange({ ...rule, message_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"
>
{messageTypeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-slate-600">
{messageTypeOptions.find(m => m.value === rule.message_type)?.description}
</p>
</div>
{rule.message_type === 'custom' && (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Custom Message
<InfoButton info="Available tokens: {MESH_SCORE}, {NODE_COUNT}, {NODES_ONLINE}, {ACTIVE_ALERTS}, {KP}, {SFI}, {DATE}, {TIME}" />
</label>
<textarea
value={rule.custom_message || ''}
onChange={(e) => onChange({ ...rule, custom_message: e.target.value })}
rows={4}
placeholder="Good morning! Mesh health: {MESH_SCORE}/100 with {NODE_COUNT} nodes online."
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"
/>
</div>
)}
</div>
)}
{/* SEND VIA section */}
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<Send size={14} />
SEND VIA
</div>
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Delivery Method
<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." />
</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 */}
{rule.delivery_type === 'mesh_broadcast' && (
<>
<ChannelPicker
label="Broadcast Channel"
value={rule.broadcast_channel ?? 0}
onChange={(v) => onChange({ ...rule, broadcast_channel: v })}
helper="Select the mesh radio channel"
mode="single"
/>
<ChannelTestButton rule={rule} />
</>
)}
{/* Mesh DM fields */}
{rule.delivery_type === 'mesh_dm' && (
<>
<NodePicker
label="Recipient Nodes"
value={rule.node_ids || []}
onChange={(v) => onChange({ ...rule, node_ids: v })}
helper="Nodes that receive direct messages"
valueType="node_id_hex"
/>
<ChannelTestButton rule={rule} />
</>
)}
{/* Email fields */}
{rule.delivery_type === 'email' && (
<div className="space-y-4">
<ListInput
label="Recipients"
value={rule.recipients || []}
onChange={(v) => onChange({ ...rule, recipients: v })}
placeholder="email@example.com"
helper="Email addresses to receive alerts"
/>
<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" />
SMTP Configuration
</summary>
<div className="mt-4 space-y-4 pl-6 border-l border-[#1e2a3a]">
<div className="grid grid-cols-2 gap-4">
<TextInput
label="SMTP Host"
value={rule.smtp_host || ''}
onChange={(v) => onChange({ ...rule, smtp_host: v })}
placeholder="smtp.gmail.com"
/>
<NumberInput
label="SMTP Port"
value={rule.smtp_port ?? 587}
onChange={(v) => onChange({ ...rule, smtp_port: v })}
min={1}
max={65535}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="Username"
value={rule.smtp_user || ''}
onChange={(v) => onChange({ ...rule, smtp_user: v })}
/>
<TextInput
label="Password"
value={rule.smtp_password || ''}
onChange={(v) => onChange({ ...rule, smtp_password: v })}
type="password"
info="Gmail users: use an App Password from myaccount.google.com/apppasswords"
/>
</div>
<Toggle
label="Use TLS"
checked={rule.smtp_tls ?? true}
onChange={(v) => onChange({ ...rule, smtp_tls: v })}
/>
<TextInput
label="From Address"
value={rule.from_address || ''}
onChange={(v) => onChange({ ...rule, from_address: v })}
placeholder="alerts@yourdomain.com"
/>
</div>
</details>
<ChannelTestButton rule={rule} />
</div>
)}
{/* Webhook fields */}
{rule.delivery_type === 'webhook' && (
<>
<TextInput
label="Webhook URL"
value={rule.webhook_url || ''}
onChange={(v) => onChange({ ...rule, webhook_url: v })}
placeholder="https://discord.com/api/webhooks/..."
helper="POST alert as JSON"
info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint."
/>
<ChannelTestButton rule={rule} />
</>
)}
</div>
{/* Behavior section */}
<div className="grid grid-cols-2 gap-4">
<NumberInput
label="Cooldown (minutes)"
value={rule.cooldown_minutes ?? 10}
onChange={(v) => onChange({ ...rule, cooldown_minutes: v })}
min={0}
helper="Min time between repeat sends"
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
/>
{quietHoursEnabled && (
<div className="flex items-end pb-1">
<Toggle
label="Override Quiet Hours"
checked={rule.override_quiet ?? false}
onChange={(v) => onChange({ ...rule, override_quiet: v })}
helper="Deliver during quiet hours"
/>
</div>
)}
</div>
{/* Rule statistics */}
{ruleStats && (
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>Last fired: {formatRelativeTime(ruleStats.last_fired)}</span>
<span>Last tested: {formatRelativeTime(ruleStats.last_test)}</span>
<span>Total fires: {ruleStats.fire_count}</span>
</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>
)
}
// 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<TestResult | null>(null)
const [testDialog, setTestDialog] = useState<{ open: boolean; ruleIndex: number; loading: boolean; action: string }>({ open: false, ruleIndex: -1, loading: false, action: '' })
const [showTemplates, setShowTemplates] = useState(false)
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 createDefaultRule = (): NotificationRuleConfig => ({
name: '',
enabled: true,
trigger_type: 'condition',
categories: [],
min_severity: 'routine',
schedule_frequency: 'daily',
schedule_time: '07:00',
schedule_time_2: '19:00',
schedule_days: ['monday'],
message_type: 'mesh_health_summary',
custom_message: '',
delivery_type: '',
broadcast_channel: 0,
node_ids: [],
smtp_host: '',
smtp_port: 587,
smtp_user: '',
smtp_password: '',
smtp_tls: true,
from_address: '',
recipients: [],
webhook_url: '',
webhook_headers: {},
cooldown_minutes: 10,
override_quiet: false,
})
const addRule = () => {
if (!config) return
setConfig({ ...config, rules: [...(config.rules || []), createDefaultRule()] })
}
const addFromTemplate = (templateId: string) => {
if (!config) return
const template = RULE_TEMPLATES.find(t => t.id === templateId)
if (!template) return
setConfig({ ...config, rules: [...(config.rules || []), { ...template.rule }] })
setShowTemplates(false)
}
const duplicateRule = (index: number) => {
if (!config) return
const original = config.rules[index]
const duplicate = { ...JSON.parse(JSON.stringify(original)), name: `${original.name} (copy)` }
const newRules = [...config.rules]
newRules.splice(index + 1, 0, duplicate)
setConfig({ ...config, rules: newRules })
}
const testRule = async (index: number) => {
setTestDialog({ open: true, ruleIndex: index, loading: true, action: '' })
try {
const res = await fetch(`/api/notifications/rules/${index}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'preview' })
})
const result = await res.json()
setTestResult(result)
setTestDialog(d => ({ ...d, loading: false }))
} catch {
setTestResult({ success: false, message: 'Failed to get preview' })
setTestDialog(d => ({ ...d, loading: false }))
}
}
const sendTestAction = async (action: string) => {
const index = testDialog.ruleIndex
setTestDialog(d => ({ ...d, loading: true, action }))
try {
const res = await fetch(`/api/notifications/rules/${index}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
})
const result = await res.json()
setTestResult(result)
setTestDialog(d => ({ ...d, loading: false }))
} catch {
setTestResult({ success: false, message: `Failed to ${action}` })
setTestDialog(d => ({ ...d, loading: false }))
}
}
const closeTestDialog = () => {
setTestDialog({ open: false, ruleIndex: -1, loading: false, action: '' })
setTestResult(null)
}
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">
{/* Test Dialog */}
{testDialog.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[85vh] overflow-auto">
<div className="p-4 border-b border-[#2a3a4a] flex items-center justify-between sticky top-0 bg-[#1a2332]">
<h3 className="text-lg font-semibold">Test Notification Rule</h3>
<button onClick={closeTestDialog} className="text-slate-500 hover:text-slate-300">
<X size={20} />
</button>
</div>
<div className="p-4 space-y-4">
{testDialog.loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw size={20} className="animate-spin text-slate-400 mr-2" />
<div className="text-slate-400">
{testDialog.action ? `${testDialog.action.replace('_', ' ').replace('send ', 'Sending ')}...` : 'Loading current data...'}
</div>
</div>
) : testResult ? (
<>
{/* Section 1: Current Data */}
<div className="space-y-2">
<div className="text-sm font-medium text-slate-400 uppercase tracking-wide">Current Data</div>
{testResult.live_data_summary && testResult.live_data_summary.length > 0 ? (
<div className="p-3 bg-slate-800/50 rounded space-y-1">
{testResult.live_data_summary.map((line, i) => (
<div
key={i}
className={`text-sm font-mono ${line.startsWith('[!]') ? 'text-amber-400' : ''}`}
>
{line}
</div>
))}
</div>
) : (
<div className="p-3 bg-slate-800/50 rounded text-sm text-slate-500">
No live data available for this rule's categories
</div>
)}
</div>
{/* Section 2: Alert Conditions */}
<div className="space-y-2">
<div className="text-sm font-medium text-slate-400 uppercase tracking-wide">Rule Matching</div>
<div className="flex items-center gap-2 flex-wrap">
{testResult.conditions_matched && testResult.conditions_matched > 0 ? (
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-sm">
{testResult.conditions_matched} condition{testResult.conditions_matched !== 1 ? 's' : ''} match - this rule WOULD fire
</span>
) : (
<span className="px-2 py-1 bg-slate-700 text-slate-400 rounded text-sm">
No conditions trigger this rule right now
</span>
)}
{testResult.conditions_below_threshold && testResult.conditions_below_threshold > 0 && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-sm">
{testResult.conditions_below_threshold} below threshold
</span>
)}
</div>
{/* Near-miss explanation */}
{testResult.conditions_below_threshold && testResult.conditions_below_threshold > 0 && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded text-sm space-y-2">
<div className="text-yellow-300">{testResult.below_threshold_summary}</div>
{testResult.below_threshold_events && testResult.below_threshold_events.length > 0 && (
<div className="space-y-1 text-yellow-200/80">
{testResult.below_threshold_events.slice(0, 3).map((evt, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 bg-yellow-500/20 rounded">{evt.severity}</span>
<span>{evt.headline}</span>
</div>
))}
</div>
)}
{testResult.suggestion && (
<div className="text-yellow-400 text-xs mt-2">Tip: {testResult.suggestion}</div>
)}
</div>
)}
</div>
{/* Section 3: Preview Messages */}
<div className="space-y-2">
<div className="text-sm font-medium text-slate-400 uppercase tracking-wide">
{testResult.is_example ? 'Example Messages' : 'Messages That Would Fire'}
</div>
{testResult.preview_messages?.map((msg, i) => (
<div key={i} className="p-3 bg-slate-800 rounded text-sm font-mono break-words">
{msg}
</div>
))}
</div>
{/* Delivery result */}
{testResult.delivered !== undefined && testResult.delivery_result && (
<div className={`p-3 rounded text-sm ${
testResult.delivered
? 'bg-green-500/10 border border-green-500/30 text-green-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}>
<div className="flex items-start gap-2">
{testResult.delivered ? <Check size={16} className="mt-0.5" /> : <X size={16} className="mt-0.5" />}
<div>
<div>{testResult.delivery_result}</div>
{testResult.delivery_error && (
<div className="mt-1 text-red-300">{testResult.delivery_error}</div>
)}
</div>
</div>
</div>
)}
{/* Legacy format support */}
{testResult.message && !testResult.preview_messages && (
<div className={`p-3 rounded text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.message}
</div>
)}
</>
) : null}
</div>
<div className="p-4 border-t border-[#2a3a4a] flex justify-between sticky bottom-0 bg-[#1a2332]">
<button
onClick={closeTestDialog}
className="px-4 py-2 text-slate-400 hover:text-slate-200"
>
Close
</button>
{testResult && !testResult.delivered && (
<div className="flex gap-2">
{!testResult.delivery_method ? (
<span className="px-3 py-2 text-amber-400 text-sm">
Configure a delivery method to send test messages
</span>
) : (
<>
{testResult.live_data_summary && testResult.live_data_summary.length > 0 && (
<button
onClick={() => sendTestAction('send_status')}
disabled={testDialog.loading}
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm disabled:opacity-50"
title="Send current conditions summary"
>
Send Current Conditions
</button>
)}
<button
onClick={() => sendTestAction('send_test')}
disabled={testDialog.loading}
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm disabled:opacity-50"
title="Send example alert message"
>
Send Example Alert
</button>
{testResult.can_send_live && (
<button
onClick={() => sendTestAction('send_live')}
disabled={testDialog.loading}
className="px-3 py-2 bg-accent hover:bg-accent/80 rounded text-sm disabled:opacity-50"
title="Send actual live alert"
>
Send Live Alert
</button>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
</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>
)}
{/* 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 or scheduled messages will be delivered. Alerts still get recorded to history."
/>
{config.enabled && (
<>
{/* Quiet Hours Section */}
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2">
<Moon size={14} className="text-slate-400" />
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
</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, ROUTINE alerts are suppressed during quiet hours. PRIORITY and IMMEDIATE always deliver."
/>
{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>
{/* 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">
Notification Rules
<InfoButton info="Each rule is self-contained: define what triggers it (condition or schedule), where to send it (mesh, email, webhook), and behavior settings." />
</label>
<span className="text-xs text-slate-500">
{config.rules?.length || 0} rule{(config.rules?.length || 0) !== 1 ? 's' : ''}
</span>
</div>
{(config.rules || []).map((rule, i) => (
<NotificationRuleCard
key={i}
rule={rule}
ruleIndex={i}
categories={categories}
quietHoursEnabled={config.quiet_hours_enabled ?? true}
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) })
}
}}
onDuplicate={() => duplicateRule(i)}
onTest={() => testRule(i)}
/>
))}
<div className="flex gap-2">
<button
onClick={addRule}
className="flex-1 py-3 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 className="relative">
<button
onClick={() => setShowTemplates(!showTemplates)}
className="py-3 px-4 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center gap-2 transition-colors"
>
<Layers size={16} /> Add from Template
</button>
{showTemplates && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowTemplates(false)} />
<div className="absolute right-0 top-full mt-2 z-50 w-80 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl overflow-hidden">
<div className="p-2 border-b border-[#2a3a4a] text-xs text-slate-500 uppercase">Rule Templates</div>
{RULE_TEMPLATES.map((t) => (
<button
key={t.id}
onClick={() => addFromTemplate(t.id)}
className="w-full p-3 text-left hover:bg-[#2a3a4a] transition-colors"
>
<div className="font-medium text-slate-200">{t.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{t.description}</div>
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
)
}