mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 17:34:44 +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
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."""
|
||||
from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
|
@ -55,6 +56,7 @@ def create_app() -> FastAPI:
|
|||
from .api.notification_routes import router as notification_router
|
||||
|
||||
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(mesh_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.gstatic.com" crossorigin>
|
||||
<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>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CHkr5tDL.css">
|
||||
<script type="module" crossorigin src="/assets/index-B7WUE5ni.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B1y0CpOn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue