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