mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
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 <RestartBanner /> 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) <noreply@anthropic.com>
This commit is contained in:
parent
24763947c3
commit
f89e9c11fb
10 changed files with 641 additions and 151 deletions
|
|
@ -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) {
|
|||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto p-6"><RestartBanner />
|
||||
{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
136
dashboard-frontend/src/components/RestartBanner.tsx
Normal file
136
dashboard-frontend/src/components/RestartBanner.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// v0.6-tail-3 RestartBanner.tsx
|
||||
//
|
||||
// Sticky top banner that becomes visible when any /api/config/<section> 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<RestartState>(() => readState())
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="bg-yellow-900/40 border-b border-yellow-700 text-yellow-100 px-4 py-2 text-sm flex items-center gap-3">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 text-yellow-300" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<strong>Container restart required</strong>
|
||||
{state.changedKeys.length > 0 && (
|
||||
<span className="text-yellow-300 ml-2">
|
||||
({state.changedKeys.length} key{state.changedKeys.length === 1 ? '' : 's'}:{' '}
|
||||
<span className="font-mono text-xs">{state.changedKeys.slice(0, 3).join(', ')}{state.changedKeys.length > 3 ? ', …' : ''}</span>)
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-yellow-300/80">
|
||||
for these changes to take effect. Until then the runtime keeps its boot-time configuration.
|
||||
</span>
|
||||
{error && <div className="text-red-400 text-xs mt-1">{error}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
disabled={restarting}
|
||||
className="flex items-center gap-1 px-3 py-1 bg-yellow-700 hover:bg-yellow-600 disabled:opacity-50 rounded text-white text-xs">
|
||||
<RotateCw className={`w-3 h-3 ${restarting ? 'animate-spin' : ''}`} />
|
||||
{restarting ? 'Restarting…' : 'Restart now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-yellow-300 hover:text-white px-1"
|
||||
title="Dismiss (you can still restart later)">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue