feat(web): keep the four info cards, enrich them, drop the status-header tiles

The dashboard used to open with six status-header tiles *and* four compact
cards restating them (runner state lived in three places at once). The tiles
are gone; each card is now the single owner of its subject, enriched with
what only the tiles had (CPU%/RAM/disk with gauges) plus what neither had
(hostname, kernel, full CPU model, the vmware-host.service row).

All four cards share the same <dl> grammar and font scale now -- VMware and
Runners were a bordered <table> at a different size/case, which read as a
visibly different style sitting next to Host and Cargo cache's plain
label/value lists.

operations/host.py also gains delete_template()/TemplateDeleteResult, used
by the template-delete route in the next commit.
This commit is contained in:
2026-07-25 19:33:19 +02:00
parent 61c4972a88
commit c598d0658e
11 changed files with 721 additions and 459 deletions
+82 -1
View File
@@ -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",
+50 -126
View File
@@ -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",
@@ -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``.
#}
<div class="dashboard-grid">
{% include "partials/host.html" %}{# Host (CPU/RAM/load) #}
{% include "partials/vmware.html" %}{# VMware modules #}
{% include "partials/runners.html" %}{# Runners #}
{% include "partials/host.html" %}{# hostname/kernel/CPU + CPU, RAM, disk gauges #}
{% include "partials/vmware.html" %}{# vmmon / vmnet / vmware-host.service #}
{% include "partials/runners.html" %}{# act_runner units #}
{% include "partials/cache.html" %}{# Cargo cache #}
{% include "partials/executions.html" %}
{% include "partials/templates.html" %}
@@ -2,7 +2,8 @@
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Cargo cache</h2>
{% if cache %}
{# Read-only info card -- the destructive "Purge" action lives in the
Maintenance card at the bottom of the dashboard (Stage E). #}
Maintenance card at the bottom of the dashboard (Stage E). Size and entry
count are back here as the only copy since Stage F retired the tiles. #}
<dl>
<dt>Root</dt><dd><code>{{ cache.root }}</code></dd>
<dt>Total size</dt><dd>{{ cache.total_bytes | humanbytes }}</dd>
@@ -1,14 +1,78 @@
{#
Host card (B5, enriched Stage F, compacted Stage G). The single home for host
resources now that the status-header tiles are gone.
Stage G levels this card's height against its three neighbours (VMware,
Runners, Cargo cache): the four near-static identity facts pair up onto two
rows, uptime moves to the header bar, and the load average becomes the CPU
gauge's sub-line -- the same shape the disk row already used for "GB free".
Eleven rows became five, for the same data.
Health is not color-only, but the level word (ok / elevated / critical /
unknown, from ``_resource_levels``) is no longer drawn as a strip under each
gauge -- it rides the gauge's ``aria-label`` instead. What a sighted viewer
reads is the number: "42%", "20.0 / 62 GB", "40%" are stated in text next to
every bar, so nothing here depends on distinguishing the fill colour.
#}
<section class="card" id="host-card" hx-get="/host" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>Host</h2>
<h2>
<svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>Host
{# Spelled out, not "up": the header has no <dt> to label it. #}
{% if resources %}<span class="head-meta">uptime {{ resources.uptime_sec | duration }}</span>{% endif %}
</h2>
{% if resources %}
{# CPU% and RAM live in the status-header tiles now -- this card keeps only
the details that aren't up there: uptime, load average, core count. #}
<dl>
<dt>Uptime</dt><dd>{{ resources.uptime_sec | duration }}</dd>
<dt>Load avg</dt>
<dd>{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}</dd>
<dt>CPU cores</dt><dd>{{ resources.ncpu }}</dd>
<dt>Memory</dt><dd>{{ "%.1f"|format(resources.mem_total_gb) }} GB total</dd>
<dt>System</dt>
<dd>{{ resources.hostname or "-" }} · <code>{{ resources.kernel or "-" }}</code></dd>
{# The raw model name is ~40 chars and wraps to three lines in a quarter-
width track, which is exactly the height this card is trying to lose --
so it is trimmed, with the untouched name on the title. #}
<dt>CPU</dt>
<dd {% if resources.cpu_model %}title="{{ resources.cpu_model }}"{% endif %}>
{{ resources.cpu_model | cpu_short or "-" }} · {{ resources.ncpu }} cores
</dd>
<dt>CPU load</dt>
<dd>
<span class="resource-value">{{ "%.0f"|format(resources.cpu_pct) }}%</span>
<div class="gauge {{ levels.cpu_class }}" role="img"
aria-label="CPU {{ '%.0f'|format(resources.cpu_pct) }} percent used, {{ levels.cpu_label }}">
<div class="gauge-fill" style="width: {{ resources.cpu_pct }}%"></div>
</div>
<span class="resource-sub text-dim" title="Load average, 1 / 5 / 15 minutes">
{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}
</span>
</dd>
<dt>Memory</dt>
<dd>
<span class="resource-value">
{{ "%.1f"|format(resources.mem_used_gb) }} / {{ "%.0f"|format(resources.mem_total_gb) }} GB
</span>
<div class="gauge {{ levels.mem_class }}" role="img"
aria-label="Memory {{ '%.0f'|format(levels.mem_pct) if levels.mem_pct is not none else 'unknown' }} percent used, {{ levels.mem_label }}">
<div class="gauge-fill" style="width: {{ levels.mem_pct if levels.mem_pct is not none else 0 }}%"></div>
</div>
</dd>
<dt>Disk</dt>
<dd>
{% if disk %}
<span class="resource-value">
{{ "%.0f"|format(disk.used_pct) }}%
<span class="text-dim">{{ "%.0f"|format(disk.free_gb) }} GB free</span>
</span>
<div class="gauge {{ levels.disk_class }}" role="img"
aria-label="Disk {{ '%.0f'|format(disk.used_pct) }} percent used, {{ levels.disk_label }}">
<div class="gauge-fill" style="width: {{ disk.used_pct }}%"></div>
</div>
{% else %}
{# No gauge to hang the level on, so the word is the value itself --
a bare dash would read as "no disk", not "could not be measured". #}
<span class="resource-value">{{ levels.disk_label }}</span>
{% endif %}
</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Host resource status unavailable.</p>
@@ -1,33 +1,25 @@
{#
Runners card. Same <dl> grammar as Host/VMware/Cargo cache -- see the note
in vmware.html. Unit name is the <dt>, the status badge plus its sub-state
share one <dd>.
#}
<div class="contents" id="runners-partial" hx-get="/runners" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
<section class="card" id="runner-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Runners</h2>
{% if runners %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Sub-state</th>
</tr>
</thead>
<tbody>
{% for runner in runners %}
<tr class="{{ 'row-alert' if not runner.active else '' }}">
<td>{{ runner.name }}</td>
<td>
{% if runner.active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
</td>
<td>{{ runner.sub_state }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<dl>
{% for runner in runners %}
<dt>{{ runner.name }}</dt>
<dd>
{% if runner.active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
<span class="text-dim">{{ runner.sub_state }}</span>
</dd>
{% endfor %}
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Runner status unavailable.</p>
{% endif %}
@@ -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.
#}
<section class="status-header" id="status-header" aria-label="System health summary">
<div class="stat-tile {{ health.vm_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-server"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">VMs</span>
<span class="stat-tile-value">
{{ health.vm_count }}
{% if health.orphan_count %}<span class="stat-tile-sub">({{ health.orphan_count }} orphan)</span>{% endif %}
</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.vm_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.vm_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.runner_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Runners</span>
<span class="stat-tile-value">{{ health.runners_active }}/{{ health.runners_total }}</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.runner_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.runner_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.cpu_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">CPU</span>
<span class="stat-tile-value">
{{ "%.0f"|format(health.cpu_pct) ~ "%" if health.cpu_pct is not none else "-" }}
{% if health.ncpu %}<span class="stat-tile-sub">/ {{ health.ncpu }} cores</span>{% endif %}
</span>
<div class="gauge" role="img"
aria-label="CPU {{ '%.0f'|format(health.cpu_pct) ~ ' percent used' if health.cpu_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.cpu_pct if health.cpu_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.cpu_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.cpu_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.mem_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-memory"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">RAM</span>
<span class="stat-tile-value">
{{ "%.1f"|format(health.mem_used_gb) if health.mem_used_gb is not none else "-" }}
{% if health.mem_total_gb is not none %}<span class="stat-tile-sub">/ {{ "%.0f"|format(health.mem_total_gb) }} GB</span>{% endif %}
</span>
<div class="gauge" role="img"
aria-label="Memory {{ '%.0f'|format(health.mem_pct) ~ ' percent used' if health.mem_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.mem_pct if health.mem_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.mem_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.mem_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.disk_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-disk"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Disk</span>
<span class="stat-tile-value">
{{ "%.0f"|format(health.disk_used_pct) ~ "%" if health.disk_used_pct is not none else "-" }}
{% if health.disk_free_gb is not none %}<span class="stat-tile-sub">{{ "%.0f"|format(health.disk_free_gb) }} GB free</span>{% endif %}
</span>
<div class="gauge" role="img"
aria-label="Disk {{ '%.0f'|format(health.disk_used_pct) ~ ' percent used' if health.disk_used_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.disk_used_pct if health.disk_used_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.disk_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.disk_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.cache_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Cache</span>
<span class="stat-tile-value">
{{ health.cache_bytes | humanbytes if health.cache_bytes is not none else "-" }}
{% if health.cache_entries is not none %}<span class="stat-tile-sub">{{ health.cache_entries | thousands }} entries</span>{% endif %}
</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.cache_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.cache_label }}
</span>
</div>
</div>
</section>
@@ -1,52 +1,45 @@
{#
VMware modules card. Same <dl> grammar as Host/Runners/Cargo cache (the
four info cards used to split table-vs-list and font-size across the row --
a <table> at 0.9rem with uppercase 0.75rem headers next to a <dl> at the
page's plain 15px body size). Component name is the <dt>, the status badge
plus its sub-state share one <dd>, same shape as Host's Disk row (value +
dim sub-text) and Runners' unit rows.
#}
<section class="card" id="vmware-card" hx-get="/vmware" hx-trigger="every 15s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>VMware modules</h2>
{% if vmware %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Component</th>
<th>Status</th>
<th>Sub-state</th>
</tr>
</thead>
<tbody>
<tr class="{{ '' if vmware.vmmon_loaded else 'row-alert' }}">
<td><code>vmmon</code></td>
<td>
{% if vmware.vmmon_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</td>
<td class="text-dim">kernel module</td>
</tr>
<tr class="{{ '' if vmware.vmnet_loaded else 'row-alert' }}">
<td><code>vmnet</code></td>
<td>
{% if vmware.vmnet_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</td>
<td class="text-dim">kernel module</td>
</tr>
<tr class="{{ '' if vmware.host_service_active else 'row-alert' }}">
<td><code>vmware-host.service</code></td>
<td>
{% if vmware.host_service_active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
</td>
<td>{{ vmware.host_service_sub_state }}</td>
</tr>
</tbody>
</table>
</div>
<dl>
<dt><code>vmmon</code></dt>
<dd>
{% if vmware.vmmon_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
<span class="text-dim">kernel module</span>
</dd>
<dt><code>vmnet</code></dt>
<dd>
{% if vmware.vmnet_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
<span class="text-dim">kernel module</span>
</dd>
<dt><code>vmware-host.service</code></dt>
<dd>
{% if vmware.host_service_active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
<span class="text-dim">{{ vmware.host_service_sub_state }}</span>
</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">VMware module status unavailable.</p>
{% endif %}
+79
View File
@@ -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
+33 -128
View File
@@ -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
+331
View File
@@ -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 <dl>; VMware and Runners used to be
a bordered <table> at a different font-size with uppercase headers --
a visibly different style for cards sitting in the same row. All four
read the same shape now: no other card renders a <table>."""
client = _client(tmp_path, monkeypatch)
for route in ("/host", "/vmware", "/runners", "/cache"):
resp = client.get(route)
assert resp.status_code == 200
assert "<dl>" in resp.text
assert "<table>" not in resp.text
def test_dashboard_renders_the_four_info_cards(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
resp = _client(tmp_path, monkeypatch).get("/")
for card_id in ('id="host-card"', 'id="vmware-card"', 'id="runner-card"', 'id="cache-card"'):
assert card_id in resp.text
# ── /host: the enriched card ─────────────────────────────────────────────
def test_host_card_pairs_identity_onto_two_rows(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Stage G: hostname+kernel share a row, CPU model+core count share a row.
Four one-datum rows made the card twice as tall as its three neighbours
for four facts that never change while the page is open.
"""
resp = _client(tmp_path, monkeypatch).get("/host")
assert resp.status_code == 200
assert "<dt>System</dt>" in resp.text
assert "ci-host" in resp.text
assert "7.0.0-28-generic" in resp.text
assert "<dt>CPU</dt>" in resp.text
assert "16 cores" in resp.text
for retired in ("<dt>Hostname</dt>", "<dt>Kernel</dt>", "<dt>Cores</dt>"):
assert retired not in resp.text
def test_host_card_shortens_the_cpu_model_but_keeps_the_full_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A one-quarter-width track cannot hold the raw ``/proc/cpuinfo`` name."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "Intel Core i9-13900K" in resp.text
assert 'title="Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz"' in resp.text
def test_host_card_holds_five_definition_rows(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""System, CPU, CPU load, Memory, Disk -- the height parity Stage G buys."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert resp.text.count("<dt>") == 5
def test_host_card_shows_cpu_ram_and_disk_with_gauges(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The three readouts that only existed in the retired tiles."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "42%" in resp.text # cpu_pct
assert "20.0" in resp.text # memory used GB
assert "60 GB free" in resp.text # disk
assert resp.text.count("gauge-fill") == 3
def test_host_card_drops_the_visible_level_strip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The check-icon + level word row is gone from the card body."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "resource-level" not in resp.text
def test_host_card_keeps_the_level_word_in_every_gauge_label(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Health is still not color-only: the level rides each gauge's accessible
name, so a screen reader states it even though no strip is drawn."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert 'aria-label="CPU 42 percent used, ok"' in resp.text
assert 'aria-label="Memory 32 percent used, ok"' in resp.text
assert 'aria-label="Disk 40 percent used, ok"' in resp.text
def test_host_card_moves_uptime_into_the_card_header(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Uptime rides the header bar, spelled out -- a bare duration there reads
as nothing in particular, and out of the ``<dl>`` it has no ``<dt>``."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "head-meta" in resp.text
assert "uptime 26h 03m" in resp.text # 93784s
assert "<dt>Uptime</dt>" not in resp.text
def test_host_card_folds_the_load_average_under_the_cpu_gauge(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Load average belongs to the CPU readout, like "GB free" to the disk."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "0.50 / 0.75 / 1.00" in resp.text
assert "<dt>Load avg</dt>" not in resp.text
def test_host_card_degrades_when_the_probe_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom() -> Any:
raise RuntimeError("boom")
client = _client(tmp_path, monkeypatch)
monkeypatch.setattr(app_module, "host_resources", _boom)
resp = client.get("/host")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
# ── /vmware, /runners, /cache ────────────────────────────────────────────
def test_vmware_card_lists_both_modules_and_the_service(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
resp = _client(tmp_path, monkeypatch).get("/vmware")
assert resp.status_code == 200
assert "vmmon" in resp.text
assert "vmnet" in resp.text
assert "vmware-host.service" in resp.text
def test_runners_card_lists_every_unit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
resp = _client(tmp_path, monkeypatch).get("/runners")
assert resp.status_code == 200
assert "act-runner.service" in resp.text
assert "act-runner-rust.service" in resp.text
def test_cache_card_shows_size_entries_and_retention(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "_fetch_cache_status", lambda _config: _CACHE)
resp = _client(tmp_path, monkeypatch).get("/cache")
assert resp.status_code == 200
assert "/var/lib/ci/cache/cargo" in resp.text
assert "1.0 KB" in resp.text
assert "7" in resp.text # entry count
assert "30 day(s)" in resp.text