mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +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 { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
import { useToast } from './ToastProvider'
|
import { useToast } from './ToastProvider'
|
||||||
|
import RestartBanner from './RestartBanner'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
@ -179,7 +180,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* 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>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import {
|
import {
|
||||||
|
|
@ -1851,6 +1852,8 @@ export default function Config() {
|
||||||
|
|
||||||
if (result.restart_required) {
|
if (result.restart_required) {
|
||||||
setRestartRequired(true)
|
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)
|
setTimeout(() => setSuccess(null), 3000)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// v0.6-4 GaugeSites table editor.
|
// v0.6-4 GaugeSites table editor.
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
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 {
|
interface GaugeSite {
|
||||||
site_id: string
|
site_id: string
|
||||||
|
|
@ -28,6 +28,8 @@ export default function GaugeSites() {
|
||||||
const [editing, setEditing] = useState<string | null>(null)
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
const [draft, setDraft] = useState<GaugeSite>(EMPTY_DRAFT)
|
const [draft, setDraft] = useState<GaugeSite>(EMPTY_DRAFT)
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
|
// v0.6-tail-3: USGS lookup is only available when usgs.feed_source==='native'.
|
||||||
|
const [feedSource, setFeedSource] = useState<string>('unknown')
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -45,6 +47,13 @@ export default function GaugeSites() {
|
||||||
|
|
||||||
useEffect(() => { refresh() }, [refresh])
|
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 beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false) }
|
||||||
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
|
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
|
||||||
const cancel = () => { setEditing(null); setAdding(false); 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.
|
ignored at envelope time. Changes propagate to the handler on the next event.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
|
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />}
|
||||||
|
|
||||||
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto">
|
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm text-slate-200">
|
<table className="w-full text-sm text-slate-200">
|
||||||
|
|
@ -117,7 +126,7 @@ export default function GaugeSites() {
|
||||||
{rows.map(r => editing === r.site_id ? (
|
{rows.map(r => editing === r.site_id ? (
|
||||||
<tr key={r.site_id} className="bg-slate-900/40">
|
<tr key={r.site_id} className="bg-slate-900/40">
|
||||||
<td colSpan={9} className="px-3 py-2">
|
<td colSpan={9} className="px-3 py-2">
|
||||||
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} />
|
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} feedSource={feedSource} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -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,
|
draft: GaugeSite, setDraft: (g: GaugeSite) => void,
|
||||||
onSave: () => void, onCancel: () => void, adding?: boolean,
|
onSave: () => void, onCancel: () => void, adding?: boolean,
|
||||||
|
feedSource?: string,
|
||||||
}) {
|
}) {
|
||||||
const upd = (k: keyof GaugeSite, v: unknown) => setDraft({ ...draft, [k]: v })
|
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<string | null>(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 (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-slate-900/50 rounded">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-slate-900/50 rounded">
|
||||||
<label className="text-xs text-slate-400 col-span-2">
|
<label className="text-xs text-slate-400 col-span-2">
|
||||||
Site ID
|
Site ID
|
||||||
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono text-xs"
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<input className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono text-xs"
|
||||||
value={draft.site_id} onChange={e => upd('site_id', e.target.value)} disabled={!adding} />
|
value={draft.site_id} onChange={e => upd('site_id', e.target.value)} disabled={!adding} />
|
||||||
|
<button type="button" onClick={onLookup} disabled={lookupDisabled || lookupBusy}
|
||||||
|
title={lookupTitle}
|
||||||
|
className="px-2 py-1 bg-slate-700 hover:bg-slate-600 disabled:opacity-30 disabled:cursor-not-allowed rounded text-xs text-slate-100 flex items-center gap-1">
|
||||||
|
{lookupBusy ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
|
||||||
|
USGS lookup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lookupError && <span className="text-amber-400 text-xs mt-1 block">{lookupError}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label className="text-xs text-slate-400 col-span-2">
|
<label className="text-xs text-slate-400 col-span-2">
|
||||||
Gauge name
|
Gauge name
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,22 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["config"])
|
router = APIRouter(tags=["config"])
|
||||||
|
|
||||||
# Sections that require restart when changed
|
# Sections that require restart when changed.
|
||||||
|
# v0.6-tail-3: environmental added. Per Central v0.10.2 OR-not-AND
|
||||||
|
# verification (Spokane fix), env_store rebuild and CentralConsumer
|
||||||
|
# subscribe both happen only at boot. A live PUT to
|
||||||
|
# environmental.<adapter>.feed_source / enabled writes to disk but the
|
||||||
|
# running process keeps polling the existing native adapters AND newly
|
||||||
|
# subscribing to Central until the container restarts -- a transient
|
||||||
|
# AND-mode that violates the architecture for as long as the user
|
||||||
|
# delays the restart.
|
||||||
RESTART_REQUIRED_SECTIONS = {
|
RESTART_REQUIRED_SECTIONS = {
|
||||||
"connection",
|
"connection",
|
||||||
"llm",
|
"llm",
|
||||||
"mesh_sources",
|
"mesh_sources",
|
||||||
"meshmonitor",
|
"meshmonitor",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
|
"environmental",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Valid config section names
|
# Valid config section names
|
||||||
|
|
@ -134,19 +143,44 @@ async def update_config_section(section: str, request: Request):
|
||||||
config_dir = get_config_dir_from_path(config_path)
|
config_dir = get_config_dir_from_path(config_path)
|
||||||
save_section(section, data_to_save, config_dir)
|
save_section(section, data_to_save, config_dir)
|
||||||
|
|
||||||
# Determine if restart is required
|
# v0.6-tail-3: compute the dotted-key diff so the UI banner can
|
||||||
restart_required = section in RESTART_REQUIRED_SECTIONS
|
# show *which* fields require a restart, not just "something
|
||||||
|
# restart-y changed". This is purely advisory -- the static OR
|
||||||
|
# enforcement at boot remains the runtime guard.
|
||||||
|
try:
|
||||||
|
before_section = _section_to_plain(getattr(
|
||||||
|
request.app.state.config, section, None))
|
||||||
|
except Exception:
|
||||||
|
before_section = None
|
||||||
|
after_section = data_to_save
|
||||||
|
changed_keys = _diff_keys(before_section, after_section,
|
||||||
|
prefix=section)
|
||||||
|
|
||||||
# Keep the live config in sync (no disk reload needed) when no restart is required
|
restart_required = (section in RESTART_REQUIRED_SECTIONS
|
||||||
|
and len(changed_keys) > 0)
|
||||||
|
|
||||||
|
# Keep the live config in sync (no disk reload needed) when no
|
||||||
|
# restart is required. When a restart IS required, the live
|
||||||
|
# config object intentionally diverges from disk until the user
|
||||||
|
# actually restarts -- otherwise the runtime would silently
|
||||||
|
# switch into the transient AND-mode this commit exists to
|
||||||
|
# prevent.
|
||||||
if not restart_required and getattr(request.app.state, "config", None) is not None:
|
if not restart_required and getattr(request.app.state, "config", None) is not None:
|
||||||
try:
|
try:
|
||||||
setattr(request.app.state.config, section, new_value)
|
setattr(request.app.state.config, section, new_value)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"Config section '{section}' updated, restart_required={restart_required}")
|
logger.info(
|
||||||
|
"Config section %r updated, restart_required=%s changed_keys=%s",
|
||||||
|
section, restart_required, changed_keys,
|
||||||
|
)
|
||||||
|
|
||||||
return {"saved": True, "restart_required": restart_required}
|
return {
|
||||||
|
"saved": True,
|
||||||
|
"restart_required": restart_required,
|
||||||
|
"changed_keys": changed_keys,
|
||||||
|
}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=422, detail=str(e))
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
|
@ -250,3 +284,51 @@ def register_config_routes_hooks(app):
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("auto-refresh middleware failed")
|
logger.exception("auto-refresh middleware failed")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---- v0.6-tail-3 diff helpers ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _section_to_plain(section_value):
|
||||||
|
"""Dataclass / list / scalar -> JSON-serializable shape."""
|
||||||
|
if section_value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(section_value, list):
|
||||||
|
return [
|
||||||
|
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
|
||||||
|
for item in section_value
|
||||||
|
]
|
||||||
|
if hasattr(section_value, "__dataclass_fields__"):
|
||||||
|
return _dataclass_to_dict(section_value)
|
||||||
|
return section_value
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_keys(before, after, *, prefix: str) -> list[str]:
|
||||||
|
"""Recursively collect dotted-path keys where `before` and `after` differ.
|
||||||
|
|
||||||
|
Lists are compared element-wise -- structural mismatch yields a single
|
||||||
|
bracketless path. The function is deliberately tolerant of None /
|
||||||
|
missing keys so a section being added or removed produces a meaningful
|
||||||
|
diff instead of crashing.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
def walk(b, a, p: str):
|
||||||
|
if b == a:
|
||||||
|
return
|
||||||
|
if isinstance(b, dict) and isinstance(a, dict):
|
||||||
|
for k in set(b.keys()) | set(a.keys()):
|
||||||
|
walk(b.get(k), a.get(k), f"{p}.{k}" if p else k)
|
||||||
|
return
|
||||||
|
if isinstance(b, list) and isinstance(a, list):
|
||||||
|
if len(b) != len(a):
|
||||||
|
out.append(p)
|
||||||
|
return
|
||||||
|
for i, (bi, ai) in enumerate(zip(b, a)):
|
||||||
|
walk(bi, ai, f"{p}[{i}]")
|
||||||
|
return
|
||||||
|
out.append(p)
|
||||||
|
|
||||||
|
walk(before, after, prefix)
|
||||||
|
return sorted(out)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Environmental data API routes."""
|
"""Environmental data API routes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
router = APIRouter(tags=["environment"])
|
||||||
|
|
||||||
|
|
@ -143,26 +143,43 @@ async def lookup_usgs_site(request: Request, site_id: str):
|
||||||
|
|
||||||
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
||||||
Used by the config UI to auto-populate fields when adding a new gauge.
|
Used by the config UI to auto-populate fields when adding a new gauge.
|
||||||
"""
|
|
||||||
|
v0.6-tail-3: when usgs.feed_source != native, this endpoint returns 404
|
||||||
|
instead of creating a temporary USGSStreamsAdapter. The pre-tail-3
|
||||||
|
behavior was an AND-mode anti-pattern -- meshai was in central-feed
|
||||||
|
mode for usgs but the lookup helper hit USGS.gov directly anyway.
|
||||||
|
With this change, the lookup is only available when meshai itself
|
||||||
|
is the polling source. In central-feed mode the GUI must source
|
||||||
|
values manually or via Central."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"error": "Environmental feeds not enabled"}
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Environmental feeds not enabled",
|
||||||
|
)
|
||||||
|
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
usgs_adapter = adapters.get("usgs")
|
usgs_adapter = adapters.get("usgs")
|
||||||
|
|
||||||
if not usgs_adapter:
|
if not usgs_adapter:
|
||||||
# Create a temporary adapter for lookup
|
# No native usgs adapter on the env_store means usgs is either
|
||||||
from meshai.env.usgs import USGSStreamsAdapter
|
# disabled or running on a non-native feed_source (central). In
|
||||||
from meshai.config import USGSConfig
|
# central-feed mode meshai must NOT make direct upstream API calls;
|
||||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
# that's the AND-model anti-pattern Central's v0.10.2 report
|
||||||
|
# called out explicitly. Surface this to the UI as a 404 so the
|
||||||
|
# frontend can switch the form to manual-entry mode.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=("site lookup unavailable in central-feed mode; values "
|
||||||
|
"must be entered manually or sourced from Central"),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = usgs_adapter.lookup_site(site_id)
|
result = usgs_adapter.lookup_site(site_id)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e), "site_id": site_id}
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/traffic")
|
@router.get("/env/traffic")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-WV9oBF1j.js"></script>
|
<script type="module" crossorigin src="/assets/index-D0oznGRE.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-j88L17ja.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BNx9Ej8o.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
181
tests/test_or_arch_continuous.py
Normal file
181
tests/test_or_arch_continuous.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""v0.6-tail-3 tests: env_routes 404 + config_routes restart_required diff."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from meshai.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Gap 1 -- env_routes lookup_usgs_site 404 when feed_source != native
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app_with_env_store(env_store):
|
||||||
|
"""Mount the env router on a minimal FastAPI app w/ env_store on state."""
|
||||||
|
from meshai.dashboard.api.env_routes import router as env_router
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.env_store = env_store
|
||||||
|
app.state.config = Config()
|
||||||
|
app.include_router(env_router, prefix="/api")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_usgs_lookup_404_when_no_env_store():
|
||||||
|
app = _build_app_with_env_store(env_store=None)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get("/api/env/usgs/lookup/13139510")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_usgs_lookup_404_when_no_native_usgs_adapter():
|
||||||
|
"""Central-feed mode: env_store exists but adapters['usgs'] is missing."""
|
||||||
|
env_store = SimpleNamespace(_adapters={}) # no usgs adapter
|
||||||
|
app = _build_app_with_env_store(env_store)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get("/api/env/usgs/lookup/13139510")
|
||||||
|
assert r.status_code == 404
|
||||||
|
body = r.json()
|
||||||
|
# Body must explain the central-feed mode reason so the UI can switch
|
||||||
|
# the form to manual-entry mode.
|
||||||
|
assert "central" in body.get("detail", "").lower()
|
||||||
|
assert "manual" in body.get("detail", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_usgs_lookup_calls_native_adapter_when_available():
|
||||||
|
"""When the env_store has a native usgs adapter, lookup proxies to it."""
|
||||||
|
fake_adapter = MagicMock()
|
||||||
|
fake_adapter.lookup_site = MagicMock(return_value={
|
||||||
|
"site_id": "13139510",
|
||||||
|
"name": "Big Lost River near Mackay",
|
||||||
|
"lat": 43.91,
|
||||||
|
"lon": -113.62,
|
||||||
|
})
|
||||||
|
env_store = SimpleNamespace(_adapters={"usgs": fake_adapter})
|
||||||
|
app = _build_app_with_env_store(env_store)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get("/api/env/usgs/lookup/13139510")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["name"] == "Big Lost River near Mackay"
|
||||||
|
fake_adapter.lookup_site.assert_called_once_with("13139510")
|
||||||
|
|
||||||
|
|
||||||
|
def test_usgs_lookup_502_on_adapter_exception():
|
||||||
|
"""A failure inside the native adapter surfaces as 502, not 200+error."""
|
||||||
|
fake_adapter = MagicMock()
|
||||||
|
fake_adapter.lookup_site = MagicMock(side_effect=RuntimeError("upstream timeout"))
|
||||||
|
env_store = SimpleNamespace(_adapters={"usgs": fake_adapter})
|
||||||
|
app = _build_app_with_env_store(env_store)
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.get("/api/env/usgs/lookup/13139510")
|
||||||
|
assert r.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Gap 2 -- config_routes RESTART_REQUIRED_SECTIONS + changed_keys diff
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_app(tmp_path, monkeypatch):
|
||||||
|
"""FastAPI app w/ config_routes mounted; uses a tmp config dir."""
|
||||||
|
from meshai.dashboard.api.config_routes import router as config_router
|
||||||
|
|
||||||
|
# save_section needs a real config dir to write to.
|
||||||
|
cfg_dir = tmp_path / "cfg"
|
||||||
|
cfg_dir.mkdir()
|
||||||
|
(cfg_dir / "config.yaml").write_text("# stub\n")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.config = Config()
|
||||||
|
app.state.config_path = str(cfg_dir / "config.yaml")
|
||||||
|
app.include_router(config_router, prefix="/api")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_environmental_in_restart_required_sections():
|
||||||
|
from meshai.dashboard.api.config_routes import RESTART_REQUIRED_SECTIONS
|
||||||
|
assert "environmental" in RESTART_REQUIRED_SECTIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_environmental_returns_restart_required_with_changed_keys(config_app):
|
||||||
|
"""A PUT to environmental that changes feed_source returns
|
||||||
|
restart_required=true + the dotted changed_keys list."""
|
||||||
|
client = TestClient(config_app)
|
||||||
|
|
||||||
|
# Fetch current environmental.
|
||||||
|
cur = client.get("/api/config/environmental")
|
||||||
|
assert cur.status_code == 200
|
||||||
|
body = cur.json()
|
||||||
|
# Mutate firms.feed_source from "native" (default) to "central".
|
||||||
|
body["firms"]["feed_source"] = "central"
|
||||||
|
|
||||||
|
r = client.put("/api/config/environmental", json=body)
|
||||||
|
assert r.status_code == 200
|
||||||
|
result = r.json()
|
||||||
|
assert result["saved"] is True
|
||||||
|
assert result["restart_required"] is True
|
||||||
|
# The dotted key must be present.
|
||||||
|
assert any(k.endswith("firms.feed_source") for k in result["changed_keys"]), (
|
||||||
|
f"changed_keys missing firms.feed_source: {result['changed_keys']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_non_restart_section_returns_restart_required_false(config_app):
|
||||||
|
"""A PUT to bot (not restart-required) returns restart_required=false."""
|
||||||
|
client = TestClient(config_app)
|
||||||
|
cur = client.get("/api/config/bot").json()
|
||||||
|
cur["name"] = "NEW_NAME"
|
||||||
|
r = client.put("/api/config/bot", json=cur)
|
||||||
|
assert r.status_code == 200
|
||||||
|
result = r.json()
|
||||||
|
assert result["restart_required"] is False
|
||||||
|
assert "bot.name" in result["changed_keys"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_with_no_changes_returns_empty_changed_keys(config_app):
|
||||||
|
"""A no-op PUT returns restart_required=false + empty changed_keys."""
|
||||||
|
client = TestClient(config_app)
|
||||||
|
cur = client.get("/api/config/bot").json()
|
||||||
|
r = client.put("/api/config/bot", json=cur)
|
||||||
|
assert r.status_code == 200
|
||||||
|
result = r.json()
|
||||||
|
assert result["restart_required"] is False
|
||||||
|
assert result["changed_keys"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_environmental_no_change_does_not_flag_restart(config_app):
|
||||||
|
"""Even though environmental is restart-required, an unchanged PUT must
|
||||||
|
not show restart_required=true. The restart-required state is
|
||||||
|
contingent on real diff."""
|
||||||
|
client = TestClient(config_app)
|
||||||
|
cur = client.get("/api/config/environmental").json()
|
||||||
|
r = client.put("/api/config/environmental", json=cur)
|
||||||
|
assert r.status_code == 200
|
||||||
|
result = r.json()
|
||||||
|
assert result["restart_required"] is False
|
||||||
|
assert result["changed_keys"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_keys_nested():
|
||||||
|
"""Spot-check the _diff_keys helper on nested dicts + lists."""
|
||||||
|
from meshai.dashboard.api.config_routes import _diff_keys
|
||||||
|
before = {"a": 1, "b": {"x": 1, "y": 2}, "c": [1, 2, 3]}
|
||||||
|
after = {"a": 1, "b": {"x": 9, "y": 2}, "c": [1, 2, 3, 4]}
|
||||||
|
keys = _diff_keys(before, after, prefix="root")
|
||||||
|
assert "root.b.x" in keys
|
||||||
|
assert "root.c" in keys # list length differs -> bracketless
|
||||||
|
assert "root.a" not in keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_keys_list_element_changes():
|
||||||
|
from meshai.dashboard.api.config_routes import _diff_keys
|
||||||
|
before = {"a": [1, 2, 3]}
|
||||||
|
after = {"a": [1, 9, 3]}
|
||||||
|
keys = _diff_keys(before, after, prefix="cfg")
|
||||||
|
assert "cfg.a[1]" in keys
|
||||||
Loading…
Add table
Add a link
Reference in a new issue