mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.6-3c): adapter_config REST API + dashboard editor
Closes the audit-doc Section A keystone (the GUI editor). Together with
v0.6-3a foundation, v0.6-3a.1 trim, and v0.6-3b handler wiring, every
Rule-17 CONFIG knob from the audit is now editable in the dashboard
without a container restart.
API (meshai/dashboard/api/adapter_config_routes.py):
GET /api/adapter-config -- {adapter: [{key, value, default,
type, description}]}
GET /api/adapter-config/<adapter> -- one adapter list
GET /api/adapter-config/<adapter>/<key> -- single row
PUT /api/adapter-config/<adapter>/<key> body {value} -- typed validation
int: int or whole-number float; rejects bool, fractional float, str
float: int or float; rejects bool
str: str only
bool: bool only
json: any JSON-serializable value
Every PUT calls invalidate_cache() so the next handler accessor
read sees the new value -- no container restart needed.
POST /api/adapter-config/<adapter>/<key>/reset -- value_json = default_json,
cache invalidated
GET /api/adapter-meta -- {adapter: {display_name,
include_in_llm_context, description}}
PUT /api/adapter-meta/<adapter> partial-update body, fields:
include_in_llm_context: bool, display_name: non-empty str
Dashboard (dashboard-frontend/src/pages/AdapterConfig.tsx):
- Per-adapter cards. Header row shows display_name, the include_in_llm_context
toggle, and an expand chevron. Adapters with zero config keys (e.g. itd_511)
still render so users can toggle their LLM-context inclusion.
- Expanded body lists each key with a type-aware widget:
bool -> checkbox, commit-on-change
int/float -> number input, commit-on-blur (or Enter)
str -> text input, commit-on-blur
json -> textarea, commit-on-blur (JSON.parse with inline error)
Each row shows the key name, type tag, description, "edited" badge when
value != default, a per-key Reset button, and a save badge (spinner,
check, error tooltip, or a small amber dot for unsaved local changes).
- Auto-save semantics: every blur/change/reset triggers PUT immediately;
no explicit Save button needed. Reset is one-click per key.
Wiring:
- meshai/dashboard/server.py registers the new router with prefix /api.
- dashboard-frontend/src/App.tsx adds the /adapter-config route.
- dashboard-frontend/src/components/Layout.tsx adds the left-nav entry
(Sliders icon, label "Adapter Config", after Reference).
- Vite build produces a fresh meshai/dashboard/static bundle. The
Dockerfile copies meshai/ so the new bundle ships with the container
image at next rebuild.
Tests (tests/test_adapter_config_api.py, 30 cases):
- GET grouped, per-adapter, single key
- GET per-adapter returns [] for adapters with zero keys (itd_511)
- PUT updates value, GET shows new value, accessor returns new value
(proves cache invalidation propagates to the in-process accessor)
- PUT type validation per (int, float, str, bool, json) incl. edge cases:
int rejects str + fractional float + bool but accepts whole-number float;
float accepts int + float, rejects bool; bool rejects int; str rejects
other types; json accepts list / dict / None
- PUT 404 on unknown key, 400 on missing value field
- POST reset restores default + invalidates cache
- GET /api/adapter-meta: include_in_llm_context defaults match registry
(central / geocoder false, rest true)
- PUT meta partial update: only provided fields change
- PUT meta rejects non-bool include_in_llm_context, empty display_name,
unknown adapter
Test count: 731 -> 761 (+30 API cases, 0 regressions).
Refs audit doc v0.6-phase1-audit.md Section A keystone + finding #4.
This commit is contained in:
parent
914d38c907
commit
42b3106e97
10 changed files with 1614 additions and 521 deletions
|
|
@ -7,6 +7,7 @@ import Config from './pages/Config'
|
||||||
import Alerts from './pages/Alerts'
|
import Alerts from './pages/Alerts'
|
||||||
import Notifications from './pages/Notifications'
|
import Notifications from './pages/Notifications'
|
||||||
import Reference from './pages/Reference'
|
import Reference from './pages/Reference'
|
||||||
|
import AdapterConfig from './pages/AdapterConfig'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -21,6 +22,7 @@ function App() {
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
<Route path="/reference" element={<Reference />} />
|
<Route path="/reference" element={<Reference />} />
|
||||||
|
<Route path="/adapter-config" element={<AdapterConfig />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
BellRing,
|
BellRing,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Sliders,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
|
|
@ -25,6 +26,7 @@ const navItems = [
|
||||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
||||||
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
||||||
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
||||||
|
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
function formatUptime(seconds: number): string {
|
||||||
|
|
|
||||||
416
dashboard-frontend/src/pages/AdapterConfig.tsx
Normal file
416
dashboard-frontend/src/pages/AdapterConfig.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
// v0.6-3c Adapter Config editor.
|
||||||
|
//
|
||||||
|
// Renders one card per adapter. Each card shows:
|
||||||
|
// - display_name + include_in_llm_context toggle at the top
|
||||||
|
// - expandable list of (config key, type-aware widget, reset button)
|
||||||
|
//
|
||||||
|
// Auto-saves on blur (text/number inputs) or change (bool toggle + select).
|
||||||
|
// Cache invalidation is server-side -- every PUT triggers it. The handler
|
||||||
|
// reads via the in-process accessor on its next call.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
ChevronDown, ChevronRight, RotateCcw, Loader2, Check, AlertCircle,
|
||||||
|
Sliders,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface ConfigRow {
|
||||||
|
adapter: string
|
||||||
|
key: string
|
||||||
|
value: unknown
|
||||||
|
default: unknown
|
||||||
|
type: 'int' | 'float' | 'str' | 'bool' | 'json'
|
||||||
|
description: string
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaRow {
|
||||||
|
display_name: string
|
||||||
|
include_in_llm_context: boolean
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupedConfig = Record<string, ConfigRow[]>
|
||||||
|
type MetaMap = Record<string, MetaRow>
|
||||||
|
|
||||||
|
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
|
||||||
|
// Brief animation after a successful save.
|
||||||
|
const SAVED_BADGE_MS = 1500
|
||||||
|
|
||||||
|
export default function AdapterConfig() {
|
||||||
|
const [config, setConfig] = useState<GroupedConfig>({})
|
||||||
|
const [meta, setMeta] = useState<MetaMap>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// Per-key save status: keyed on `${adapter}.${key}` or `meta:${adapter}`.
|
||||||
|
const [saveStatus, setSaveStatus] = useState<Record<string, SaveStatus>>({})
|
||||||
|
const [saveError, setSaveError] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [cfgRes, metaRes] = await Promise.all([
|
||||||
|
fetch('/api/adapter-config'),
|
||||||
|
fetch('/api/adapter-meta'),
|
||||||
|
])
|
||||||
|
if (!cfgRes.ok) throw new Error(`GET /adapter-config: ${cfgRes.status}`)
|
||||||
|
if (!metaRes.ok) throw new Error(`GET /adapter-meta: ${metaRes.status}`)
|
||||||
|
setConfig(await cfgRes.json())
|
||||||
|
setMeta(await metaRes.json())
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
|
||||||
|
const markStatus = useCallback((id: string, status: SaveStatus, errMsg?: string) => {
|
||||||
|
setSaveStatus((s) => ({ ...s, [id]: status }))
|
||||||
|
if (errMsg) setSaveError((s) => ({ ...s, [id]: errMsg }))
|
||||||
|
if (status === 'saved') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaveStatus((s) => (s[id] === 'saved' ? { ...s, [id]: 'idle' } : s))
|
||||||
|
}, SAVED_BADGE_MS)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ---------- key-level mutations ----------------------------------------
|
||||||
|
|
||||||
|
const putValue = useCallback(async (adapter: string, key: string, value: unknown) => {
|
||||||
|
const id = `${adapter}.${key}`
|
||||||
|
markStatus(id, 'saving')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
const detail = body.detail || res.statusText
|
||||||
|
markStatus(id, 'error', String(detail))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated: ConfigRow = await res.json()
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
|
||||||
|
}))
|
||||||
|
markStatus(id, 'saved')
|
||||||
|
} catch (e) {
|
||||||
|
markStatus(id, 'error', String(e))
|
||||||
|
}
|
||||||
|
}, [markStatus])
|
||||||
|
|
||||||
|
const resetValue = useCallback(async (adapter: string, key: string) => {
|
||||||
|
const id = `${adapter}.${key}`
|
||||||
|
markStatus(id, 'saving')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/adapter-config/${adapter}/${key}/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
markStatus(id, 'error', `reset failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated: ConfigRow = await res.json()
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
|
||||||
|
}))
|
||||||
|
markStatus(id, 'saved')
|
||||||
|
} catch (e) {
|
||||||
|
markStatus(id, 'error', String(e))
|
||||||
|
}
|
||||||
|
}, [markStatus])
|
||||||
|
|
||||||
|
const putMeta = useCallback(async (adapter: string, body: Partial<MetaRow>) => {
|
||||||
|
const id = `meta:${adapter}`
|
||||||
|
markStatus(id, 'saving')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/adapter-meta/${adapter}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const b = await res.json().catch(() => ({}))
|
||||||
|
markStatus(id, 'error', String(b.detail || res.statusText))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated: MetaRow = await res.json()
|
||||||
|
setMeta((m) => ({ ...m, [adapter]: updated }))
|
||||||
|
markStatus(id, 'saved')
|
||||||
|
} catch (e) {
|
||||||
|
markStatus(id, 'error', String(e))
|
||||||
|
}
|
||||||
|
}, [markStatus])
|
||||||
|
|
||||||
|
// ---------- render ------------------------------------------------------
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex items-center gap-2 text-slate-400">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> Loading adapter config…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-red-400">
|
||||||
|
<AlertCircle className="w-5 h-5 inline mr-2" />
|
||||||
|
Failed to load: {error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union of all adapters: any meta row + any config-having adapter.
|
||||||
|
const allAdapters = Array.from(new Set([
|
||||||
|
...Object.keys(meta),
|
||||||
|
...Object.keys(config),
|
||||||
|
])).sort()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-slate-200">
|
||||||
|
<Sliders className="w-5 h-5" />
|
||||||
|
<h1 className="text-xl font-semibold">Adapter Config</h1>
|
||||||
|
<span className="text-xs text-slate-500 ml-2">
|
||||||
|
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 max-w-3xl">
|
||||||
|
Per-adapter tunables (thresholds, freshness windows, toggles, curation lists).
|
||||||
|
Changes take effect on the next handler call -- no container restart needed.
|
||||||
|
Sentence templates, emoji, and translation maps live in code by design.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{allAdapters.map((adapter) => {
|
||||||
|
const m = meta[adapter] || {
|
||||||
|
display_name: adapter,
|
||||||
|
include_in_llm_context: true,
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
const rows = config[adapter] || []
|
||||||
|
const isExpanded = expanded[adapter] ?? false
|
||||||
|
const metaId = `meta:${adapter}`
|
||||||
|
const metaStatus = saveStatus[metaId] || 'idle'
|
||||||
|
return (
|
||||||
|
<div key={adapter} className="bg-slate-800/60 border border-slate-700 rounded-lg">
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="p-4 flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((e) => ({ ...e, [adapter]: !e[adapter] }))}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
aria-label="toggle expand"
|
||||||
|
>
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronDown className="w-5 h-5" />
|
||||||
|
: <ChevronRight className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-base font-semibold text-slate-100">{m.display_name}</h2>
|
||||||
|
<code className="text-xs text-slate-500">{adapter}</code>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-400 ml-1">({rows.length} settings)</span>
|
||||||
|
)}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-500 ml-1 italic">(meta only)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.description && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{m.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* include_in_llm_context toggle */}
|
||||||
|
<label className="flex items-center gap-2 text-xs text-slate-300 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={m.include_in_llm_context}
|
||||||
|
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-cyan-500"
|
||||||
|
/>
|
||||||
|
LLM context
|
||||||
|
<SaveBadge status={metaStatus} error={saveError[metaId]} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded body */}
|
||||||
|
{isExpanded && rows.length > 0 && (
|
||||||
|
<div className="border-t border-slate-700 divide-y divide-slate-700/60">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<KeyRow
|
||||||
|
key={row.key}
|
||||||
|
row={row}
|
||||||
|
status={saveStatus[`${adapter}.${row.key}`] || 'idle'}
|
||||||
|
error={saveError[`${adapter}.${row.key}`]}
|
||||||
|
onCommit={(v) => putValue(adapter, row.key, v)}
|
||||||
|
onReset={() => resetValue(adapter, row.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- KeyRow ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
interface KeyRowProps {
|
||||||
|
row: ConfigRow
|
||||||
|
status: SaveStatus
|
||||||
|
error?: string
|
||||||
|
onCommit: (v: unknown) => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) {
|
||||||
|
// Use a local draft so number/text inputs don't fight the parent state
|
||||||
|
// mid-edit. Commit on blur.
|
||||||
|
const [draft, setDraft] = useState<string>(stringifyForInput(row))
|
||||||
|
|
||||||
|
// If the parent value changes (e.g. via reset), refresh the local draft.
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(stringifyForInput(row))
|
||||||
|
}, [row.value, row.type])
|
||||||
|
|
||||||
|
const isDirty = draft !== stringifyForInput(row)
|
||||||
|
const isDefault = JSON.stringify(row.value) === JSON.stringify(row.default)
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const parsed = parseFromInput(draft, row.type)
|
||||||
|
if (parsed.error) return // error shown inline; do not PUT
|
||||||
|
if (!parsed.changed(row.value)) return
|
||||||
|
onCommit(parsed.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-3 flex items-start gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-sm font-mono text-cyan-300">{row.key}</code>
|
||||||
|
<span className="text-xs text-slate-500">[{row.type}]</span>
|
||||||
|
{!isDefault && (
|
||||||
|
<span className="text-xs text-amber-400">edited</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.description && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{row.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
||||||
|
{row.type === 'bool' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.value === true}
|
||||||
|
onChange={(e) => onCommit(e.target.checked)}
|
||||||
|
className="w-5 h-5 accent-cyan-500"
|
||||||
|
/>
|
||||||
|
) : row.type === 'json' ? (
|
||||||
|
<textarea
|
||||||
|
className="w-72 h-20 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs font-mono text-slate-100"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={row.type === 'int' || row.type === 'float' ? 'number' : 'text'}
|
||||||
|
step={row.type === 'float' ? 'any' : '1'}
|
||||||
|
className="w-48 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SaveBadge status={status} error={error} dirty={isDirty} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={isDefault}
|
||||||
|
className="text-slate-400 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="Reset to default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- SaveBadge ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
function SaveBadge({ status, error, dirty }: { status: SaveStatus; error?: string; dirty?: boolean }) {
|
||||||
|
if (status === 'saving') return <Loader2 className="w-4 h-4 text-cyan-400 animate-spin" />
|
||||||
|
if (status === 'saved') return <Check className="w-4 h-4 text-emerald-400" />
|
||||||
|
if (status === 'error') return (
|
||||||
|
<span title={error} className="text-red-400 cursor-help">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
if (dirty) return <span className="w-2 h-2 bg-amber-400 rounded-full" title="unsaved" />
|
||||||
|
return <span className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- input <-> JSON helpers ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
function stringifyForInput(row: ConfigRow): string {
|
||||||
|
if (row.type === 'bool') return String(row.value === true)
|
||||||
|
if (row.type === 'json') return JSON.stringify(row.value, null, 2)
|
||||||
|
if (row.value === null || row.value === undefined) return ''
|
||||||
|
return String(row.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFromInput(s: string, type: ConfigRow['type']):
|
||||||
|
| { error: string; value: null; changed: () => boolean }
|
||||||
|
| { error: null; value: unknown; changed: (prev: unknown) => boolean }
|
||||||
|
{
|
||||||
|
if (type === 'int') {
|
||||||
|
const n = Number(s)
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||||
|
return { error: 'expected integer', value: null, changed: () => false }
|
||||||
|
}
|
||||||
|
return { error: null, value: n, changed: (prev) => prev !== n }
|
||||||
|
}
|
||||||
|
if (type === 'float') {
|
||||||
|
const n = Number(s)
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return { error: 'expected number', value: null, changed: () => false }
|
||||||
|
}
|
||||||
|
return { error: null, value: n, changed: (prev) => prev !== n }
|
||||||
|
}
|
||||||
|
if (type === 'str') {
|
||||||
|
return { error: null, value: s, changed: (prev) => prev !== s }
|
||||||
|
}
|
||||||
|
if (type === 'json') {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(s)
|
||||||
|
return { error: null, value: v, changed: (prev) => JSON.stringify(prev) !== JSON.stringify(v) }
|
||||||
|
} catch {
|
||||||
|
return { error: 'invalid JSON', value: null, changed: () => false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// bool branch handled inline -- never reaches parseFromInput.
|
||||||
|
return { error: null, value: s, changed: () => true }
|
||||||
|
}
|
||||||
317
meshai/dashboard/api/adapter_config_routes.py
Normal file
317
meshai/dashboard/api/adapter_config_routes.py
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
"""v0.6-3c adapter_config REST API.
|
||||||
|
|
||||||
|
CRUD over the adapter_config + adapter_meta tables seeded in v0.6-3a.1.
|
||||||
|
Every PUT / reset / meta-update calls invalidate_cache() so the next
|
||||||
|
handler read sees the new value -- no container restart required.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
GET /api/adapter-config
|
||||||
|
Grouped view: {adapter: [{key, value, default, type, description}, ...]}
|
||||||
|
GET /api/adapter-config/{adapter}
|
||||||
|
One adapter's keys.
|
||||||
|
GET /api/adapter-config/{adapter}/{key}
|
||||||
|
Single key.
|
||||||
|
PUT /api/adapter-config/{adapter}/{key} body {"value": <typed>}
|
||||||
|
Validates against the row's declared `type`; 400 on mismatch.
|
||||||
|
Cache invalidated on success.
|
||||||
|
POST /api/adapter-config/{adapter}/{key}/reset
|
||||||
|
Sets value_json = default_json. Cache invalidated.
|
||||||
|
|
||||||
|
GET /api/adapter-meta
|
||||||
|
{adapter: {display_name, include_in_llm_context, description}}
|
||||||
|
PUT /api/adapter-meta/{adapter} body {"include_in_llm_context"?: bool,
|
||||||
|
"display_name"?: str}
|
||||||
|
Partial update; only the provided fields change.
|
||||||
|
|
||||||
|
Type validation rules:
|
||||||
|
int -- accepts int or int-castable JSON number with no fractional part
|
||||||
|
float -- accepts int or float (cast to float)
|
||||||
|
str -- accepts str
|
||||||
|
bool -- accepts bool (true/false only)
|
||||||
|
json -- accepts any JSON value (list / dict / scalar / null)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from meshai.adapter_config import invalidate_cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["adapter_config"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
"""Return a sqlite connection via the persistence layer."""
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
return get_db()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(r) -> dict:
|
||||||
|
"""sqlite3.Row -> plain dict with value_json decoded to a Python value."""
|
||||||
|
out = {
|
||||||
|
"adapter": r["adapter"],
|
||||||
|
"key": r["key"],
|
||||||
|
"type": r["type"],
|
||||||
|
"description": r["description"] or "",
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
}
|
||||||
|
try: out["value"] = json.loads(r["value_json"])
|
||||||
|
except Exception: out["value"] = None
|
||||||
|
try: out["default"] = json.loads(r["default_json"])
|
||||||
|
except Exception: out["default"] = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_value(value: Any, type_tag: str) -> Any:
|
||||||
|
"""Coerce + validate a request value against the declared type.
|
||||||
|
|
||||||
|
Raises HTTPException(400) on mismatch.
|
||||||
|
Returns the coerced Python value (suitable for json.dumps).
|
||||||
|
"""
|
||||||
|
if type_tag == "int":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise HTTPException(400, "int expected, got bool")
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return int(value)
|
||||||
|
raise HTTPException(400, f"int expected, got {type(value).__name__}: {value!r}")
|
||||||
|
|
||||||
|
if type_tag == "float":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise HTTPException(400, "float expected, got bool")
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
raise HTTPException(400, f"float expected, got {type(value).__name__}: {value!r}")
|
||||||
|
|
||||||
|
if type_tag == "str":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise HTTPException(400, f"str expected, got {type(value).__name__}: {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
if type_tag == "bool":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
raise HTTPException(400, f"bool expected, got {type(value).__name__}: {value!r}")
|
||||||
|
|
||||||
|
if type_tag == "json":
|
||||||
|
# Any JSON-encodable value: scalar, list, dict, null. Verify by round-trip.
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
return value
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise HTTPException(400, f"json expected (not serializable): {e}")
|
||||||
|
|
||||||
|
raise HTTPException(500, f"unknown type tag {type_tag!r} in adapter_config row")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# adapter_config endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adapter-config")
|
||||||
|
async def list_adapter_config(request: Request) -> dict:
|
||||||
|
"""Returns a grouped view: {adapter: [{key, value, default, type, description}, ...]}.
|
||||||
|
|
||||||
|
Ordered: adapter names alphabetically, keys within each adapter alphabetically.
|
||||||
|
"""
|
||||||
|
conn = _get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT adapter, key, value_json, default_json, type, description, updated_at "
|
||||||
|
"FROM adapter_config ORDER BY adapter, key"
|
||||||
|
).fetchall()
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
grouped.setdefault(r["adapter"], []).append(_row_to_dict(r))
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adapter-config/{adapter}")
|
||||||
|
async def list_adapter(adapter: str, request: Request) -> list[dict]:
|
||||||
|
conn = _get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT adapter, key, value_json, default_json, type, description, updated_at "
|
||||||
|
"FROM adapter_config WHERE adapter=? ORDER BY key",
|
||||||
|
(adapter,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
# Empty list is valid for an adapter that has zero config keys (e.g.
|
||||||
|
# itd_511 post v0.6-3a.1). The caller can distinguish from a fully
|
||||||
|
# unknown adapter via /api/adapter-meta.
|
||||||
|
return []
|
||||||
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adapter-config/{adapter}/{key}")
|
||||||
|
async def get_one(adapter: str, key: str, request: Request) -> dict:
|
||||||
|
conn = _get_conn()
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT adapter, key, value_json, default_json, type, description, updated_at "
|
||||||
|
"FROM adapter_config WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
if r is None:
|
||||||
|
raise HTTPException(404, f"adapter_config: {adapter}.{key} not found")
|
||||||
|
return _row_to_dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/adapter-config/{adapter}/{key}")
|
||||||
|
async def put_one(adapter: str, key: str, request: Request) -> dict:
|
||||||
|
"""Update value_json. Body must be {"value": <typed-or-json-compatible>}.
|
||||||
|
|
||||||
|
Validates against the row's declared type; 400 on mismatch.
|
||||||
|
Invalidates the accessor cache on success.
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict) or "value" not in body:
|
||||||
|
raise HTTPException(400, "body must be a JSON object with a 'value' field")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT type FROM adapter_config WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
if r is None:
|
||||||
|
raise HTTPException(404, f"adapter_config: {adapter}.{key} not found")
|
||||||
|
|
||||||
|
coerced = _validate_value(body["value"], r["type"])
|
||||||
|
new_value_json = json.dumps(coerced)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE adapter_config SET value_json=?, updated_at=? "
|
||||||
|
"WHERE adapter=? AND key=?",
|
||||||
|
(new_value_json, time.time(), adapter, key),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the in-process cache so the next handler read sees the new value.
|
||||||
|
invalidate_cache()
|
||||||
|
logger.info("adapter_config PUT: %s.%s = %r", adapter, key, coerced)
|
||||||
|
|
||||||
|
# Return the updated row.
|
||||||
|
r2 = conn.execute(
|
||||||
|
"SELECT adapter, key, value_json, default_json, type, description, updated_at "
|
||||||
|
"FROM adapter_config WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(r2)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/adapter-config/{adapter}/{key}/reset")
|
||||||
|
async def reset_one(adapter: str, key: str, request: Request) -> dict:
|
||||||
|
"""Set value_json = default_json. Invalidates the accessor cache."""
|
||||||
|
conn = _get_conn()
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT default_json FROM adapter_config WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
if r is None:
|
||||||
|
raise HTTPException(404, f"adapter_config: {adapter}.{key} not found")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE adapter_config SET value_json=?, updated_at=? "
|
||||||
|
"WHERE adapter=? AND key=?",
|
||||||
|
(r["default_json"], time.time(), adapter, key),
|
||||||
|
)
|
||||||
|
invalidate_cache()
|
||||||
|
logger.info("adapter_config RESET: %s.%s -> default", adapter, key)
|
||||||
|
|
||||||
|
r2 = conn.execute(
|
||||||
|
"SELECT adapter, key, value_json, default_json, type, description, updated_at "
|
||||||
|
"FROM adapter_config WHERE adapter=? AND key=?",
|
||||||
|
(adapter, key),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(r2)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# adapter_meta endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adapter-meta")
|
||||||
|
async def list_meta(request: Request) -> dict:
|
||||||
|
conn = _get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT adapter, display_name, include_in_llm_context, description "
|
||||||
|
"FROM adapter_meta ORDER BY adapter"
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
r["adapter"]: {
|
||||||
|
"display_name": r["display_name"],
|
||||||
|
"include_in_llm_context": bool(r["include_in_llm_context"]),
|
||||||
|
"description": r["description"] or "",
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/adapter-meta/{adapter}")
|
||||||
|
async def put_meta(adapter: str, request: Request) -> dict:
|
||||||
|
"""Partial update: only the fields present in the body change.
|
||||||
|
|
||||||
|
Accepted fields:
|
||||||
|
include_in_llm_context : bool
|
||||||
|
display_name : str (non-empty)
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "body must be a JSON object")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT display_name, include_in_llm_context, description "
|
||||||
|
"FROM adapter_meta WHERE adapter=?",
|
||||||
|
(adapter,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(404, f"adapter_meta: {adapter} not found")
|
||||||
|
|
||||||
|
new_include = existing["include_in_llm_context"]
|
||||||
|
new_name = existing["display_name"]
|
||||||
|
|
||||||
|
if "include_in_llm_context" in body:
|
||||||
|
v = body["include_in_llm_context"]
|
||||||
|
if not isinstance(v, bool):
|
||||||
|
raise HTTPException(400, "include_in_llm_context must be bool")
|
||||||
|
new_include = 1 if v else 0
|
||||||
|
|
||||||
|
if "display_name" in body:
|
||||||
|
v = body["display_name"]
|
||||||
|
if not isinstance(v, str) or not v.strip():
|
||||||
|
raise HTTPException(400, "display_name must be a non-empty string")
|
||||||
|
new_name = v.strip()
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE adapter_meta SET display_name=?, include_in_llm_context=?, updated_at=? "
|
||||||
|
"WHERE adapter=?",
|
||||||
|
(new_name, new_include, time.time(), adapter),
|
||||||
|
)
|
||||||
|
# adapter_meta doesn't affect the in-memory cache directly, but the meta
|
||||||
|
# might gate LLM-context inclusion in the env_reporter (commit #5).
|
||||||
|
# Invalidating is cheap insurance.
|
||||||
|
invalidate_cache()
|
||||||
|
logger.info("adapter_meta PUT: %s -> %s", adapter, body)
|
||||||
|
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT display_name, include_in_llm_context, description "
|
||||||
|
"FROM adapter_meta WHERE adapter=?",
|
||||||
|
(adapter,),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"display_name": r["display_name"],
|
||||||
|
"include_in_llm_context": bool(r["include_in_llm_context"]),
|
||||||
|
"description": r["description"] or "",
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""FastAPI server for MeshAI dashboard."""
|
"""FastAPI server for MeshAI dashboard."""
|
||||||
|
from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -55,6 +56,7 @@ def create_app() -> FastAPI:
|
||||||
from .api.notification_routes import router as notification_router
|
from .api.notification_routes import router as notification_router
|
||||||
|
|
||||||
app.include_router(system_router, prefix="/api")
|
app.include_router(system_router, prefix="/api")
|
||||||
|
app.include_router(adapter_config_router, prefix="/api")
|
||||||
app.include_router(config_router, prefix="/api")
|
app.include_router(config_router, prefix="/api")
|
||||||
app.include_router(mesh_router, prefix="/api")
|
app.include_router(mesh_router, prefix="/api")
|
||||||
app.include_router(env_router, prefix="/api")
|
app.include_router(env_router, prefix="/api")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
528
meshai/dashboard/static/assets/index-B7WUE5ni.js
Normal file
528
meshai/dashboard/static/assets/index-B7WUE5ni.js
Normal file
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-DwsA2DLM.js"></script>
|
<script type="module" crossorigin src="/assets/index-B7WUE5ni.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CHkr5tDL.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B1y0CpOn.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
344
tests/test_adapter_config_api.py
Normal file
344
tests/test_adapter_config_api.py
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
"""v0.6-3c API tests for adapter_config + adapter_meta routes.
|
||||||
|
|
||||||
|
Uses FastAPI TestClient against a tmp DB seeded by the conftest autouse
|
||||||
|
fixture. Covers: GET (list, per-adapter, single), PUT (incl. type
|
||||||
|
validation), POST reset, GET/PUT meta, cache invalidation propagation
|
||||||
|
to the accessor.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from meshai.adapter_config import adapter_config, invalidate_cache
|
||||||
|
from meshai.dashboard.api.adapter_config_routes import router
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router, prefix="/api")
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET /api/adapter-config (grouped)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_returns_all_43_keys(client):
|
||||||
|
r = client.get("/api/adapter-config")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# 14 adapters with at least one key (itd_511 has zero -- not in the
|
||||||
|
# grouped dict because the SQL only returns rows that exist).
|
||||||
|
total = sum(len(v) for v in body.values())
|
||||||
|
assert total == 43
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_grouped_by_adapter(client):
|
||||||
|
r = client.get("/api/adapter-config")
|
||||||
|
body = r.json()
|
||||||
|
assert "wfigs" in body
|
||||||
|
keys = {row["key"] for row in body["wfigs"]}
|
||||||
|
assert keys == {"cooldown_seconds", "anchor_max_mi",
|
||||||
|
"broadcast_on_acres", "broadcast_on_contained"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_includes_type_value_default_description(client):
|
||||||
|
r = client.get("/api/adapter-config")
|
||||||
|
body = r.json()
|
||||||
|
row = next(row for row in body["wfigs"] if row["key"] == "cooldown_seconds")
|
||||||
|
assert row["value"] == 28800
|
||||||
|
assert row["default"] == 28800
|
||||||
|
assert row["type"] == "int"
|
||||||
|
assert row["description"]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET /api/adapter-config/{adapter}
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_adapter_list(client):
|
||||||
|
r = client.get("/api/adapter-config/usgs_quake")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert isinstance(body, list)
|
||||||
|
keys = {row["key"] for row in body}
|
||||||
|
assert keys == {
|
||||||
|
"regional_centroid", "regional_radius_mi",
|
||||||
|
"broadcast_pager_alerts", "global_mag_floor",
|
||||||
|
"regional_mag_floor", "escalate_mag_floor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_adapter_empty_for_itd_511(client):
|
||||||
|
"""itd_511 has zero config keys post-3a.1; returns empty list, not 404."""
|
||||||
|
r = client.get("/api/adapter-config/itd_511")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET /api/adapter-config/{adapter}/{key}
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_single_key(client):
|
||||||
|
r = client.get("/api/adapter-config/usgs_quake/global_mag_floor")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["value"] == 3.0
|
||||||
|
assert body["default"] == 3.0
|
||||||
|
assert body["type"] == "float"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unknown_key_404(client):
|
||||||
|
r = client.get("/api/adapter-config/wfigs/no_such_key")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PUT /api/adapter-config/{adapter}/{key}
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_updates_value(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
||||||
|
json={"value": 2.8},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["value"] == 2.8
|
||||||
|
# GET reflects the new value.
|
||||||
|
g = client.get("/api/adapter-config/usgs_quake/global_mag_floor")
|
||||||
|
assert g.json()["value"] == 2.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_invalidates_accessor_cache(client):
|
||||||
|
"""The handler-side accessor reads the new value WITHOUT a restart."""
|
||||||
|
# Prime the cache with the default.
|
||||||
|
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
||||||
|
# Mutate via API.
|
||||||
|
client.put(
|
||||||
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
||||||
|
json={"value": 2.5},
|
||||||
|
)
|
||||||
|
# Accessor returns the new value -- cache invalidation worked.
|
||||||
|
assert adapter_config.usgs_quake.global_mag_floor == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_int_validation(client):
|
||||||
|
"""int field rejects string body."""
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
||||||
|
json={"value": "two hours"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_int_rejects_float_with_fraction(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
||||||
|
json={"value": 3600.5},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_int_accepts_float_with_integer_value(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
||||||
|
json={"value": 3600.0},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["value"] == 3600
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_float_accepts_int(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
||||||
|
json={"value": 4},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["value"] == 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_bool_rejects_int(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/wfigs/broadcast_on_acres",
|
||||||
|
json={"value": 1},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_str_validation(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/geocoder/photon_url",
|
||||||
|
json={"value": 42},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_json_accepts_list(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/nws/broadcast_severities",
|
||||||
|
json={"value": ["Extreme"]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["value"] == ["Extreme"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_json_accepts_dict(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/central/severity_thresholds",
|
||||||
|
json={"value": {"routine_max": 0, "priority_max": 1, "immediate_min": 2}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_json_accepts_none(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/firms/bbox",
|
||||||
|
json={"value": None},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["value"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_unknown_key_404(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/wfigs/no_such_key",
|
||||||
|
json={"value": 1},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_missing_value_field(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-config/wfigs/cooldown_seconds",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# POST /api/adapter-config/{adapter}/{key}/reset
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_restores_default(client):
|
||||||
|
# Mutate.
|
||||||
|
client.put(
|
||||||
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
||||||
|
json={"value": 2.8},
|
||||||
|
)
|
||||||
|
assert client.get("/api/adapter-config/usgs_quake/global_mag_floor").json()["value"] == 2.8
|
||||||
|
|
||||||
|
# Reset.
|
||||||
|
r = client.post("/api/adapter-config/usgs_quake/global_mag_floor/reset")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["value"] == 3.0
|
||||||
|
# Accessor too.
|
||||||
|
invalidate_cache()
|
||||||
|
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_invalidates_cache(client):
|
||||||
|
"""Reset must invalidate the accessor cache the same as PUT."""
|
||||||
|
client.put(
|
||||||
|
"/api/adapter-config/usgs_quake/global_mag_floor",
|
||||||
|
json={"value": 2.8},
|
||||||
|
)
|
||||||
|
# Prime cache with the post-PUT value.
|
||||||
|
assert adapter_config.usgs_quake.global_mag_floor == 2.8
|
||||||
|
client.post("/api/adapter-config/usgs_quake/global_mag_floor/reset")
|
||||||
|
# Cache cleared -- next read returns the default.
|
||||||
|
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_unknown_key_404(client):
|
||||||
|
r = client.post("/api/adapter-config/wfigs/no_such_key/reset")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET /api/adapter-meta
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_meta(client):
|
||||||
|
r = client.get("/api/adapter-meta")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "wfigs" in body
|
||||||
|
assert body["wfigs"]["include_in_llm_context"] is True
|
||||||
|
# central / geocoder default to False
|
||||||
|
assert body["central"]["include_in_llm_context"] is False
|
||||||
|
assert body["geocoder"]["include_in_llm_context"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PUT /api/adapter-meta/{adapter}
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_meta_toggles_llm_context(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-meta/itd_511",
|
||||||
|
json={"include_in_llm_context": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["include_in_llm_context"] is False
|
||||||
|
# GET reflects the change.
|
||||||
|
g = client.get("/api/adapter-meta").json()
|
||||||
|
assert g["itd_511"]["include_in_llm_context"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_meta_updates_display_name(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-meta/wfigs",
|
||||||
|
json={"display_name": "Active wildfires (WFIGS)"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["display_name"] == "Active wildfires (WFIGS)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_meta_partial_update(client):
|
||||||
|
"""Only the fields in the body change; others survive."""
|
||||||
|
original = client.get("/api/adapter-meta").json()["wfigs"]
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-meta/wfigs",
|
||||||
|
json={"include_in_llm_context": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["display_name"] == original["display_name"] # unchanged
|
||||||
|
assert body["include_in_llm_context"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_meta_rejects_bad_bool(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-meta/wfigs",
|
||||||
|
json={"include_in_llm_context": "yes"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_meta_rejects_empty_display_name(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-meta/wfigs",
|
||||||
|
json={"display_name": " "},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_meta_unknown_adapter_404(client):
|
||||||
|
r = client.put(
|
||||||
|
"/api/adapter-meta/nonexistent_adapter",
|
||||||
|
json={"include_in_llm_context": False},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
Loading…
Add table
Add a link
Reference in a new issue