mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
Adds a GENERAL > Coverage page: a Leaflet map to draw/set the universal coverage bbox (with numeric W/S/E/N inputs), an enabled toggle, and per- adapter override toggles (excluded_adapters). Environment's per-adapter geographic-scope fields now show only when that adapter is overridden; otherwise they point to the Coverage map. Saves via PUT /api/config/coverage. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
364 lines
12 KiB
Python
364 lines
12 KiB
Python
"""Configuration API routes."""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
from meshai.config import (
|
|
Config,
|
|
_dataclass_to_dict,
|
|
_dict_to_dataclass,
|
|
load_config,
|
|
save_config,
|
|
)
|
|
from meshai.config_loader import save_section, get_config_dir_from_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["config"])
|
|
|
|
# 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 = {
|
|
"connection",
|
|
"llm",
|
|
"mesh_sources",
|
|
"meshmonitor",
|
|
"dashboard",
|
|
"environmental",
|
|
"coverage",
|
|
}
|
|
|
|
# Valid config section names
|
|
VALID_SECTIONS = {
|
|
"timezone",
|
|
"notifications",
|
|
"environmental",
|
|
"bot",
|
|
"connection",
|
|
"response",
|
|
"history",
|
|
"memory",
|
|
"context",
|
|
"meshcore_context",
|
|
"commands",
|
|
"llm",
|
|
"weather",
|
|
"meshmonitor",
|
|
"knowledge",
|
|
"mesh_sources",
|
|
"mesh_intelligence",
|
|
"dashboard",
|
|
"danger_zones",
|
|
"coverage",
|
|
}
|
|
|
|
|
|
@router.get("/config")
|
|
async def get_full_config(request: Request):
|
|
"""Get full configuration."""
|
|
config = request.app.state.config
|
|
return _dataclass_to_dict(config)
|
|
|
|
|
|
@router.get("/config/{section}")
|
|
async def get_config_section(section: str, request: Request):
|
|
"""Get a specific configuration section."""
|
|
if section not in VALID_SECTIONS:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}"
|
|
)
|
|
|
|
config = request.app.state.config
|
|
|
|
if not hasattr(config, section):
|
|
raise HTTPException(status_code=404, detail=f"Section '{section}' not found")
|
|
|
|
section_data = getattr(config, section)
|
|
|
|
# Handle list types (mesh_sources)
|
|
if isinstance(section_data, list):
|
|
return [
|
|
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
|
|
for item in section_data
|
|
]
|
|
|
|
# Handle dataclass types
|
|
if hasattr(section_data, "__dataclass_fields__"):
|
|
return _dataclass_to_dict(section_data)
|
|
|
|
return section_data
|
|
|
|
|
|
@router.put("/config/{section}")
|
|
async def update_config_section(section: str, request: Request):
|
|
"""Update a configuration section."""
|
|
if section not in VALID_SECTIONS:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}"
|
|
)
|
|
|
|
config_path = request.app.state.config_path
|
|
if not config_path:
|
|
raise HTTPException(status_code=500, detail="Config path not set")
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}")
|
|
|
|
try:
|
|
# Get the section's dataclass type
|
|
field_info = Config.__dataclass_fields__.get(section)
|
|
if not field_info:
|
|
raise HTTPException(status_code=404, detail=f"Section '{section}' not found")
|
|
|
|
field_type = field_info.type
|
|
|
|
# Validate by coercing to the dataclass (runs __post_init__ validators),
|
|
# then persist via the multi-file / !include-aware save_section. The
|
|
# monolithic save_config cannot parse the !include orchestrator and blew
|
|
# up on every save in the prod layout (v0.4 C.2.1 fix).
|
|
if section == "mesh_sources":
|
|
from meshai.config import MeshSourceConfig
|
|
new_value = [
|
|
_dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item
|
|
for item in body
|
|
]
|
|
data_to_save = [
|
|
_dataclass_to_dict(v) if hasattr(v, "__dataclass_fields__") else v
|
|
for v in new_value
|
|
]
|
|
elif hasattr(field_type, "__dataclass_fields__"):
|
|
new_value = _dict_to_dataclass(field_type, body)
|
|
data_to_save = _dataclass_to_dict(new_value)
|
|
else:
|
|
new_value = body
|
|
data_to_save = body
|
|
|
|
config_dir = get_config_dir_from_path(config_path)
|
|
save_section(section, data_to_save, config_dir)
|
|
|
|
# v0.6-tail-3: compute the dotted-key diff so the UI banner can
|
|
# 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)
|
|
|
|
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:
|
|
try:
|
|
setattr(request.app.state.config, section, new_value)
|
|
except Exception:
|
|
pass
|
|
if section == "context":
|
|
_refresh_mesh_context(request.app, new_value)
|
|
|
|
logger.info(
|
|
"Config section %r updated, restart_required=%s changed_keys=%s",
|
|
section, restart_required, changed_keys,
|
|
)
|
|
|
|
return {
|
|
"saved": True,
|
|
"restart_required": restart_required,
|
|
"changed_keys": changed_keys,
|
|
}
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Config update error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/config/test-llm")
|
|
async def test_llm_connection(request: Request):
|
|
"""Test LLM backend connection."""
|
|
config = request.app.state.config
|
|
|
|
try:
|
|
# Create LLM backend based on config
|
|
api_key = config.resolve_api_key()
|
|
if not api_key:
|
|
return {"success": False, "error": "No API key configured"}
|
|
|
|
backend_name = config.llm.backend.lower()
|
|
|
|
if backend_name == "openai":
|
|
from meshai.backends import OpenAIBackend
|
|
backend = OpenAIBackend(config.llm, api_key, 0, 0)
|
|
elif backend_name == "anthropic":
|
|
from meshai.backends import AnthropicBackend
|
|
backend = AnthropicBackend(config.llm, api_key, 0, 0)
|
|
elif backend_name == "google":
|
|
from meshai.backends import GoogleBackend
|
|
backend = GoogleBackend(config.llm, api_key, 0, 0)
|
|
else:
|
|
return {"success": False, "error": f"Unknown backend: {backend_name}"}
|
|
|
|
# Send test prompt — generate(messages: list[dict], system_prompt: str)
|
|
response = await backend.generate(
|
|
[{"role": "user", "content": "Reply with 'OK' if you can read this."}],
|
|
"",
|
|
)
|
|
await backend.close()
|
|
|
|
return {"success": True, "response": response}
|
|
|
|
except Exception as e:
|
|
logger.error(f"LLM test error: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
# v0.6-6 -- live ToggleFilter refresh endpoint.
|
|
# Called by the frontend after PUT /api/config/notifications so the
|
|
# Inhibitor + Grouper + Dispatcher pick up the new enabled toggle set
|
|
# on the next event without a container restart.
|
|
def _refresh_toggle_filter(app) -> bool:
|
|
"""Best-effort live refresh of the running ToggleFilter. Returns True
|
|
when the refresh actually fired, False if the pipeline isn t up yet
|
|
(typical during tests / early startup). Never raises."""
|
|
try:
|
|
bus = getattr(app.state, "bus", None)
|
|
config = getattr(app.state, "config", None)
|
|
if bus is None or config is None:
|
|
return False
|
|
components = getattr(bus, "_pipeline_components", {}) or {}
|
|
tf = components.get("toggle_filter")
|
|
if tf is None:
|
|
return False
|
|
tf.refresh(config)
|
|
return True
|
|
except Exception:
|
|
logger.exception("toggle_filter refresh failed")
|
|
return False
|
|
|
|
|
|
def _refresh_mesh_context(app, new_ctx_cfg) -> bool:
|
|
"""Best-effort live refresh of the running MeshContext after a context
|
|
config PUT. Returns True when the refresh actually fired, False if the
|
|
context instance is absent/None (passive context disabled, or early
|
|
startup). Never raises."""
|
|
try:
|
|
ctx = getattr(app.state, "mesh_context", None)
|
|
if ctx is None:
|
|
return False
|
|
ctx.update_settings(
|
|
max_age=new_ctx_cfg.max_age,
|
|
observe_channels=new_ctx_cfg.observe_channels,
|
|
ignore_nodes=new_ctx_cfg.ignore_nodes,
|
|
)
|
|
return True
|
|
except Exception:
|
|
logger.exception("mesh_context refresh failed")
|
|
return False
|
|
|
|
|
|
@router.post("/notifications/refresh-toggles")
|
|
async def refresh_toggles(request: Request):
|
|
"""Explicit refresh endpoint (kept for backwards-compat with the
|
|
dashboard's manual ping path)."""
|
|
bus = getattr(request.app.state, "bus", None)
|
|
config = getattr(request.app.state, "config", None)
|
|
if bus is None or config is None:
|
|
raise HTTPException(503, "pipeline bus not yet initialized")
|
|
components = getattr(bus, "_pipeline_components", {}) or {}
|
|
tf = components.get("toggle_filter")
|
|
if tf is None:
|
|
raise HTTPException(503, "toggle_filter not on pipeline bus")
|
|
tf.refresh(config)
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
# v0.6-tail item 1: auto-refresh the ToggleFilter after any successful
|
|
# config PUT that touches notifications. Registered from server.py at
|
|
# startup via register_config_routes_hooks(app).
|
|
def register_config_routes_hooks(app):
|
|
@app.middleware("http")
|
|
async def _auto_refresh_toggle_filter(request, call_next):
|
|
response = await call_next(request)
|
|
try:
|
|
method = request.method.upper()
|
|
path = request.url.path
|
|
if (method == "PUT"
|
|
and 200 <= response.status_code < 300
|
|
and ("/api/config/notifications" in path
|
|
or path.rstrip("/").endswith("/api/config"))):
|
|
_refresh_toggle_filter(request.app)
|
|
except Exception:
|
|
logger.exception("auto-refresh middleware failed")
|
|
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)
|