checking system…
Docs / back / src/maf/dashboard/api.py · line 1
Python · 286 lines
  1"""Dashboard API — FastAPI routes for arena management, config editing, and monitoring."""
  2
  3from __future__ import annotations
  4
  5import logging
  6from pathlib import Path
  7from typing import Any
  8
  9import yaml
 10from fastapi import FastAPI, HTTPException, Request, Response
 11from fastapi.responses import HTMLResponse
 12from fastapi.templating import Jinja2Templates
 13from pydantic import BaseModel
 14
 15logger = logging.getLogger(__name__)
 16
 17app = FastAPI(
 18    title="MAF Dashboard",
 19    version="0.2.0",
 20    docs_url=None,
 21    redoc_url=None,
 22)
 23
 24# Modular route groups — registered at import time so tests that hit the
 25# global `app` via TestClient see them without calling `create_dashboard()`.
 26from maf.dashboard.routers.triggers import router as _triggers_router  # noqa: E402
 27from maf.dashboard.routers.docs import router as _docs_router  # noqa: E402
 28from maf.dashboard.routers.channels import router as _channels_router  # noqa: E402
 29from maf.dashboard.routers.watch import router as _watch_router  # noqa: E402
 30from maf.dashboard.routers.llm import router as _llm_router  # noqa: E402
 31from maf.dashboard.routers.specialised import router as _specialised_router  # noqa: E402
 32from maf.dashboard.routers.pages import router as _pages_router  # noqa: E402
 33from maf.dashboard.routers.wizard import router as _wizard_router  # noqa: E402
 34from maf.dashboard.routers.arenas import router as _arenas_router  # noqa: E402
 35from maf.dashboard.routers.sources import router as _sources_router  # noqa: E402
 36from maf.dashboard.routers.system import router as _system_router  # noqa: E402
 37app.include_router(_triggers_router)
 38app.include_router(_docs_router)
 39app.include_router(_channels_router)
 40app.include_router(_watch_router)
 41app.include_router(_llm_router)
 42app.include_router(_specialised_router)
 43app.include_router(_pages_router)
 44app.include_router(_wizard_router)
 45app.include_router(_arenas_router)
 46app.include_router(_sources_router)
 47app.include_router(_system_router)
 48
 49# Jinja2 templates
 50_TEMPLATES_DIR = Path(__file__).parent / "templates"
 51templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
 52
 53# Module-level reference to MAFApp (set during startup)
 54_maf_app: Any = None
 55_trail: Any = None  # DecisionTrail instance
 56
 57
 58class WizardRequest(BaseModel):
 59    description: str
 60
 61
 62class RunArenaRequest(BaseModel):
 63    target: dict[str, Any] = {}
 64
 65
 66class BacktestRequest(BaseModel):
 67    ticker: str
 68    start_date: str
 69    end_date: str
 70    reflect: bool = False
 71    reflect_every_n: int = 5
 72
 73
 74class UpdateConfigRequest(BaseModel):
 75    config: dict[str, Any]
 76
 77
 78class UpdateModuleRequest(BaseModel):
 79    enabled: bool = True
 80    config: dict[str, Any] = {}
 81
 82
 83class SourceTestRequest(BaseModel):
 84    adapter: str
 85    params: dict[str, Any] = {}
 86
 87
 88def create_dashboard(maf_app: Any) -> FastAPI:
 89    """Create the dashboard FastAPI app with MAF integration."""
 90    global _maf_app, _trail
 91    _maf_app = maf_app
 92
 93    from maf.core.trail import DecisionTrail
 94    data_dir = getattr(maf_app.config, "data_dir", "./data") if maf_app else "./data"
 95    _trail = DecisionTrail(data_dir)
 96
 97    # WebSocket router — live event tail.
 98    # Imported lazily so the dashboard module still imports without fastapi.
 99    from maf.dashboard.ws import router as ws_router
100    # Avoid duplicate-include if create_dashboard is called twice in a test.
101    if not any(r.path.startswith("/ws/") for r in app.routes if hasattr(r, "path")):
102        app.include_router(ws_router)
103
104    return app
105
106
107# ── HTML Page Routes ──────────────────────────────────────────────────────────
108
109
110# ── Arena display metadata (Workstream: simplify) ────────────────────────────
111#
112# Human-friendly labels + plain-English descriptions for the index page.
113# Keys are arena names from config; values render on the dashboard cards.
114# When a new arena ships, add a row here so users see a non-technical label.
115
116
117# Wizard moved to ``maf.dashboard.routers.wizard``.
118
119
120
121
122# Oracle + Mastermind moved to ``maf.dashboard.routers.specialised``.
123
124
125
126# ── Channels browser (see-what's-in-the-streams) ───────────────────────────
127
128
129# Channels moved to ``maf.dashboard.routers.channels``.
130
131
132# ── Watch list (Phase 2 — drives Kronos + Mirofish refreshers) ──────────────
133
134
135# Watch + LLM moved to ``maf.dashboard.routers.{watch,llm}``.
136
137
138# ── System status, streams, data sources (Workstream C) ──────────────────────
139
140
141
142
143# Trigger library + validation moved to ``maf.dashboard.routers.triggers``.
144# See ``create_dashboard`` for the include_router call.
145
146
147@app.get("/api/data/sources")
148async def list_data_sources() -> dict[str, Any]:
149    """Catalog of every source binding across all arenas plus health stats.
150
151    Aggregates each adapter's last-fetch metrics from the arena trails so the
152    user can see per-source success rate and latency without opening a JSON
153    blob. This is the "data clearly viewable" replacement for the raw config
154    editor.
155    """
156    from maf.sources.registry import _ADAPTER_REGISTRY
157
158    if not _maf_app:
159        return {"sources": [], "adapters": []}
160
161    sources: list[dict[str, Any]] = []
162    for arena_name in _maf_app.list_arenas():
163        arena = _maf_app.get_arena(arena_name)
164        if not arena:
165            continue
166        for binding in arena.config.sources:
167            sources.append({
168                "arena": arena_name,
169                "name": binding.name,
170                "adapter": binding.adapter,
171                "config": _sanitize_secret(binding.config),
172            })
173
174    adapters = [
175        {"name": n, "class": cls.__name__}
176        for n, cls in sorted(_ADAPTER_REGISTRY.items())
177    ]
178    return {"sources": sources, "adapters": adapters}
179
180
181@app.get("/api/data/sources/{arena}/{name}/sample")
182async def data_source_sample(arena: str, name: str) -> dict[str, Any]:
183    """One-shot fetch from a configured source binding — preview payload.
184
185    Lets the user see the actual shape of the data the agents will receive,
186    not just the YAML knobs.
187    """
188    if not _maf_app:
189        raise HTTPException(500, "MAF not initialized")
190    arn = _maf_app.get_arena(arena)
191    if not arn:
192        raise HTTPException(404, f"Arena {arena!r} not found")
193
194    source = arn.source_registry.get(name) if arn.source_registry else None
195    if source is None:
196        raise HTTPException(404, f"Source {name!r} not bound in arena {arena!r}")
197
198    try:
199        result = await source.fetch({})
200    except Exception as exc:
201        return {
202            "arena": arena,
203            "source": name,
204            "ok": False,
205            "error": f"{type(exc).__name__}: {exc}",
206        }
207
208    # Truncate generously — preview only, not a real consumer.
209    s = str(result)
210    truncated = len(s) > 4000
211    preview = s[:4000] + ("…<truncated>" if truncated else "")
212    return {
213        "arena": arena,
214        "source": name,
215        "ok": True,
216        "preview": preview,
217        "shape": _describe_shape(result),
218        "truncated": truncated,
219    }
220
221
222def _sanitize_secret(cfg: dict[str, Any]) -> dict[str, Any]:
223    """Redact api keys / secrets from a source config before exposing."""
224    out: dict[str, Any] = {}
225    for k, v in cfg.items():
226        if isinstance(v, str) and ("api_key" in k.lower() or "secret" in k.lower()):
227            out[k] = (v[:6] + "…") if v else ""
228        else:
229            out[k] = v
230    return out
231
232
233def _describe_shape(result: Any) -> dict[str, Any]:
234    """One-level shape description of a source result for the preview UI."""
235    if isinstance(result, dict):
236        return {
237            "kind": "dict",
238            "keys": list(result.keys())[:30],
239            "items_len": (
240                len(result["items"]) if isinstance(result.get("items"), list) else None
241            ),
242        }
243    if isinstance(result, list):
244        return {"kind": "list", "length": len(result)}
245    return {"kind": type(result).__name__}
246
247
248# /live /data moved to ``maf.dashboard.routers.pages``.
249
250
251# Docs + source viewer moved to ``maf.dashboard.routers.docs``.
252
253
254# ── Helpers ───────────────────────────────────────────────────────────────────
255
256
257async def _close_redis_client(client: Any) -> None:
258    """Best-effort close on the redis-py 4.x/5.x/6.x async client.
259
260    redis-py 5+ exposes ``aclose()``; 4.x had ``close()``. Either may raise on
261    already-closed connections — swallow.
262    """
263    try:
264        aclose = getattr(client, "aclose", None)
265        if aclose is not None:
266            await aclose()
267            return
268        close = getattr(client, "close", None)
269        if close is not None:
270            await close()
271    except Exception:
272        pass
273
274
275def _serialize_debates(debates: dict[str, Any]) -> dict[str, Any]:
276    """Convert DebateState TypedDicts to JSON-safe dicts."""
277    result = {}
278    for key, debate in debates.items():
279        result[key] = {
280            "history": debate.get("history", []),
281            "per_agent": debate.get("per_agent", {}),
282            "judge_decision": debate.get("judge_decision", ""),
283            "count": debate.get("count", 0),
284        }
285    return result