feat(web): read-only dashboard app + ci web serve (W4)

This commit is contained in:
2026-07-23 17:41:06 +02:00
parent 07263e20fb
commit ce37ec2e3b
12 changed files with 791 additions and 0 deletions
+185
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
/* placeholder -- real dark Gitea-style design tokens land in W7 */
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ci-orchestrator{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<nav class="navbar">
<span class="navbar-brand">ci-orchestrator</span>
</nav>
<main class="content">
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Dashboard - ci-orchestrator{% endblock %}
{% block content %}
<section class="card" id="vms-card">
{% include "partials/vms.html" %}
</section>
<section class="card" id="cache-card">
{% include "partials/cache.html" %}
</section>
{% include "partials/host.html" %}
{% endblock %}
@@ -0,0 +1,15 @@
<div id="cache-partial">
<h2>Cargo cache</h2>
{% if cache %}
<dl>
<dt>Root</dt><dd><code>{{ cache.root }}</code></dd>
<dt>Total size</dt><dd>{{ cache.total_bytes }} bytes</dd>
<dt>Entries</dt><dd>{{ cache.entry_count }}</dd>
<dt>Oldest entry age</dt>
<dd>{{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}</dd>
<dt>Retention</dt><dd>{{ cache.retention_days }} day(s)</dd>
</dl>
{% else %}
<p class="notice notice-warn">Cargo cache status unavailable.</p>
{% endif %}
</div>
@@ -0,0 +1,39 @@
<section class="card" id="disk-card">
<h2>Disk</h2>
{% if disk %}
<dl>
<dt>Path</dt><dd><code>{{ disk.path }}</code></dd>
<dt>Total</dt><dd>{{ "%.1f"|format(disk.total_gb) }} GB</dd>
<dt>Free</dt><dd>{{ "%.1f"|format(disk.free_gb) }} GB</dd>
<dt>Used</dt><dd>{{ "%.1f"|format(disk.used_pct) }}%</dd>
</dl>
{% else %}
<p class="notice notice-warn">Disk status unavailable.</p>
{% endif %}
</section>
<section class="card" id="runner-card">
<h2>Runners</h2>
{% if runners %}
<table>
<thead>
<tr>
<th>Service</th>
<th>Active</th>
<th>Sub-state</th>
</tr>
</thead>
<tbody>
{% for runner in runners %}
<tr class="{{ 'row-ok' if runner.active else 'row-alert' }}">
<td>{{ runner.name }}</td>
<td>{{ "yes" if runner.active else "no" }}</td>
<td>{{ runner.sub_state }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="notice notice-warn">Runner status unavailable.</p>
{% endif %}
</section>
@@ -0,0 +1,34 @@
<div id="vms-partial" hx-get="/vms" hx-trigger="every 5s" hx-swap="outerHTML">
<h2>Live VMs</h2>
{% if not backend_available %}
<p class="notice notice-warn">Backend unavailable (vmrun not found on this host).</p>
{% endif %}
{% if vms %}
<table>
<thead>
<tr>
<th>Name</th>
<th>VMX</th>
<th>IP</th>
<th>Guest OS</th>
<th>Uptime (s)</th>
<th>Orphan?</th>
</tr>
</thead>
<tbody>
{% for vm in vms %}
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
<td>{{ vm.name }}</td>
<td><code>{{ vm.vmx }}</code></td>
<td>{{ vm.ip or "-" }}</td>
<td>{{ vm.guest_os or "-" }}</td>
<td>{{ vm.uptime_sec if vm.uptime_sec is not none else "-" }}</td>
<td>{{ "yes" if vm.is_orphan else "no" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No VMs currently running.</p>
{% endif %}
</div>