From f89e9c11fb7a4177ac492af0c7869eb95685190e Mon Sep 17 00:00:00 2001 From: "Matt Johnson (via Claude)" Date: Sat, 6 Jun 2026 03:51:10 +0000 Subject: [PATCH] feat(v0.6-tail-3): enforce OR-not-AND continuously -- close USGS direct-lookup leak + flag environmental config changes as restart-required Gap 1 -- env_routes.lookup_usgs_site no longer creates a temporary USGSStreamsAdapter to hit USGS.gov directly. When the env_store has no native usgs adapter (because usgs.feed_source != native), the endpoint returns HTTP 404 with a body that says "site lookup unavailable in central-feed mode; values must be entered manually or sourced from Central". This closes the AND-mode anti-pattern Central's v0.10.2 report flagged: meshai was in central-feed mode for usgs but the lookup helper would still call USGS.gov directly the first time the dashboard opened the Add-Gauge form. Gap 2 -- config_routes.RESTART_REQUIRED_SECTIONS gains "environmental" and the PUT handler now diffs the section before/after, returning {saved, restart_required, changed_keys}. restart_required is true only when there are actual changes AND the section is in the restart-required set, so a no-op PUT to environmental never raises a false alarm. Frontend wiring: - New RestartBanner component (yellow top-of-main banner) listens to a meshai:restart-required CustomEvent + cross-tab storage event, persists across navigations via localStorage, shows changed_keys preview + Restart-now button (POSTs /api/system/restart) + dismiss. - Layout.tsx mounts above {children} so it surfaces on every page. - Config.tsx saveSection() now calls notifyRestartRequired(changed_keys) alongside its existing setRestartRequired(true) when the API flags the section. - GaugeSites.tsx probes /api/config/environmental at mount and shows a "USGS lookup" button next to the site_id input. The button is disabled with an explanatory tooltip when usgs.feed_source != native, and gracefully renders the 404 detail when the API returns 404 in central-feed mode -- enter-manually UX, no silent fallback. Tests -- tests/test_or_arch_continuous.py (11 cases, all passing): - USGS lookup 404 with no env_store / no native usgs adapter - 502 on native-adapter exception - 200 + payload on native-adapter happy path - environmental in RESTART_REQUIRED_SECTIONS - PUT environmental with changed feed_source -> restart_required:true + changed_keys list including foo.feed_source dotted path - PUT bot (non-restart section) -> restart_required:false - No-op PUT to bot / environmental -> restart_required:false, empty changed_keys - _diff_keys helper unit tests (nested dicts, list-element changes) Why this matters: per the Spokane post-mortem and Central's v0.10.2 response, both sides need belt-and-suspenders against transient AND-modes. meshai's static OR enforcement at env_store boot is the runtime guard; this commit makes the GUI honor it continuously -- the lookup helper can't sneak past it any more, and the user is told explicitly that an environmental config change does not take effect until the container restarts. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard-frontend/src/components/Layout.tsx | 4 +- .../src/components/RestartBanner.tsx | 136 +++++++++ dashboard-frontend/src/pages/Config.tsx | 3 + dashboard-frontend/src/pages/GaugeSites.tsx | 76 +++++- meshai/dashboard/api/config_routes.py | 94 ++++++- meshai/dashboard/api/env_routes.py | 33 ++- ...{index-j88L17ja.css => index-BNx9Ej8o.css} | 2 +- .../{index-WV9oBF1j.js => index-D0oznGRE.js} | 257 +++++++++--------- meshai/dashboard/static/index.html | 4 +- tests/test_or_arch_continuous.py | 181 ++++++++++++ 10 files changed, 640 insertions(+), 150 deletions(-) create mode 100644 dashboard-frontend/src/components/RestartBanner.tsx rename meshai/dashboard/static/assets/{index-j88L17ja.css => index-BNx9Ej8o.css} (64%) rename meshai/dashboard/static/assets/{index-WV9oBF1j.js => index-D0oznGRE.js} (61%) create mode 100644 tests/test_or_arch_continuous.py diff --git a/dashboard-frontend/src/components/Layout.tsx b/dashboard-frontend/src/components/Layout.tsx index 8708bcc..759751b 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -15,6 +15,7 @@ import { import { fetchStatus, type SystemStatus } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' import { useToast } from './ToastProvider' +import RestartBanner from './RestartBanner' interface LayoutProps { children: ReactNode @@ -179,7 +180,8 @@ export default function Layout({ children }: LayoutProps) { {/* Page content */} -
{children}
+
+ {children}
) diff --git a/dashboard-frontend/src/components/RestartBanner.tsx b/dashboard-frontend/src/components/RestartBanner.tsx new file mode 100644 index 0000000..ee96984 --- /dev/null +++ b/dashboard-frontend/src/components/RestartBanner.tsx @@ -0,0 +1,136 @@ +// v0.6-tail-3 RestartBanner.tsx +// +// Sticky top banner that becomes visible when any /api/config/
PUT +// returns restart_required:true. Reads from localStorage so the banner +// persists across page navigations until the user explicitly restarts or +// dismisses. +// +// Producer: pages doing a config PUT call notifyRestartRequired(...). +// Consumer: this component, mounted once at the Layout level, listens to +// the 'meshai:restart-required' window CustomEvent and to the 'storage' +// event so a tab opened in two windows stays in sync. + +import { useEffect, useState, useCallback } from 'react' +import { AlertTriangle, RotateCw, X } from 'lucide-react' + +const LS_KEY = 'meshai.restartRequired.v1' + +interface RestartState { + required: boolean + changedKeys: string[] + ts: number // when the most recent restart-required PUT happened +} + + +function readState(): RestartState { + try { + const raw = localStorage.getItem(LS_KEY) + if (!raw) return { required: false, changedKeys: [], ts: 0 } + const parsed = JSON.parse(raw) + return { + required: Boolean(parsed.required), + changedKeys: Array.isArray(parsed.changedKeys) ? parsed.changedKeys : [], + ts: Number(parsed.ts) || 0, + } + } catch { + return { required: false, changedKeys: [], ts: 0 } + } +} + + +export function notifyRestartRequired(changedKeys: string[]) { + const state: RestartState = { + required: true, + changedKeys: [...new Set(changedKeys)], + ts: Date.now(), + } + localStorage.setItem(LS_KEY, JSON.stringify(state)) + window.dispatchEvent(new CustomEvent('meshai:restart-required', { detail: state })) +} + + +export function clearRestartRequired() { + localStorage.removeItem(LS_KEY) + window.dispatchEvent(new CustomEvent('meshai:restart-required', + { detail: { required: false, changedKeys: [], ts: 0 } })) +} + + +export default function RestartBanner() { + const [state, setState] = useState(() => readState()) + const [restarting, setRestarting] = useState(false) + const [error, setError] = useState(null) + + // Subscribe to both same-tab CustomEvent and cross-tab storage event. + useEffect(() => { + const onCustom = (e: Event) => { + const detail = (e as CustomEvent).detail as RestartState + setState(detail) + } + const onStorage = (e: StorageEvent) => { + if (e.key === LS_KEY) setState(readState()) + } + window.addEventListener('meshai:restart-required', onCustom) + window.addEventListener('storage', onStorage) + return () => { + window.removeEventListener('meshai:restart-required', onCustom) + window.removeEventListener('storage', onStorage) + } + }, []) + + const onRestart = useCallback(async () => { + setRestarting(true) + setError(null) + try { + const res = await fetch('/api/system/restart', { method: 'POST' }) + if (!res.ok && res.status !== 202) { + const body = await res.json().catch(() => ({})) + throw new Error(body.detail || `HTTP ${res.status}`) + } + // Clear the banner immediately; the container will tear down and the + // dashboard will need a refresh anyway. + clearRestartRequired() + } catch (e) { + setError(String(e)) + setRestarting(false) + } + }, []) + + const onDismiss = useCallback(() => { + clearRestartRequired() + }, []) + + if (!state.required) return null + + return ( +
+ +
+ Container restart required + {state.changedKeys.length > 0 && ( + + ({state.changedKeys.length} key{state.changedKeys.length === 1 ? '' : 's'}:{' '} + {state.changedKeys.slice(0, 3).join(', ')}{state.changedKeys.length > 3 ? ', …' : ''}) + + )} + + for these changes to take effect. Until then the runtime keeps its boot-time configuration. + + {error &&
{error}
} +
+ + +
+ ) +} diff --git a/dashboard-frontend/src/pages/Config.tsx b/dashboard-frontend/src/pages/Config.tsx index 6ddc7a6..1fbcd3a 100644 --- a/dashboard-frontend/src/pages/Config.tsx +++ b/dashboard-frontend/src/pages/Config.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' +import { notifyRestartRequired } from '@/components/RestartBanner' import NodePicker from '@/components/NodePicker' import ChannelPicker from '@/components/ChannelPicker' import { @@ -1851,6 +1852,8 @@ export default function Config() { if (result.restart_required) { setRestartRequired(true) + // v0.6-tail-3: surface the cross-page banner with the changed-key list. + notifyRestartRequired(Array.isArray(result.changed_keys) ? result.changed_keys : []) } setTimeout(() => setSuccess(null), 3000) diff --git a/dashboard-frontend/src/pages/GaugeSites.tsx b/dashboard-frontend/src/pages/GaugeSites.tsx index 439f5f4..ff364fb 100644 --- a/dashboard-frontend/src/pages/GaugeSites.tsx +++ b/dashboard-frontend/src/pages/GaugeSites.tsx @@ -1,6 +1,6 @@ // v0.6-4 GaugeSites table editor. import { useEffect, useState, useCallback } from 'react' -import { Loader2, Plus, Trash2, Check, X, Droplets } from 'lucide-react' +import { Loader2, Plus, Trash2, Check, X, Droplets, Search } from 'lucide-react' interface GaugeSite { site_id: string @@ -28,6 +28,8 @@ export default function GaugeSites() { const [editing, setEditing] = useState(null) const [draft, setDraft] = useState(EMPTY_DRAFT) const [adding, setAdding] = useState(false) + // v0.6-tail-3: USGS lookup is only available when usgs.feed_source==='native'. + const [feedSource, setFeedSource] = useState('unknown') const refresh = useCallback(async () => { setLoading(true) @@ -45,6 +47,13 @@ export default function GaugeSites() { useEffect(() => { refresh() }, [refresh]) + // v0.6-tail-3: probe usgs feed_source once at mount. + useEffect(() => { + fetch('/api/config/environmental').then(r => r.json()) + .then(env => setFeedSource(env?.usgs?.feed_source || 'unknown')) + .catch(() => setFeedSource('unknown')) + }, []) + const beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false) } const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) } const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) } @@ -96,7 +105,7 @@ export default function GaugeSites() { ignored at envelope time. Changes propagate to the handler on the next event.

- {adding && } + {adding && }
@@ -117,7 +126,7 @@ export default function GaugeSites() { {rows.map(r => editing === r.site_id ? ( ) : ( @@ -144,17 +153,72 @@ export default function GaugeSites() { } -function RowEditor({ draft, setDraft, onSave, onCancel, adding }: { +function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: { draft: GaugeSite, setDraft: (g: GaugeSite) => void, onSave: () => void, onCancel: () => void, adding?: boolean, + feedSource?: string, }) { const upd = (k: keyof GaugeSite, v: unknown) => setDraft({ ...draft, [k]: v }) + + // v0.6-tail-3: USGS lookup helper. Only available when usgs.feed_source + // is 'native' -- in central-feed mode a direct upstream call would be + // the AND-model anti-pattern Central's v0.10.2 report flagged. + const [lookupBusy, setLookupBusy] = useState(false) + const [lookupError, setLookupError] = useState(null) + const lookupDisabled = feedSource !== 'native' || !draft.site_id.trim() + const lookupTitle = feedSource !== 'native' + ? 'USGS lookup not available in central-feed mode (would be AND-model anti-pattern). Enter values manually.' + : !draft.site_id.trim() + ? 'Enter a site_id first' + : 'Auto-populate from USGS / NWS NWPS' + const onLookup = async () => { + if (lookupDisabled) return + setLookupBusy(true); setLookupError(null) + try { + const raw = draft.site_id.replace(/^USGS-/i, '') + const res = await fetch(`/api/env/usgs/lookup/${encodeURIComponent(raw)}`) + if (res.status === 404) { + const body = await res.json().catch(() => ({})) + setLookupError(body.detail || 'Lookup unavailable -- enter values manually') + setLookupBusy(false) + return + } + if (!res.ok) { + setLookupError(`Lookup failed (${res.status})`) + setLookupBusy(false) + return + } + const data = await res.json() + const next = { ...draft } + if (data.name && !next.gauge_name) next.gauge_name = data.name + if (typeof data.lat === 'number') next.lat = data.lat + if (typeof data.lon === 'number') next.lon = data.lon + if (typeof data.action_ft === 'number') next.action_ft = data.action_ft + if (typeof data.flood_minor_ft === 'number') next.flood_minor_ft = data.flood_minor_ft + if (typeof data.flood_moderate_ft === 'number') next.flood_moderate_ft = data.flood_moderate_ft + if (typeof data.flood_major_ft === 'number') next.flood_major_ft = data.flood_major_ft + setDraft(next) + } catch (e) { + setLookupError(String(e)) + } finally { + setLookupBusy(false) + } + } return (
- +