feat(web): read-only dashboard app + ci web serve (W4)
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"""FastAPI application factory for the read-only web dashboard (W4).
|
||||
|
||||
**In-process only**: routes call ``operations/`` + ``backends.load_backend``
|
||||
directly and render Jinja2 templates. They never shell out to the CLI (see
|
||||
``CLAUDE.md`` / the web-UI plan's architecture rules).
|
||||
|
||||
**No auth in this step** (that is W5) -- the app binds ``127.0.0.1`` by
|
||||
default via :class:`~ci_orchestrator.config.WebConfig`; ``create_app`` itself
|
||||
never chooses the bind address, that is ``commands/web.py``'s job.
|
||||
|
||||
**Backend may be unavailable**: :func:`get_backend` is a FastAPI dependency
|
||||
that swallows :class:`~ci_orchestrator.backends.errors.BackendError` (raised
|
||||
when ``vmrun`` is missing on this host) and degrades to ``None`` so a route
|
||||
never 500s. Tests override it via ``app.dependency_overrides[get_backend]``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ci_orchestrator.backends import load_backend
|
||||
from ci_orchestrator.backends.errors import BackendError
|
||||
from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status
|
||||
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_status
|
||||
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ci_orchestrator.backends.protocol import VmBackend
|
||||
from ci_orchestrator.config import Config
|
||||
|
||||
_WEB_DIR = Path(__file__).resolve().parent
|
||||
|
||||
#: The two real ``act_runner`` systemd units on the Linux host (see
|
||||
#: CLAUDE.md's "Phase C"). A module constant per the W4 plan section.
|
||||
RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────── dependency
|
||||
|
||||
|
||||
def get_backend(request: Request) -> VmBackend | None:
|
||||
"""FastAPI dependency: the VM backend for this request, or ``None``.
|
||||
|
||||
Catches :class:`BackendError` (e.g. ``vmrun`` missing on this host --
|
||||
``BackendNotAvailable``) so routes degrade to an empty/"unavailable"
|
||||
state instead of raising. Overridable in tests via
|
||||
``app.dependency_overrides[get_backend]`` with a fake backend.
|
||||
"""
|
||||
config: Config = request.app.state.config
|
||||
try:
|
||||
return load_backend(config)
|
||||
except BackendError:
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────── data fetchers
|
||||
|
||||
|
||||
def _fetch_live_vms(backend: VmBackend | None, config: Config) -> list[LiveVm]:
|
||||
"""Render-ready VM list. Degrades to ``[]`` on any backend hiccup."""
|
||||
if backend is None:
|
||||
return []
|
||||
try:
|
||||
return list_live_vms(backend, config)
|
||||
except Exception: # a route must never 500 on a backend hiccup
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_cache_status(config: Config) -> CacheStatus | None:
|
||||
"""Cargo cache snapshot, or ``None`` if it could not be read."""
|
||||
try:
|
||||
return cargo_cache_status(config)
|
||||
except Exception: # degrade, never 500
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_disk_status(config: Config) -> DiskStatus | None:
|
||||
"""Disk usage for ``config.paths.root``, or ``None`` if unreadable."""
|
||||
try:
|
||||
return disk_status(config.paths.root)
|
||||
except Exception: # degrade, never 500
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_runner_status() -> list[RunnerStatus]:
|
||||
"""Runner service statuses, or ``[]`` if the query could not run at all."""
|
||||
try:
|
||||
return runner_status(RUNNER_SERVICE_NAMES)
|
||||
except Exception: # degrade, never 500
|
||||
return []
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────── routes
|
||||
|
||||
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Liveness probe for systemd/monitor. Always ``200``."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def dashboard(
|
||||
request: Request, backend: VmBackend | None = Depends(get_backend)
|
||||
) -> HTMLResponse:
|
||||
"""Full dashboard: VM live, cache, disk, runner cards."""
|
||||
config: Config = request.app.state.config
|
||||
templates: Jinja2Templates = request.app.state.templates
|
||||
context = {
|
||||
"vms": _fetch_live_vms(backend, config),
|
||||
"backend_available": backend is not None,
|
||||
"cache": _fetch_cache_status(config),
|
||||
"disk": _fetch_disk_status(config),
|
||||
"runners": _fetch_runner_status(),
|
||||
}
|
||||
return templates.TemplateResponse(request, "dashboard.html", context)
|
||||
|
||||
|
||||
def vms_partial(
|
||||
request: Request, backend: VmBackend | None = Depends(get_backend)
|
||||
) -> HTMLResponse:
|
||||
"""``GET /vms`` -- polled every 5s by the dashboard via htmx."""
|
||||
config: Config = request.app.state.config
|
||||
templates: Jinja2Templates = request.app.state.templates
|
||||
context = {
|
||||
"vms": _fetch_live_vms(backend, config),
|
||||
"backend_available": backend is not None,
|
||||
}
|
||||
return templates.TemplateResponse(request, "partials/vms.html", context)
|
||||
|
||||
|
||||
def cache_partial(request: Request) -> HTMLResponse:
|
||||
"""``GET /cache`` -- cargo cache status partial."""
|
||||
config: Config = request.app.state.config
|
||||
templates: Jinja2Templates = request.app.state.templates
|
||||
context = {"cache": _fetch_cache_status(config)}
|
||||
return templates.TemplateResponse(request, "partials/cache.html", context)
|
||||
|
||||
|
||||
def host_partial(request: Request) -> HTMLResponse:
|
||||
"""``GET /host`` -- disk + runner status partial."""
|
||||
config: Config = request.app.state.config
|
||||
templates: Jinja2Templates = request.app.state.templates
|
||||
context = {
|
||||
"disk": _fetch_disk_status(config),
|
||||
"runners": _fetch_runner_status(),
|
||||
}
|
||||
return templates.TemplateResponse(request, "partials/host.html", context)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────── factory
|
||||
|
||||
|
||||
def create_app(config: Config) -> FastAPI:
|
||||
"""Build the read-only dashboard app.
|
||||
|
||||
W4 scope: no auth, read-only routes only. Binding to a concrete address
|
||||
(always ``127.0.0.1`` by default -- never ``0.0.0.0``) is
|
||||
``commands/web.py``'s job, not this factory's.
|
||||
"""
|
||||
app = FastAPI(title="ci-orchestrator web")
|
||||
app.state.config = config
|
||||
app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates"))
|
||||
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
|
||||
|
||||
app.get("/healthz")(healthz)
|
||||
app.get("/", response_class=HTMLResponse)(dashboard)
|
||||
app.get("/vms", response_class=HTMLResponse)(vms_partial)
|
||||
app.get("/cache", response_class=HTMLResponse)(cache_partial)
|
||||
app.get("/host", response_class=HTMLResponse)(host_partial)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RUNNER_SERVICE_NAMES",
|
||||
"create_app",
|
||||
"dashboard",
|
||||
"get_backend",
|
||||
"healthz",
|
||||
]
|
||||
Reference in New Issue
Block a user