diff --git a/pyproject.toml b/pyproject.toml
index e41690c..19ac506 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,6 +65,12 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B011", "SIM117"]
+[tool.ruff.lint.flake8-bugbear]
+# FastAPI's `Depends(...)` in a parameter default is the framework's own
+# idiom (see web/app.py), not the mutable-default footgun B008 guards
+# against -- it is evaluated once and cached by FastAPI's dependency cache.
+extend-immutable-calls = ["fastapi.Depends"]
+
[tool.mypy]
python_version = "3.11"
strict = true
diff --git a/src/ci_orchestrator/__main__.py b/src/ci_orchestrator/__main__.py
index acaf56f..1821bc3 100644
--- a/src/ci_orchestrator/__main__.py
+++ b/src/ci_orchestrator/__main__.py
@@ -33,6 +33,7 @@ from ci_orchestrator.commands.wait import ( # noqa: F401 (intentional re-expor
load_backend,
wait_ready,
)
+from ci_orchestrator.commands.web import web as web_cmd
@click.group()
@@ -55,6 +56,7 @@ cli.add_command(smoke)
cli.add_command(validate)
cli.add_command(creds)
cli.add_command(vmware)
+cli.add_command(web_cmd)
if __name__ == "__main__": # pragma: no cover
diff --git a/src/ci_orchestrator/commands/web.py b/src/ci_orchestrator/commands/web.py
new file mode 100644
index 0000000..f8fad74
--- /dev/null
+++ b/src/ci_orchestrator/commands/web.py
@@ -0,0 +1,56 @@
+"""``web`` sub-commands -- ``ci web serve`` launches the dashboard app.
+
+**Import discipline (non-negotiable, see CLAUDE.md / the web-UI plan)**: this
+module imports only ``click`` and stdlib at module scope. ``fastapi``,
+``uvicorn`` and ``ci_orchestrator.web`` (which pulls in fastapi/jinja2) are
+imported lazily, inside :func:`serve`'s body -- never at import time. This
+keeps ``ci --help`` (and every other CLI command) working on a host where
+the optional ``[web]`` extra is not installed.
+"""
+
+from __future__ import annotations
+
+import click
+
+
+@click.group("web")
+def web() -> None:
+ """Web dashboard commands (read-only in this phase, no auth)."""
+
+
+@web.command("serve")
+@click.option(
+ "--host",
+ "host",
+ default=None,
+ help="Bind address (default: config web.bind_host, normally 127.0.0.1).",
+)
+@click.option(
+ "--port",
+ "port",
+ type=int,
+ default=None,
+ help="Bind port (default: config web.bind_port).",
+)
+def serve(host: str | None, port: int | None) -> None:
+ """Run the read-only web dashboard in the foreground (uvicorn).
+
+ Binds to ``127.0.0.1`` by default (never ``0.0.0.0``) -- pass ``--host``
+ explicitly (or set ``[web].bind_host`` in ``config.toml``) to expose it
+ on the LAN.
+ """
+ # Heavy imports deferred to here on purpose -- see module docstring.
+ import uvicorn
+
+ from ci_orchestrator.config import load_config
+ from ci_orchestrator.web.app import create_app
+
+ config = load_config()
+ bind_host = host if host is not None else config.web.bind_host
+ bind_port = port if port is not None else config.web.bind_port
+
+ app = create_app(config)
+ uvicorn.run(app, host=bind_host, port=bind_port)
+
+
+__all__ = ["web"]
diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py
new file mode 100644
index 0000000..e75d36d
--- /dev/null
+++ b/src/ci_orchestrator/web/app.py
@@ -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",
+]
diff --git a/src/ci_orchestrator/web/static/app.css b/src/ci_orchestrator/web/static/app.css
new file mode 100644
index 0000000..cabb305
--- /dev/null
+++ b/src/ci_orchestrator/web/static/app.css
@@ -0,0 +1 @@
+/* placeholder -- real dark Gitea-style design tokens land in W7 */
diff --git a/src/ci_orchestrator/web/templates/base.html b/src/ci_orchestrator/web/templates/base.html
new file mode 100644
index 0000000..b00dd81
--- /dev/null
+++ b/src/ci_orchestrator/web/templates/base.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ {% block title %}ci-orchestrator{% endblock %}
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
diff --git a/src/ci_orchestrator/web/templates/dashboard.html b/src/ci_orchestrator/web/templates/dashboard.html
new file mode 100644
index 0000000..22b899e
--- /dev/null
+++ b/src/ci_orchestrator/web/templates/dashboard.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block title %}Dashboard - ci-orchestrator{% endblock %}
+
+{% block content %}
+
+ {% include "partials/vms.html" %}
+
+
+
+ {% include "partials/cache.html" %}
+
+
+{% include "partials/host.html" %}
+{% endblock %}
diff --git a/src/ci_orchestrator/web/templates/partials/cache.html b/src/ci_orchestrator/web/templates/partials/cache.html
new file mode 100644
index 0000000..3ad9a3d
--- /dev/null
+++ b/src/ci_orchestrator/web/templates/partials/cache.html
@@ -0,0 +1,15 @@
+
+
Cargo cache
+ {% if cache %}
+
+ - Root
{{ cache.root }}
+ - Total size
- {{ cache.total_bytes }} bytes
+ - Entries
- {{ cache.entry_count }}
+ - Oldest entry age
+ - {{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}
+ - Retention
- {{ cache.retention_days }} day(s)
+
+ {% else %}
+
Cargo cache status unavailable.
+ {% endif %}
+
diff --git a/src/ci_orchestrator/web/templates/partials/host.html b/src/ci_orchestrator/web/templates/partials/host.html
new file mode 100644
index 0000000..4f6e8e8
--- /dev/null
+++ b/src/ci_orchestrator/web/templates/partials/host.html
@@ -0,0 +1,39 @@
+
+ Disk
+ {% if disk %}
+
+ - Path
{{ disk.path }}
+ - Total
- {{ "%.1f"|format(disk.total_gb) }} GB
+ - Free
- {{ "%.1f"|format(disk.free_gb) }} GB
+ - Used
- {{ "%.1f"|format(disk.used_pct) }}%
+
+ {% else %}
+ Disk status unavailable.
+ {% endif %}
+
+
+
+ Runners
+ {% if runners %}
+
+
+
+ | Service |
+ Active |
+ Sub-state |
+
+
+
+ {% for runner in runners %}
+
+ | {{ runner.name }} |
+ {{ "yes" if runner.active else "no" }} |
+ {{ runner.sub_state }} |
+
+ {% endfor %}
+
+
+ {% else %}
+ Runner status unavailable.
+ {% endif %}
+
diff --git a/src/ci_orchestrator/web/templates/partials/vms.html b/src/ci_orchestrator/web/templates/partials/vms.html
new file mode 100644
index 0000000..bf419ad
--- /dev/null
+++ b/src/ci_orchestrator/web/templates/partials/vms.html
@@ -0,0 +1,34 @@
+
+
Live VMs
+ {% if not backend_available %}
+
Backend unavailable (vmrun not found on this host).
+ {% endif %}
+ {% if vms %}
+
+
+
+ | Name |
+ VMX |
+ IP |
+ Guest OS |
+ Uptime (s) |
+ Orphan? |
+
+
+
+ {% for vm in vms %}
+
+ | {{ vm.name }} |
+ {{ vm.vmx }} |
+ {{ vm.ip or "-" }} |
+ {{ vm.guest_os or "-" }} |
+ {{ vm.uptime_sec if vm.uptime_sec is not none else "-" }} |
+ {{ "yes" if vm.is_orphan else "no" }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No VMs currently running.
+ {% endif %}
+
diff --git a/tests/python/test_commands_web.py b/tests/python/test_commands_web.py
new file mode 100644
index 0000000..44e304e
--- /dev/null
+++ b/tests/python/test_commands_web.py
@@ -0,0 +1,150 @@
+"""Tests for ``ci web serve`` (W4 of the web-UI plan).
+
+Two concerns:
+
+1. Behaviour: ``ci web serve --help`` works and ``serve`` builds the app and
+ calls ``uvicorn.run`` with the right host/port (``uvicorn.run`` is
+ monkeypatched -- no real server is ever started). ``CI_ROOT``/``CI_CONFIG``
+ are pointed at an empty tmp dir so the test never depends on this host's
+ real ``/var/lib/ci/config.toml``.
+2. Architecture: ``commands/web.py`` must import only ``click``/stdlib at
+ module scope. ``fastapi``/``uvicorn``/``ci_orchestrator.web`` may only be
+ imported lazily, inside the ``serve`` function body -- this keeps
+ ``ci --help`` working when the optional ``[web]`` extra is not installed.
+"""
+
+from __future__ import annotations
+
+import ast
+import inspect
+import sys
+from pathlib import Path
+from typing import Any
+
+import pytest
+from click.testing import CliRunner
+
+import ci_orchestrator.commands.web as web_module
+from ci_orchestrator.__main__ import cli
+from ci_orchestrator.config import Config
+
+
+@pytest.fixture(autouse=True)
+def _isolated_config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Point config resolution at an empty tmp dir (no config.toml present).
+
+ Every test in this file exercises ``ci web serve``, which calls
+ ``load_config()``; without this, the real ``/var/lib/ci/config.toml`` on
+ this host (see CLAUDE.md) could leak in and make assertions flaky.
+ """
+ monkeypatch.setenv("CI_ROOT", str(tmp_path))
+ monkeypatch.setenv("CI_CONFIG", str(tmp_path / "does-not-exist.toml"))
+
+
+def _install_fake_uvicorn(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
+ calls: list[dict[str, Any]] = []
+
+ class _FakeUvicorn:
+ @staticmethod
+ def run(app: object, **kwargs: Any) -> None:
+ calls.append({"app": app, **kwargs})
+
+ monkeypatch.setitem(sys.modules, "uvicorn", _FakeUvicorn())
+ return calls
+
+
+def test_web_serve_help_works() -> None:
+ runner = CliRunner()
+
+ result = runner.invoke(cli, ["web", "serve", "--help"])
+
+ assert result.exit_code == 0
+ assert "--host" in result.output
+ assert "--port" in result.output
+
+
+def test_web_serve_calls_uvicorn_run_with_host_and_port(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = _install_fake_uvicorn(monkeypatch)
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["web", "serve", "--host", "127.0.0.1", "--port", "9999"])
+
+ assert result.exit_code == 0, result.output
+ assert len(calls) == 1
+ assert calls[0]["host"] == "127.0.0.1"
+ assert calls[0]["port"] == 9999
+ assert calls[0]["app"] is not None
+
+
+def test_web_serve_defaults_host_and_port_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = _install_fake_uvicorn(monkeypatch)
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["web", "serve"])
+
+ assert result.exit_code == 0, result.output
+ assert len(calls) == 1
+ assert calls[0]["host"] == "127.0.0.1"
+ assert calls[0]["port"] == 8800
+
+
+def test_web_serve_host_flag_overrides_config_default_only(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls = _install_fake_uvicorn(monkeypatch)
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["web", "serve", "--port", "8123"])
+
+ assert result.exit_code == 0, result.output
+ assert calls[0]["host"] == "127.0.0.1" # config default, untouched
+ assert calls[0]["port"] == 8123 # flag wins
+
+
+def test_web_serve_never_binds_0_0_0_0_by_default() -> None:
+ """Sanity: the un-overridden default host must never be 0.0.0.0."""
+ from ci_orchestrator.config import WebConfig
+
+ assert WebConfig().bind_host == "127.0.0.1"
+
+
+# ── architecture: commands/web.py stays dep-light ───────────────────────────
+
+
+def test_commands_web_module_has_no_top_level_heavy_imports() -> None:
+ """Static check: no ``fastapi``/``uvicorn``/``ci_orchestrator.web`` import
+ at module (top) level -- only inside function bodies. This is what keeps
+ ``ci --help`` working when the optional ``[web]`` extra is not installed.
+ """
+ source = inspect.getsource(web_module)
+ tree = ast.parse(source)
+ forbidden_prefixes = ("fastapi", "uvicorn", "ci_orchestrator.web")
+
+ for node in tree.body: # module (top) level statements only
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ assert not alias.name.startswith(
+ forbidden_prefixes
+ ), f"top-level `import {alias.name}` in commands/web.py"
+ elif isinstance(node, ast.ImportFrom):
+ module = node.module or ""
+ assert not module.startswith(
+ forbidden_prefixes
+ ), f"top-level `from {module} import ...` in commands/web.py"
+
+
+def test_commands_web_module_object_carries_no_fastapi_or_uvicorn_attribute() -> None:
+ """Belt-and-braces: the module object itself has no fastapi/uvicorn name bound."""
+ assert not hasattr(web_module, "FastAPI")
+ assert not hasattr(web_module, "uvicorn")
+ assert not hasattr(web_module, "create_app")
+
+
+def test_web_group_registered_on_root_cli() -> None:
+ assert "web" in cli.commands
+ assert "serve" in cli.commands["web"].commands # type: ignore[attr-defined]
+
+
+def test_config_import_does_not_require_web_extra() -> None:
+ """``Config``/``load_config`` (imported lazily inside serve) is core, not [web]."""
+ assert Config is not None
diff --git a/tests/python/test_web_app.py b/tests/python/test_web_app.py
new file mode 100644
index 0000000..858144d
--- /dev/null
+++ b/tests/python/test_web_app.py
@@ -0,0 +1,270 @@
+"""Tests for the read-only (W4) web dashboard app.
+
+In-process only: routes call ``operations/`` + a backend injected via
+FastAPI dependency override -- no real ``vmrun`` subprocess is ever
+spawned. ``shutil.disk_usage``/``shutil.which`` are monkeypatched so no
+real host state (disk, systemd) leaks into the assertions either.
+"""
+
+from __future__ import annotations
+
+import shutil
+from collections import namedtuple
+from pathlib import Path
+from typing import Any
+
+import pytest
+from fastapi.testclient import TestClient
+
+import ci_orchestrator.web.app as app_module
+from ci_orchestrator.backends.errors import BackendNotAvailable
+from ci_orchestrator.backends.protocol import VmHandle
+from ci_orchestrator.config import BackendConfig, CargoCacheConfig, Config, Paths
+from ci_orchestrator.web.app import create_app, get_backend
+
+_DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"])
+
+
+def _fake_disk_usage(_path: Any) -> _DiskUsage:
+ gb = 1024**3
+ return _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb)
+
+
+def _make_config(root: Path) -> Config:
+ return Config(
+ paths=Paths(
+ root=root,
+ templates=root / "templates",
+ build_vms=root / "build-vms",
+ artifacts=root / "artifacts",
+ keys=root / "keys",
+ ),
+ backend=BackendConfig(),
+ cargo_cache=CargoCacheConfig(
+ enabled=True, root=root / "cache" / "cargo", retention_days=30
+ ),
+ )
+
+
+class _FakeBackend:
+ """Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked."""
+
+ def __init__(self, handles: list[VmHandle], ip: str | None = "192.168.79.201") -> None:
+ self._handles = handles
+ self._ip = ip
+ self.list_running_calls = 0
+ self.get_ip_calls = 0
+
+ def list_running(self) -> list[VmHandle]:
+ self.list_running_calls += 1
+ return self._handles
+
+ def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
+ self.get_ip_calls += 1
+ return self._ip
+
+
+class _BoomBackend:
+ """Backend whose ``list_running`` raises something other than ``BackendError``."""
+
+ def list_running(self) -> list[VmHandle]:
+ raise RuntimeError("boom")
+
+ def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
+ return None
+
+
+def _quiet_host(monkeypatch: pytest.MonkeyPatch) -> None:
+ """No real disk/systemctl calls from ``operations.host`` during a test."""
+ monkeypatch.setattr(shutil, "disk_usage", _fake_disk_usage)
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
+
+
+# ── /healthz ──────────────────────────────────────────────────────────────
+
+
+def test_healthz_returns_ok(tmp_path: Path) -> None:
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/healthz")
+
+ assert resp.status_code == 200
+ assert resp.json() == {"status": "ok"}
+
+
+# ── / (dashboard) ────────────────────────────────────────────────────────
+
+
+def test_dashboard_renders_all_four_cards(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ _quiet_host(monkeypatch)
+ app = create_app(_make_config(tmp_path))
+ app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
+ client = TestClient(app)
+
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ for needle in ("Live VMs", "Cargo cache", "Disk", "Runners"):
+ assert needle in resp.text
+
+
+def test_dashboard_shows_backend_unavailable_notice_when_backend_is_none(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ _quiet_host(monkeypatch)
+ app = create_app(_make_config(tmp_path))
+ app.dependency_overrides[get_backend] = lambda: None
+ client = TestClient(app)
+
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ assert "unavailable" in resp.text.lower()
+
+
+# ── /vms ──────────────────────────────────────────────────────────────────
+
+
+def test_vms_route_renders_injected_vm_and_never_touches_real_vmrun(tmp_path: Path) -> None:
+ vmx = tmp_path / "build-vms" / "job1" / "job1.vmx"
+ fake = _FakeBackend([VmHandle(identifier=str(vmx), name="job1")])
+ app = create_app(_make_config(tmp_path))
+ app.dependency_overrides[get_backend] = lambda: fake
+ client = TestClient(app)
+
+ resp = client.get("/vms")
+
+ assert resp.status_code == 200
+ assert "job1" in resp.text
+ assert fake.list_running_calls == 1
+
+
+def test_vms_route_degrades_when_backend_is_none(tmp_path: Path) -> None:
+ app = create_app(_make_config(tmp_path))
+ app.dependency_overrides[get_backend] = lambda: None
+ client = TestClient(app)
+
+ resp = client.get("/vms")
+
+ assert resp.status_code == 200
+ assert "unavailable" in resp.text.lower()
+
+
+def test_vms_route_degrades_on_unexpected_backend_error(tmp_path: Path) -> None:
+ app = create_app(_make_config(tmp_path))
+ app.dependency_overrides[get_backend] = lambda: _BoomBackend()
+ client = TestClient(app)
+
+ resp = client.get("/vms")
+
+ assert resp.status_code == 200
+
+
+def test_get_backend_degrades_to_none_when_vmrun_missing(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def _raise(_config: Config) -> Any:
+ raise BackendNotAvailable("vmrun not found")
+
+ monkeypatch.setattr(app_module, "load_backend", _raise)
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/vms")
+
+ assert resp.status_code == 200
+ assert "unavailable" in resp.text.lower()
+
+
+# ── /cache ────────────────────────────────────────────────────────────────
+
+
+def test_cache_route_renders_status(tmp_path: Path) -> None:
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/cache")
+
+ assert resp.status_code == 200
+ assert "Cargo cache" in resp.text
+
+
+def test_cache_route_degrades_when_operations_raises(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def _boom(_config: Config) -> Any:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(app_module, "cargo_cache_status", _boom)
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/cache")
+
+ assert resp.status_code == 200
+
+
+# ── /host ─────────────────────────────────────────────────────────────────
+
+
+def test_host_route_renders_disk_and_runner_status(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ _quiet_host(monkeypatch)
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/host")
+
+ assert resp.status_code == 200
+ assert "Disk" in resp.text
+ assert "Runners" in resp.text
+ assert "act-runner.service" in resp.text
+ assert "act-runner-rust.service" in resp.text
+
+
+def test_host_route_degrades_when_disk_status_raises(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def _boom(_path: Path) -> Any:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(app_module, "disk_status", _boom)
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/host")
+
+ assert resp.status_code == 200
+
+
+def test_host_route_degrades_when_runner_status_raises(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def _boom(_names: list[str]) -> Any:
+ raise RuntimeError("boom")
+
+ _quiet_host(monkeypatch)
+ monkeypatch.setattr(app_module, "runner_status", _boom)
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/host")
+
+ assert resp.status_code == 200
+
+
+# ── static mount ─────────────────────────────────────────────────────────
+
+
+def test_static_mount_serves_app_css(tmp_path: Path) -> None:
+ app = create_app(_make_config(tmp_path))
+ client = TestClient(app)
+
+ resp = client.get("/static/app.css")
+
+ assert resp.status_code == 200