diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index b24d85e..f053979 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -297,7 +297,24 @@ export async function updateConfig( body: JSON.stringify(data), }) if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) + // Surface the server's `detail` when there is one. A bare + // "API error: 500 Internal Server Error" hid the actual cause of the + // 2026-07-17 outage from the operator -- the real message was + // "[Errno 13] Permission denied: '/data/config/local.yaml'", which would + // have named the problem outright. + let detail = '' + try { + const body = await response.json() as { detail?: unknown } + if (typeof body?.detail === 'string') detail = body.detail + else if (body?.detail != null) detail = JSON.stringify(body.detail) + } catch { + // non-JSON error body — fall back to the status line + } + throw new Error( + detail + ? `${detail} (${response.status})` + : `API error: ${response.status} ${response.statusText}` + ) } return response.json() } diff --git a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx index bd9dd4d..61cca20 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx @@ -61,9 +61,14 @@ export default function MeshCoreCompanion() { // Auto-advert control state — interval in hours (0 = disabled) // Loaded from connection config; editable in-page and PUTted back. - const [advertIntervalHours, setAdvertIntervalHours] = useState(3) + const [advertIntervalHours, setAdvertIntervalHours] = useState(24) const [advertIntervalSaving, setAdvertIntervalSaving] = useState(false) const [advertIntervalSaved, setAdvertIntervalSaved] = useState(false) + const [advertIntervalError, setAdvertIntervalError] = useState(null) + // The FULL connection section as fetched. Saving spreads this so the PUT + // carries every field, matching every other updateConfig('connection', ...) + // caller. See handleSaveAdvertInterval. + const [connConfig, setConnConfig] = useState | null>(null) useEffect(() => { document.title = 'Companion & Channels - MeshAI' @@ -101,6 +106,7 @@ export default function MeshCoreCompanion() { const resp = await fetch('/api/config/connection') if (resp.ok) { const data = await resp.json() as Record + setConnConfig(data) const sec = data['meshcore_advert_interval_seconds'] if (typeof sec === 'number') { setAdvertIntervalHours(sec > 0 ? sec / 3600 : 0) @@ -140,17 +146,32 @@ export default function MeshCoreCompanion() { const handleSaveAdvertInterval = useCallback(async () => { setAdvertIntervalSaving(true) setAdvertIntervalSaved(false) + setAdvertIntervalError(null) try { const seconds = Math.round(advertIntervalHours * 3600) - await updateConfig('connection', { meshcore_advert_interval_seconds: seconds }) + // Spread the full fetched section, don't PUT a lone key. On 2026-07-17 a + // single-key body here reset every OMITTED connection field to its + // dataclass default (type -> serial, meshcore_host -> '', ...) and took + // both radios offline. The route now merges partial bodies server-side, + // but this page still sends the whole object like every other caller: + // belt and braces, and it keeps the PUT's meaning explicit. + const current = connConfig ?? {} + await updateConfig('connection', { + ...current, + meshcore_advert_interval_seconds: seconds, + }) + setConnConfig({ ...current, meshcore_advert_interval_seconds: seconds }) setAdvertIntervalSaved(true) setTimeout(() => setAdvertIntervalSaved(false), 2000) - } catch { - // keep saving=false, let UI show failure implicitly + } catch (err) { + // A failed save MUST be visible. This handler used to swallow the error + // and "let the UI show failure implicitly" -- it showed nothing at all, + // so the operator saw a silent no-op while the write had already failed. + setAdvertIntervalError(err instanceof Error ? err.message : 'Save failed') } finally { setAdvertIntervalSaving(false) } - }, [advertIntervalHours]) + }, [advertIntervalHours, connConfig]) const handleCopyKey = useCallback(async (key: string) => { try { @@ -343,10 +364,10 @@ export default function MeshCoreCompanion() { > - + - +