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 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,13 +413,23 @@ export default function DangerZonesPanel() {
|
|||
)}
|
||||
|
||||
{cfg.delivery_type === 'webhook' && (
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
value={cfg.webhook_url || ''}
|
||||
onChange={(v) => upd({ webhook_url: v })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
helper="POST alert as JSON"
|
||||
/>
|
||||
<>
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
value={cfg.webhook_url || ''}
|
||||
onChange={(v) => upd({ webhook_url: v })}
|
||||
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' && (
|
||||
|
|
|
|||
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 {
|
||||
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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
→ 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 */}
|
||||
|
|
|
|||
|
|
@ -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 & 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">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class BotConfig:
|
|||
|
||||
name: str = "ai"
|
||||
owner: str = ""
|
||||
contact_email: str = ""
|
||||
respond_to_dms: bool = True
|
||||
filter_bbs_protocols: bool = True
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ RESTART_REQUIRED_SECTIONS = {
|
|||
|
||||
# Valid config section names
|
||||
VALID_SECTIONS = {
|
||||
"timezone",
|
||||
"notifications",
|
||||
"environmental",
|
||||
"bot",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue