2026-05-13 04:26:17 +00:00
import { useState , useEffect , useCallback } from 'react'
import {
Settings , Bot , Wifi , MessageSquare , Database , Brain , Eye ,
Terminal , Cpu , Cloud , Radio , BookOpen , Layers , Activity ,
Thermometer , LayoutDashboard , Save , RotateCcw , RefreshCw ,
Plus , Trash2 , ChevronDown , ChevronRight , AlertTriangle ,
2026-05-13 04:47:42 +00:00
Check , X , Eye as EyeIcon , EyeOff , ExternalLink , Bell , Send
2026-05-13 04:26:17 +00:00
} from 'lucide-react'
// Types for config sections
interface BotConfig {
name : string
owner : string
respond_to_dms : boolean
filter_bbs_protocols : boolean
}
interface ConnectionConfig {
type : string
serial_port : string
tcp_host : string
tcp_port : number
}
interface ResponseConfig {
delay_min : number
delay_max : number
max_length : number
max_messages : number
}
interface HistoryConfig {
database : string
max_messages_per_user : number
conversation_timeout : number
auto_cleanup : boolean
cleanup_interval_hours : number
max_age_days : number
}
interface MemoryConfig {
enabled : boolean
window_size : number
summarize_threshold : number
}
interface ContextConfig {
enabled : boolean
observe_channels : number [ ]
ignore_nodes : string [ ]
max_age : number
max_context_items : number
}
interface CommandsConfig {
enabled : boolean
prefix : string
disabled_commands : string [ ]
custom_commands : Record < string , string >
}
interface LLMConfig {
backend : string
api_key : string
base_url : string
model : string
timeout : number
max_response_tokens : number
system_prompt : string
use_system_prompt : boolean
web_search : boolean
google_grounding : boolean
}
interface WeatherConfig {
primary : string
fallback : string
default_location : string
openmeteo : { url : string }
wttr : { url : string }
}
interface MeshMonitorConfig {
enabled : boolean
url : string
inject_into_prompt : boolean
refresh_interval : number
polite_mode : boolean
}
interface KnowledgeConfig {
enabled : boolean
backend : string
qdrant_host : string
qdrant_port : number
qdrant_collection : string
tei_host : string
tei_port : number
sparse_host : string
sparse_port : number
use_sparse : boolean
db_path : string
top_k : number
}
interface MeshSourceConfig {
name : string
type : string
url : string
api_token : string
refresh_interval : number
polite_mode : boolean
enabled : boolean
host? : string
port? : number
username? : string
password? : string
topic_root? : string
use_tls? : boolean
}
interface RegionAnchor {
name : string
lat : number
lon : number
local_name : string
description : string
aliases : string [ ]
cities : string [ ]
}
interface AlertRulesConfig {
infra_offline : boolean
infra_recovery : boolean
new_router : boolean
battery_trend_declining : boolean
battery_warning : boolean
battery_critical : boolean
battery_emergency : boolean
battery_warning_threshold : number
battery_critical_threshold : number
battery_emergency_threshold : number
power_source_change : boolean
solar_not_charging : boolean
sustained_high_util : boolean
high_util_threshold : number
high_util_hours : number
packet_flood : boolean
packet_flood_threshold : number
infra_single_gateway : boolean
feeder_offline : boolean
region_total_blackout : boolean
mesh_score_alert : boolean
mesh_score_threshold : number
region_score_alert : boolean
region_score_threshold : number
}
interface MeshIntelligenceConfig {
enabled : boolean
regions : RegionAnchor [ ]
locality_radius_miles : number
offline_threshold_hours : number
packet_threshold : number
battery_warning_percent : number
critical_nodes : string [ ]
alert_channel : number
alert_cooldown_minutes : number
alert_rules : AlertRulesConfig
}
interface NWSConfig {
enabled : boolean
tick_seconds : number
areas : string [ ]
severity_min : string
user_agent : string
}
interface EnvironmentalConfig {
enabled : boolean
nws_zones : string [ ]
nws : NWSConfig
swpc : { enabled : boolean }
ducting : { enabled : boolean ; tick_seconds : number ; latitude : number ; longitude : number }
fires : { enabled : boolean ; tick_seconds : number ; state : string }
avalanche : { enabled : boolean ; tick_seconds : number ; center_ids : string [ ] ; season_months : number [ ] }
usgs : { enabled : boolean ; tick_seconds : number ; sites : string [ ] }
traffic : { enabled : boolean ; tick_seconds : number ; api_key : string ; corridors : { name : string ; lat : number ; lon : number } [ ] }
roads511 : { enabled : boolean ; tick_seconds : number ; api_key : string ; base_url : string ; endpoints : string [ ] ; bbox : number [ ] }
firms : { enabled : boolean ; tick_seconds : number ; map_key : string ; source : string ; bbox : number [ ] ; day_range : number ; confidence_min : string ; proximity_km : number }
}
interface DashboardConfig {
enabled : boolean
port : number
host : string
}
2026-05-13 04:47:42 +00:00
interface NotificationChannelConfig {
id : string
type : string
enabled : boolean
channel_index : number
node_ids : string [ ]
smtp_host : string
smtp_port : number
smtp_user : string
smtp_password : string
smtp_tls : boolean
from_address : string
recipients : string [ ]
url : string
headers : Record < string , string >
}
interface NotificationRuleConfig {
name : string
categories : string [ ]
min_severity : string
channel_ids : string [ ]
override_quiet : boolean
}
interface NotificationsConfig {
enabled : boolean
quiet_hours_start : string
quiet_hours_end : string
dedup_seconds : number
channels : NotificationChannelConfig [ ]
rules : NotificationRuleConfig [ ]
}
interface AlertCategory {
id : string
name : string
description : string
default_severity : string
}
2026-05-13 04:26:17 +00:00
interface FullConfig {
bot : BotConfig
connection : ConnectionConfig
response : ResponseConfig
history : HistoryConfig
memory : MemoryConfig
context : ContextConfig
commands : CommandsConfig
llm : LLMConfig
weather : WeatherConfig
meshmonitor : MeshMonitorConfig
knowledge : KnowledgeConfig
mesh_sources : MeshSourceConfig [ ]
mesh_intelligence : MeshIntelligenceConfig
environmental : EnvironmentalConfig
dashboard : DashboardConfig
2026-05-13 04:47:42 +00:00
notifications : NotificationsConfig
2026-05-13 04:26:17 +00:00
}
type SectionKey = keyof FullConfig
const SECTIONS : { key : SectionKey ; label : string ; icon : typeof Settings } [ ] = [
{ key : 'bot' , label : 'Bot' , icon : Bot } ,
{ key : 'connection' , label : 'Connection' , icon : Wifi } ,
{ key : 'response' , label : 'Response' , icon : MessageSquare } ,
{ key : 'history' , label : 'History' , icon : Database } ,
{ key : 'memory' , label : 'Memory' , icon : Brain } ,
{ key : 'context' , label : 'Context' , icon : Eye } ,
{ key : 'commands' , label : 'Commands' , icon : Terminal } ,
{ key : 'llm' , label : 'LLM' , icon : Cpu } ,
{ key : 'weather' , label : 'Weather' , icon : Cloud } ,
{ key : 'meshmonitor' , label : 'MeshMonitor' , icon : Radio } ,
{ key : 'knowledge' , label : 'Knowledge' , icon : BookOpen } ,
{ key : 'mesh_sources' , label : 'Mesh Sources' , icon : Layers } ,
{ key : 'mesh_intelligence' , label : 'Intelligence' , icon : Activity } ,
{ key : 'environmental' , label : 'Environmental' , icon : Thermometer } ,
2026-05-13 04:47:42 +00:00
{ key : 'notifications' , label : 'Notifications' , icon : Bell } ,
2026-05-13 04:26:17 +00:00
{ key : 'dashboard' , label : 'Dashboard' , icon : LayoutDashboard } ,
]
// Section descriptions
const SECTION_DESCRIPTIONS : Record < SectionKey , string > = {
bot : 'Identity and behavior settings for the bot on the mesh network.' ,
connection : 'How MeshAI connects to your Meshtastic radio.' ,
response : 'Controls how quickly and how much the bot responds on the mesh.' ,
history : 'Conversation history storage and cleanup.' ,
memory : 'Short-term conversation memory management. Controls how the bot maintains context within a conversation.' ,
context : 'Passive channel monitoring. The bot listens to mesh channels and uses recent messages as context when responding.' ,
commands : 'Mesh commands available via the configured prefix. Toggle individual commands on or off.' ,
llm : 'AI model configuration. MeshAI uses an LLM to understand questions and generate responses.' ,
weather : 'Weather data for the !weather command. This is separate from NWS environmental alerts.' ,
meshmonitor : 'AIDA MeshMonitor integration. An additional data source for mesh network monitoring.' ,
knowledge : 'Knowledge base for answering questions from stored documents. Connects to Qdrant vector database or local SQLite.' ,
mesh_sources : 'Data sources for mesh network information. MeshAI can pull data from multiple sources simultaneously and merge them into a unified view.' ,
mesh_intelligence : 'Advanced mesh analysis: health scoring, region management, and automated alerting. The intelligence engine monitors your mesh and detects problems automatically.' ,
environmental : 'Live environmental data feeds for situational awareness. Each feed polls a public or authenticated API for real-time conditions affecting your area.' ,
2026-05-13 04:47:42 +00:00
notifications : 'Alert delivery system. Configure where alerts get sent (mesh, email, webhooks) and which conditions trigger them.' ,
2026-05-13 04:26:17 +00:00
dashboard : "Web dashboard settings. You're looking at it right now." ,
}
// Available commands with descriptions
const AVAILABLE_COMMANDS = [
{ name : 'help' , description : 'Show available commands and usage' } ,
{ name : 'health' , description : 'Mesh network health overview with status dots' } ,
{ name : 'status' , description : 'Quick mesh status summary' } ,
{ name : 'region' , description : 'List regions or get detailed region breakdown' } ,
{ name : 'neighbors' , description : 'Show top infrastructure neighbors with signal quality' } ,
{ name : 'ping' , description : 'Test bot responsiveness' } ,
{ name : 'clear' , description : 'Clear your conversation history' } ,
{ name : 'reset' , description : 'Reset conversation context' } ,
{ name : 'sub' , description : 'Subscribe to scheduled reports or alerts' } ,
{ name : 'unsub' , description : 'Remove a subscription' } ,
{ name : 'mysubs' , description : 'List your active subscriptions' } ,
{ name : 'alerts' , description : 'Active NWS weather alerts for mesh area' } ,
{ name : 'solar' , description : 'Space weather and HF propagation conditions' } ,
{ name : 'hf' , description : 'HF radio propagation (alias for !solar)' } ,
{ name : 'fire' , description : 'Active wildfires near the mesh' } ,
{ name : 'avy' , description : 'Avalanche advisories for configured zones' } ,
{ name : 'hotspots' , description : 'NASA FIRMS satellite fire detections' } ,
{ name : 'streams' , description : 'USGS stream gauge readings' } ,
{ name : 'roads' , description : 'Road conditions and closures' } ,
{ name : 'traffic' , description : 'Traffic flow on monitored corridors' } ,
]
// US States for dropdown
const US_STATES = [
{ value : 'US-AL' , label : 'Alabama' } , { value : 'US-AK' , label : 'Alaska' } ,
{ value : 'US-AZ' , label : 'Arizona' } , { value : 'US-AR' , label : 'Arkansas' } ,
{ value : 'US-CA' , label : 'California' } , { value : 'US-CO' , label : 'Colorado' } ,
{ value : 'US-CT' , label : 'Connecticut' } , { value : 'US-DE' , label : 'Delaware' } ,
{ value : 'US-FL' , label : 'Florida' } , { value : 'US-GA' , label : 'Georgia' } ,
{ value : 'US-HI' , label : 'Hawaii' } , { value : 'US-ID' , label : 'Idaho' } ,
{ value : 'US-IL' , label : 'Illinois' } , { value : 'US-IN' , label : 'Indiana' } ,
{ value : 'US-IA' , label : 'Iowa' } , { value : 'US-KS' , label : 'Kansas' } ,
{ value : 'US-KY' , label : 'Kentucky' } , { value : 'US-LA' , label : 'Louisiana' } ,
{ value : 'US-ME' , label : 'Maine' } , { value : 'US-MD' , label : 'Maryland' } ,
{ value : 'US-MA' , label : 'Massachusetts' } , { value : 'US-MI' , label : 'Michigan' } ,
{ value : 'US-MN' , label : 'Minnesota' } , { value : 'US-MS' , label : 'Mississippi' } ,
{ value : 'US-MO' , label : 'Missouri' } , { value : 'US-MT' , label : 'Montana' } ,
{ value : 'US-NE' , label : 'Nebraska' } , { value : 'US-NV' , label : 'Nevada' } ,
{ value : 'US-NH' , label : 'New Hampshire' } , { value : 'US-NJ' , label : 'New Jersey' } ,
{ value : 'US-NM' , label : 'New Mexico' } , { value : 'US-NY' , label : 'New York' } ,
{ value : 'US-NC' , label : 'North Carolina' } , { value : 'US-ND' , label : 'North Dakota' } ,
{ value : 'US-OH' , label : 'Ohio' } , { value : 'US-OK' , label : 'Oklahoma' } ,
{ value : 'US-OR' , label : 'Oregon' } , { value : 'US-PA' , label : 'Pennsylvania' } ,
{ value : 'US-RI' , label : 'Rhode Island' } , { value : 'US-SC' , label : 'South Carolina' } ,
{ value : 'US-SD' , label : 'South Dakota' } , { value : 'US-TN' , label : 'Tennessee' } ,
{ value : 'US-TX' , label : 'Texas' } , { value : 'US-UT' , label : 'Utah' } ,
{ value : 'US-VT' , label : 'Vermont' } , { value : 'US-VA' , label : 'Virginia' } ,
{ value : 'US-WA' , label : 'Washington' } , { value : 'US-WV' , label : 'West Virginia' } ,
{ value : 'US-WI' , label : 'Wisconsin' } , { value : 'US-WY' , label : 'Wyoming' } ,
]
// InfoButton component
function InfoButton ( { info , link , linkText = 'Learn more' } : { info : string ; link? : string ; linkText? : string } ) {
const [ open , setOpen ] = useState ( false )
return (
< div className = "relative inline-block" >
< button
type = "button"
onClick = { ( e ) = > { e . stopPropagation ( ) ; setOpen ( ! open ) } }
className = "ml-1.5 w-4 h-4 rounded-full bg-slate-700 hover:bg-slate-600 text-slate-400 hover:text-slate-200 inline-flex items-center justify-center text-xs transition-colors"
title = "More info"
>
?
< / button >
{ open && (
< >
< div className = "fixed inset-0 z-40" onClick = { ( ) = > setOpen ( false ) } / >
< div className = "absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed" >
{ info }
{ link && (
< a
href = { link }
target = "_blank"
rel = "noopener noreferrer"
className = "mt-2 flex items-center gap-1 text-accent hover:underline"
onClick = { ( e ) = > e . stopPropagation ( ) }
>
{ linkText } < ExternalLink size = { 10 } / >
< / a >
) }
< / div >
< / >
) }
< / div >
)
}
// Section description component
function SectionDescription ( { text } : { text : string } ) {
return (
< p className = "text-sm text-slate-500 mb-6 pb-4 border-b border-[#1e2a3a]" > { text } < / p >
)
}
// Form components
function TextInput ( { label , value , onChange , type = 'text' , placeholder = '' , helper = '' , info = '' , infoLink = '' } : {
label : string
value : string
onChange : ( v : string ) = > void
type ? : string
placeholder? : string
helper? : string
info? : string
infoLink? : 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 } link = { infoLink } / > }
< / 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 = '' , infoLink = '' } : {
label : string
value : number
onChange : ( v : number ) = > void
min? : number
max? : number
step? : number
helper? : string
info? : string
infoLink? : 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 } link = { infoLink } / > }
< / 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 = '' , infoLink = '' } : {
label : string
checked : boolean
onChange : ( v : boolean ) = > void
helper? : string
info? : string
infoLink? : 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 } link = { infoLink } / > }
< / span >
{ helper && < p className = "text-xs text-slate-600" > { helper } < / p > }
< / div >
< button
type = "button"
onClick = { ( ) = > onChange ( ! checked ) }
className = { ` relative w-11 h-6 rounded-full transition-colors ${
checked ? 'bg-accent' : 'bg-[#1e2a3a]'
} ` }
>
< span
className = { ` absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
checked ? 'translate-x-5' : ''
} ` }
/ >
< / button >
< / div >
)
}
function SelectInput ( { label , value , onChange , options , helper = '' , info = '' , infoLink = '' } : {
label : string
value : string
onChange : ( v : string ) = > void
options : { value : string ; label : string } [ ]
helper? : string
info? : string
infoLink? : 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 } link = { infoLink } / > }
< / label >
< select
value = { value }
onChange = { ( e ) = > onChange ( e . target . value ) }
className = "w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{ options . map ( ( opt ) = > (
< option key = { opt . value } value = { opt . value } > { opt . label } < / option >
) ) }
< / select >
{ helper && < p className = "text-xs text-slate-600" > { helper } < / p > }
< / div >
)
}
function TextArea ( { label , value , onChange , rows = 4 , helper = '' , info = '' , infoLink = '' } : {
label : string
value : string
onChange : ( v : string ) = > void
rows? : number
helper? : string
info? : string
infoLink? : 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 } link = { infoLink } / > }
< / label >
< textarea
value = { value }
onChange = { ( e ) = > onChange ( e . target . value ) }
rows = { rows }
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 resize-y"
/ >
{ helper && < p className = "text-xs text-slate-600" > { helper } < / p > }
< / div >
)
}
function ListInput ( { label , value , onChange , helper = '' , info = '' , infoLink = '' } : {
label : string
value : string [ ]
onChange : ( v : string [ ] ) = > void
helper? : string
info? : string
infoLink? : string
} ) {
const [ text , setText ] = useState ( value . join ( ', ' ) )
useEffect ( ( ) = > {
setText ( value . join ( ', ' ) )
} , [ value ] )
const handleBlur = ( ) = > {
const items = text . split ( ',' ) . map ( s = > s . trim ( ) ) . filter ( Boolean )
onChange ( items )
}
return (
< div className = "space-y-1" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
{ label }
{ info && < InfoButton info = { info } link = { infoLink } / > }
< / label >
< input
type = "text"
value = { text }
onChange = { ( e ) = > setText ( e . target . value ) }
onBlur = { handleBlur }
placeholder = "item1, item2, item3"
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"
/ >
{ helper && < p className = "text-xs text-slate-600" > { helper } < / p > }
< / div >
)
}
function NumberListInput ( { label , value , onChange , helper = '' , info = '' , infoLink = '' } : {
label : string
value : number [ ]
onChange : ( v : number [ ] ) = > void
helper? : string
info? : string
infoLink? : string
} ) {
const [ text , setText ] = useState ( value . join ( ', ' ) )
useEffect ( ( ) = > {
setText ( value . join ( ', ' ) )
} , [ value ] )
const handleBlur = ( ) = > {
const items = text . split ( ',' ) . map ( s = > parseInt ( s . trim ( ) , 10 ) ) . filter ( n = > ! isNaN ( n ) )
onChange ( items )
}
return (
< div className = "space-y-1" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
{ label }
{ info && < InfoButton info = { info } link = { infoLink } / > }
< / label >
< input
type = "text"
value = { text }
onChange = { ( e ) = > setText ( e . target . value ) }
onBlur = { handleBlur }
placeholder = "0, 1, 2"
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"
/ >
{ helper && < p className = "text-xs text-slate-600" > { helper } < / p > }
< / div >
)
}
// Alert rule with description component
function AlertRuleToggle ( { label , description , checked , onChange , threshold , onThresholdChange , thresholdLabel , thresholdMin , thresholdMax , thresholdStep = 1 , thresholdSuffix = '' } : {
label : string
description : string
checked : boolean
onChange : ( v : boolean ) = > void
threshold? : number
onThresholdChange ? : ( v : number ) = > void
thresholdLabel? : string
thresholdMin? : number
thresholdMax? : number
thresholdStep? : number
thresholdSuffix? : string
} ) {
return (
< div className = "border border-[#1e2a3a] rounded-lg p-3 space-y-2" >
< div className = "flex items-center justify-between" >
< div className = "flex-1" >
< span className = "text-sm text-slate-300" > { label } < / span >
< p className = "text-xs text-slate-600" > { description } < / p >
< / div >
< button
type = "button"
onClick = { ( ) = > onChange ( ! checked ) }
className = { ` relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${
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 >
{ checked && threshold !== undefined && onThresholdChange && (
< div className = "flex items-center gap-2 pt-2 border-t border-[#1e2a3a]" >
< span className = "text-xs text-slate-500" > { thresholdLabel || 'Threshold' } : < / span >
< input
type = "number"
value = { threshold }
onChange = { ( e ) = > onThresholdChange ( Number ( e . target . value ) ) }
min = { thresholdMin }
max = { thresholdMax }
step = { thresholdStep }
className = "w-20 px-2 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 font-mono"
/ >
{ thresholdSuffix && < span className = "text-xs text-slate-500" > { thresholdSuffix } < / span > }
< / div >
) }
< / div >
)
}
// Section renderers
function BotSection ( { data , onChange } : { data : BotConfig ; onChange : ( d : BotConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . bot } / >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "Bot Name"
value = { data . name }
onChange = { ( v ) = > onChange ( { . . . data , name : v } ) }
helper = "Name the bot responds to on the mesh"
info = "When someone sends a message containing this name, the bot will respond. Also used as the sender name in broadcasts. Changing this requires a restart."
/ >
< TextInput
label = "Owner"
value = { data . owner }
onChange = { ( v ) = > onChange ( { . . . data , owner : v } ) }
helper = "Your callsign or identifier"
info = "Identifies the bot operator. Shown in !help responses and used for admin-level commands."
/ >
< / div >
< Toggle
label = "Respond to DMs"
checked = { data . respond_to_dms }
onChange = { ( v ) = > onChange ( { . . . data , respond_to_dms : v } ) }
helper = "Reply when someone sends a direct message"
info = "When enabled, the bot responds to direct messages from any node. When disabled, the bot only responds to channel messages that mention its name."
/ >
< Toggle
label = "Filter BBS Protocols"
checked = { data . filter_bbs_protocols }
onChange = { ( v ) = > onChange ( { . . . data , filter_bbs_protocols : v } ) }
helper = "Ignore BBS bulletin board traffic"
info = "Filters out automated BBS protocol messages (advBBS, MAIL*, BOARD*) so the bot doesn't try to respond to machine-to-machine traffic."
/ >
< / div >
)
}
function ConnectionSection ( { data , onChange } : { data : ConnectionConfig ; onChange : ( d : ConnectionConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . connection } / >
< SelectInput
label = "Connection Type"
value = { data . type }
onChange = { ( v ) = > onChange ( { . . . data , type : v } ) }
options = { [
{ value : 'serial' , label : 'Serial (USB)' } ,
{ value : 'tcp' , label : 'TCP (Network)' } ,
] }
helper = "Serial for USB-connected radios, TCP for network or meshtasticd"
info = "Serial: direct USB connection to a Meshtastic radio. TCP: connect over the network to a radio's IP or to meshtasticd running on another machine."
/ >
{ data . type === 'serial' ? (
< TextInput
label = "Serial Port"
value = { data . serial_port }
onChange = { ( v ) = > onChange ( { . . . data , serial_port : v } ) }
placeholder = "/dev/ttyUSB0"
helper = "Device path for your USB radio"
info = "Usually /dev/ttyUSB0 on Linux or /dev/ttyACM0. Check with 'ls /dev/tty*' after plugging in your radio."
/ >
) : (
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "TCP Host"
value = { data . tcp_host }
onChange = { ( v ) = > onChange ( { . . . data , tcp_host : v } ) }
placeholder = "192.168.1.100"
helper = "IP address or hostname of the radio/meshtasticd"
/ >
< NumberInput
label = "TCP Port"
value = { data . tcp_port }
onChange = { ( v ) = > onChange ( { . . . data , tcp_port : v } ) }
min = { 1 }
max = { 65535 }
helper = "Default 4403 for meshtasticd"
/ >
< / div >
) }
< / div >
)
}
function ResponseSection ( { data , onChange } : { data : ResponseConfig ; onChange : ( d : ResponseConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . response } / >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Delay Min (sec)"
value = { data . delay_min }
onChange = { ( v ) = > onChange ( { . . . data , delay_min : v } ) }
min = { 0 }
step = { 0.1 }
helper = "Minimum wait before responding"
info = "Adds a random delay between min and max before the bot sends a response. Prevents the bot from appearing to respond instantly, which can feel unnatural on a radio network."
/ >
< NumberInput
label = "Delay Max (sec)"
value = { data . delay_max }
onChange = { ( v ) = > onChange ( { . . . data , delay_max : v } ) }
min = { 0 }
step = { 0.1 }
helper = "Maximum wait before responding"
info = "Also prevents collisions with other traffic by staggering transmissions."
/ >
< / div >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Max Length"
value = { data . max_length }
onChange = { ( v ) = > onChange ( { . . . data , max_length : v } ) }
min = { 50 }
max = { 500 }
helper = "Maximum characters per response message"
info = "Meshtastic packets have limited size. This caps how long each message chunk can be. The bot will split longer responses into multiple messages up to Max Messages."
/ >
< NumberInput
label = "Max Messages"
value = { data . max_messages }
onChange = { ( v ) = > onChange ( { . . . data , max_messages : v } ) }
min = { 1 }
max = { 10 }
helper = "Maximum chunks per response"
info = "If a response is longer than Max Length, the bot splits it into this many chunks at most. Higher values = more complete answers but more airtime used."
/ >
< / div >
< / div >
)
}
function HistorySection ( { data , onChange } : { data : HistoryConfig ; onChange : ( d : HistoryConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . history } / >
< TextInput
label = "Database Path"
value = { data . database }
onChange = { ( v ) = > onChange ( { . . . data , database : v } ) }
helper = "SQLite file for storing conversation history"
info = "Path to the SQLite database file. Created automatically if it doesn't exist. Stores all conversation history for context."
/ >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Max Messages Per User"
value = { data . max_messages_per_user }
onChange = { ( v ) = > onChange ( { . . . data , max_messages_per_user : v } ) }
min = { 0 }
helper = "History limit per user (0 = unlimited)"
info = "Limits how many messages are stored per user. Older messages are pruned when the limit is reached. Set to 0 for no limit."
/ >
< NumberInput
label = "Conversation Timeout (sec)"
value = { data . conversation_timeout }
onChange = { ( v ) = > onChange ( { . . . data , conversation_timeout : v } ) }
min = { 0 }
helper = "Seconds before context resets"
info = "If a user doesn't message for this long, their next message starts a new conversation context. The bot won't remember the previous topic."
/ >
< / div >
< Toggle
label = "Auto Cleanup"
checked = { data . auto_cleanup }
onChange = { ( v ) = > onChange ( { . . . data , auto_cleanup : v } ) }
helper = "Automatically prune old conversations"
/ >
{ data . auto_cleanup && (
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Cleanup Interval (hours)"
value = { data . cleanup_interval_hours }
onChange = { ( v ) = > onChange ( { . . . data , cleanup_interval_hours : v } ) }
min = { 1 }
helper = "Hours between cleanup runs"
/ >
< NumberInput
label = "Max Age (days)"
value = { data . max_age_days }
onChange = { ( v ) = > onChange ( { . . . data , max_age_days : v } ) }
min = { 1 }
helper = "Delete conversations older than this"
/ >
< / div >
) }
< / div >
)
}
function MemorySection ( { data , onChange } : { data : MemoryConfig ; onChange : ( d : MemoryConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . memory } / >
< Toggle
label = "Enable Memory"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Keep conversation context between messages"
/ >
{ data . enabled && (
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Window Size"
value = { data . window_size }
onChange = { ( v ) = > onChange ( { . . . data , window_size : v } ) }
min = { 1 }
helper = "Recent message pairs kept in full"
info = "The bot keeps this many recent exchanges (user message + bot response pairs) as full text in context. Older messages are summarized to save token space."
/ >
< NumberInput
label = "Summarize Threshold"
value = { data . summarize_threshold }
onChange = { ( v ) = > onChange ( { . . . data , summarize_threshold : v } ) }
min = { 1 }
helper = "Messages before older context is summarized"
info = "When the conversation exceeds this many messages, older ones outside the window are compressed into a summary by the LLM."
/ >
< / div >
) }
< / div >
)
}
function ContextSection ( { data , onChange } : { data : ContextConfig ; onChange : ( d : ContextConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . context } / >
< Toggle
label = "Enable Passive Context"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Listen to channel traffic for context"
info = "When enabled, the bot monitors mesh channels and includes recent messages in its context. This lets the bot reference things other people said on the channel."
/ >
{ data . enabled && (
< >
< NumberListInput
label = "Observe Channels"
value = { data . observe_channels }
onChange = { ( v ) = > onChange ( { . . . data , observe_channels : v } ) }
helper = "Channel indexes to monitor (empty = all)"
info = "Meshtastic channel numbers to listen on. Channel 0 is the default primary channel. Leave empty to monitor all channels."
/ >
< ListInput
label = "Ignore Nodes"
value = { data . ignore_nodes }
onChange = { ( v ) = > onChange ( { . . . data , ignore_nodes : v } ) }
helper = "Node IDs to exclude from context"
info = "Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
/ >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Max Age (sec)"
value = { data . max_age }
onChange = { ( v ) = > onChange ( { . . . data , max_age : v } ) }
min = { 0 }
helper = "Ignore messages older than this"
/ >
< NumberInput
label = "Max Context Items"
value = { data . max_context_items }
onChange = { ( v ) = > onChange ( { . . . data , max_context_items : v } ) }
min = { 1 }
helper = "Maximum recent messages to include"
/ >
< / div >
< / >
) }
< / div >
)
}
function CommandsSection ( { data , onChange } : { data : CommandsConfig ; onChange : ( d : CommandsConfig ) = > void } ) {
const disabledSet = new Set ( data . disabled_commands . map ( c = > c . toLowerCase ( ) ) )
const toggleCommand = ( cmdName : string ) = > {
const lowerName = cmdName . toLowerCase ( )
if ( disabledSet . has ( lowerName ) ) {
onChange ( { . . . data , disabled_commands : data.disabled_commands.filter ( c = > c . toLowerCase ( ) !== lowerName ) } )
} else {
onChange ( { . . . data , disabled_commands : [ . . . data . disabled_commands , cmdName ] } )
}
}
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . commands } / >
< Toggle
label = "Enable Commands"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Allow !commands on the mesh"
/ >
{ data . enabled && (
< >
< TextInput
label = "Command Prefix"
value = { data . prefix }
onChange = { ( v ) = > onChange ( { . . . data , prefix : v } ) }
helper = "Character that triggers commands (e.g. ! for !help)"
info = "Users type this character followed by the command name. Only single characters recommended."
/ >
< div className = "space-y-2" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Available Commands
< InfoButton info = "Toggle commands on or off. Disabled commands won't respond when users invoke them." / >
< / label >
< div className = "grid gap-1" >
{ AVAILABLE_COMMANDS . map ( ( cmd ) = > {
const isEnabled = ! disabledSet . has ( cmd . name . toLowerCase ( ) )
return (
< div
key = { cmd . name }
className = "flex items-center justify-between p-2 bg-[#0a0e17] border border-[#1e2a3a] rounded hover:border-[#2a3a4a] transition-colors"
>
< div className = "flex items-center gap-3" >
< code className = "text-accent text-sm" > ! { cmd . name } < / code >
< span className = "text-xs text-slate-500" > { cmd . description } < / span >
< / div >
< button
type = "button"
onClick = { ( ) = > toggleCommand ( cmd . name ) }
className = { ` relative w-9 h-5 rounded-full transition-colors ${
isEnabled ? 'bg-accent' : 'bg-[#1e2a3a]'
} ` }
>
< span
className = { ` absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
isEnabled ? 'translate-x-4' : ''
} ` }
/ >
< / button >
< / div >
)
} ) }
< / div >
< / div >
< / >
) }
< / div >
)
}
function LLMSection ( { data , onChange } : { data : LLMConfig ; onChange : ( d : LLMConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . llm } / >
< div className = "grid grid-cols-2 gap-4" >
< SelectInput
label = "Backend"
value = { data . backend }
onChange = { ( v ) = > onChange ( { . . . data , backend : v } ) }
options = { [
{ value : 'openai' , label : 'OpenAI' } ,
{ value : 'anthropic' , label : 'Anthropic' } ,
{ value : 'google' , label : 'Google (Gemini)' } ,
] }
helper = "LLM provider to use"
info = "OpenAI: GPT models (gpt-4o, gpt-4o-mini). Anthropic: Claude models (claude-sonnet-4-20250514). Google: Gemini models. Can also point to compatible APIs like Ollama, LM Studio, or Open WebUI by changing the Base URL."
/ >
< TextInput
label = "Model"
value = { data . model }
onChange = { ( v ) = > onChange ( { . . . data , model : v } ) }
placeholder = "gpt-4o-mini"
helper = "Specific model name"
info = "The specific model to use. Common choices: gpt-4o-mini (fast, cheap), gpt-4o (better, costs more), claude-sonnet-4-20250514 (Anthropic equivalent). For local models via Ollama, use the model name you pulled (e.g. llama3.1)."
/ >
< / div >
< TextInput
label = "API Key"
value = { data . api_key }
onChange = { ( v ) = > onChange ( { . . . data , api_key : v } ) }
type = "password"
helper = "Supports ${ENV_VAR} syntax"
info = "Your API key from the provider. You can also use ${ENV_VAR} syntax to read from an environment variable instead of storing the key in the config file."
/ >
< TextInput
label = "Base URL"
value = { data . base_url }
onChange = { ( v ) = > onChange ( { . . . data , base_url : v } ) }
placeholder = "https://api.openai.com/v1"
helper = "API endpoint (change for local LLMs)"
info = "Default API endpoint for the selected backend. Change this to point to a local LLM server (Ollama at http://localhost:11434/v1, Open WebUI, LM Studio, etc.) or a proxy."
/ >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Timeout (sec)"
value = { data . timeout }
onChange = { ( v ) = > onChange ( { . . . data , timeout : v } ) }
min = { 5 }
max = { 120 }
helper = "Maximum seconds to wait for response"
/ >
< NumberInput
label = "Max Response Tokens"
value = { data . max_response_tokens }
onChange = { ( v ) = > onChange ( { . . . data , max_response_tokens : v } ) }
min = { 100 }
helper = "Token limit for LLM responses"
/ >
< / div >
< Toggle
label = "Use System Prompt"
checked = { data . use_system_prompt }
onChange = { ( v ) = > onChange ( { . . . data , use_system_prompt : v } ) }
helper = "Enable custom system instructions"
/ >
{ data . use_system_prompt && (
< TextArea
label = "System Prompt"
value = { data . system_prompt }
onChange = { ( v ) = > onChange ( { . . . data , system_prompt : v } ) }
rows = { 6 }
helper = "Instructions that shape the bot's personality"
info = "Instructions that shape the bot's personality and behavior. The bot always follows these instructions. MeshAI adds mesh health data and environmental context automatically — you don't need to include those here."
/ >
) }
< Toggle
label = "Web Search"
checked = { data . web_search }
onChange = { ( v ) = > onChange ( { . . . data , web_search : v } ) }
helper = "Enable web search tool (Open WebUI feature)"
/ >
< Toggle
label = "Google Grounding"
checked = { data . google_grounding }
onChange = { ( v ) = > onChange ( { . . . data , google_grounding : v } ) }
helper = "Ground responses in web search (Gemini only)"
/ >
< / div >
)
}
function WeatherSection ( { data , onChange } : { data : WeatherConfig ; onChange : ( d : WeatherConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . weather } / >
< div className = "grid grid-cols-2 gap-4" >
< SelectInput
label = "Primary Provider"
value = { data . primary }
onChange = { ( v ) = > onChange ( { . . . data , primary : v } ) }
options = { [
{ value : 'openmeteo' , label : 'Open-Meteo' } ,
{ value : 'wttr' , label : 'wttr.in' } ,
{ value : 'llm' , label : 'LLM' } ,
] }
helper = "Main weather data source"
/ >
< SelectInput
label = "Fallback Provider"
value = { data . fallback }
onChange = { ( v ) = > onChange ( { . . . data , fallback : v } ) }
options = { [
{ value : 'openmeteo' , label : 'Open-Meteo' } ,
{ value : 'wttr' , label : 'wttr.in' } ,
{ value : 'llm' , label : 'LLM' } ,
{ value : 'none' , label : 'None' } ,
] }
helper = "Backup if primary fails"
/ >
< / div >
< TextInput
label = "Default Location"
value = { data . default_location }
onChange = { ( v ) = > onChange ( { . . . data , default_location : v } ) }
placeholder = "Your city, state"
helper = "Location when none specified"
/ >
< / div >
)
}
function MeshMonitorSection ( { data , onChange } : { data : MeshMonitorConfig ; onChange : ( d : MeshMonitorConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . meshmonitor } / >
< Toggle
label = "Enable MeshMonitor"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Connect to AIDA MeshMonitor instance"
info = "MeshMonitor by Yeraze provides node data, battery info, telemetry, and auto-responder patterns. MeshAI uses this as a data source and avoids duplicate responses."
/ >
{ data . enabled && (
< >
< TextInput
label = "URL"
value = { data . url }
onChange = { ( v ) = > onChange ( { . . . data , url : v } ) }
placeholder = "http://192.168.1.100:8080"
helper = "MeshMonitor API endpoint"
info = "Full URL to your MeshMonitor instance. Usually runs on port 8080."
/ >
< Toggle
label = "Inject Into Prompt"
checked = { data . inject_into_prompt }
onChange = { ( v ) = > onChange ( { . . . data , inject_into_prompt : v } ) }
helper = "Tell LLM about MeshMonitor commands"
info = "Adds MeshMonitor's auto-responder patterns to the LLM context so it knows what commands MeshMonitor handles."
/ >
< NumberInput
label = "Refresh Interval (sec)"
value = { data . refresh_interval }
onChange = { ( v ) = > onChange ( { . . . data , refresh_interval : v } ) }
min = { 10 }
helper = "How often to fetch patterns"
/ >
< Toggle
label = "Polite Mode"
checked = { data . polite_mode }
onChange = { ( v ) = > onChange ( { . . . data , polite_mode : v } ) }
helper = "Reduce polling frequency"
info = "Reduces polling frequency for shared instances to be a good neighbor."
/ >
< / >
) }
< / div >
)
}
function KnowledgeSection ( { data , onChange } : { data : KnowledgeConfig ; onChange : ( d : KnowledgeConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . knowledge } / >
< Toggle
label = "Enable Knowledge Base"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Answer questions from stored documents"
info = "Uses RAG (Retrieval-Augmented Generation) to answer questions from a knowledge base. Supports Qdrant vector database or local SQLite with FTS5."
/ >
{ data . enabled && (
< >
< SelectInput
label = "Backend"
value = { data . backend }
onChange = { ( v ) = > onChange ( { . . . data , backend : v } ) }
options = { [
{ value : 'auto' , label : 'Auto (Qdrant -> SQLite)' } ,
{ value : 'qdrant' , label : 'Qdrant' } ,
{ value : 'sqlite' , label : 'SQLite' } ,
] }
helper = "Knowledge storage backend"
info = "Auto tries Qdrant first, falls back to SQLite. Qdrant provides hybrid search with dense+sparse embeddings. SQLite uses FTS5 keyword search."
/ >
{ ( data . backend === 'qdrant' || data . backend === 'auto' ) && (
< >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "Qdrant Host"
value = { data . qdrant_host }
onChange = { ( v ) = > onChange ( { . . . data , qdrant_host : v } ) }
helper = "Qdrant server hostname"
info = "IP or hostname of your Qdrant vector database server."
/ >
< NumberInput
label = "Qdrant Port"
value = { data . qdrant_port }
onChange = { ( v ) = > onChange ( { . . . data , qdrant_port : v } ) }
helper = "Default 6333"
/ >
< / div >
< TextInput
label = "Collection"
value = { data . qdrant_collection }
onChange = { ( v ) = > onChange ( { . . . data , qdrant_collection : v } ) }
helper = "Qdrant collection name"
/ >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "TEI Host"
value = { data . tei_host }
onChange = { ( v ) = > onChange ( { . . . data , tei_host : v } ) }
helper = "Text Embeddings Inference host"
info = "TEI service for generating dense embeddings. Uses BAAI/bge-m3 model."
/ >
< NumberInput
label = "TEI Port"
value = { data . tei_port }
onChange = { ( v ) = > onChange ( { . . . data , tei_port : v } ) }
helper = "Default 8090"
/ >
< / div >
< Toggle
label = "Use Sparse Embeddings"
checked = { data . use_sparse }
onChange = { ( v ) = > onChange ( { . . . data , use_sparse : v } ) }
helper = "Enable hybrid search with sparse vectors"
info = "Combines dense embeddings with sparse (keyword-based) embeddings using Reciprocal Rank Fusion for better search results."
/ >
< / >
) }
< TextInput
label = "SQLite DB Path"
value = { data . db_path }
onChange = { ( v ) = > onChange ( { . . . data , db_path : v } ) }
helper = "Local knowledge database file"
/ >
< NumberInput
label = "Top K Results"
value = { data . top_k }
onChange = { ( v ) = > onChange ( { . . . data , top_k : v } ) }
min = { 1 }
max = { 20 }
helper = "Number of documents to retrieve"
/ >
< / >
) }
< / div >
)
}
function MeshSourceCard ( { source , onChange , onDelete } : {
source : MeshSourceConfig
onChange : ( s : MeshSourceConfig ) = > void
onDelete : ( ) = > void
} ) {
const [ expanded , setExpanded ] = useState ( false )
const typeInfo : Record < string , string > = {
meshview : 'Web-based mesh monitoring tool. Enter the full URL of a MeshView instance. No API key typically required.' ,
meshmonitor : 'AIDA MeshMonitor API. Provides node data and network statistics. Requires API token.' ,
mqtt : 'Subscribe directly to a Meshtastic MQTT broker for real-time packet data. This is push-based (instant) vs the polling approach of MeshView/MeshMonitor.' ,
}
return (
< div className = "border border-[#1e2a3a] rounded-lg overflow-hidden" >
< div
className = "flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick = { ( ) = > setExpanded ( ! expanded ) }
>
< div className = "flex items-center gap-3" >
{ expanded ? < ChevronDown size = { 16 } / > : < ChevronRight size = { 16 } / > }
< div className = { ` w-2 h-2 rounded-full ${ source . enabled ? 'bg-green-500' : 'bg-slate-500' } ` } / >
< span className = "font-mono text-sm text-slate-200" > { source . name || 'Unnamed Source' } < / span >
< span className = "text-xs text-slate-500 bg-[#1e2a3a] px-2 py-0.5 rounded" > { source . type } < / span >
< / div >
< button
onClick = { ( e ) = > { e . stopPropagation ( ) ; onDelete ( ) ; } }
className = "p-1 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
< Trash2 size = { 14 } / >
< / button >
< / div >
{ expanded && (
< div className = "p-4 space-y-4 border-t border-[#1e2a3a]" >
< div className = "grid grid-cols-2 gap-4" >
< TextInput label = "Name" value = { source . name } onChange = { ( v ) = > onChange ( { . . . source , name : v } ) } helper = "Friendly name for this source" / >
< SelectInput
label = "Type"
value = { source . type }
onChange = { ( v ) = > onChange ( { . . . source , type : v } ) }
options = { [
{ value : 'meshview' , label : 'MeshView' } ,
{ value : 'meshmonitor' , label : 'MeshMonitor' } ,
{ value : 'mqtt' , label : 'MQTT Broker' } ,
] }
info = { typeInfo [ source . type ] || '' }
/ >
< / div >
{ source . type !== 'mqtt' && (
< TextInput label = "URL" value = { source . url } onChange = { ( v ) = > onChange ( { . . . source , url : v } ) } helper = "Full URL including protocol" / >
) }
{ source . type === 'meshmonitor' && (
< TextInput label = "API Token" value = { source . api_token } onChange = { ( v ) = > onChange ( { . . . source , api_token : v } ) } type = "password" helper = "Bearer token for authentication" / >
) }
{ source . type === 'mqtt' && (
< >
< div className = "grid grid-cols-2 gap-4" >
< TextInput label = "Host" value = { source . host || '' } onChange = { ( v ) = > onChange ( { . . . source , host : v } ) } helper = "MQTT broker hostname" / >
< NumberInput label = "Port" value = { source . port || 1883 } onChange = { ( v ) = > onChange ( { . . . source , port : v } ) } min = { 1 } max = { 65535 } helper = "1883 plain, 8883 TLS" / >
< / div >
< div className = "grid grid-cols-2 gap-4" >
< TextInput label = "Username" value = { source . username || '' } onChange = { ( v ) = > onChange ( { . . . source , username : v } ) } / >
< TextInput label = "Password" value = { source . password || '' } onChange = { ( v ) = > onChange ( { . . . source , password : v } ) } type = "password" / >
< / div >
< TextInput label = "Topic Root" value = { source . topic_root || 'msh/US' } onChange = { ( v ) = > onChange ( { . . . source , topic_root : v } ) } helper = "Base topic to subscribe to" / >
< Toggle label = "Use TLS" checked = { source . use_tls || false } onChange = { ( v ) = > onChange ( { . . . source , use_tls : v } ) } helper = "Encrypt MQTT connection" / >
< / >
) }
< NumberInput label = "Refresh Interval (sec)" value = { source . refresh_interval } onChange = { ( v ) = > onChange ( { . . . source , refresh_interval : v } ) } min = { 10 } helper = "Polling frequency" / >
< Toggle label = "Enabled" checked = { source . enabled } onChange = { ( v ) = > onChange ( { . . . source , enabled : v } ) } / >
< Toggle label = "Polite Mode" checked = { source . polite_mode } onChange = { ( v ) = > onChange ( { . . . source , polite_mode : v } ) } helper = "Reduce polling for shared instances" / >
< / div >
) }
< / div >
)
}
function MeshSourcesSection ( { data , onChange } : { data : MeshSourceConfig [ ] ; onChange : ( d : MeshSourceConfig [ ] ) = > void } ) {
const addSource = ( ) = > {
onChange ( [ . . . data , {
name : 'New Source' ,
type : 'meshview' ,
url : '' ,
api_token : '' ,
refresh_interval : 30 ,
polite_mode : false ,
enabled : true ,
host : '' ,
port : 1883 ,
username : '' ,
password : '' ,
topic_root : 'msh/US' ,
use_tls : false ,
} ] )
}
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . mesh_sources } / >
{ data . map ( ( source , i ) = > (
< MeshSourceCard
key = { i }
source = { source }
onChange = { ( s ) = > {
const newData = [ . . . data ]
newData [ i ] = s
onChange ( newData )
} }
onDelete = { ( ) = > {
if ( confirm ( ` Delete source " ${ source . name } "? ` ) ) {
onChange ( data . filter ( ( _ , j ) = > j !== i ) )
}
} }
/ >
) ) }
< button
onClick = { addSource }
className = "w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
< Plus size = { 16 } / > Add Source
< / button >
< / div >
)
}
function MeshIntelligenceSection ( { data , onChange } : { data : MeshIntelligenceConfig ; onChange : ( d : MeshIntelligenceConfig ) = > void } ) {
const [ expandedRegion , setExpandedRegion ] = useState < number | null > ( null )
return (
< div className = "space-y-6" >
< SectionDescription text = { SECTION_DESCRIPTIONS . mesh_intelligence } / >
< Toggle
label = "Enable Mesh Intelligence"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Activate health scoring and alerting"
/ >
{ data . enabled && (
< >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Locality Radius (miles)"
value = { data . locality_radius_miles }
onChange = { ( v ) = > onChange ( { . . . data , locality_radius_miles : v } ) }
min = { 1 }
step = { 0.5 }
helper = "Region assignment radius"
info = "Nodes within this distance of a region anchor point are assigned to that region."
/ >
< NumberInput
label = "Offline Threshold (hours)"
value = { data . offline_threshold_hours }
onChange = { ( v ) = > onChange ( { . . . data , offline_threshold_hours : v } ) }
min = { 1 }
helper = "Time until node marked offline"
info = "A node is considered offline after not being heard for this many hours."
/ >
< / div >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Packet Threshold"
value = { data . packet_threshold }
onChange = { ( v ) = > onChange ( { . . . data , packet_threshold : v } ) }
min = { 0 }
helper = "Min packets per 24h to flag"
info = "Minimum packets per 24 hours. Nodes below this are flagged as low activity."
/ >
< NumberInput
label = "Battery Warning %"
value = { data . battery_warning_percent }
onChange = { ( v ) = > onChange ( { . . . data , battery_warning_percent : v } ) }
min = { 1 }
max = { 100 }
helper = "Global battery warning level"
/ >
< / div >
< ListInput
label = "Critical Nodes"
value = { data . critical_nodes }
onChange = { ( v ) = > onChange ( { . . . data , critical_nodes : v } ) }
helper = "Short names of critical infrastructure"
info = "Nodes that get priority alerting when they go offline. Use the node's short name (e.g., MHR, HPR)."
/ >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Alert Channel"
value = { data . alert_channel }
onChange = { ( v ) = > onChange ( { . . . data , alert_channel : v } ) }
min = { - 1 }
helper = "-1 = disabled"
info = "Meshtastic channel number for broadcast alerts. Set to -1 to disable channel broadcasting."
/ >
< NumberInput
label = "Alert Cooldown (min)"
value = { data . alert_cooldown_minutes }
onChange = { ( v ) = > onChange ( { . . . data , alert_cooldown_minutes : v } ) }
min = { 1 }
helper = "Min time between repeat alerts"
info = "Minimum minutes between repeated alerts for the same condition. Uses scaling cooldown (12h, 24h, 48h)."
/ >
< / div >
< div className = "space-y-2" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Regions
< InfoButton info = "Regions group mesh nodes by geographic area. Each region has an anchor point (lat/lon) and nodes within the region radius are automatically assigned. Regions enable localized reports, alerts, and health scoring." / >
< / label >
{ data . regions . map ( ( region , i ) = > (
< div key = { i } className = "border border-[#1e2a3a] rounded-lg overflow-hidden" >
< div
className = "flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick = { ( ) = > setExpandedRegion ( expandedRegion === i ? null : i ) }
>
< div className = "flex items-center gap-3" >
{ expandedRegion === i ? < ChevronDown size = { 16 } / > : < ChevronRight size = { 16 } / > }
< span className = "font-medium text-slate-200" > { region . name || 'Unnamed Region' } < / span >
< span className = "text-xs text-slate-500" > { region . local_name } < / span >
< / div >
< button
onClick = { ( e ) = > {
e . stopPropagation ( )
if ( confirm ( ` Delete region " ${ region . name || 'Unnamed Region' } "? ` ) ) {
const newRegions = data . regions . filter ( ( _ , j ) = > j !== i )
onChange ( { . . . data , regions : newRegions } )
}
} }
className = "p-1 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
< Trash2 size = { 14 } / >
< / button >
< / div >
{ expandedRegion === i && (
< div className = "p-4 space-y-3 border-t border-[#1e2a3a]" >
< div className = "grid grid-cols-2 gap-4" >
< TextInput label = "Name" value = { region . name } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , name : v }
onChange ( { . . . data , regions : newRegions } )
} } / >
< TextInput label = "Local Name" value = { region . local_name } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , local_name : v }
onChange ( { . . . data , regions : newRegions } )
} } / >
< / div >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput label = "Latitude" value = { region . lat } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , lat : v }
onChange ( { . . . data , regions : newRegions } )
} } step = { 0.0001 } / >
< NumberInput label = "Longitude" value = { region . lon } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , lon : v }
onChange ( { . . . data , regions : newRegions } )
} } step = { 0.0001 } / >
< / div >
< TextInput label = "Description" value = { region . description } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , description : v }
onChange ( { . . . data , regions : newRegions } )
} } / >
< ListInput label = "Aliases" value = { region . aliases } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , aliases : v }
onChange ( { . . . data , regions : newRegions } )
} } / >
< ListInput label = "Cities" value = { region . cities } onChange = { ( v ) = > {
const newRegions = [ . . . data . regions ]
newRegions [ i ] = { . . . region , cities : v }
onChange ( { . . . data , regions : newRegions } )
} } / >
< / div >
) }
< / div >
) ) }
< button
onClick = { ( ) = > {
const newRegion : RegionAnchor = {
name : '' ,
local_name : '' ,
lat : 0 ,
lon : 0 ,
description : '' ,
aliases : [ ] ,
cities : [ ] ,
}
onChange ( { . . . data , regions : [ . . . data . regions , newRegion ] } )
setExpandedRegion ( data . regions . length )
} }
className = "w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
< Plus size = { 16 } / > Add Region
< / button >
< / div >
< div className = "space-y-3" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Alert Rules
< InfoButton info = "Configure which conditions trigger alerts. Each rule can have an optional threshold value." / >
< / label >
< div className = "space-y-2" >
< h4 className = "text-xs text-slate-400 font-medium" > Infrastructure < / h4 >
< AlertRuleToggle
label = "Infra Offline"
description = "Alert when an infrastructure node (router/repeater) goes offline"
checked = { data . alert_rules . infra_offline }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , infra_offline : v } } ) }
/ >
< AlertRuleToggle
label = "Infra Recovery"
description = "Alert when an offline infrastructure node comes back online"
checked = { data . alert_rules . infra_recovery }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , infra_recovery : v } } ) }
/ >
< AlertRuleToggle
label = "New Router"
description = "Alert when a new router/repeater appears on the mesh"
checked = { data . alert_rules . new_router }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , new_router : v } } ) }
/ >
< AlertRuleToggle
label = "Feeder Offline"
description = "Alert when a data source (MeshView/MeshMonitor) stops responding"
checked = { data . alert_rules . feeder_offline }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , feeder_offline : v } } ) }
/ >
< AlertRuleToggle
label = "Single Gateway"
description = "Alert when an infrastructure node has only one connection path"
checked = { data . alert_rules . infra_single_gateway }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , infra_single_gateway : v } } ) }
/ >
< AlertRuleToggle
label = "Region Blackout"
description = "Alert when all infrastructure in a region goes offline"
checked = { data . alert_rules . region_total_blackout }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , region_total_blackout : v } } ) }
/ >
< / div >
< div className = "space-y-2" >
< h4 className = "text-xs text-slate-400 font-medium" > Power < / h4 >
< AlertRuleToggle
label = "Battery Warning"
description = "Alert when infra node battery drops below warning threshold"
checked = { data . alert_rules . battery_warning }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_warning : v } } ) }
threshold = { data . alert_rules . battery_warning_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_warning_threshold : v } } ) }
thresholdLabel = "Below"
thresholdMin = { 10 }
thresholdMax = { 90 }
thresholdSuffix = "%"
/ >
< AlertRuleToggle
label = "Battery Critical"
description = "Alert at critical battery level"
checked = { data . alert_rules . battery_critical }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_critical : v } } ) }
threshold = { data . alert_rules . battery_critical_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_critical_threshold : v } } ) }
thresholdLabel = "Below"
thresholdMin = { 5 }
thresholdMax = { 50 }
thresholdSuffix = "%"
/ >
< AlertRuleToggle
label = "Battery Emergency"
description = "Alert at emergency battery level"
checked = { data . alert_rules . battery_emergency }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_emergency : v } } ) }
threshold = { data . alert_rules . battery_emergency_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_emergency_threshold : v } } ) }
thresholdLabel = "Below"
thresholdMin = { 1 }
thresholdMax = { 25 }
thresholdSuffix = "%"
/ >
< AlertRuleToggle
label = "Battery Trend Declining"
description = "Alert when battery shows a declining trend over 7 days"
checked = { data . alert_rules . battery_trend_declining }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , battery_trend_declining : v } } ) }
/ >
< AlertRuleToggle
label = "Power Source Change"
description = "Alert when a node switches between battery and USB power"
checked = { data . alert_rules . power_source_change }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , power_source_change : v } } ) }
/ >
< AlertRuleToggle
label = "Solar Not Charging"
description = "Alert when a solar-powered node isn't charging during daylight"
checked = { data . alert_rules . solar_not_charging }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , solar_not_charging : v } } ) }
/ >
< / div >
< div className = "space-y-2" >
< h4 className = "text-xs text-slate-400 font-medium" > Utilization < / h4 >
< AlertRuleToggle
label = "High Utilization"
description = "Alert when channel utilization stays high for extended periods"
checked = { data . alert_rules . sustained_high_util }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , sustained_high_util : v } } ) }
threshold = { data . alert_rules . high_util_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , high_util_threshold : v } } ) }
thresholdLabel = "Above"
thresholdMin = { 5 }
thresholdMax = { 50 }
thresholdSuffix = { ` % for ${ data . alert_rules . high_util_hours } h ` }
/ >
< AlertRuleToggle
label = "Packet Flood"
description = "Alert when a single node sends excessive packets"
checked = { data . alert_rules . packet_flood }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , packet_flood : v } } ) }
threshold = { data . alert_rules . packet_flood_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , packet_flood_threshold : v } } ) }
thresholdLabel = "Over"
thresholdMin = { 100 }
thresholdMax = { 2000 }
thresholdSuffix = "pkts/24h"
/ >
< / div >
< div className = "space-y-2" >
< h4 className = "text-xs text-slate-400 font-medium" > Health Scores < / h4 >
< AlertRuleToggle
label = "Mesh Score Alert"
description = "Alert when overall mesh health score drops below threshold"
checked = { data . alert_rules . mesh_score_alert }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , mesh_score_alert : v } } ) }
threshold = { data . alert_rules . mesh_score_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , mesh_score_threshold : v } } ) }
thresholdLabel = "Below"
thresholdMin = { 30 }
thresholdMax = { 90 }
thresholdSuffix = "/100"
/ >
< AlertRuleToggle
label = "Region Score Alert"
description = "Alert when a region's health score drops below threshold"
checked = { data . alert_rules . region_score_alert }
onChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , region_score_alert : v } } ) }
threshold = { data . alert_rules . region_score_threshold }
onThresholdChange = { ( v ) = > onChange ( { . . . data , alert_rules : { . . . data . alert_rules , region_score_threshold : v } } ) }
thresholdLabel = "Below"
thresholdMin = { 30 }
thresholdMax = { 90 }
thresholdSuffix = "/100"
/ >
< / div >
< / div >
< / >
) }
< / div >
)
}
function EnvironmentalSection ( { data , onChange } : { data : EnvironmentalConfig ; onChange : ( d : EnvironmentalConfig ) = > void } ) {
return (
< div className = "space-y-6" >
< SectionDescription text = { SECTION_DESCRIPTIONS . environmental } / >
< Toggle
label = "Enable Environmental Feeds"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Activate live data polling"
/ >
{ data . enabled && (
< >
< ListInput
label = "NWS Zones"
value = { data . nws_zones }
onChange = { ( v ) = > onChange ( { . . . data , nws_zones : v } ) }
helper = "Zone IDs like IDZ016, IDZ030"
info = "NWS forecast zones covering your mesh area. Find yours at https://www.weather.gov/pimar/PubZone"
infoLink = "https://www.weather.gov/pimar/PubZone"
/ >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< span className = "text-sm font-medium text-slate-300" > NWS Weather Alerts < / span >
< Toggle label = "" checked = { data . nws . enabled } onChange = { ( v ) = > onChange ( { . . . data , nws : { . . . data . nws , enabled : v } } ) } / >
< / div >
{ data . nws . enabled && (
< >
< TextInput
label = "User Agent"
value = { data . nws . user_agent }
onChange = { ( v ) = > onChange ( { . . . data , nws : { . . . data . nws , user_agent : v } } ) }
placeholder = "(MeshAI, your@email.com)"
helper = "Required format: (app_name, contact_email)"
info = "Required by NWS. You make it up - just use the format (app_name, your_email). No signup needed."
/ >
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Tick Seconds"
value = { data . nws . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , nws : { . . . data . nws , tick_seconds : v } } ) }
min = { 30 }
helper = "Polling interval"
/ >
< SelectInput
label = "Min Severity"
value = { data . nws . severity_min }
onChange = { ( v ) = > onChange ( { . . . data , nws : { . . . data . nws , severity_min : v } } ) }
options = { [
{ value : 'minor' , label : 'Minor' } ,
{ value : 'moderate' , label : 'Moderate' } ,
{ value : 'severe' , label : 'Severe' } ,
{ value : 'extreme' , label : 'Extreme' } ,
] }
helper = "Filter out lower severity alerts"
info = "Minimum severity level to display. 'Moderate' filters out minor advisories. 'Severe' shows only serious warnings."
/ >
< / div >
< / >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > NOAA Space Weather ( SWPC ) < / span >
< p className = "text-xs text-slate-600" > Solar indices , geomagnetic storms , HF propagation < / p >
< / div >
< Toggle label = "" checked = { data . swpc . enabled } onChange = { ( v ) = > onChange ( { . . . data , swpc : { . . . data . swpc , enabled : v } } ) } / >
< / div >
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > Tropospheric Ducting < / span >
< p className = "text-xs text-slate-600" > VHF / UHF extended range conditions < / p >
< / div >
< Toggle label = "" checked = { data . ducting . enabled } onChange = { ( v ) = > onChange ( { . . . data , ducting : { . . . data . ducting , enabled : v } } ) } / >
< / div >
{ data . ducting . enabled && (
< div className = "grid grid-cols-3 gap-4" >
< NumberInput
label = "Tick Seconds"
value = { data . ducting . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , ducting : { . . . data . ducting , tick_seconds : v } } ) }
min = { 60 }
/ >
< NumberInput
label = "Latitude"
value = { data . ducting . latitude }
onChange = { ( v ) = > onChange ( { . . . data , ducting : { . . . data . ducting , latitude : v } } ) }
step = { 0.01 }
info = "Center point of your mesh coverage area. The ducting adapter checks atmospheric conditions at this location."
/ >
< NumberInput
label = "Longitude"
value = { data . ducting . longitude }
onChange = { ( v ) = > onChange ( { . . . data , ducting : { . . . data . ducting , longitude : v } } ) }
step = { 0.01 }
/ >
< / div >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > NIFC Fire Perimeters < / span >
< p className = "text-xs text-slate-600" > Active wildfires from National Interagency Fire Center < / p >
< / div >
< Toggle label = "" checked = { data . fires . enabled } onChange = { ( v ) = > onChange ( { . . . data , fires : { . . . data . fires , enabled : v } } ) } / >
< / div >
{ data . fires . enabled && (
< div className = "grid grid-cols-2 gap-4" >
< NumberInput
label = "Tick Seconds"
value = { data . fires . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , fires : { . . . data . fires , tick_seconds : v } } ) }
min = { 60 }
/ >
< SelectInput
label = "State"
value = { data . fires . state }
onChange = { ( v ) = > onChange ( { . . . data , fires : { . . . data . fires , state : v } } ) }
options = { US_STATES }
helper = "Filter fires by state"
info = "Two-letter state code for NIFC wildfire filtering."
/ >
< / div >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > Avalanche Advisories < / span >
< p className = "text-xs text-slate-600" > Backcountry avalanche danger ratings < / p >
< / div >
< Toggle label = "" checked = { data . avalanche . enabled } onChange = { ( v ) = > onChange ( { . . . data , avalanche : { . . . data . avalanche , enabled : v } } ) } / >
< / div >
{ data . avalanche . enabled && (
< >
< NumberInput
label = "Tick Seconds"
value = { data . avalanche . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , avalanche : { . . . data . avalanche , tick_seconds : v } } ) }
min = { 60 }
/ >
< ListInput
label = "Center IDs"
value = { data . avalanche . center_ids }
onChange = { ( v ) = > onChange ( { . . . data , avalanche : { . . . data . avalanche , center_ids : v } } ) }
helper = "e.g., SNFAC, IPAC, FAC"
info = "Find your local center at https://avalanche.org/avalanche-centers/"
infoLink = "https://avalanche.org/avalanche-centers/"
/ >
< NumberListInput
label = "Season Months"
value = { data . avalanche . season_months }
onChange = { ( v ) = > onChange ( { . . . data , avalanche : { . . . data . avalanche , season_months : v } } ) }
helper = "e.g., 12, 1, 2, 3, 4"
info = "Months when avalanche forecasts are active. Default Dec-Apr. Adjust for your region's season."
/ >
< / >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > USGS Stream Gauges < / span >
< p className = "text-xs text-slate-600" > River and stream water levels < / p >
< / div >
< Toggle label = "" checked = { data . usgs ? . enabled || false } onChange = { ( v ) = > onChange ( { . . . data , usgs : { . . . data . usgs , enabled : v , tick_seconds : data.usgs?.tick_seconds || 900 , sites : data.usgs?.sites || [ ] } } ) } / >
< / div >
{ data . usgs ? . enabled && (
< >
< NumberInput
label = "Tick Seconds"
value = { data . usgs . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , usgs : { . . . data . usgs , tick_seconds : v } } ) }
min = { 900 }
helper = "Minimum 15 min (900s)"
/ >
< ListInput
label = "Site IDs"
value = { data . usgs . sites }
onChange = { ( v ) = > onChange ( { . . . data , usgs : { . . . data . usgs , sites : v } } ) }
helper = "USGS gauge site numbers"
info = "Find site IDs at waterdata.usgs.gov/nwis"
infoLink = "https://waterdata.usgs.gov/nwis"
/ >
< / >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > TomTom Traffic < / span >
< p className = "text-xs text-slate-600" > Traffic flow on monitored corridors < / p >
< / div >
< Toggle label = "" checked = { data . traffic ? . enabled || false } onChange = { ( v ) = > onChange ( { . . . data , traffic : { . . . data . traffic , enabled : v , tick_seconds : data.traffic?.tick_seconds || 300 , api_key : data.traffic?.api_key || '' , corridors : data.traffic?.corridors || [ ] } } ) } / >
< / div >
{ data . traffic ? . enabled && (
< >
< TextInput
label = "API Key"
value = { data . traffic . api_key }
onChange = { ( v ) = > onChange ( { . . . data , traffic : { . . . data . traffic , api_key : v } } ) }
type = "password"
helper = "Get key at developer.tomtom.com"
infoLink = "https://developer.tomtom.com"
/ >
< NumberInput
label = "Tick Seconds"
value = { data . traffic . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , traffic : { . . . data . traffic , tick_seconds : v } } ) }
min = { 60 }
/ >
< div className = "text-xs text-slate-500 mt-2" > Corridors ( each with name , lat , lon ) : < / div >
{ ( data . traffic . corridors || [ ] ) . map ( ( c , i ) = > (
< div key = { i } className = "grid grid-cols-4 gap-2 items-end" >
< TextInput label = "Name" value = { c . name } onChange = { ( v ) = > {
const newCorridors = [ . . . data . traffic . corridors ]
newCorridors [ i ] = { . . . c , name : v }
onChange ( { . . . data , traffic : { . . . data . traffic , corridors : newCorridors } } )
} } / >
< NumberInput label = "Lat" value = { c . lat } onChange = { ( v ) = > {
const newCorridors = [ . . . data . traffic . corridors ]
newCorridors [ i ] = { . . . c , lat : v }
onChange ( { . . . data , traffic : { . . . data . traffic , corridors : newCorridors } } )
} } step = { 0.01 } / >
< NumberInput label = "Lon" value = { c . lon } onChange = { ( v ) = > {
const newCorridors = [ . . . data . traffic . corridors ]
newCorridors [ i ] = { . . . c , lon : v }
onChange ( { . . . data , traffic : { . . . data . traffic , corridors : newCorridors } } )
} } step = { 0.01 } / >
< button
onClick = { ( ) = > onChange ( { . . . data , traffic : { . . . data . traffic , corridors : data.traffic.corridors.filter ( ( _ , j ) = > j !== i ) } } ) }
className = "px-2 py-2 text-xs text-red-400 hover:text-red-300 border border-red-400/30 rounded"
> Remove < / button >
< / div >
) ) }
< button
onClick = { ( ) = > onChange ( { . . . data , traffic : { . . . data . traffic , corridors : [ . . . ( data . traffic . corridors || [ ] ) , { name : '' , lat : 0 , lon : 0 } ] } } ) }
className = "text-xs text-accent hover:underline"
> + Add Corridor < / button >
< / >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > 511 Road Conditions < / span >
< p className = "text-xs text-slate-600" > State DOT road events and closures < / p >
< / div >
< Toggle label = "" checked = { data . roads511 ? . enabled || false } onChange = { ( v ) = > onChange ( { . . . data , roads511 : { . . . data . roads511 , enabled : v , tick_seconds : data.roads511?.tick_seconds || 300 , api_key : data.roads511?.api_key || '' , base_url : data.roads511?.base_url || '' , endpoints : data.roads511?.endpoints || [ '/get/event' ] , bbox : data.roads511?.bbox || [ ] } } ) } / >
< / div >
{ data . roads511 ? . enabled && (
< >
< TextInput
label = "Base URL"
value = { data . roads511 . base_url }
onChange = { ( v ) = > onChange ( { . . . data , roads511 : { . . . data . roads511 , base_url : v } } ) }
placeholder = "https://511.yourstate.gov/api/v2"
helper = "State 511 API endpoint"
/ >
< TextInput
label = "API Key"
value = { data . roads511 . api_key }
onChange = { ( v ) = > onChange ( { . . . data , roads511 : { . . . data . roads511 , api_key : v } } ) }
type = "password"
helper = "Leave empty if not required"
/ >
< NumberInput
label = "Tick Seconds"
value = { data . roads511 . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , roads511 : { . . . data . roads511 , tick_seconds : v } } ) }
min = { 60 }
/ >
< ListInput
label = "Endpoints"
value = { data . roads511 . endpoints }
onChange = { ( v ) = > onChange ( { . . . data , roads511 : { . . . data . roads511 , endpoints : v } } ) }
helper = "e.g., /get/event, /get/mountainpasses"
/ >
< div className = "grid grid-cols-4 gap-2" >
< NumberInput label = "West" value = { data . roads511 . bbox ? . [ 0 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . roads511 . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 0 ] = v
onChange ( { . . . data , roads511 : { . . . data . roads511 , bbox } } )
} } step = { 0.01 } / >
< NumberInput label = "South" value = { data . roads511 . bbox ? . [ 1 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . roads511 . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 1 ] = v
onChange ( { . . . data , roads511 : { . . . data . roads511 , bbox } } )
} } step = { 0.01 } / >
< NumberInput label = "East" value = { data . roads511 . bbox ? . [ 2 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . roads511 . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 2 ] = v
onChange ( { . . . data , roads511 : { . . . data . roads511 , bbox } } )
} } step = { 0.01 } / >
< NumberInput label = "North" value = { data . roads511 . bbox ? . [ 3 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . roads511 . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 3 ] = v
onChange ( { . . . data , roads511 : { . . . data . roads511 , bbox } } )
} } step = { 0.01 } / >
< / div >
< div className = "text-xs text-slate-500" > Bounding box filter ( leave all 0 to disable ) < / div >
< / >
) }
< / div >
< div className = "border border-[#1e2a3a] rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< div >
< span className = "text-sm font-medium text-slate-300" > NASA FIRMS Satellite Fire Detection < / span >
< p className = "text-xs text-slate-600" > Near real - time thermal anomalies from satellites < / p >
< / div >
< Toggle label = "" checked = { data . firms ? . enabled || false } onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , enabled : v , tick_seconds : data.firms?.tick_seconds || 1800 , map_key : data.firms?.map_key || '' , source : data.firms?.source || 'VIIRS_SNPP_NRT' , bbox : data.firms?.bbox || [ ] , day_range : data.firms?.day_range || 1 , confidence_min : data.firms?.confidence_min || 'nominal' , proximity_km : data.firms?.proximity_km || 10 } } ) } / >
< / div >
{ data . firms ? . enabled && (
< >
< TextInput
label = "MAP Key"
value = { data . firms . map_key }
onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , map_key : v } } ) }
type = "password"
helper = "Get key at firms.modaps.eosdis.nasa.gov/api/area/"
infoLink = "https://firms.modaps.eosdis.nasa.gov/api/area/"
/ >
< NumberInput
label = "Tick Seconds"
value = { data . firms . tick_seconds }
onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , tick_seconds : v } } ) }
min = { 300 }
helper = "Minimum 5 min (300s)"
/ >
< SelectInput
label = "Satellite Source"
value = { data . firms . source }
onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , source : v } } ) }
options = { [
{ value : 'VIIRS_SNPP_NRT' , label : 'VIIRS SNPP (Near Real-Time)' } ,
{ value : 'VIIRS_NOAA20_NRT' , label : 'VIIRS NOAA-20 (Near Real-Time)' } ,
{ value : 'MODIS_NRT' , label : 'MODIS (Near Real-Time)' } ,
] }
/ >
< NumberInput
label = "Day Range"
value = { data . firms . day_range }
onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , day_range : v } } ) }
min = { 1 }
max = { 10 }
helper = "1-10 days of data"
/ >
< SelectInput
label = "Minimum Confidence"
value = { data . firms . confidence_min }
onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , confidence_min : v } } ) }
options = { [
{ value : 'low' , label : 'Low' } ,
{ value : 'nominal' , label : 'Nominal' } ,
{ value : 'high' , label : 'High' } ,
] }
/ >
< NumberInput
label = "Proximity (km)"
value = { data . firms . proximity_km }
onChange = { ( v ) = > onChange ( { . . . data , firms : { . . . data . firms , proximity_km : v } } ) }
step = { 0.5 }
helper = "Distance to match known fires"
/ >
< div className = "grid grid-cols-4 gap-2" >
< NumberInput label = "West" value = { data . firms . bbox ? . [ 0 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . firms . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 0 ] = v
onChange ( { . . . data , firms : { . . . data . firms , bbox } } )
} } step = { 0.01 } / >
< NumberInput label = "South" value = { data . firms . bbox ? . [ 1 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . firms . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 1 ] = v
onChange ( { . . . data , firms : { . . . data . firms , bbox } } )
} } step = { 0.01 } / >
< NumberInput label = "East" value = { data . firms . bbox ? . [ 2 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . firms . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 2 ] = v
onChange ( { . . . data , firms : { . . . data . firms , bbox } } )
} } step = { 0.01 } / >
< NumberInput label = "North" value = { data . firms . bbox ? . [ 3 ] || 0 } onChange = { ( v ) = > {
const bbox = [ . . . ( data . firms . bbox || [ 0 , 0 , 0 , 0 ] ) ]
bbox [ 3 ] = v
onChange ( { . . . data , firms : { . . . data . firms , bbox } } )
} } step = { 0.01 } / >
< / div >
< div className = "text-xs text-slate-500" > Bounding box for monitoring area ( required ) < / div >
< / >
) }
< / div >
< / >
) }
< / div >
)
}
2026-05-13 04:47:42 +00:00
// Notification Channel Card Component
function NotificationChannelCard ( {
channel ,
onChange ,
onDelete ,
onTest ,
} : {
channel : NotificationChannelConfig
onChange : ( c : NotificationChannelConfig ) = > void
onDelete : ( ) = > void
onTest : ( ) = > void
} ) {
const [ expanded , setExpanded ] = useState ( false )
const [ testing , setTesting ] = useState ( false )
const typeOptions = [
{ value : 'mesh_broadcast' , label : 'Mesh Broadcast' } ,
{ value : 'mesh_dm' , label : 'Mesh DM' } ,
{ value : 'email' , label : 'Email' } ,
{ value : 'webhook' , label : 'Webhook' } ,
]
const typeDescriptions : Record < string , string > = {
mesh_broadcast : 'Broadcast alerts to a mesh channel. All nodes on that channel receive the alert.' ,
mesh_dm : 'Send alerts as direct messages to specific nodes.' ,
email : 'Send alert emails via SMTP. Works with Gmail, Outlook, and any SMTP server.' ,
webhook : 'POST alert JSON to any URL. Works with Discord webhooks, ntfy.sh, Pushover, Slack, Home Assistant, or any service that accepts HTTP POST.' ,
}
const handleTest = async ( ) = > {
setTesting ( true )
await onTest ( )
setTesting ( false )
}
return (
< div className = "border border-[#1e2a3a] rounded-lg overflow-hidden" >
< div
className = "flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick = { ( ) = > setExpanded ( ! expanded ) }
>
< div className = "flex items-center gap-3" >
{ expanded ? < ChevronDown size = { 16 } / > : < ChevronRight size = { 16 } / > }
< div className = { ` w-2 h-2 rounded-full ${ channel . enabled ? 'bg-green-500' : 'bg-slate-500' } ` } / >
< span className = "font-medium text-slate-200" > { channel . id || 'New Channel' } < / span >
< span className = "text-xs text-slate-500 bg-[#1e2a3a] px-2 py-0.5 rounded" >
{ typeOptions . find ( t = > t . value === channel . type ) ? . label || channel . type }
< / span >
< / div >
< div className = "flex items-center gap-2" >
< button
onClick = { ( e ) = > { e . stopPropagation ( ) ; handleTest ( ) } }
disabled = { testing }
className = "p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50"
title = "Send test alert"
>
< Send size = { 14 } / >
< / button >
< button
onClick = { ( e ) = > { e . stopPropagation ( ) ; onDelete ( ) } }
className = "p-1 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
< Trash2 size = { 14 } / >
< / button >
< / div >
< / div >
{ expanded && (
< div className = "p-4 space-y-4 border-t border-[#1e2a3a]" >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "Channel ID"
value = { channel . id }
onChange = { ( v ) = > onChange ( { . . . channel , id : v } ) }
helper = "Unique identifier for this channel"
info = "Used to reference this channel in notification rules. Use lowercase with hyphens (e.g., 'mesh-main', 'email-admin')."
/ >
< SelectInput
label = "Type"
value = { channel . type }
onChange = { ( v ) = > onChange ( { . . . channel , type : v } ) }
options = { typeOptions }
info = { typeDescriptions [ channel . type ] || 'Select a channel type' }
/ >
< / div >
< Toggle
label = "Enabled"
checked = { channel . enabled }
onChange = { ( v ) = > onChange ( { . . . channel , enabled : v } ) }
helper = "Disable to temporarily stop alerts on this channel"
/ >
{ /* Mesh Broadcast fields */ }
{ channel . type === 'mesh_broadcast' && (
< NumberInput
label = "Channel Index"
value = { channel . channel_index }
onChange = { ( v ) = > onChange ( { . . . channel , channel_index : v } ) }
min = { 0 }
max = { 7 }
helper = "Mesh channel number (0-7)"
info = "The mesh channel to broadcast alerts on. Channel 0 is typically the default channel."
/ >
) }
{ /* Mesh DM fields */ }
{ channel . type === 'mesh_dm' && (
< ListInput
label = "Node IDs"
value = { channel . node_ids }
onChange = { ( v ) = > onChange ( { . . . channel , node_ids : v } ) }
helper = "Node IDs to receive DM alerts"
info = "Node IDs that receive direct message alerts. Enter the full node ID (e.g., '!a1b2c3d4') for each recipient."
/ >
) }
{ /* Email fields */ }
{ channel . type === 'email' && (
< >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "SMTP Host"
value = { channel . smtp_host }
onChange = { ( v ) = > onChange ( { . . . channel , smtp_host : v } ) }
placeholder = "smtp.gmail.com"
helper = "SMTP server hostname"
info = "The SMTP server for sending emails. Gmail: smtp.gmail.com, Outlook: smtp.office365.com"
/ >
< NumberInput
label = "SMTP Port"
value = { channel . smtp_port }
onChange = { ( v ) = > onChange ( { . . . channel , smtp_port : v } ) }
min = { 1 }
max = { 65535 }
helper = "587 (TLS) or 465 (SSL)"
info = "SMTP port. Use 587 for TLS (recommended) or 465 for SSL."
/ >
< / div >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "SMTP User"
value = { channel . smtp_user }
onChange = { ( v ) = > onChange ( { . . . channel , smtp_user : v } ) }
placeholder = "you@gmail.com"
helper = "Login username"
/ >
< TextInput
label = "SMTP Password"
value = { channel . smtp_password }
onChange = { ( v ) = > onChange ( { . . . channel , smtp_password : v } ) }
type = "password"
helper = "App password recommended"
info = "SMTP server for sending alert emails. Gmail users: use an App Password, not your regular password. Generate one at myaccount.google.com/apppasswords"
/ >
< / div >
< Toggle
label = "Use TLS"
checked = { channel . smtp_tls }
onChange = { ( v ) = > onChange ( { . . . channel , smtp_tls : v } ) }
helper = "Encrypt SMTP connection"
info = "Enable TLS encryption for the SMTP connection. Required for most modern email servers."
/ >
< TextInput
label = "From Address"
value = { channel . from_address }
onChange = { ( v ) = > onChange ( { . . . channel , from_address : v } ) }
placeholder = "alerts@yourdomain.com"
helper = "Sender email address"
info = "The email address that appears as the sender. Some servers require this to match your login."
/ >
< ListInput
label = "Recipients"
value = { channel . recipients }
onChange = { ( v ) = > onChange ( { . . . channel , recipients : v } ) }
helper = "Email addresses to receive alerts"
info = "List of email addresses that will receive alerts from this channel."
/ >
< / >
) }
{ /* Webhook fields */ }
{ channel . type === 'webhook' && (
< >
< TextInput
label = "Webhook URL"
value = { channel . url }
onChange = { ( v ) = > onChange ( { . . . channel , url : v } ) }
placeholder = "https://discord.com/api/webhooks/..."
helper = "POST endpoint for alerts"
info = "POST alert JSON to any URL. Works with Discord webhooks, ntfy.sh, Pushover, Slack, Home Assistant, or any service that accepts HTTP POST."
/ >
< div className = "space-y-1" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Headers ( optional )
< InfoButton info = "Additional HTTP headers to send with the webhook request. Useful for authentication tokens or custom headers required by the receiving service." / >
< / label >
< div className = "text-xs text-slate-600" >
Custom headers can be configured in the YAML config file
< / div >
< / div >
< / >
) }
< / div >
) }
< / div >
)
}
// Notification Rule Card Component
function NotificationRuleCard ( {
rule ,
categories ,
channels ,
onChange ,
onDelete ,
} : {
rule : NotificationRuleConfig
categories : AlertCategory [ ]
channels : NotificationChannelConfig [ ]
onChange : ( r : NotificationRuleConfig ) = > void
onDelete : ( ) = > void
} ) {
const [ expanded , setExpanded ] = useState ( false )
const severityOptions = [
{ value : 'info' , label : 'Info' } ,
{ value : 'advisory' , label : 'Advisory' } ,
{ value : 'watch' , label : 'Watch' } ,
{ value : 'warning' , label : 'Warning' } ,
{ value : 'critical' , label : 'Critical' } ,
{ value : 'emergency' , label : 'Emergency' } ,
]
const toggleCategory = ( catId : string ) = > {
const current = rule . categories || [ ]
if ( current . includes ( catId ) ) {
onChange ( { . . . rule , categories : current.filter ( c = > c !== catId ) } )
} else {
onChange ( { . . . rule , categories : [ . . . current , catId ] } )
}
}
const toggleChannel = ( channelId : string ) = > {
const current = rule . channel_ids || [ ]
if ( current . includes ( channelId ) ) {
onChange ( { . . . rule , channel_ids : current.filter ( c = > c !== channelId ) } )
} else {
onChange ( { . . . rule , channel_ids : [ . . . current , channelId ] } )
}
}
return (
< div className = "border border-[#1e2a3a] rounded-lg overflow-hidden" >
< div
className = "flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick = { ( ) = > setExpanded ( ! expanded ) }
>
< div className = "flex items-center gap-3" >
{ expanded ? < ChevronDown size = { 16 } / > : < ChevronRight size = { 16 } / > }
< span className = "font-medium text-slate-200" > { rule . name || 'New Rule' } < / span >
< span className = "text-xs text-slate-500" >
{ rule . categories ? . length || 0 } categories → { rule . channel_ids ? . length || 0 } channels
< / span >
< / div >
< button
onClick = { ( e ) = > { e . stopPropagation ( ) ; onDelete ( ) } }
className = "p-1 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
< Trash2 size = { 14 } / >
< / button >
< / div >
{ expanded && (
< div className = "p-4 space-y-4 border-t border-[#1e2a3a]" >
< TextInput
label = "Rule Name"
value = { rule . name }
onChange = { ( v ) = > onChange ( { . . . rule , name : v } ) }
helper = "Human-readable name for this rule"
info = "A descriptive name to identify this rule. Example: 'Emergency Alerts', 'Fire Notifications', 'Infrastructure Warnings'"
/ >
< SelectInput
label = "Minimum Severity"
value = { rule . min_severity }
onChange = { ( v ) = > onChange ( { . . . rule , min_severity : v } ) }
options = { severityOptions }
helper = "Only alerts at or above this severity"
info = "Only alerts at this severity or above will trigger this rule. 'warning' is recommended for most channels. Use 'info' to receive all alerts."
/ >
< div className = "space-y-2" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Alert Categories
< InfoButton info = "Which alert types this rule applies to. Select none to match all categories. Alerts matching any selected category (AND meeting severity threshold) will trigger this rule." / >
< / label >
< div className = "text-xs text-slate-500 mb-2" >
{ rule . categories ? . length === 0 ? 'All categories (none selected)' : ` ${ rule . categories ? . length } selected ` }
< / div >
< div className = "max-h-48 overflow-y-auto border border-[#1e2a3a] rounded-lg p-2 space-y-1" >
{ categories . map ( ( cat ) = > (
< label
key = { cat . id }
className = "flex items-start gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
< input
type = "checkbox"
checked = { rule . categories ? . includes ( cat . id ) || false }
onChange = { ( ) = > toggleCategory ( cat . id ) }
className = "mt-0.5 rounded border-slate-600 bg-[#0a0e17] text-accent focus:ring-accent"
/ >
< div className = "flex-1 min-w-0" >
< div className = "text-sm text-slate-200" > { cat . name } < / div >
< div className = "text-xs text-slate-500" > { cat . description } < / div >
< / div >
< / label >
) ) }
< / div >
< / div >
< div className = "space-y-2" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Delivery Channels
< InfoButton info = "Which channels receive alerts matching this rule. Select at least one channel." / >
< / label >
{ channels . length === 0 ? (
< div className = "text-xs text-slate-500 p-2 border border-[#1e2a3a] rounded-lg" >
No channels configured . Add channels above first .
< / div >
) : (
< div className = "border border-[#1e2a3a] rounded-lg p-2 space-y-1" >
{ channels . map ( ( ch ) = > (
< label
key = { ch . id }
className = "flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
< input
type = "checkbox"
checked = { rule . channel_ids ? . includes ( ch . id ) || false }
onChange = { ( ) = > toggleChannel ( ch . id ) }
className = "rounded border-slate-600 bg-[#0a0e17] text-accent focus:ring-accent"
/ >
< span className = "text-sm text-slate-200" > { ch . id } < / span >
< span className = "text-xs text-slate-500" > ( { ch . type } ) < / span >
< / label >
) ) }
< / div >
) }
< / div >
< Toggle
label = "Override Quiet Hours"
checked = { rule . override_quiet }
onChange = { ( v ) = > onChange ( { . . . rule , override_quiet : v } ) }
helper = "Send alerts even during quiet hours"
info = "When enabled, this rule sends alerts even during quiet hours. Use for critical conditions like fires or infrastructure failures."
/ >
< / div >
) }
< / div >
)
}
// Main Notifications Section Component
function NotificationsSection ( { data , onChange } : { data : NotificationsConfig ; onChange : ( d : NotificationsConfig ) = > void } ) {
const [ categories , setCategories ] = useState < AlertCategory [ ] > ( [ ] )
const [ testResult , setTestResult ] = useState < { success : boolean ; message : string } | null > ( null )
// Fetch categories on mount
useEffect ( ( ) = > {
fetch ( '/api/notifications/categories' )
. then ( res = > res . json ( ) )
. then ( setCategories )
. catch ( ( ) = > setCategories ( [ ] ) )
} , [ ] )
const addChannel = ( ) = > {
const newChannel : NotificationChannelConfig = {
id : '' ,
type : 'mesh_broadcast' ,
enabled : true ,
channel_index : 0 ,
node_ids : [ ] ,
smtp_host : '' ,
smtp_port : 587 ,
smtp_user : '' ,
smtp_password : '' ,
smtp_tls : true ,
from_address : '' ,
recipients : [ ] ,
url : '' ,
headers : { } ,
}
onChange ( { . . . data , channels : [ . . . ( data . channels || [ ] ) , newChannel ] } )
}
const addRule = ( ) = > {
const newRule : NotificationRuleConfig = {
name : '' ,
categories : [ ] ,
min_severity : 'warning' ,
channel_ids : [ ] ,
override_quiet : false ,
}
onChange ( { . . . data , rules : [ . . . ( data . rules || [ ] ) , newRule ] } )
}
const testChannel = async ( channelId : string ) = > {
try {
const res = await fetch ( ` /api/notifications/channels/ ${ channelId } /test ` , { method : 'POST' } )
const result = await res . json ( )
setTestResult ( result )
setTimeout ( ( ) = > setTestResult ( null ) , 5000 )
} catch {
setTestResult ( { success : false , message : 'Test failed' } )
setTimeout ( ( ) = > setTestResult ( null ) , 5000 )
}
}
return (
< div className = "space-y-6" >
< SectionDescription text = { SECTION_DESCRIPTIONS . notifications } / >
< Toggle
label = "Enable Notifications"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Master switch for all notification delivery"
info = "When disabled, no alerts will be delivered through any channel. The alert engine still runs and records alerts to history."
/ >
{ testResult && (
< div className = { ` p-3 rounded-lg text-sm ${ testResult . success ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20' } ` } >
{ testResult . success ? < Check size = { 14 } className = "inline mr-2" / > : < X size = { 14 } className = "inline mr-2" / > }
{ testResult . message }
< / div >
) }
{ data . enabled && (
< >
{ /* Channels Section */ }
< div className = "space-y-3" >
< div className = "flex items-center justify-between" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Channels
< InfoButton info = "Where alerts get delivered. Add channels for each destination you want to receive alerts." / >
< / label >
< / div >
< p className = "text-sm text-slate-500 -mt-1" >
Where alerts get delivered . Add channels for each destination you want to receive alerts .
< / p >
{ ( data . channels || [ ] ) . map ( ( channel , i ) = > (
< NotificationChannelCard
key = { i }
channel = { channel }
onChange = { ( c ) = > {
const newChannels = [ . . . ( data . channels || [ ] ) ]
newChannels [ i ] = c
onChange ( { . . . data , channels : newChannels } )
} }
onDelete = { ( ) = > {
if ( confirm ( ` Delete channel " ${ channel . id || 'New Channel' } "? ` ) ) {
onChange ( { . . . data , channels : ( data . channels || [ ] ) . filter ( ( _ , j ) = > j !== i ) } )
}
} }
onTest = { ( ) = > testChannel ( channel . id ) }
/ >
) ) }
< button
onClick = { addChannel }
className = "w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
< Plus size = { 16 } / > Add Channel
< / button >
< / div >
{ /* Rules Section */ }
< div className = "space-y-3" >
< div className = "flex items-center justify-between" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Rules
< InfoButton info = "Rules connect alert categories to delivery channels. When a condition matches a rule, the alert is sent to all channels in that rule." / >
< / label >
< / div >
< p className = "text-sm text-slate-500 -mt-1" >
Rules connect alert categories to delivery channels . When a condition matches a rule , the alert is sent to all channels in that rule .
< / p >
{ ( data . rules || [ ] ) . map ( ( rule , i ) = > (
< NotificationRuleCard
key = { i }
rule = { rule }
categories = { categories }
channels = { data . channels || [ ] }
onChange = { ( r ) = > {
const newRules = [ . . . ( data . rules || [ ] ) ]
newRules [ i ] = r
onChange ( { . . . data , rules : newRules } )
} }
onDelete = { ( ) = > {
if ( confirm ( ` Delete rule " ${ rule . name || 'New Rule' } "? ` ) ) {
onChange ( { . . . data , rules : ( data . rules || [ ] ) . filter ( ( _ , j ) = > j !== i ) } )
}
} }
/ >
) ) }
< button
onClick = { addRule }
className = "w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
< Plus size = { 16 } / > Add Rule
< / button >
< / div >
{ /* Quiet Hours Section */ }
< div className = "space-y-3" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Quiet Hours
< InfoButton info = "Suppress non-emergency alerts during sleeping hours. Emergency and critical alerts always get through. Rules with 'Override Quiet Hours' enabled will also deliver during this time." / >
< / label >
< p className = "text-sm text-slate-500 -mt-1" >
Suppress non - emergency alerts during sleeping hours . Emergency and critical alerts always get through .
< / p >
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "Start Time"
value = { data . quiet_hours_start || '22:00' }
onChange = { ( v ) = > onChange ( { . . . data , quiet_hours_start : v } ) }
placeholder = "22:00"
helper = "When quiet hours begin"
info = "Time in 24-hour format (HH:MM) when quiet hours start. Alerts below emergency severity will be held until quiet hours end."
/ >
< TextInput
label = "End Time"
value = { data . quiet_hours_end || '06:00' }
onChange = { ( v ) = > onChange ( { . . . data , quiet_hours_end : v } ) }
placeholder = "06:00"
helper = "When quiet hours end"
info = "Time in 24-hour format (HH:MM) when quiet hours end. Held alerts will be delivered at this time."
/ >
< / div >
< / div >
{ /* Dedup Section */ }
< div className = "space-y-3" >
< label className = "flex items-center text-xs text-slate-500 uppercase tracking-wide" >
Deduplication
< InfoButton info = "Prevents alert spam. If the same condition fires multiple times within this window, only the first one is delivered." / >
< / label >
< NumberInput
label = "Dedup Window (seconds)"
value = { data . dedup_seconds || 600 }
onChange = { ( v ) = > onChange ( { . . . data , dedup_seconds : v } ) }
min = { 0 }
max = { 86400 }
helper = "Don't re-send the same alert within this window"
info = "Prevents alert spam. If the same condition fires multiple times within this window, only the first one is delivered. Default is 600 seconds (10 minutes)."
/ >
< / div >
< / >
) }
< / div >
)
}
2026-05-13 04:26:17 +00:00
function DashboardSection ( { data , onChange } : { data : DashboardConfig ; onChange : ( d : DashboardConfig ) = > void } ) {
return (
< div className = "space-y-4" >
< SectionDescription text = { SECTION_DESCRIPTIONS . dashboard } / >
< Toggle
label = "Enable Dashboard"
checked = { data . enabled }
onChange = { ( v ) = > onChange ( { . . . data , enabled : v } ) }
helper = "Run the web dashboard"
/ >
{ data . enabled && (
< div className = "grid grid-cols-2 gap-4" >
< TextInput
label = "Host"
value = { data . host }
onChange = { ( v ) = > onChange ( { . . . data , host : v } ) }
placeholder = "0.0.0.0"
helper = "Network bind address"
info = "0.0.0.0 = accessible from any device on the network. 127.0.0.1 = only accessible from this machine."
/ >
< NumberInput
label = "Port"
value = { data . port }
onChange = { ( v ) = > onChange ( { . . . data , port : v } ) }
min = { 1 }
max = { 65535 }
helper = "Dashboard URL port"
info = "Port number for the web dashboard URL. You access the dashboard at http://your-ip:port"
/ >
< / div >
) }
< / div >
)
}
export default function Config() {
const [ config , setConfig ] = useState < FullConfig | null > ( null )
const [ originalConfig , setOriginalConfig ] = useState < FullConfig | null > ( null )
const [ activeSection , setActiveSection ] = useState < SectionKey > ( 'bot' )
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 [ restartRequired , setRestartRequired ] = useState ( false )
const [ hasChanges , setHasChanges ] = useState ( false )
const fetchConfig = useCallback ( async ( ) = > {
try {
const res = await fetch ( '/api/config' )
if ( ! res . ok ) throw new Error ( 'Failed to fetch config' )
const data = await res . json ( )
setConfig ( data )
setOriginalConfig ( JSON . parse ( JSON . stringify ( data ) ) )
setHasChanges ( false )
setError ( null )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Unknown error' )
} finally {
setLoading ( false )
}
} , [ ] )
useEffect ( ( ) = > {
document . title = 'Config — MeshAI'
fetchConfig ( )
} , [ fetchConfig ] )
useEffect ( ( ) = > {
if ( config && originalConfig ) {
setHasChanges ( JSON . stringify ( config ) !== JSON . stringify ( originalConfig ) )
}
} , [ config , originalConfig ] )
const saveSection = async ( ) = > {
if ( ! config ) return
setSaving ( true )
setError ( null )
setSuccess ( null )
try {
const sectionData = config [ activeSection ]
const res = await fetch ( ` /api/config/ ${ activeSection } ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( sectionData ) ,
} )
const result = await res . json ( )
if ( ! res . ok ) {
throw new Error ( result . detail || 'Save failed' )
}
setSuccess ( ` ${ activeSection } saved successfully ` )
setOriginalConfig ( JSON . parse ( JSON . stringify ( config ) ) )
setHasChanges ( false )
if ( result . restart_required ) {
setRestartRequired ( true )
}
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 restartService = async ( ) = > {
try {
await fetch ( '/api/restart' , { method : 'POST' } )
setRestartRequired ( false )
setSuccess ( 'Restart initiated' )
} catch {
setError ( 'Restart failed' )
}
}
const updateSection = < K extends SectionKey > ( section : K , data : FullConfig [ K ] ) = > {
if ( ! config ) return
setConfig ( { . . . config , [ section ] : data } )
}
if ( loading ) {
return (
< div className = "flex items-center justify-center h-64" >
< div className = "text-slate-400" > Loading configuration . . . < / div >
< / div >
)
}
if ( ! config ) {
return (
< div className = "flex items-center justify-center h-64" >
< div className = "text-red-400" > Failed to load configuration < / div >
< / div >
)
}
const renderSection = ( ) = > {
switch ( activeSection ) {
case 'bot' : return < BotSection data = { config . bot } onChange = { ( d ) = > updateSection ( 'bot' , d ) } / >
case 'connection' : return < ConnectionSection data = { config . connection } onChange = { ( d ) = > updateSection ( 'connection' , d ) } / >
case 'response' : return < ResponseSection data = { config . response } onChange = { ( d ) = > updateSection ( 'response' , d ) } / >
case 'history' : return < HistorySection data = { config . history } onChange = { ( d ) = > updateSection ( 'history' , d ) } / >
case 'memory' : return < MemorySection data = { config . memory } onChange = { ( d ) = > updateSection ( 'memory' , d ) } / >
case 'context' : return < ContextSection data = { config . context } onChange = { ( d ) = > updateSection ( 'context' , d ) } / >
case 'commands' : return < CommandsSection data = { config . commands } onChange = { ( d ) = > updateSection ( 'commands' , d ) } / >
case 'llm' : return < LLMSection data = { config . llm } onChange = { ( d ) = > updateSection ( 'llm' , d ) } / >
case 'weather' : return < WeatherSection data = { config . weather } onChange = { ( d ) = > updateSection ( 'weather' , d ) } / >
case 'meshmonitor' : return < MeshMonitorSection data = { config . meshmonitor } onChange = { ( d ) = > updateSection ( 'meshmonitor' , d ) } / >
case 'knowledge' : return < KnowledgeSection data = { config . knowledge } onChange = { ( d ) = > updateSection ( 'knowledge' , d ) } / >
case 'mesh_sources' : return < MeshSourcesSection data = { config . mesh_sources } onChange = { ( d ) = > updateSection ( 'mesh_sources' , d ) } / >
case 'mesh_intelligence' : return < MeshIntelligenceSection data = { config . mesh_intelligence } onChange = { ( d ) = > updateSection ( 'mesh_intelligence' , d ) } / >
case 'environmental' : return < EnvironmentalSection data = { config . environmental } onChange = { ( d ) = > updateSection ( 'environmental' , d ) } / >
2026-05-13 04:47:42 +00:00
case 'notifications' : return < NotificationsSection data = { config . notifications } onChange = { ( d ) = > updateSection ( 'notifications' , d ) } / >
2026-05-13 04:26:17 +00:00
case 'dashboard' : return < DashboardSection data = { config . dashboard } onChange = { ( d ) = > updateSection ( 'dashboard' , d ) } / >
default : return null
}
}
const activeLabel = SECTIONS . find ( s = > s . key === activeSection ) ? . label || activeSection
return (
< div className = "flex gap-6 h-[calc(100vh-8rem)]" >
< div className = "w-48 flex-shrink-0 space-y-1" >
{ SECTIONS . map ( ( { key , label , icon : Icon } ) = > (
< button
key = { key }
onClick = { ( ) = > setActiveSection ( key ) }
className = { ` w-full flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
activeSection === key
? 'bg-accent text-white'
: 'text-slate-400 hover:text-slate-200 hover:bg-bg-hover'
} ` }
>
< Icon size = { 16 } / >
< span > { label } < / span >
{ hasChanges && activeSection === key && (
< span className = "ml-auto w-2 h-2 bg-amber-500 rounded-full" / >
) }
< / button >
) ) }
< / div >
< div className = "flex-1 flex flex-col min-w-0" >
< div className = "flex items-center justify-between mb-6" >
< div className = "flex items-center gap-3" >
< Settings size = { 20 } className = "text-slate-500" / >
< h2 className = "text-lg font-semibold text-slate-200" > { activeLabel } < / h2 >
< / div >
< div className = "flex items-center gap-2" >
{ hasChanges && (
< button
onClick = { discardChanges }
className = "flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 hover:text-slate-200 bg-bg-hover rounded transition-colors"
>
< RotateCcw size = { 14 } / >
Discard
< / button >
) }
< button
onClick = { saveSection }
disabled = { saving || ! hasChanges }
className = "flex items-center gap-1.5 px-4 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{ saving ? < RefreshCw size = { 14 } className = "animate-spin" / > : < Save size = { 14 } / > }
Save
< / button >
< / div >
< / div >
{ restartRequired && (
< div className = "flex items-center justify-between p-3 mb-4 bg-amber-500/10 border border-amber-500/30 rounded-lg" >
< div className = "flex items-center gap-2 text-amber-400" >
< AlertTriangle size = { 16 } / >
< span className = "text-sm" > Restart required for changes to take effect < / span >
< / div >
< button
onClick = { restartService }
className = "px-3 py-1 text-sm bg-amber-500 text-white rounded hover:bg-amber-600 transition-colors"
>
Restart Now
< / button >
< / div >
) }
{ error && (
< div className = "flex items-center gap-2 p-3 mb-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400" >
< X size = { 16 } / >
< span className = "text-sm" > { error } < / span >
< / div >
) }
{ success && (
< div className = "flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400" >
< Check size = { 16 } / >
< span className = "text-sm" > { success } < / span >
< / div >
) }
< div className = "flex-1 overflow-y-auto pr-2" >
< div className = "bg-bg-card border border-border rounded-lg p-6" >
{ renderSection ( ) }
< / div >
< / div >
< / div >
< / div >
)
}