2026-05-13 18:40:18 -06:00
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 = [
2026-05-13 19:05:50 -06:00
{ 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)' } ,
2026-05-13 18:40:18 -06:00
]
// 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" ] ,
2026-05-13 19:05:50 -06:00
min_severity : "routine" ,
2026-05-13 18:40:18 -06:00
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" ] ,
2026-05-13 19:05:50 -06:00
min_severity : "priority" ,
2026-05-13 18:40:18 -06:00
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" ] ,
2026-05-13 19:05:50 -06:00
min_severity : "routine" ,
2026-05-13 18:40:18 -06:00
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" ] ,
2026-05-13 19:05:50 -06:00
min_severity : "routine" ,
2026-05-13 18:40:18 -06:00
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 [ ] ,
2026-05-13 19:05:50 -06:00
min_severity : "immediate" ,
2026-05-13 18:40:18 -06:00
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 : "info" ,
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 )
2026-05-13 19:05:50 -06:00
const selected = SEVERITY_OPTIONS . find ( s = > s . value === value ) || SEVERITY_OPTIONS [ 0 ]
2026-05-13 18:40:18 -06:00
return (
< div className = "space-y-1" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Severity Threshold
2026-05-13 19:05:50 -06:00
< InfoButton info = "Only alerts at or above this severity trigger this rule. ROUTINE = informational, PRIORITY = needs attention, IMMEDIATE = act now." / >
2026-05-13 18:40:18 -06:00
< / 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 : [ ] ,
2026-05-13 19:05:50 -06:00
min_severity : 'routine' ,
2026-05-13 18:40:18 -06:00
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"
2026-05-13 19:05:50 -06:00
info = "When enabled, ROUTINE alerts are suppressed during quiet hours. PRIORITY and IMMEDIATE always deliver."
2026-05-13 18:40:18 -06:00
/ >
{ 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 >
)
}