diff --git a/src/ci_orchestrator/operations/host.py b/src/ci_orchestrator/operations/host.py index 5012549..5ee218c 100644 --- a/src/ci_orchestrator/operations/host.py +++ b/src/ci_orchestrator/operations/host.py @@ -70,9 +70,25 @@ class BackupResult: error: str | None +@dataclass(frozen=True) +class TemplateDeleteResult: + """Outcome of a (real or dry-run) template directory deletion.""" + + template: str + ok: bool + error: str | None + + @dataclass(frozen=True) class HostResources: - """Point-in-time host CPU/memory/uptime/load snapshot (Stage B, B1).""" + """Point-in-time host CPU/memory/uptime/load snapshot (Stage B, B1). + + ``cpu_model``/``kernel``/``hostname`` are the near-static identity fields + (Stage F): they change only across a reboot or a CPU swap, but they belong + to the same ``/proc`` read so callers get one snapshot instead of two. + Each is ``""`` when unreadable -- never ``None``, so templates can print + it without a guard. + """ cpu_pct: float ncpu: int @@ -82,6 +98,9 @@ class HostResources: load1: float load5: float load15: float + cpu_model: str = "" + kernel: str = "" + hostname: str = "" @dataclass(frozen=True) @@ -171,6 +190,32 @@ def _parse_stat_cpu_line(text: str) -> tuple[int, int]: return 0, 0 +def _read_proc_optional(path: str) -> str: + """Read a near-static ``/proc`` file, ``""`` if unreadable. + + Kept separate from the live-metric reads in :func:`host_resources`: a + missing ``/proc/cpuinfo`` must cost us the CPU *name* only, never the CPU + *percentage* (which shares the surrounding ``try``). + """ + try: + return _read_proc(path).strip() + except OSError: + return "" + + +def _parse_cpu_model(text: str) -> str: + """First ``model name`` value in ``/proc/cpuinfo`` text; ``""`` if absent. + + Absent on architectures that don't print it (arm64 uses ``Hardware`` / + ``Model``), so the empty string is a normal outcome, not an error. + """ + for line in text.splitlines(): + if line.startswith("model name"): + _, _, value = line.partition(":") + return value.strip() + return "" + + def _parse_meminfo(text: str) -> tuple[int, int]: """Parse ``/proc/meminfo`` text into ``(MemTotal_kb, MemAvailable_kb)``.""" total_kb = 0 @@ -194,6 +239,9 @@ def host_resources(*, cpu_sample_ms: int = 150) -> HostResources: :func:`os.cpu_count` since that call never touches ``/proc``. """ ncpu = os.cpu_count() or 1 + cpu_model = _parse_cpu_model(_read_proc_optional("/proc/cpuinfo")) + kernel = _read_proc_optional("/proc/sys/kernel/osrelease") + hostname = _read_proc_optional("/proc/sys/kernel/hostname") try: idle1, total1 = _parse_stat_cpu_line(_read_proc("/proc/stat")) time.sleep(cpu_sample_ms / 1000.0) @@ -226,6 +274,9 @@ def host_resources(*, cpu_sample_ms: int = 150) -> HostResources: load1=0.0, load5=0.0, load15=0.0, + cpu_model=cpu_model, + kernel=kernel, + hostname=hostname, ) return HostResources( @@ -237,6 +288,9 @@ def host_resources(*, cpu_sample_ms: int = 150) -> HostResources: load1=load1, load5=load5, load15=load15, + cpu_model=cpu_model, + kernel=kernel, + hostname=hostname, ) @@ -325,13 +379,40 @@ def backup_template(config: Config, template: str, *, dry_run: bool = False) -> return BackupResult(template=template, archive_path=archive_path, ok=True, error=None) +def delete_template(config: Config, template: str, *, dry_run: bool = False) -> TemplateDeleteResult: + """Permanently remove a template directory -- the golden image VM clones + are made from, not just one backup archive. + + ``dry_run`` only confirms the directory exists; nothing is removed. On + failure (missing template dir, or the removal step raising), returns + ``ok=False`` and never raises. + """ + tmpl_dir = config.paths.templates / template + if not tmpl_dir.is_dir(): + return TemplateDeleteResult( + template=template, ok=False, error=f"Template directory not found: {tmpl_dir}" + ) + + if dry_run: + return TemplateDeleteResult(template=template, ok=True, error=None) + + try: + shutil.rmtree(tmpl_dir) + except OSError as exc: + return TemplateDeleteResult(template=template, ok=False, error=str(exc)) + + return TemplateDeleteResult(template=template, ok=True, error=None) + + __all__ = [ "BackupResult", "DiskStatus", "HostResources", "RunnerStatus", + "TemplateDeleteResult", "VmwareModuleHealth", "backup_template", + "delete_template", "disk_status", "host_resources", "runner_status", diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py index ebc9c93..428372c 100644 --- a/src/ci_orchestrator/web/app.py +++ b/src/ci_orchestrator/web/app.py @@ -305,54 +305,34 @@ def _fetch_service_states(config: Config) -> list[ServiceState]: return [] -# ────────────────────────────────────────────────────────── status header +# ─────────────────────────────────────────────────── resource health levels @dataclass(frozen=True) -class HealthSummary: - """Render-ready values for the status-header tiles (A3). +class ResourceLevels: + """Health level for each gauge on the Host card (Stage F). - Every field is derived purely from data the dashboard route already - fetched -- :func:`_health_summary` makes no new call of its own. Each - tile carries both a CSS class (``*_class``) and a short text - ``*_label`` so health never reads from color alone. + Stage F deleted the status-header tiles and gave their readouts back to + the cards, but kept the tiles' one non-negotiable rule: health is never + color-only. Each gauge therefore carries a CSS class (``*_class``, still + the ``tile-*`` names the gauge colors key off) *and* a short text + ``*_label`` printed next to it. + + ``mem_pct`` is derived here rather than in the template: it is the only + gauge whose percentage is not already a field on its source dataclass. """ - vm_count: int - orphan_count: int - vm_class: str - vm_label: str - - runners_active: int - runners_total: int - runner_class: str - runner_label: str - - cpu_pct: float | None - ncpu: int cpu_class: str cpu_label: str - - mem_used_gb: float | None - mem_total_gb: float | None mem_pct: float | None mem_class: str mem_label: str - - disk_used_pct: float | None - disk_free_gb: float | None - disk_total_gb: float | None disk_class: str disk_label: str - cache_bytes: int | None - cache_entries: int | None - cache_class: str - cache_label: str - def _threshold_level(pct: float, *, elevated: float, critical: float) -> tuple[str, str]: - """Map a usage percentage to a ``(tile-class, label)`` on the shared red/amber/green scale.""" + """Map a usage percentage to a ``(css-class, label)`` on the shared red/amber/green scale.""" if pct > critical: return _TILE_BAD, "critical" if pct > elevated: @@ -360,98 +340,46 @@ def _threshold_level(pct: float, *, elevated: float, critical: float) -> tuple[s return _TILE_OK, "ok" -def _health_summary( - vms: list[LiveVm], - *, - backend_available: bool, - cache: CacheStatus | None, - disk: DiskStatus | None, - runners: list[RunnerStatus], - resources: HostResources | None = None, -) -> HealthSummary: - """Derive the A3 status-header tiles from already-fetched dashboard context. +def _resource_levels( + resources: HostResources | None, disk: DiskStatus | None +) -> ResourceLevels: + """Derive the Host card's three gauge levels from already-fetched data. - Thresholds (see the A3 brief): disk ``used_pct`` > 85 -> red, > 70 -> - amber, else green; any runner not active -> red; an orphaned VM present - -> amber. A missing data source (backend down, disk/cache unreadable) - renders as a neutral "unknown" tile rather than false-green. + Makes no call of its own. A missing source (``/proc`` unreadable, disk + unreadable) renders as a neutral "unknown" level rather than false-green. """ - orphan_count = sum(1 for vm in vms if vm.is_orphan) - if not backend_available: - vm_class, vm_label = _TILE_UNKNOWN, "unknown" - elif orphan_count > 0: - vm_class, vm_label = _TILE_WARN, "orphaned" - else: - vm_class, vm_label = _TILE_OK, "ok" - - runners_total = len(runners) - runners_active = sum(1 for r in runners if r.active) - if runners_total == 0: - runner_class, runner_label = _TILE_UNKNOWN, "unknown" - elif runners_active < runners_total: - runner_class, runner_label = _TILE_BAD, "down" - else: - runner_class, runner_label = _TILE_OK, "ok" - if resources is None: - cpu_pct, ncpu = None, 0 cpu_class, cpu_label = _TILE_UNKNOWN, "unknown" - mem_used_gb = mem_total_gb = mem_pct = None + mem_pct = None mem_class, mem_label = _TILE_UNKNOWN, "unknown" else: - cpu_pct, ncpu = resources.cpu_pct, resources.ncpu cpu_class, cpu_label = _threshold_level( - cpu_pct, elevated=_CPU_PCT_ELEVATED, critical=_CPU_PCT_CRITICAL + resources.cpu_pct, elevated=_CPU_PCT_ELEVATED, critical=_CPU_PCT_CRITICAL + ) + mem_pct = ( + (resources.mem_used_gb / resources.mem_total_gb * 100.0) + if resources.mem_total_gb > 0 + else 0.0 ) - mem_used_gb, mem_total_gb = resources.mem_used_gb, resources.mem_total_gb - mem_pct = (mem_used_gb / mem_total_gb * 100.0) if mem_total_gb > 0 else 0.0 mem_class, mem_label = _threshold_level( mem_pct, elevated=_MEM_PCT_ELEVATED, critical=_MEM_PCT_CRITICAL ) - disk_used_pct = disk.used_pct if disk is not None else None - disk_free_gb = disk.free_gb if disk is not None else None - disk_total_gb = disk.total_gb if disk is not None else None - if disk_used_pct is None: + if disk is None: disk_class, disk_label = _TILE_UNKNOWN, "unknown" else: disk_class, disk_label = _threshold_level( - disk_used_pct, elevated=_DISK_PCT_ELEVATED, critical=_DISK_PCT_CRITICAL + disk.used_pct, elevated=_DISK_PCT_ELEVATED, critical=_DISK_PCT_CRITICAL ) - cache_bytes = cache.total_bytes if cache is not None else None - cache_entries = cache.entry_count if cache is not None else None - cache_class, cache_label = ( - (_TILE_OK, "ok") if cache is not None else (_TILE_UNKNOWN, "unknown") - ) - - return HealthSummary( - vm_count=len(vms), - orphan_count=orphan_count, - vm_class=vm_class, - vm_label=vm_label, - runners_active=runners_active, - runners_total=runners_total, - runner_class=runner_class, - runner_label=runner_label, - cpu_pct=cpu_pct, - ncpu=ncpu, + return ResourceLevels( cpu_class=cpu_class, cpu_label=cpu_label, - mem_used_gb=mem_used_gb, - mem_total_gb=mem_total_gb, mem_pct=mem_pct, mem_class=mem_class, mem_label=mem_label, - disk_used_pct=disk_used_pct, - disk_free_gb=disk_free_gb, - disk_total_gb=disk_total_gb, disk_class=disk_class, disk_label=disk_label, - cache_bytes=cache_bytes, - cache_entries=cache_entries, - cache_class=cache_class, - cache_label=cache_label, ) @@ -466,39 +394,28 @@ def healthz() -> dict[str, str]: async def dashboard( request: Request, backend: VmBackend | None = Depends(get_backend) ) -> HTMLResponse: - """Full dashboard: executions, cache, disk, runner, and Stage-B info cards.""" + """Full dashboard: the four info cards + executions, templates, files, services.""" config: Config = request.app.state.config templates: Jinja2Templates = request.app.state.templates 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() resources = _fetch_resources() - template_rows = _fetch_templates(config) context = { "vms": vms, "executions": await _fetch_executions(request, config, vms), "limit": config.web.executions_limit, "limit_choices": EXECUTIONS_LIMIT_CHOICES, - "backend_available": backend_available, - "cache": cache, + "backend_available": backend is not None, + "cache": _fetch_cache_status(config), "disk": disk, - "runners": runners, + "runners": _fetch_runner_status(), "resources": resources, + "levels": _resource_levels(resources, disk), "vmware": _fetch_vmware_health(), - "templates": template_rows, + "templates": _fetch_templates(config), "template_backups": backups_by_template(config), "artifacts": _fetch_artifacts(config), "services": _fetch_service_states(config), - "health": _health_summary( - vms, - backend_available=backend_available, - cache=cache, - disk=disk, - runners=runners, - resources=resources, - ), "csrf_token": issue_csrf(request), } return templates.TemplateResponse(request, "dashboard.html", context) @@ -545,21 +462,28 @@ def cache_partial(request: Request) -> HTMLResponse: def runners_partial(request: Request) -> HTMLResponse: - """``GET /runners`` -- runner status partial (Stage E: disk/backup moved out). - - The disk card was folded into the status-header Disk tile and the - standalone template-backup card into a per-row Templates action, so this - read-only poll now carries only the runner statuses. - """ + """``GET /runners`` -- runner status partial.""" templates: Jinja2Templates = request.app.state.templates context = {"runners": _fetch_runner_status()} return templates.TemplateResponse(request, "partials/runners.html", context) def host_partial(request: Request) -> HTMLResponse: - """``GET /host`` (B5) -- host resources (CPU/RAM/load) partial, polled every 5s.""" + """``GET /host`` (B5) -- the Host card, polled every 5s. + + Stage F folded the retired status-header tiles' readouts back in here, so + this context now also carries the disk snapshot and the + :class:`ResourceLevels` that colour (and *label*) the CPU/RAM/disk gauges. + """ + config: Config = request.app.state.config templates: Jinja2Templates = request.app.state.templates - context = {"resources": _fetch_resources()} + resources = _fetch_resources() + disk = _fetch_disk_status(config) + context = { + "resources": resources, + "disk": disk, + "levels": _resource_levels(resources, disk), + } return templates.TemplateResponse(request, "partials/host.html", context) @@ -731,7 +655,7 @@ def create_app(config: Config) -> FastAPI: __all__ = [ "EXECUTIONS_LIMIT_CHOICES", "RUNNER_SERVICE_NAMES", - "HealthSummary", + "ResourceLevels", "create_app", "dashboard", "executions_partial", diff --git a/src/ci_orchestrator/web/templates/dashboard.html b/src/ci_orchestrator/web/templates/dashboard.html index e42c10e..0ca0b7c 100644 --- a/src/ci_orchestrator/web/templates/dashboard.html +++ b/src/ci_orchestrator/web/templates/dashboard.html @@ -4,22 +4,24 @@ {% block content %} {{ super() }} -{% include "partials/status_header.html" %} {# - Layout ordering (Stage E). Narrative top-to-bottom: infrastructure health -> - what the CI has been doing (Executions: recent runs, live ones included, - with their VMs) -> assets (templates + their backups) -> build outputs - (artifacts) -> managed services -> the dedicated Maintenance/danger card - last. The wide, many-column tables (executions / templates / files / - services / maintenance) span the full grid row via ``.card--wide``; the four - compact info cards flow in one breathing-room row at the top, ordered Host - -> VMware -> Runners -> Cargo cache, followed by the Executions table and - then the Templates table. + Layout ordering (Stage E, revised Stage F). Stage F removed the + status-header tile row: every readout it carried is back in the card that + owns the subject (CPU/RAM/disk in Host, size+entries in Cargo cache, unit + state in Runners), so nothing is stated twice and there is no second place + to keep in sync. + + Narrative top-to-bottom: infrastructure health (the four compact info cards, + Host -> VMware -> Runners -> Cargo cache) -> what the CI has been doing + (Executions: recent runs, live ones included, with their VMs) -> assets + (templates + their backups) -> build outputs (artifacts) -> managed services + -> the dedicated Maintenance/danger card last. The wide, many-column tables + span the full grid row via ``.card--wide``. #}
{{ cache.root }}{{ resources.kernel or "-" }}Host resource status unavailable.
diff --git a/src/ci_orchestrator/web/templates/partials/runners.html b/src/ci_orchestrator/web/templates/partials/runners.html index c56645e..2d9b2f5 100644 --- a/src/ci_orchestrator/web/templates/partials/runners.html +++ b/src/ci_orchestrator/web/templates/partials/runners.html @@ -1,33 +1,25 @@ +{# + Runners card. Same| Service | -Status | -Sub-state | -
|---|---|---|
| {{ runner.name }} | -- {% if runner.active %} - active - {% else %} - down - {% endif %} - | -{{ runner.sub_state }} | -
Runner status unavailable.
{% endif %} diff --git a/src/ci_orchestrator/web/templates/partials/status_header.html b/src/ci_orchestrator/web/templates/partials/status_header.html deleted file mode 100644 index 3f7319d..0000000 --- a/src/ci_orchestrator/web/templates/partials/status_header.html +++ /dev/null @@ -1,110 +0,0 @@ -{# - Status header (A3, enriched Stage E): a row of stat tiles fed only from the - dashboard context already fetched by app.py's ``dashboard()`` route (via - ``_health_summary`` -> ``health``) -- no fetch of its own. Health is never - color-only: each tile pairs its color class with a text label and a - check/alert icon. Stage E adds the CPU and RAM tiles (so the near-empty - Disk card could be retired) and gives Disk/Cache a value sub-line. -#} -| Component | -Status | -Sub-state | -
|---|---|---|
vmmon |
- - {% if vmware.vmmon_loaded %} - loaded - {% else %} - not loaded - {% endif %} - | -kernel module | -
vmnet |
- - {% if vmware.vmnet_loaded %} - loaded - {% else %} - not loaded - {% endif %} - | -kernel module | -
vmware-host.service |
- - {% if vmware.host_service_active %} - active - {% else %} - down - {% endif %} - | -{{ vmware.host_service_sub_state }} | -
vmmonvmnetvmware-host.serviceVMware module status unavailable.
{% endif %} diff --git a/tests/python/test_host_resources.py b/tests/python/test_host_resources.py index 632d4a5..a782aa2 100644 --- a/tests/python/test_host_resources.py +++ b/tests/python/test_host_resources.py @@ -40,6 +40,19 @@ _MEMINFO = ( _UPTIME = "123456.78 987654.32\n" _LOADAVG = "0.50 0.75 1.00 3/456 7890\n" +# Two cores' worth of /proc/cpuinfo -- the parser takes the FIRST "model name". +_CPUINFO = ( + "processor\t: 0\n" + "vendor_id\t: GenuineIntel\n" + "model name\t: Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz\n" + "cache size\t: 36864 KB\n" + "\n" + "processor\t: 1\n" + "model name\t: Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz\n" +) +_OSRELEASE = "7.0.0-28-generic\n" +_HOSTNAME = "ci-host\n" + def _fake_reader(calls: dict[str, int]) -> Any: def _read(path: str) -> str: @@ -52,6 +65,12 @@ def _fake_reader(calls: dict[str, int]) -> Any: return _UPTIME if path == "/proc/loadavg": return _LOADAVG + if path == "/proc/cpuinfo": + return _CPUINFO + if path == "/proc/sys/kernel/osrelease": + return _OSRELEASE + if path == "/proc/sys/kernel/hostname": + return _HOSTNAME raise AssertionError(f"unexpected proc path: {path}") return _read @@ -113,6 +132,61 @@ def test_host_resources_uptime_and_loadavg(monkeypatch: pytest.MonkeyPatch) -> N assert result.load15 == pytest.approx(1.00) +# ── host_resources: static identity fields (CPU model, kernel, hostname) ───── + + +def test_host_resources_reports_cpu_model_kernel_and_hostname( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_proc(monkeypatch) + + result = host_resources() + + assert result.cpu_model == "Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz" + assert result.kernel == "7.0.0-28-generic" + assert result.hostname == "ci-host" + + +def test_host_resources_cpu_model_is_empty_when_cpuinfo_has_no_model_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Not every architecture prints ``model name`` (e.g. arm64) -- degrade, don't crash.""" + calls: dict[str, int] = {} + inner = _fake_reader(calls) + + def _read(path: str) -> str: + if path == "/proc/cpuinfo": + return "processor\t: 0\nBogoMIPS\t: 50.00\n" + return str(inner(path)) + + monkeypatch.setattr(host_module, "_read_proc", _read) + monkeypatch.setattr(host_module.time, "sleep", lambda _s: None) + + assert host_resources().cpu_model == "" + + +def test_host_resources_unreadable_static_fields_do_not_zero_live_metrics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing ``/proc/cpuinfo`` must not cost us CPU%/RAM/load as well.""" + calls: dict[str, int] = {} + inner = _fake_reader(calls) + + def _read(path: str) -> str: + if path in ("/proc/cpuinfo", "/proc/sys/kernel/osrelease", "/proc/sys/kernel/hostname"): + raise OSError("gone") + return str(inner(path)) + + monkeypatch.setattr(host_module, "_read_proc", _read) + monkeypatch.setattr(host_module.time, "sleep", lambda _s: None) + + result = host_resources() + + assert result.cpu_pct == pytest.approx(50.0) + assert result.load1 == pytest.approx(0.50) + assert (result.cpu_model, result.kernel, result.hostname) == ("", "", "") + + # ── host_resources: degrade to zeros on OSError, never raise ──────────────── @@ -134,6 +208,9 @@ def test_host_resources_degrades_to_zeros_on_oserror(monkeypatch: pytest.MonkeyP load1=0.0, load5=0.0, load15=0.0, + cpu_model="", + kernel="", + hostname="", ) @@ -144,6 +221,8 @@ def test_host_resources_degrades_to_zeros_when_second_stat_sample_fails( calls = {"n": 0} def _flaky(path: str) -> str: + if path.startswith("/proc/sys/kernel/") or path == "/proc/cpuinfo": + return "" # static identity reads happen before the live-metric block if path != "/proc/stat": raise AssertionError("should not reach meminfo after stat blows up") calls["n"] += 1 diff --git a/tests/python/test_web_app.py b/tests/python/test_web_app.py index 8d869e2..1f71f51 100644 --- a/tests/python/test_web_app.py +++ b/tests/python/test_web_app.py @@ -20,8 +20,6 @@ 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.operations.cache import CacheStatus -from ci_orchestrator.operations.host import DiskStatus, RunnerStatus from ci_orchestrator.operations.vms import LiveVm from ci_orchestrator.web.app import create_app, get_backend @@ -182,7 +180,7 @@ def test_get_backend_degrades_to_none_when_vmrun_missing( assert "unavailable" in resp.text.lower() -# ── /cache ──────────────────────────────────────────────────────────────── +# ── /cache, /runners ────────────────────────────────────────────────────── def test_cache_route_renders_status(tmp_path: Path) -> None: @@ -210,14 +208,9 @@ def test_cache_route_degrades_when_operations_raises( assert resp.status_code == 200 -# ── /host ───────────────────────────────────────────────────────────────── - - -def test_runners_route_renders_runner_status( +def test_runners_route_renders_every_unit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # Stage E: the /runners partial carries only the runner statuses -- the - # disk card was folded into the status-header Disk tile. _quiet_host(monkeypatch) app = create_app(_make_config(tmp_path)) client = TestClient(app) @@ -230,9 +223,10 @@ def test_runners_route_renders_runner_status( assert "act-runner-rust.service" in resp.text -def test_runners_route_degrades_when_disk_status_raises( +def test_host_route_degrades_when_disk_status_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Stage F put the disk gauge in the Host card, so this is its degrade path.""" def _boom(_path: Path) -> Any: raise RuntimeError("boom") @@ -241,9 +235,10 @@ def test_runners_route_degrades_when_disk_status_raises( app = create_app(_make_config(tmp_path)) client = TestClient(app) - resp = client.get("/runners") + resp = client.get("/host") assert resp.status_code == 200 + assert "unknown" in resp.text def test_runners_route_degrades_when_runner_status_raises( @@ -274,131 +269,22 @@ def test_static_mount_serves_app_css(tmp_path: Path) -> None: assert resp.status_code == 200 -# ── _health_summary (A3) ───────────────────────────────────────────────── +# ── dashboard composition (Stage F) ────────────────────────────────────── -_ACTIVE_RUNNER = RunnerStatus(name="act-runner.service", active=True, sub_state="running") -_DEAD_RUNNER = RunnerStatus(name="act-runner-rust.service", active=False, sub_state="dead") _LIVE_VM = LiveVm( name="job1", vmx="/x/job1.vmx", ip="10.0.0.5", guest_os="ubuntu-64", uptime_sec=120, is_orphan=False, ) -_ORPHAN_VM = LiveVm( - name="job2", vmx="/x/job2.vmx", ip=None, guest_os="ubuntu-64", - uptime_sec=99999, is_orphan=True, -) -_CACHE = CacheStatus( - root=Path("/cache"), total_bytes=1024, entry_count=1, oldest_age_days=1, retention_days=30 -) -def test_health_summary_backend_unavailable_is_vm_unknown() -> None: - health = app_module._health_summary( - [], backend_available=False, cache=None, disk=None, runners=[] - ) - assert health.vm_class == "tile-unknown" - assert health.vm_label == "unknown" - - -def test_health_summary_orphan_present_is_vm_warn() -> None: - health = app_module._health_summary( - [_LIVE_VM, _ORPHAN_VM], backend_available=True, cache=None, disk=None, runners=[] - ) - assert health.vm_count == 2 - assert health.orphan_count == 1 - assert health.vm_class == "tile-warn" - assert health.vm_label == "orphaned" - - -def test_health_summary_no_orphans_is_vm_ok() -> None: - health = app_module._health_summary( - [_LIVE_VM], backend_available=True, cache=None, disk=None, runners=[] - ) - assert health.vm_class == "tile-ok" - assert health.vm_label == "ok" - - -def test_health_summary_no_runners_is_unknown() -> None: - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=None, runners=[] - ) - assert health.runners_total == 0 - assert health.runner_class == "tile-unknown" - - -def test_health_summary_all_runners_active_is_ok() -> None: - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=None, runners=[_ACTIVE_RUNNER] - ) - assert health.runners_active == 1 - assert health.runners_total == 1 - assert health.runner_class == "tile-ok" - - -def test_health_summary_a_down_runner_is_bad() -> None: - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=None, - runners=[_ACTIVE_RUNNER, _DEAD_RUNNER], - ) - assert health.runners_active == 1 - assert health.runners_total == 2 - assert health.runner_class == "tile-bad" - assert health.runner_label == "down" - - -def test_health_summary_disk_none_is_unknown() -> None: - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=None, runners=[] - ) - assert health.disk_used_pct is None - assert health.disk_class == "tile-unknown" - - -def test_health_summary_disk_above_critical_is_bad() -> None: - disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=5.0, used_pct=90.0) - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=disk, runners=[] - ) - assert health.disk_class == "tile-bad" - assert health.disk_label == "critical" - - -def test_health_summary_disk_above_elevated_is_warn() -> None: - disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=25.0, used_pct=75.0) - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=disk, runners=[] - ) - assert health.disk_class == "tile-warn" - assert health.disk_label == "elevated" - - -def test_health_summary_disk_healthy_is_ok() -> None: - disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=60.0, used_pct=40.0) - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=disk, runners=[] - ) - assert health.disk_class == "tile-ok" - assert health.disk_label == "ok" - - -def test_health_summary_cache_none_is_unknown() -> None: - health = app_module._health_summary( - [], backend_available=True, cache=None, disk=None, runners=[] - ) - assert health.cache_bytes is None - assert health.cache_class == "tile-unknown" - - -def test_health_summary_cache_present_is_ok() -> None: - health = app_module._health_summary( - [], backend_available=True, cache=_CACHE, disk=None, runners=[] - ) - assert health.cache_bytes == 1024 - assert health.cache_class == "tile-ok" - - -def test_dashboard_includes_status_header_tiles( +def test_dashboard_renders_the_info_cards_without_a_status_header( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Stage F: the tiles are gone; every readout they carried is in a card. + + Threshold levels themselves are covered in ``test_web_cards.py`` + (``_resource_levels``) -- this only pins the page composition. + """ _quiet_host(monkeypatch) app = create_app(_make_config(tmp_path)) app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) @@ -407,4 +293,23 @@ def test_dashboard_includes_status_header_tiles( resp = client.get("/") assert resp.status_code == 200 - assert 'id="status-header"' in resp.text + assert 'id="status-header"' not in resp.text + assert 'id="host-card"' in resp.text + + +def test_dashboard_lists_a_running_vm_under_its_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The VM count left with the tiles -- the Executions card is where VMs show.""" + _quiet_host(monkeypatch) + vmx = tmp_path / "build-vms" / "job1" / "job1.vmx" + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend( + [VmHandle(identifier=str(vmx), name=_LIVE_VM.name)] + ) + client = TestClient(app) + + resp = client.get("/") + + assert resp.status_code == 200 + assert "job1" in resp.text diff --git a/tests/python/test_web_cards.py b/tests/python/test_web_cards.py new file mode 100644 index 0000000..3105173 --- /dev/null +++ b/tests/python/test_web_cards.py @@ -0,0 +1,331 @@ +"""Tests for the Stage F card layout: four info cards, no status header. + +The dashboard used to open with six status-header tiles *and* four compact +cards that restated them (runner state lived in three places at once). Stage F +keeps the cards and deletes the tiles: each card is now the single owner of its +subject, enriched with what only the tiles used to show (CPU%/RAM/disk with +their gauges) plus what neither had (hostname, kernel, CPU model, and the +``vmware-host.service`` row). + +Health is still never color-only: every gauge is paired with a text level +(``ok``/``elevated``/``critical``/``unknown``) from :func:`_resource_levels`. +""" + +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.config import BackendConfig, CargoCacheConfig, Config, Paths +from ci_orchestrator.operations.cache import CacheStatus +from ci_orchestrator.operations.host import DiskStatus, HostResources, VmwareModuleHealth +from ci_orchestrator.web.app import create_app, get_backend + +_DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"]) + +_RESOURCES = HostResources( + cpu_pct=42.0, + ncpu=16, + mem_total_gb=62.5, + mem_used_gb=20.0, + uptime_sec=93784, + load1=0.5, + load5=0.75, + load15=1.0, + cpu_model="Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz", + kernel="7.0.0-28-generic", + hostname="ci-host", +) +_VMWARE_OK = VmwareModuleHealth( + vmmon_loaded=True, vmnet_loaded=True, host_service_active=True, + host_service_sub_state="running", +) +_CACHE = CacheStatus( + root=Path("/var/lib/ci/cache/cargo"), + total_bytes=1024, + entry_count=7, + oldest_age_days=4, + retention_days=30, +) + + +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: + def list_running(self) -> list[Any]: + return [] + + def get_ip(self, handle: Any, timeout: float = 0.0) -> str | None: + return None + + +def _client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + gb = 1024**3 + monkeypatch.setattr( + shutil, "disk_usage", lambda _p: _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb) + ) + monkeypatch.setattr(shutil, "which", lambda _name: None) + monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES) + monkeypatch.setattr(app_module, "vmware_module_health", lambda: _VMWARE_OK) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend() + return TestClient(app) + + +# ── _resource_levels: thresholds behind the card gauges ────────────────── + +_DISK_OK = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=60.0, used_pct=40.0) +_DISK_ELEVATED = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=25.0, used_pct=75.0) +_DISK_CRITICAL = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=5.0, used_pct=90.0) + + +def test_resource_levels_without_a_snapshot_is_unknown_not_false_green() -> None: + levels = app_module._resource_levels(resources=None, disk=None) + + assert levels.cpu_class == "tile-unknown" + assert levels.cpu_label == "unknown" + assert levels.mem_class == "tile-unknown" + assert levels.disk_class == "tile-unknown" + assert levels.mem_pct is None + + +def test_resource_levels_healthy_snapshot_is_ok() -> None: + levels = app_module._resource_levels(resources=_RESOURCES, disk=_DISK_OK) + + assert (levels.cpu_class, levels.cpu_label) == ("tile-ok", "ok") + assert (levels.mem_class, levels.mem_label) == ("tile-ok", "ok") + assert (levels.disk_class, levels.disk_label) == ("tile-ok", "ok") + assert levels.mem_pct == pytest.approx(32.0) + + +def test_resource_levels_disk_above_elevated_is_warn() -> None: + levels = app_module._resource_levels(resources=_RESOURCES, disk=_DISK_ELEVATED) + + assert (levels.disk_class, levels.disk_label) == ("tile-warn", "elevated") + + +def test_resource_levels_disk_above_critical_is_bad() -> None: + levels = app_module._resource_levels(resources=_RESOURCES, disk=_DISK_CRITICAL) + + assert (levels.disk_class, levels.disk_label) == ("tile-bad", "critical") + + +def test_resource_levels_busy_cpu_and_full_memory_are_bad() -> None: + busy = HostResources( + cpu_pct=95.0, ncpu=16, mem_total_gb=62.5, mem_used_gb=61.0, + uptime_sec=1, load1=0.0, load5=0.0, load15=0.0, + ) + levels = app_module._resource_levels(resources=busy, disk=_DISK_OK) + + assert (levels.cpu_class, levels.cpu_label) == ("tile-bad", "critical") + assert (levels.mem_class, levels.mem_label) == ("tile-bad", "critical") + + +# ── the status header is gone ──────────────────────────────────────────── + + +def test_dashboard_has_no_status_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + resp = _client(tmp_path, monkeypatch).get("/") + + assert resp.status_code == 200 + assert 'id="status-header"' not in resp.text + assert "stat-tile" not in resp.text + + +@pytest.mark.parametrize("route", ["/status-header", "/platform"]) +def test_retired_routes_are_gone( + route: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert _client(tmp_path, monkeypatch).get(route).status_code == 404 + + +def test_all_four_info_cards_share_the_same_dl_grammar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Host and Cargo cache were always a