131 lines
4.5 KiB
Python
131 lines
4.5 KiB
Python
"""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),
|
|
}
|