diff --git a/work/dashboard-frontend/src/components/DangerZonesPanel.tsx b/work/dashboard-frontend/src/components/DangerZonesPanel.tsx index 74c4363..679e832 100644 --- a/work/dashboard-frontend/src/components/DangerZonesPanel.tsx +++ b/work/dashboard-frontend/src/components/DangerZonesPanel.tsx @@ -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' && ( - upd({ webhook_url: v })} - placeholder="https://discord.com/api/webhooks/..." - helper="POST alert as JSON" - /> + <> + upd({ webhook_url: v })} + placeholder="https://discord.com/api/webhooks/..." + helper="POST alert as JSON" + /> + upd({ webhook_headers: v })} + helper="Custom HTTP headers sent with the danger-zone webhook" + keyPlaceholder="Header" + valuePlaceholder="Value" + /> + )} {cfg.delivery_type === 'email' && ( diff --git a/work/dashboard-frontend/src/components/KeyValueInput.tsx b/work/dashboard-frontend/src/components/KeyValueInput.tsx new file mode 100644 index 0000000..393cbd6 --- /dev/null +++ b/work/dashboard-frontend/src/components/KeyValueInput.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react' +import { Plus, Trash2 } from 'lucide-react' + +// Reusable key -> value dict editor for a Record (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 ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {info} +
+ + )} +
+ ) +} + +export function KeyValueInput({ + label, + value, + onChange, + helper, + info, + keyPlaceholder = 'Key', + valuePlaceholder = 'Value', +}: { + label: string + value: Record + onChange: (v: Record) => 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 = {} + 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 ( +
+ + {rows.map(([k, v], i) => ( +
+ 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" + /> + 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" + /> + +
+ ))} + + {helper &&

{helper}

} +
+ ) +} diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index e9dc8a2..fb9b9b8 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -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." /> + 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." + />
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 = {} + 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 = {} + 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: ( })} + +
+ + {customRows.map(([name, response], i) => ( +
+ { + 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" + /> + { + 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" + /> + +
+ ))} + +
)} @@ -1265,6 +1352,22 @@ function KnowledgeSection({ data, onChange }: { data: KnowledgeConfig; onChange: helper="Default 8090" /> +
+ 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." + /> + onChange({ ...data, sparse_port: v })} + helper="Default 8091" + /> +
+ {data.alert_rules.sustained_high_util && ( +
+ onChange({ ...data, alert_rules: { ...data.alert_rules, high_util_hours: v } })} + min={1} + helper="Sustained duration above the utilization threshold before alerting" + /> +
+ )} void }) { + const [tz, setTz] = useState(timezone) + useEffect(() => { setTz(timezone) }, [timezone]) + + return ( +
+ + { 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." + /> +
+ ) +} + export default function Config() { const { setDirty } = useDirty() const [config, setConfig] = useState(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 (
@@ -1930,7 +2085,12 @@ export default function Config() { const renderSection = () => { switch (activeSection) { - case 'bot': return updateSection('bot', d)} /> + case 'bot': return ( + <> + + updateSection('bot', d)} /> + + ) case 'response': return updateSection('response', d)} /> case 'history': return updateSection('history', d)} /> case 'memory': return updateSection('memory', d)} /> diff --git a/work/dashboard-frontend/src/pages/Environment.tsx b/work/dashboard-frontend/src/pages/Environment.tsx index aba76ec..d56dada 100644 --- a/work/dashboard-frontend/src/pages/Environment.tsx +++ b/work/dashboard-frontend/src/pages/Environment.tsx @@ -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; 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("") + // 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(null) + const [floodThreshErr, setFloodThreshErr] = useState(null) + // ── Notification family gating state ────────────────────────────────────── const [notifConfig, setNotifConfig] = useState(null) const [notifOriginal, setNotifOriginal] = useState('') @@ -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 (<> up({ nws_zones: v })} helper="Zone IDs like IDZ016, IDZ030" infoLink="https://www.weather.gov/pimar/PubZone" /> + up({ nws: { ...env.nws, areas: v } })} helper="State codes NWS pulls, e.g. ID" /> {env.nws.feed_source !== 'central' && ( <> 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 (<> 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." /> up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" /> +
+ +