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:
@@ -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
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user