"""FastAPI application — Sanad Dashboard. Each route module is imported INDIVIDUALLY inside try/except so that one broken router (missing dep, syntax error in a sibling) cannot break the entire dashboard. Failed routers are logged and the server starts without them. """ from __future__ import annotations import importlib import logging # Backfill asyncio.to_thread on Python 3.8 — must run before any router import. from Project.Sanad.core import asyncio_compat # noqa: F401 from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from Project.Sanad.config import BASE_DIR from Project.Sanad.core.logger import get_logger log = get_logger("dashboard.app") from Project.Sanad.core.config_loader import section as _cfg_section _APP_CFG = _cfg_section("dashboard", "app") app = FastAPI( title=_APP_CFG.get("title", "Sanad Dashboard"), version=_APP_CFG.get("version", "1.0.0"), ) # -- isolated route registration -- _REST_ROUTES: list[tuple[str, str, str]] = [ # (module_name, prefix, tag) ("health", "/api", "health"), ("system", "/api/system", "system"), ("voice", "/api/voice", "voice"), ("motion", "/api/motion", "motion"), ("skills", "/api/skills", "skills"), ("macros", "/api/macros", "macros"), ("logs", "/api/logs", "logs"), ("replay", "/api/replay", "replay"), ("audio_control", "/api/audio", "audio"), ("scripts", "/api/scripts", "scripts"), ("records", "/api/records", "records"), ("prompt", "/api/prompt", "prompt"), ("wake_phrases", "/api/wake-phrases", "wake-phrases"), ("live_voice", "/api/live-voice", "live-voice"), ("live_subprocess", "/api/live-subprocess", "live-subprocess"), ("typed_replay", "/api/typed-replay", "typed-replay"), ] _WS_ROUTES: list[str] = ["log_stream"] _loaded_routes: list[str] = [] _failed_routes: dict[str, str] = {} def _register_router(module_name: str, prefix: str | None = None, tag: str | None = None, package: str = "Project.Sanad.dashboard.routes"): """Import + register one router. Failures are logged, never raised.""" full_name = f"{package}.{module_name}" try: mod = importlib.import_module(full_name) if not hasattr(mod, "router"): raise AttributeError(f"{full_name} has no 'router' attribute") kwargs: dict = {} if prefix is not None: kwargs["prefix"] = prefix if tag is not None: kwargs["tags"] = [tag] app.include_router(mod.router, **kwargs) _loaded_routes.append(module_name) log.info("Registered router: %s", module_name) except Exception as exc: _failed_routes[module_name] = str(exc) log.exception("Failed to register router %s — skipping", module_name) # REST routes for mod_name, prefix, tag in _REST_ROUTES: _register_router(mod_name, prefix=prefix, tag=tag) # WebSocket routes for mod_name in _WS_ROUTES: _register_router( mod_name, package="Project.Sanad.dashboard.websockets", tag="websocket", ) # -- Static files (dashboard UI) — best effort -- STATIC_DIR = BASE_DIR / _APP_CFG.get("static_subdir", "dashboard/static") try: STATIC_DIR.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") log.info("Static dir mounted: %s", STATIC_DIR) except Exception: log.exception("Could not mount static dir %s — serving without it", STATIC_DIR) @app.get("/") async def root(): """Serve the dashboard SPA.""" index = STATIC_DIR / "index.html" if index.exists(): from fastapi.responses import HTMLResponse try: return HTMLResponse(index.read_text(encoding="utf-8")) except OSError as exc: return {"error": f"Could not read index.html: {exc}"} return { "message": "Sanad Dashboard — index.html not found", "loaded_routes": _loaded_routes, "failed_routes": _failed_routes, } @app.get("/api/_dashboard_status") async def dashboard_load_status(): """Diagnostic — which routers loaded, which failed.""" return { "loaded": _loaded_routes, "failed": _failed_routes, "total_loaded": len(_loaded_routes), "total_failed": len(_failed_routes), }