mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
3495eb31de
commit
ad5509cb12
10 changed files with 566 additions and 22 deletions
|
|
@ -14,6 +14,7 @@ import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '
|
||||||
import { Toggle, NumberInput, TextInput, InfoButton } from '@/pages/Notifications'
|
import { Toggle, NumberInput, TextInput, InfoButton } from '@/pages/Notifications'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
|
import { KeyValueInput } from './KeyValueInput'
|
||||||
|
|
||||||
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const
|
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: '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: '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: '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: '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 },
|
{ key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain },
|
||||||
|
|
@ -412,13 +413,23 @@ export default function DangerZonesPanel() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.delivery_type === 'webhook' && (
|
{cfg.delivery_type === 'webhook' && (
|
||||||
<TextInput
|
<>
|
||||||
label="Webhook URL"
|
<TextInput
|
||||||
value={cfg.webhook_url || ''}
|
label="Webhook URL"
|
||||||
onChange={(v) => upd({ webhook_url: v })}
|
value={cfg.webhook_url || ''}
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
onChange={(v) => upd({ webhook_url: v })}
|
||||||
helper="POST alert as JSON"
|
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' && (
|
{cfg.delivery_type === 'email' && (
|
||||||
|
|
|
||||||
117
work/dashboard-frontend/src/components/KeyValueInput.tsx
Normal file
117
work/dashboard-frontend/src/components/KeyValueInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
interface BotConfig {
|
interface BotConfig {
|
||||||
name: string
|
name: string
|
||||||
owner: string
|
owner: string
|
||||||
|
contact_email?: string
|
||||||
respond_to_dms: boolean
|
respond_to_dms: boolean
|
||||||
filter_bbs_protocols: boolean
|
filter_bbs_protocols: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +29,13 @@ export interface ConnectionConfig {
|
||||||
tcp_port: number
|
tcp_port: number
|
||||||
meshcore_host?: string
|
meshcore_host?: string
|
||||||
meshcore_port?: number
|
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 {
|
interface ResponseConfig {
|
||||||
|
|
@ -206,6 +214,7 @@ interface DashboardConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FullConfig {
|
interface FullConfig {
|
||||||
|
timezone: string
|
||||||
bot: BotConfig
|
bot: BotConfig
|
||||||
connection: ConnectionConfig
|
connection: ConnectionConfig
|
||||||
response: ResponseConfig
|
response: ResponseConfig
|
||||||
|
|
@ -686,6 +695,13 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf
|
||||||
helper="Your callsign or identifier"
|
helper="Your callsign or identifier"
|
||||||
info="Identifies the bot operator. Shown in !help responses and used for admin-level commands."
|
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>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Respond to DMs"
|
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 }) {
|
function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: (d: CommandsConfig) => void }) {
|
||||||
const disabledSet = new Set(data.disabled_commands.map(c => c.toLowerCase()))
|
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 toggleCommand = (cmdName: string) => {
|
||||||
const lowerName = cmdName.toLowerCase()
|
const lowerName = cmdName.toLowerCase()
|
||||||
if (disabledSet.has(lowerName)) {
|
if (disabledSet.has(lowerName)) {
|
||||||
|
|
@ -1005,6 +1046,52 @@ function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: (
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
@ -1265,6 +1352,22 @@ function KnowledgeSection({ data, onChange }: { data: KnowledgeConfig; onChange:
|
||||||
helper="Default 8090"
|
helper="Default 8090"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Toggle
|
||||||
label="Use Sparse Embeddings"
|
label="Use Sparse Embeddings"
|
||||||
checked={data.use_sparse}
|
checked={data.use_sparse}
|
||||||
|
|
@ -1713,6 +1816,17 @@ export function MeshIntelligenceSection({ data, onChange }: { data: MeshIntellig
|
||||||
thresholdMax={50}
|
thresholdMax={50}
|
||||||
thresholdSuffix={`% for ${data.alert_rules.high_util_hours}h`}
|
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
|
<AlertRuleToggle
|
||||||
label="Packet Flood"
|
label="Packet Flood"
|
||||||
description="Alert when a single node sends excessive packets"
|
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() {
|
export default function Config() {
|
||||||
const { setDirty } = useDirty()
|
const { setDirty } = useDirty()
|
||||||
const [config, setConfig] = useState<FullConfig | null>(null)
|
const [config, setConfig] = useState<FullConfig | null>(null)
|
||||||
|
|
@ -1912,6 +2048,25 @@ export default function Config() {
|
||||||
setConfig({ ...config, [section]: data })
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|
@ -1930,7 +2085,12 @@ export default function Config() {
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
switch (activeSection) {
|
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 'response': return <ResponseSection data={config.response} onChange={(d) => updateSection('response', d)} />
|
||||||
case 'history': return <HistorySection data={config.history} onChange={(d) => updateSection('history', 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 'memory': return <MemorySection data={config.memory} onChange={(d) => updateSection('memory', d)} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
||||||
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
|
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
|
||||||
Sliders,
|
Sliders, ChevronRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
||||||
|
|
@ -21,16 +21,16 @@ type FeedSource = 'native' | 'central'
|
||||||
interface EnvConfig {
|
interface EnvConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
nws_zones: string[]
|
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 }
|
swpc: { enabled: boolean; feed_source?: FeedSource }
|
||||||
ducting: { enabled: boolean; tick_seconds: number; latitude: number; longitude: number; 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 }
|
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 }
|
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: { 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; 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_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 }
|
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 }
|
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 }
|
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.
|
// Native satpass (SGP4) YAML layer — drives env/satpass.py + env/tle_fetch.py.
|
||||||
// Distinct from the Central adapter_config/satpass layer (see SatpassConfig
|
// Distinct from the Central adapter_config/satpass layer (see SatpassConfig
|
||||||
|
|
@ -43,9 +43,11 @@ interface EnvConfig {
|
||||||
min_elevation_deg: number
|
min_elevation_deg: number
|
||||||
window_hours: number
|
window_hours: number
|
||||||
tle_refresh_seconds: number
|
tle_refresh_seconds: number
|
||||||
|
broadcast_lead_seconds?: number
|
||||||
feed_source?: FeedSource
|
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
|
// 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,
|
min_elevation_deg: 10,
|
||||||
window_hours: 24,
|
window_hours: 24,
|
||||||
tle_refresh_seconds: 21600,
|
tle_refresh_seconds: 21600,
|
||||||
|
broadcast_lead_seconds: 3600,
|
||||||
feed_source: 'central',
|
feed_source: 'central',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,6 +377,12 @@ export default function Environment() {
|
||||||
})
|
})
|
||||||
const [satpassOriginal, setSatpassOriginal] = useState<string>("")
|
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 ──────────────────────────────────────
|
// ── Notification family gating state ──────────────────────────────────────
|
||||||
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
|
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
|
||||||
const [notifOriginal, setNotifOriginal] = useState<string>('')
|
const [notifOriginal, setNotifOriginal] = useState<string>('')
|
||||||
|
|
@ -810,6 +819,7 @@ const save = async () => {
|
||||||
setAvalancheConfig(JSON.parse(avalancheOriginal || JSON.stringify(avalancheConfig)))
|
setAvalancheConfig(JSON.parse(avalancheOriginal || JSON.stringify(avalancheConfig)))
|
||||||
setSwpcConfig(JSON.parse(swpcOriginal || JSON.stringify(swpcConfig)))
|
setSwpcConfig(JSON.parse(swpcOriginal || JSON.stringify(swpcConfig)))
|
||||||
setSatpassConfig(JSON.parse(satpassOriginal || JSON.stringify(satpassConfig)))
|
setSatpassConfig(JSON.parse(satpassOriginal || JSON.stringify(satpassConfig)))
|
||||||
|
setFloodThreshText(null); setFloodThreshErr(null)
|
||||||
}
|
}
|
||||||
const restart = async () => {
|
const restart = async () => {
|
||||||
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
|
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
|
||||||
|
|
@ -874,6 +884,7 @@ const save = async () => {
|
||||||
min_severity: mine.min_severity,
|
min_severity: mine.min_severity,
|
||||||
freshness_seconds: mine.freshness_seconds ?? (freshT as NotificationToggle).freshness_seconds ?? 600,
|
freshness_seconds: mine.freshness_seconds ?? (freshT as NotificationToggle).freshness_seconds ?? 600,
|
||||||
cooldown_seconds: mine.cooldown_seconds ?? (freshT as NotificationToggle).cooldown_seconds ?? 0,
|
cooldown_seconds: mine.cooldown_seconds ?? (freshT as NotificationToggle).cooldown_seconds ?? 0,
|
||||||
|
regions: mine.regions ?? (freshT as NotificationToggle).regions ?? [],
|
||||||
} as NotificationToggle
|
} as NotificationToggle
|
||||||
}
|
}
|
||||||
const res = await fetch('/api/config/notifications', {
|
const res = await fetch('/api/config/notifications', {
|
||||||
|
|
@ -915,6 +926,7 @@ const save = async () => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'nws': return (<>
|
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 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' && (
|
{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)" />
|
<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 (<>
|
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." />
|
<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" />
|
<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 (
|
case 'usgs_quake': return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -1107,6 +1141,23 @@ const save = async () => {
|
||||||
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, region: v } })} />
|
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, region: v } })} />
|
||||||
</div>
|
</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>
|
||||||
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
|
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
|
||||||
Magnitude Thresholds
|
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 } })}
|
<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"
|
placeholder="https://datahub.transportation.gov/resource/69qe-yiui.json?$limit=200"
|
||||||
helper="FHWA WZDx Feed Registry (Socrata) URL — lists every state DOT feed" />
|
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">
|
<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}
|
<NumberInput label="TLE Refresh (sec)" value={env.satpass.tle_refresh_seconds}
|
||||||
onChange={(v) => up({ satpass: { ...env.satpass, tle_refresh_seconds: v } })}
|
onChange={(v) => up({ satpass: { ...env.satpass, tle_refresh_seconds: v } })}
|
||||||
min={3600} helper="How often to re-fetch TLEs (default 21600 = 6h)" />
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1534,6 +1590,9 @@ const save = async () => {
|
||||||
<TextInput label="Durable" value={env.central.durable || ""}
|
<TextInput label="Durable" value={env.central.durable || ""}
|
||||||
onChange={(v) => up({ central: { ...env.central!, durable: v } })}
|
onChange={(v) => up({ central: { ...env.central!, durable: v } })}
|
||||||
placeholder="meshai-v04" />
|
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 || ""}
|
<TextInput label="Region" value={env.central.region || ""}
|
||||||
onChange={(v) => up({ central: { ...env.central!, region: v } })}
|
onChange={(v) => up({ central: { ...env.central!, region: v } })}
|
||||||
placeholder="us.id"
|
placeholder="us.id"
|
||||||
|
|
@ -1568,7 +1627,6 @@ const save = async () => {
|
||||||
freshness window, and cooldown. Delivery routing (which mesh channels, email, webhook) is
|
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{' '}
|
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.
|
<a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> pages.
|
||||||
Regions filter is dormant and intentionally hidden.
|
|
||||||
</p>
|
</p>
|
||||||
{notifError && <div className="text-sm text-red-400 bg-red-500/10 p-3">{notifError}</div>}
|
{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>}
|
{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"
|
helper="0 = no throttle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<ListInput
|
||||||
|
label="Regions"
|
||||||
|
value={t.regions ?? []}
|
||||||
|
onChange={(v) => updNotif(key, { regions: v })}
|
||||||
|
helper="Empty = all regions; otherwise only these region names"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
</> /* end curated tab */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 { TextInput, NumberInput, Toggle, ListInput } from './Config'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
|
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
|
||||||
|
|
@ -16,6 +16,8 @@ interface ConnectionConfig {
|
||||||
tcp_port?: number
|
tcp_port?: number
|
||||||
meshcore_host?: string
|
meshcore_host?: string
|
||||||
meshcore_port?: number
|
meshcore_port?: number
|
||||||
|
meshcore_auto_reconnect?: boolean
|
||||||
|
meshcore_max_reconnect_attempts?: number
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,6 +265,27 @@ export default function MeshCoreConnection() {
|
||||||
→ Meshtastic connection
|
→ Meshtastic connection
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */}
|
{/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
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 { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfig } from './Config'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
|
|
@ -208,6 +208,53 @@ export default function MeshtasticConnection() {
|
||||||
<ConnectionSection data={config} onChange={setConfig} />
|
<ConnectionSection data={config} onChange={setConfig} />
|
||||||
</div>
|
</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 & 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 */}
|
{/* Bot behavior card */}
|
||||||
{context && bot && (
|
{context && bot && (
|
||||||
<div className="bg-bg-card border border-border p-6 space-y-4">
|
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
import { ManagedSecret } from '@/components/ManagedSecret'
|
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||||
|
import { KeyValueInput } from '../components/KeyValueInput'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface NotificationRuleConfig {
|
interface NotificationRuleConfig {
|
||||||
|
|
@ -63,11 +64,18 @@ export interface NotificationToggle {
|
||||||
webhook_headers: Record<string, string>
|
webhook_headers: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigestConfig {
|
||||||
|
schedule: string
|
||||||
|
include: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationsConfig {
|
export interface NotificationsConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
cold_start_grace_seconds?: number
|
cold_start_grace_seconds?: number
|
||||||
band_conditions_enabled?: boolean
|
band_conditions_enabled?: boolean
|
||||||
band_conditions_schedule?: string[]
|
band_conditions_schedule?: string[]
|
||||||
|
band_conditions_tz?: string
|
||||||
|
digest?: DigestConfig
|
||||||
rules: NotificationRuleConfig[]
|
rules: NotificationRuleConfig[]
|
||||||
toggles?: Record<string, NotificationToggle>
|
toggles?: Record<string, NotificationToggle>
|
||||||
}
|
}
|
||||||
|
|
@ -1617,7 +1625,9 @@ function mergeMeshtasticAndOtherFields(
|
||||||
key: string,
|
key: string,
|
||||||
): NotificationToggle {
|
): NotificationToggle {
|
||||||
const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key }
|
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.
|
// Merge severity_channels: keep meshcore_* from fresh, overlay non-meshcore from mine.
|
||||||
const freshSC = fresh?.severity_channels || {}
|
const freshSC = fresh?.severity_channels || {}
|
||||||
|
|
@ -1660,7 +1670,7 @@ function MeshtasticDeliveryGrid({
|
||||||
onChange: (t: Record<string, NotificationToggle>) => void
|
onChange: (t: Record<string, NotificationToggle>) => void
|
||||||
}) {
|
}) {
|
||||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -1721,7 +1731,7 @@ function OtherChannelsGrid({
|
||||||
onChange: (t: Record<string, NotificationToggle>) => void
|
onChange: (t: Record<string, NotificationToggle>) => void
|
||||||
}) {
|
}) {
|
||||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -1774,6 +1784,29 @@ function OtherChannelsGrid({
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
helper="POST alert as JSON"
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -1858,6 +1891,8 @@ export default function Notifications() {
|
||||||
const merged: NotificationsConfig = {
|
const merged: NotificationsConfig = {
|
||||||
...fresh,
|
...fresh,
|
||||||
enabled: config.enabled, // global master switch lives on this page
|
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
|
rules: config.rules, // rules are only edited on this page
|
||||||
toggles: { ...(fresh.toggles || {}) },
|
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."
|
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 */}
|
{/* Meshtastic delivery grids — always visible regardless of master switch */}
|
||||||
{config.toggles && (
|
{config.toggles && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class BotConfig:
|
||||||
|
|
||||||
name: str = "ai"
|
name: str = "ai"
|
||||||
owner: str = ""
|
owner: str = ""
|
||||||
|
contact_email: str = ""
|
||||||
respond_to_dms: bool = True
|
respond_to_dms: bool = True
|
||||||
filter_bbs_protocols: bool = True
|
filter_bbs_protocols: bool = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ SECTION_TO_FILE: dict[str, str] = {
|
||||||
LOCAL_FIELDS: dict[str, str] = {
|
LOCAL_FIELDS: dict[str, str] = {
|
||||||
"bot.name": "identity.name",
|
"bot.name": "identity.name",
|
||||||
"bot.owner": "identity.owner",
|
"bot.owner": "identity.owner",
|
||||||
|
"bot.contact_email": "identity.contact_email",
|
||||||
"connection.tcp_host": "infrastructure.tcp_host",
|
"connection.tcp_host": "infrastructure.tcp_host",
|
||||||
"knowledge.qdrant_host": "infrastructure.qdrant_host",
|
"knowledge.qdrant_host": "infrastructure.qdrant_host",
|
||||||
"knowledge.tei_host": "infrastructure.tei_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"]
|
data["bot"]["name"] = identity["name"]
|
||||||
if identity.get("owner"):
|
if identity.get("owner"):
|
||||||
data["bot"]["owner"] = identity["owner"]
|
data["bot"]["owner"] = identity["owner"]
|
||||||
|
if identity.get("contact_email"):
|
||||||
|
data["bot"]["contact_email"] = identity["contact_email"]
|
||||||
|
|
||||||
# Infrastructure hosts
|
# Infrastructure hosts
|
||||||
infra = local.get("infrastructure", {})
|
infra = local.get("infrastructure", {})
|
||||||
|
|
@ -789,9 +792,15 @@ def save_section(
|
||||||
for i, item in enumerate(data)
|
for i, item in enumerate(data)
|
||||||
]
|
]
|
||||||
local_updates = {}
|
local_updates = {}
|
||||||
else:
|
elif isinstance(data, dict):
|
||||||
data = check_secrets(data)
|
data = check_secrets(data)
|
||||||
domain_data, local_updates = _extract_local_fields(section_name, 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
|
# Load existing target file (v0.6-tail-4: preserve !include directives
|
||||||
# for inline-section saves to config.yaml; safe_load would crash).
|
# for inline-section saves to config.yaml; safe_load would crash).
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ RESTART_REQUIRED_SECTIONS = {
|
||||||
|
|
||||||
# Valid config section names
|
# Valid config section names
|
||||||
VALID_SECTIONS = {
|
VALID_SECTIONS = {
|
||||||
|
"timezone",
|
||||||
"notifications",
|
"notifications",
|
||||||
"environmental",
|
"environmental",
|
||||||
"bot",
|
"bot",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue