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 (
- +