feat(gui): surface remaining config in the dashboard — 100% GUI coverage (#48)

Add bound GUI controls for every remaining user-facing + internal config
setting so the dashboard is the complete config surface (secrets stay in
.env via the ManagedSecret widgets). Per the exhaustive per-key audit.

19 GAP controls (real settings with no prior control):
- Config: global timezone (+ backend: timezone in VALID_SECTIONS + scalar
  save_section branch), commands.custom_commands (kv editor), knowledge
  sparse_host/port, alert_rules.high_util_hours
- Data Feeds: nws.areas, usgs.flood_thresholds (json), usgs_quake
  feed_url/min_magnitude(native floor)/bbox, wzdx.registry_ttl,
  satpass.broadcast_lead_seconds, central.connect_timeout,
  toggles.<family>.regions (un-hidden + save-merge fix)
- Notifications: band_conditions_tz, digest.schedule, digest.include
- Danger Zones: snow.enabled + buffer_mi (unlocked)

18 INTERNAL knobs (Advanced subsections):
- connection reconnect/timing (7) split across MT/MeshCore Connection
- environmental.geocoder url/timeout/radius/limit (4)
- identity.contact_email (+ LOCAL_FIELDS mirror -> local.yaml; still feeds
  NWS User-Agent); mesh_sources url + regions lat/lon already editable
- toggles.<family>.name/webhook_headers, danger_zones.webhook_headers
  (new reusable KeyValueInput component)

alert_node_ids intentionally not surfaced (synthetic local default with no
dataclass home; overlaps the editable per-rule node_ids). No dead controls.

Frontend validated at Docker build. Backend py_compile OK.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-05 19:22:55 -06:00 committed by GitHub
commit ad5509cb12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 566 additions and 22 deletions

View file

@ -14,6 +14,7 @@ import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '
import { Toggle, NumberInput, TextInput, InfoButton } from '@/pages/Notifications'
import NodePicker from '@/components/NodePicker'
import ChannelPicker from '@/components/ChannelPicker'
import { KeyValueInput } from './KeyValueInput'
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const
@ -36,7 +37,7 @@ const DZ_FAMILIES: {
}[] = [
{ key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true },
{ key: 'weather', label: 'Weather', description: 'Severe weather warnings near a node.', Icon: Cloud },
{ key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake, tabled: true },
{ key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake },
{ key: 'flood', label: 'Flood (sub-gate of Seismic)', description: 'Stream/flood gauge events.', Icon: Activity },
{ key: 'avalanche', label: 'Avalanche', description: 'Avalanche advisories near a node.', Icon: Mountain },
{ key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain },
@ -412,6 +413,7 @@ export default function DangerZonesPanel() {
)}
{cfg.delivery_type === 'webhook' && (
<>
<TextInput
label="Webhook URL"
value={cfg.webhook_url || ''}
@ -419,6 +421,15 @@ export default function DangerZonesPanel() {
placeholder="https://discord.com/api/webhooks/..."
helper="POST alert as JSON"
/>
<KeyValueInput
label="Webhook Headers"
value={cfg.webhook_headers || {}}
onChange={(v) => upd({ webhook_headers: v })}
helper="Custom HTTP headers sent with the danger-zone webhook"
keyPlaceholder="Header"
valuePlaceholder="Value"
/>
</>
)}
{cfg.delivery_type === 'email' && (

View file

@ -0,0 +1,117 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2 } from 'lucide-react'
// Reusable key -> value dict editor for a Record<string,string> (e.g. HTTP
// headers). Styling/behavior mirrors the inline custom-command editor in
// pages/Config.tsx (CommandsSection): ordered rows kept in local state so a
// half-typed (blank-key) row isn't dropped mid-edit; the blank-filtered dict is
// committed up to the parent on every change.
// Self-contained "?" info popover so this component doesn't depend on any page.
function InfoBadge({ info }: { info: string }) {
const [open, setOpen] = useState(false)
return (
<div className="relative inline-block">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
className="ml-1.5 w-4 h-4 rounded-full bg-slate-700 hover:bg-slate-600 text-slate-400 hover:text-slate-200 inline-flex items-center justify-center text-xs transition-colors"
title="More info"
>
?
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] shadow-xl text-xs text-slate-300 leading-relaxed">
{info}
</div>
</>
)}
</div>
)
}
export function KeyValueInput({
label,
value,
onChange,
helper,
info,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
}: {
label: string
value: Record<string, string>
onChange: (v: Record<string, string>) => void
helper?: string
info?: string
keyPlaceholder?: string
valuePlaceholder?: string
}) {
const [rows, setRows] = useState<[string, string][]>(() =>
Object.entries(value || {})
)
// Re-sync local rows if the parent value changes out from under us (reset,
// fresh fetch, family switch) — but only when the committed view differs, so
// an in-progress blank-key row isn't clobbered by our own onChange echo.
useEffect(() => {
const committed: Record<string, string> = {}
for (const [k, v] of rows) {
if (k.trim()) committed[k.trim()] = v
}
if (JSON.stringify(committed) !== JSON.stringify(value || {})) {
setRows(Object.entries(value || {}))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
const commit = (next: [string, string][]) => {
setRows(next)
onChange(Object.fromEntries(next.filter(([k]) => k.trim())))
}
return (
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoBadge info={info} />}
</label>
{rows.map(([k, v], i) => (
<div key={i} className="flex items-start gap-2">
<input
type="text"
value={k}
onChange={(e) => commit(rows.map((r, j) => (j === i ? [e.target.value, r[1]] as [string, string] : r)))}
placeholder={keyPlaceholder}
className="w-40 flex-shrink-0 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"
/>
<input
type="text"
value={v}
onChange={(e) => commit(rows.map((r, j) => (j === i ? [r[0], e.target.value] as [string, string] : r)))}
placeholder={valuePlaceholder}
className="flex-1 px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent placeholder-slate-600"
/>
<button
type="button"
onClick={() => commit(rows.filter((_, j) => j !== i))}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded flex-shrink-0"
aria-label="Remove header"
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() => commit([...rows, ['', '']])}
className="w-full py-2 border border-dashed border-[#1e2a3a] text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} /> Add Header
</button>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}

View file

@ -17,6 +17,7 @@ import {
interface BotConfig {
name: string
owner: string
contact_email?: string
respond_to_dms: boolean
filter_bbs_protocols: boolean
}
@ -28,6 +29,13 @@ export interface ConnectionConfig {
tcp_port: number
meshcore_host?: string
meshcore_port?: number
reconnect?: boolean
reconnect_initial_delay?: number
reconnect_max_delay?: number
reconnect_health_interval?: number
mesh_max_chars?: number
meshcore_auto_reconnect?: boolean
meshcore_max_reconnect_attempts?: number
}
interface ResponseConfig {
@ -206,6 +214,7 @@ interface DashboardConfig {
}
interface FullConfig {
timezone: string
bot: BotConfig
connection: ConnectionConfig
response: ResponseConfig
@ -686,6 +695,13 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf
helper="Your callsign or identifier"
info="Identifies the bot operator. Shown in !help responses and used for admin-level commands."
/>
<TextInput
label="Contact Email"
value={data.contact_email || ''}
onChange={(v) => onChange({ ...data, contact_email: v })}
helper="Used to synthesize the NWS User-Agent"
info="An email address identifying the operator; sent as the User-Agent when fetching NWS weather data (NWS requires a contact). Stored in local.yaml."
/>
</div>
<Toggle
label="Respond to DMs"
@ -942,6 +958,31 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: (d: CommandsConfig) => void }) {
const disabledSet = new Set(data.disabled_commands.map(c => c.toLowerCase()))
// custom_commands is a {name: response} dict. Edit it as ordered rows in
// local state so a half-typed (blank-key) row isn't dropped mid-edit; commit
// the blank-filtered dict up to the parent on every change.
const [customRows, setCustomRows] = useState<[string, string][]>(() =>
Object.entries(data.custom_commands || {})
)
useEffect(() => {
const committed: Record<string, string> = {}
for (const [k, v] of customRows) {
if (k.trim()) committed[k.trim()] = v
}
if (JSON.stringify(committed) !== JSON.stringify(data.custom_commands || {})) {
setCustomRows(Object.entries(data.custom_commands || {}))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.custom_commands])
const commitCustomRows = (rows: [string, string][]) => {
setCustomRows(rows)
const obj: Record<string, string> = {}
for (const [k, v] of rows) {
if (k.trim()) obj[k.trim()] = v
}
onChange({ ...data, custom_commands: obj })
}
const toggleCommand = (cmdName: string) => {
const lowerName = cmdName.toLowerCase()
if (disabledSet.has(lowerName)) {
@ -1005,6 +1046,52 @@ function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: (
})}
</div>
</div>
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Custom Commands
<InfoButton info="Define your own commands. When a user types the prefix followed by the name, the bot replies with the response text verbatim." />
</label>
{customRows.map(([name, response], i) => (
<div key={i} className="flex items-start gap-2">
<input
type="text"
value={name}
onChange={(e) => {
const rows = customRows.map((r, j) => j === i ? [e.target.value, r[1]] as [string, string] : r)
commitCustomRows(rows)
}}
placeholder="name"
className="w-40 flex-shrink-0 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"
/>
<input
type="text"
value={response}
onChange={(e) => {
const rows = customRows.map((r, j) => j === i ? [r[0], e.target.value] as [string, string] : r)
commitCustomRows(rows)
}}
placeholder="response text"
className="flex-1 px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent placeholder-slate-600"
/>
<button
type="button"
onClick={() => commitCustomRows(customRows.filter((_, j) => j !== i))}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded flex-shrink-0"
aria-label="Remove custom command"
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() => commitCustomRows([...customRows, ['', '']])}
className="w-full py-2 border border-dashed border-[#1e2a3a] text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} /> Add Custom Command
</button>
</div>
</>
)}
</div>
@ -1265,6 +1352,22 @@ function KnowledgeSection({ data, onChange }: { data: KnowledgeConfig; onChange:
helper="Default 8090"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="Sparse Host"
value={data.sparse_host}
onChange={(v) => onChange({ ...data, sparse_host: v })}
placeholder="localhost"
helper="SPLADE sparse-embedding service host"
info="Host of the SPLADE service that generates sparse (keyword-weighted) embeddings for hybrid search."
/>
<NumberInput
label="Sparse Port"
value={data.sparse_port}
onChange={(v) => onChange({ ...data, sparse_port: v })}
helper="Default 8091"
/>
</div>
<Toggle
label="Use Sparse Embeddings"
checked={data.use_sparse}
@ -1713,6 +1816,17 @@ export function MeshIntelligenceSection({ data, onChange }: { data: MeshIntellig
thresholdMax={50}
thresholdSuffix={`% for ${data.alert_rules.high_util_hours}h`}
/>
{data.alert_rules.sustained_high_util && (
<div className="pl-3">
<NumberInput
label="High-Util Window (hours)"
value={data.alert_rules.high_util_hours}
onChange={(v) => onChange({ ...data, alert_rules: { ...data.alert_rules, high_util_hours: v } })}
min={1}
helper="Sustained duration above the utilization threshold before alerting"
/>
</div>
)}
<AlertRuleToggle
label="Packet Flood"
description="Alert when a single node sends excessive packets"
@ -1796,6 +1910,28 @@ function DashboardSection({ data, onChange }: { data: DashboardConfig; onChange:
)
}
// Global settings that aren't part of any dataclass section. `timezone` is a
// top-level scalar on Config, saved via its own PUT /api/config/timezone with
// the bare string as the JSON body (not part of the section Save button flow).
function GeneralSettings({ timezone, onSave }: { timezone: string; onSave: (tz: string) => void }) {
const [tz, setTz] = useState(timezone)
useEffect(() => { setTz(timezone) }, [timezone])
return (
<div className="space-y-4 mb-6 pb-6 border-b border-[#1e2a3a]">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">General</label>
<TextInput
label="Timezone"
value={tz}
onChange={(v) => { setTz(v); onSave(v) }}
placeholder="America/Boise"
helper="Global IANA timezone, e.g. America/Boise"
info="Global IANA timezone used for local time display across MeshAI. Saved immediately."
/>
</div>
)
}
export default function Config() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<FullConfig | null>(null)
@ -1912,6 +2048,25 @@ export default function Config() {
setConfig({ ...config, [section]: data })
}
// Top-level `timezone` scalar: PUT the bare string to /api/config/timezone.
// Kept out of the section Save flow (it isn't a SectionKey); on success we
// sync both config + originalConfig so it doesn't register as unsaved.
const saveTimezone = async (tz: string) => {
try {
const res = await fetch('/api/config/timezone', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tz),
})
const result = await res.json()
if (!res.ok) throw new Error(result.detail || 'Save failed')
setConfig((prev) => (prev ? { ...prev, timezone: tz } : prev))
setOriginalConfig((prev) => (prev ? { ...prev, timezone: tz } : prev))
} catch (err) {
setError(err instanceof Error ? err.message : 'Timezone save failed')
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@ -1930,7 +2085,12 @@ export default function Config() {
const renderSection = () => {
switch (activeSection) {
case 'bot': return <BotSection data={config.bot} onChange={(d) => updateSection('bot', d)} />
case 'bot': return (
<>
<GeneralSettings timezone={config.timezone} onSave={saveTimezone} />
<BotSection data={config.bot} onChange={(d) => updateSection('bot', 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)} />

View file

@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'
import {
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
Sliders,
Sliders, ChevronRight,
} from 'lucide-react'
import {
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
@ -21,16 +21,16 @@ type FeedSource = 'native' | 'central'
interface EnvConfig {
enabled: boolean
nws_zones: string[]
nws: { enabled: boolean; user_agent: string; tick_seconds: number; severity_min: string; feed_source?: FeedSource }
nws: { enabled: boolean; user_agent: string; tick_seconds: number; severity_min: string; areas?: string[]; feed_source?: FeedSource }
swpc: { enabled: boolean; feed_source?: FeedSource }
ducting: { enabled: boolean; tick_seconds: number; latitude: number; longitude: number; feed_source?: FeedSource }
fires: { enabled: boolean; tick_seconds: number; state: string; feed_source?: FeedSource }
avalanche: { enabled: boolean; tick_seconds: number; center_ids: string[]; season_months: number[]; feed_source?: FeedSource }
usgs: { enabled: boolean; tick_seconds: number; sites: string[]; feed_source?: FeedSource }
usgs_quake: { enabled: boolean; tick_seconds: number; feed_url: string; global_mag_floor: number; regional_mag_floor: number; regional_radius_mi: number; escalate_mag_floor: number; broadcast_pager_alerts: string[]; region: string; feed_source?: FeedSource }
usgs: { enabled: boolean; tick_seconds: number; sites: string[]; flood_thresholds?: Record<string, { flow?: number; height?: number }>; feed_source?: FeedSource }
usgs_quake: { enabled: boolean; tick_seconds: number; feed_url: string; min_magnitude?: number; bbox?: number[]; global_mag_floor: number; regional_mag_floor: number; regional_radius_mi: number; escalate_mag_floor: number; broadcast_pager_alerts: string[]; region: string; feed_source?: FeedSource }
traffic: { enabled: boolean; tick_seconds: number; api_key: string; corridors: { name: string; lat: number; lon: number }[]; feed_source?: FeedSource }
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; feed_source?: FeedSource }
wzdx: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; states: string[]; registry_url: string; feed_source?: FeedSource }
wzdx: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; states: string[]; registry_url: string; registry_ttl?: number; feed_source?: FeedSource }
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number; feed_source?: FeedSource }
// Native satpass (SGP4) YAML layer — drives env/satpass.py + env/tle_fetch.py.
// Distinct from the Central adapter_config/satpass layer (see SatpassConfig
@ -43,9 +43,11 @@ interface EnvConfig {
min_elevation_deg: number
window_hours: number
tle_refresh_seconds: number
broadcast_lead_seconds?: number
feed_source?: FeedSource
}
central?: { enabled: boolean; url: string; durable: string; region: string }
central?: { enabled: boolean; url: string; durable: string; region: string; connect_timeout?: number }
geocoder?: { url?: string; timeout_seconds?: number; radius_km?: number; limit?: number }
}
// Sane defaults for the native satpass block so a GET payload predating
@ -58,6 +60,7 @@ const SATPASS_NATIVE_DEFAULT: EnvConfig['satpass'] = {
min_elevation_deg: 10,
window_hours: 24,
tle_refresh_seconds: 21600,
broadcast_lead_seconds: 3600,
feed_source: 'central',
}
@ -374,6 +377,12 @@ export default function Environment() {
})
const [satpassOriginal, setSatpassOriginal] = useState<string>("")
// USGS flood_thresholds advanced JSON editor: raw text buffer (so invalid
// intermediate edits stay in the textarea) + inline parse error. Only valid
// parses are committed to env state via up().
const [floodThreshText, setFloodThreshText] = useState<string | null>(null)
const [floodThreshErr, setFloodThreshErr] = useState<string | null>(null)
// ── Notification family gating state ──────────────────────────────────────
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
const [notifOriginal, setNotifOriginal] = useState<string>('')
@ -810,6 +819,7 @@ const save = async () => {
setAvalancheConfig(JSON.parse(avalancheOriginal || JSON.stringify(avalancheConfig)))
setSwpcConfig(JSON.parse(swpcOriginal || JSON.stringify(swpcConfig)))
setSatpassConfig(JSON.parse(satpassOriginal || JSON.stringify(satpassConfig)))
setFloodThreshText(null); setFloodThreshErr(null)
}
const restart = async () => {
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
@ -874,6 +884,7 @@ const save = async () => {
min_severity: mine.min_severity,
freshness_seconds: mine.freshness_seconds ?? (freshT as NotificationToggle).freshness_seconds ?? 600,
cooldown_seconds: mine.cooldown_seconds ?? (freshT as NotificationToggle).cooldown_seconds ?? 0,
regions: mine.regions ?? (freshT as NotificationToggle).regions ?? [],
} as NotificationToggle
}
const res = await fetch('/api/config/notifications', {
@ -915,6 +926,7 @@ const save = async () => {
switch (key) {
case 'nws': return (<>
<ListInput label="NWS Zones" value={env.nws_zones} onChange={(v) => up({ nws_zones: v })} helper="Zone IDs like IDZ016, IDZ030" infoLink="https://www.weather.gov/pimar/PubZone" />
<ListInput label="NWS Areas" value={env.nws.areas ?? []} onChange={(v) => up({ nws: { ...env.nws, areas: v } })} helper="State codes NWS pulls, e.g. ID" />
{env.nws.feed_source !== 'central' && (
<>
<TextInput label="User Agent" value={env.nws.user_agent} onChange={(v) => up({ nws: { ...env.nws, user_agent: v } })} placeholder="(MeshAI, you@email.com)" helper="Format: (app_name, contact_email)" />
@ -1095,6 +1107,28 @@ const save = async () => {
case 'usgs': return (<>
<NumberInput label="Tick Seconds" value={env.usgs.tick_seconds} onChange={(v) => up({ usgs: { ...env.usgs, tick_seconds: v } })} min={900} helper="Minimum 15 min (900s). tick_seconds is the native-mode poll interval; ignored when this adapter is set to feed_source=central." />
<ListInput label="Site IDs" value={env.usgs.sites} onChange={(v) => up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" />
<div>
<label className="text-xs font-sans text-[#777] mb-1 block">Flood Thresholds (advanced JSON)</label>
<textarea
value={floodThreshText ?? JSON.stringify(env.usgs.flood_thresholds ?? {}, null, 2)}
onChange={(e) => {
const raw = e.target.value
setFloodThreshText(raw)
try {
const parsed = JSON.parse(raw)
setFloodThreshErr(null)
up({ usgs: { ...env.usgs, flood_thresholds: parsed } })
} catch (err) {
setFloodThreshErr(err instanceof Error ? err.message : 'Invalid JSON')
}
}}
rows={6}
spellCheck={false}
className="w-full bg-[#0d0d0d] border border-border px-3 py-2 text-sm font-mono"
/>
{floodThreshErr && <p className="text-xs text-red-400 mt-1">Invalid JSON not saved: {floodThreshErr}</p>}
<p className="text-xs text-[#666] mt-1">Per-site flood levels, shape {'{'}"site_id": {'{'} "flow": X, "height": Y {'}'}{'}'}</p>
</div>
</>)
case 'usgs_quake': return (
<div className="space-y-6">
@ -1107,6 +1141,23 @@ const save = async () => {
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, region: v } })} />
</div>
)}
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Native Feed
</div>
<div className="space-y-3">
<TextInput label="Quake Feed URL" value={env.usgs_quake.feed_url}
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, feed_url: v } })} />
<div className="grid grid-cols-2 gap-4">
<NumberInput label="Min Magnitude" value={env.usgs_quake.min_magnitude ?? 2.5}
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, min_magnitude: v } })}
step={0.1} min={0} helper="Native quake magnitude floor" />
</div>
<NumberListInput label="Bounding Box [W, S, E, N]" value={env.usgs_quake.bbox ?? []}
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, bbox: v } })}
helper="Four values: west, south, east, north" />
</div>
</div>
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Magnitude Thresholds
@ -1269,6 +1320,8 @@ const save = async () => {
<TextInput label="Registry URL" value={env.wzdx?.registry_url ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, registry_url: v } })}
placeholder="https://datahub.transportation.gov/resource/69qe-yiui.json?$limit=200"
helper="FHWA WZDx Feed Registry (Socrata) URL — lists every state DOT feed" />
<NumberInput label="Registry TTL (sec)" value={env.wzdx?.registry_ttl ?? 21600} onChange={(v) => up({ wzdx: { ...env.wzdx!, registry_ttl: v } })}
min={0} helper="How often to re-fetch the WZDx registry (default 21600 = 6h)" />
</>
)}
<div className="border-t border-border pt-4 mt-4">
@ -1432,6 +1485,9 @@ const save = async () => {
<NumberInput label="TLE Refresh (sec)" value={env.satpass.tle_refresh_seconds}
onChange={(v) => up({ satpass: { ...env.satpass, tle_refresh_seconds: v } })}
min={3600} helper="How often to re-fetch TLEs (default 21600 = 6h)" />
<NumberInput label="Broadcast Lead (sec)" value={env.satpass.broadcast_lead_seconds ?? 3600}
onChange={(v) => up({ satpass: { ...env.satpass, broadcast_lead_seconds: v } })}
min={0} helper="How far ahead of a pass to announce" />
</div>
</div>
</div>
@ -1534,6 +1590,9 @@ const save = async () => {
<TextInput label="Durable" value={env.central.durable || ""}
onChange={(v) => up({ central: { ...env.central!, durable: v } })}
placeholder="meshai-v04" />
<NumberInput label="Connect Timeout (sec)" value={env.central.connect_timeout ?? 10}
onChange={(v) => up({ central: { ...env.central!, connect_timeout: v } })}
step={0.5} min={0} helper="NATS connect timeout for the Central consumer" />
<TextInput label="Region" value={env.central.region || ""}
onChange={(v) => up({ central: { ...env.central!, region: v } })}
placeholder="us.id"
@ -1568,7 +1627,6 @@ const save = async () => {
freshness window, and cooldown. Delivery routing (which mesh channels, email, webhook) is
configured on the <a href="/notifications" className="text-accent hover:underline">Meshtastic Routing</a> and{' '}
<a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> pages.
Regions filter is dormant and intentionally hidden.
</p>
{notifError && <div className="text-sm text-red-400 bg-red-500/10 p-3">{notifError}</div>}
{notifSuccess && <div className="text-sm text-green-400 bg-green-500/10 p-3">{notifSuccess}</div>}
@ -1614,6 +1672,12 @@ const save = async () => {
helper="0 = no throttle"
/>
</div>
<ListInput
label="Regions"
value={t.regions ?? []}
onChange={(v) => updNotif(key, { regions: v })}
helper="Empty = all regions; otherwise only these region names"
/>
</div>
</div>
)
@ -1682,6 +1746,50 @@ const save = async () => {
</>
)}
{/* ── Advanced: Geocoder — infra, not a feed ──────────────────────────── */}
<details className="group border border-border p-4">
<summary className="flex items-center gap-2 cursor-pointer text-sm font-medium text-[#e0e0e0] hover:text-white">
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
Advanced: Geocoder
</summary>
<p className="mt-2 text-xs text-[#666]">
Configures the Photon reverse-geocoder used to resolve place names.
</p>
<div className="mt-4 space-y-3 pl-6 border-l border-border">
<TextInput
label="Geocoder URL"
value={env.geocoder?.url ?? 'https://photon.komoot.io'}
onChange={(v) => up({ geocoder: { ...env.geocoder, url: v } })}
placeholder="https://photon.komoot.io"
helper="Photon geocoding endpoint"
/>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Timeout (s)"
value={env.geocoder?.timeout_seconds ?? 2}
onChange={(v) => up({ geocoder: { ...env.geocoder, timeout_seconds: v } })}
min={0}
step={0.5}
helper="HTTP timeout per geocode request"
/>
<NumberInput
label="Search Radius (km)"
value={env.geocoder?.radius_km ?? 80}
onChange={(v) => up({ geocoder: { ...env.geocoder, radius_km: v } })}
min={0}
helper="Bias radius around the configured center for results"
/>
<NumberInput
label="Result Limit"
value={env.geocoder?.limit ?? 10}
onChange={(v) => up({ geocoder: { ...env.geocoder, limit: v } })}
min={1}
helper="Max candidate results to consider"
/>
</div>
</div>
</details>
</> /* end curated tab */}
</div>
)

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react'
import { TextInput, NumberInput, Toggle, ListInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
@ -16,6 +16,8 @@ interface ConnectionConfig {
tcp_port?: number
meshcore_host?: string
meshcore_port?: number
meshcore_auto_reconnect?: boolean
meshcore_max_reconnect_attempts?: number
[key: string]: unknown
}
@ -263,6 +265,27 @@ export default function MeshCoreConnection() {
&rarr; Meshtastic connection
</Link>
</div>
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
Advanced MeshCore Reconnect
</summary>
<div className="mt-4 space-y-4 pl-6 border-l border-[#1e2a3a]">
<Toggle
label="Auto-reconnect (MeshCore)"
checked={config.meshcore_auto_reconnect ?? true}
onChange={(v) => upd({ meshcore_auto_reconnect: v })}
helper="Automatically reconnect to the MeshCore companion if the link drops"
/>
<NumberInput
label="Max Reconnect Attempts"
value={config.meshcore_max_reconnect_attempts ?? 5}
onChange={(v) => upd({ meshcore_max_reconnect_attempts: v })}
min={0}
helper="Maximum reconnect attempts before giving up (0 = unlimited)"
/>
</div>
</details>
</div>
{/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */}

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react'
import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfig } from './Config'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
@ -208,6 +208,53 @@ export default function MeshtasticConnection() {
<ConnectionSection data={config} onChange={setConfig} />
</div>
{/* Advanced — reconnect & packet tuning */}
<div className="bg-bg-card border border-border p-6">
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" />
Advanced Reconnect &amp; Packet Tuning
</summary>
<div className="mt-4 space-y-4 pl-6 border-l border-[#1e2a3a]">
<Toggle
label="Auto-reconnect"
checked={config.reconnect ?? true}
onChange={(v) => setConfig({ ...config, reconnect: v })}
helper="Automatically reconnect if the mesh link drops"
/>
<NumberInput
label="Reconnect Initial Delay (s)"
value={config.reconnect_initial_delay ?? 2}
onChange={(v) => setConfig({ ...config, reconnect_initial_delay: v })}
min={0}
step={0.5}
helper="Backoff delay before the first reconnect attempt"
/>
<NumberInput
label="Reconnect Max Delay (s)"
value={config.reconnect_max_delay ?? 60}
onChange={(v) => setConfig({ ...config, reconnect_max_delay: v })}
min={0}
helper="Ceiling for exponential reconnect backoff"
/>
<NumberInput
label="Reconnect Health Interval (s)"
value={config.reconnect_health_interval ?? 30}
onChange={(v) => setConfig({ ...config, reconnect_health_interval: v })}
min={1}
helper="How often the socket-probe watchdog checks link health"
/>
<NumberInput
label="Mesh Max Chars"
value={config.mesh_max_chars ?? 140}
onChange={(v) => setConfig({ ...config, mesh_max_chars: v })}
min={1}
helper="Per-packet character budget for the transport"
/>
</div>
</details>
</div>
{/* Bot behavior card */}
{context && bot && (
<div className="bg-bg-card border border-border p-6 space-y-4">

View file

@ -10,6 +10,7 @@ import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
import { useDirty } from '@/context/DirtyContext'
import { ManagedSecret } from '@/components/ManagedSecret'
import { KeyValueInput } from '../components/KeyValueInput'
// Types
interface NotificationRuleConfig {
@ -63,11 +64,18 @@ export interface NotificationToggle {
webhook_headers: Record<string, string>
}
export interface DigestConfig {
schedule: string
include: string[]
}
export interface NotificationsConfig {
enabled: boolean
cold_start_grace_seconds?: number
band_conditions_enabled?: boolean
band_conditions_schedule?: string[]
band_conditions_tz?: string
digest?: DigestConfig
rules: NotificationRuleConfig[]
toggles?: Record<string, NotificationToggle>
}
@ -1617,7 +1625,9 @@ function mergeMeshtasticAndOtherFields(
key: string,
): NotificationToggle {
const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key }
base.name = base.name || key
// Prefer a locally-edited display name; else keep the server's; else default
// to the family id. (mine.name is overlaid here so Display Name edits persist.)
base.name = mine.name || base.name || key
// Merge severity_channels: keep meshcore_* from fresh, overlay non-meshcore from mine.
const freshSC = fresh?.severity_channels || {}
@ -1660,7 +1670,7 @@ function MeshtasticDeliveryGrid({
onChange: (t: Record<string, NotificationToggle>) => void
}) {
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle })
return (
<div className="space-y-3">
@ -1721,7 +1731,7 @@ function OtherChannelsGrid({
onChange: (t: Record<string, NotificationToggle>) => void
}) {
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle })
return (
<div className="space-y-3">
@ -1774,6 +1784,29 @@ function OtherChannelsGrid({
placeholder="https://..."
helper="POST alert as JSON"
/>
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-200">
<ChevronRight size={12} className="group-open:rotate-90 transition-transform" />
Advanced
</summary>
<div className="mt-2 space-y-3 pl-4 border-l border-[#1e2a3a]">
<TextInput
label="Display Name"
value={t.name || ''}
onChange={(v) => upd(key, { name: v })}
placeholder={key}
helper="Human-readable label for this alert family (defaults to the family id)"
/>
<KeyValueInput
label="Webhook Headers"
value={t.webhook_headers || {}}
onChange={(v) => upd(key, { webhook_headers: v })}
helper="Custom HTTP headers sent with this family's webhook (e.g. Authorization)"
keyPlaceholder="Header"
valuePlaceholder="Value"
/>
</div>
</details>
</div>
</div>
)
@ -1858,6 +1891,8 @@ export default function Notifications() {
const merged: NotificationsConfig = {
...fresh,
enabled: config.enabled, // global master switch lives on this page
band_conditions_tz: config.band_conditions_tz, // top-level, edited on this page
digest: config.digest, // daily digest schedule/include, edited on this page
rules: config.rules, // rules are only edited on this page
toggles: { ...(fresh.toggles || {}) },
}
@ -2245,6 +2280,38 @@ export default function Notifications() {
info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
/>
{/* Band-condition timezone (top-level notification setting) */}
<TextInput
label="Band Conditions Timezone"
value={config.band_conditions_tz ?? 'America/Boise'}
onChange={(v) => setConfig({ ...config, band_conditions_tz: v })}
placeholder="America/Boise"
helper="IANA tz for band-condition slot times, e.g. America/Boise"
/>
{/* Daily digest (top-level notification setting) */}
{(() => {
const digest = config.digest ?? { schedule: '07:00', include: [] }
return (
<>
<TextInput
label="Digest Time"
value={digest.schedule}
onChange={(v) => setConfig({ ...config, digest: { ...digest, schedule: v } })}
placeholder="07:00"
helper="Daily digest fire time HH:MM"
/>
<ListInput
label="Digest Families"
value={digest.include}
onChange={(v) => setConfig({ ...config, digest: { ...digest, include: v } })}
placeholder="Add family..."
helper="Notification families to include in the daily digest"
/>
</>
)
})()}
{/* Meshtastic delivery grids — always visible regardless of master switch */}
{config.toggles && (
<>

View file

@ -17,6 +17,7 @@ class BotConfig:
name: str = "ai"
owner: str = ""
contact_email: str = ""
respond_to_dms: bool = True
filter_bbs_protocols: bool = True

View file

@ -62,6 +62,7 @@ SECTION_TO_FILE: dict[str, str] = {
LOCAL_FIELDS: dict[str, str] = {
"bot.name": "identity.name",
"bot.owner": "identity.owner",
"bot.contact_email": "identity.contact_email",
"connection.tcp_host": "infrastructure.tcp_host",
"knowledge.qdrant_host": "infrastructure.qdrant_host",
"knowledge.tei_host": "infrastructure.tei_host",
@ -345,6 +346,8 @@ def _merge_local_values(data: dict, local: dict) -> dict:
data["bot"]["name"] = identity["name"]
if identity.get("owner"):
data["bot"]["owner"] = identity["owner"]
if identity.get("contact_email"):
data["bot"]["contact_email"] = identity["contact_email"]
# Infrastructure hosts
infra = local.get("infrastructure", {})
@ -789,9 +792,15 @@ def save_section(
for i, item in enumerate(data)
]
local_updates = {}
else:
elif isinstance(data, dict):
data = check_secrets(data)
domain_data, local_updates = _extract_local_fields(section_name, data)
else:
# Scalar section (e.g. the top-level `timezone` string). A bare scalar
# can't carry a secret ${VAR} ref or a local-identifying field, so it is
# written straight through to its target file (config.yaml) as-is.
domain_data = data
local_updates = {}
# Load existing target file (v0.6-tail-4: preserve !include directives
# for inline-section saves to config.yaml; safe_load would crash).

View file

@ -37,6 +37,7 @@ RESTART_REQUIRED_SECTIONS = {
# Valid config section names
VALID_SECTIONS = {
"timezone",
"notifications",
"environmental",
"bot",