From 42b3106e9787e212006972afccf56475c4e9efb5 Mon Sep 17 00:00:00 2001 From: "Matt Johnson (via Claude)" Date: Fri, 5 Jun 2026 18:50:30 +0000 Subject: [PATCH] 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/ -- one adapter list GET /api/adapter-config// -- single row PUT /api/adapter-config// 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///reset -- value_json = default_json, cache invalidated GET /api/adapter-meta -- {adapter: {display_name, include_in_llm_context, description}} PUT /api/adapter-meta/ 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. --- dashboard-frontend/src/App.tsx | 2 + dashboard-frontend/src/components/Layout.tsx | 2 + .../src/pages/AdapterConfig.tsx | 416 ++++++++++++++ meshai/dashboard/api/adapter_config_routes.py | 317 +++++++++++ meshai/dashboard/server.py | 2 + ...{index-CHkr5tDL.css => index-B1y0CpOn.css} | 2 +- .../dashboard/static/assets/index-B7WUE5ni.js | 528 ++++++++++++++++++ .../dashboard/static/assets/index-DwsA2DLM.js | 518 ----------------- meshai/dashboard/static/index.html | 4 +- tests/test_adapter_config_api.py | 344 ++++++++++++ 10 files changed, 1614 insertions(+), 521 deletions(-) create mode 100644 dashboard-frontend/src/pages/AdapterConfig.tsx create mode 100644 meshai/dashboard/api/adapter_config_routes.py rename meshai/dashboard/static/assets/{index-CHkr5tDL.css => index-B1y0CpOn.css} (51%) create mode 100644 meshai/dashboard/static/assets/index-B7WUE5ni.js delete mode 100644 meshai/dashboard/static/assets/index-DwsA2DLM.js create mode 100644 tests/test_adapter_config_api.py diff --git a/dashboard-frontend/src/App.tsx b/dashboard-frontend/src/App.tsx index 7e330bd..33226b3 100644 --- a/dashboard-frontend/src/App.tsx +++ b/dashboard-frontend/src/App.tsx @@ -7,6 +7,7 @@ import Config from './pages/Config' import Alerts from './pages/Alerts' import Notifications from './pages/Notifications' import Reference from './pages/Reference' +import AdapterConfig from './pages/AdapterConfig' import { ToastProvider } from './components/ToastProvider' function App() { @@ -21,6 +22,7 @@ function App() { } /> } /> } /> + } /> diff --git a/dashboard-frontend/src/components/Layout.tsx b/dashboard-frontend/src/components/Layout.tsx index 82bcd6a..e966260 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -8,6 +8,7 @@ import { Bell, BellRing, BookOpen, + Sliders, } from 'lucide-react' import { fetchStatus, type SystemStatus } from '@/lib/api' import { useWebSocket } from '@/hooks/useWebSocket' @@ -25,6 +26,7 @@ const navItems = [ { path: '/alerts', label: 'Alerts', icon: Bell }, { path: '/notifications', label: 'Notifications', icon: BellRing }, { path: '/reference', label: 'Reference', icon: BookOpen }, + { path: '/adapter-config', label: 'Adapter Config', icon: Sliders }, ] function formatUptime(seconds: number): string { diff --git a/dashboard-frontend/src/pages/AdapterConfig.tsx b/dashboard-frontend/src/pages/AdapterConfig.tsx new file mode 100644 index 0000000..a0b1d61 --- /dev/null +++ b/dashboard-frontend/src/pages/AdapterConfig.tsx @@ -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 +type MetaMap = Record + +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({}) + const [meta, setMeta] = useState({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState>({}) + + // Per-key save status: keyed on `${adapter}.${key}` or `meta:${adapter}`. + const [saveStatus, setSaveStatus] = useState>({}) + const [saveError, setSaveError] = useState>({}) + + 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) => { + 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 ( +
+ Loading adapter config… +
+ ) + } + + if (error) { + return ( +
+ + Failed to load: {error} +
+ ) + } + + // 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 ( +
+
+ +

Adapter Config

+ + {Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters + +
+

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

+ + {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 ( +
+ {/* Card header */} +
+ +
+
+

{m.display_name}

+ {adapter} + {rows.length > 0 && ( + ({rows.length} settings) + )} + {rows.length === 0 && ( + (meta only) + )} +
+ {m.description && ( +

{m.description}

+ )} +
+ + {/* include_in_llm_context toggle */} + +
+ + {/* Expanded body */} + {isExpanded && rows.length > 0 && ( +
+ {rows.map((row) => ( + putValue(adapter, row.key, v)} + onReset={() => resetValue(adapter, row.key)} + /> + ))} +
+ )} +
+ ) + })} +
+ ) +} + + +// ---------- 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(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 ( +
+
+
+ {row.key} + [{row.type}] + {!isDefault && ( + edited + )} +
+ {row.description && ( +

{row.description}

+ )} +
+ +
+ {row.type === 'bool' ? ( + onCommit(e.target.checked)} + className="w-5 h-5 accent-cyan-500" + /> + ) : row.type === 'json' ? ( +