feat(web): dashboard redesign — Gitea palette, health panels, VM/audit enrichment (stages A–C)

Stage A: swap CSS tokens to the real Gitea dark palette (accent blue #4183c4); add Jinja formatting filters (humanbytes/duration/relative_time/localtime/shorten_path/thousands), a health status-header, and an inline SVG icon sprite; fix two UI bugs — unthemed action pages (htmx swap into #action-slot with a full-page HX-Request fallback) and polling wiping form input (hx-preserve).

Stage B: four read-only panels that degrade to notices (never 500) — host resources (/proc, two-sample CPU%), VMware module health, template inventory (.vmem warn + last backup), and a path-contained file browser; backup card becomes a validated template dropdown; expensive dir-size walk is TTL-cached. Adds operations/inventory.py plus host_resources()/vmware_module_health() in operations/host.py.

Stage C: parse_clone_name() adds a Job column (job_id + started) to the VM table; nav gains a Gitea link-out derived from the OIDC issuer origin (no fabricated run URLs); audit page gets action icons, relative time, and a client-side filter.

Gates: 989 tests pass, coverage 94.12%, ruff + mypy --strict clean. Stage D (service management) intentionally deferred.
This commit is contained in:
2026-07-24 11:46:50 +02:00
parent 907e79818e
commit 9d7bbee35d
33 changed files with 4099 additions and 65 deletions
+34
View File
@@ -19,6 +19,7 @@ import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
_ENV_KEYS = ("CI_ROOT", "CI_TEMPLATES", "CI_BUILD_VMS", "CI_ARTIFACTS", "CI_KEYS")
@@ -101,6 +102,13 @@ class WebConfig:
``session_secret_cred`` is a **keyring key name**, never the secret
itself — same pattern as :attr:`WebOidcConfig.client_secret_cred`.
``gitea_url`` (Stage C2) is the top-level Gitea instance URL used for
the nav's "Gitea ↗" link-out. When unset, :func:`gitea_base_url` derives
it from ``oidc.issuer``'s origin -- see that function's docstring. There
is deliberately no per-run Actions URL here: the exact Gitea Actions
run-URL path is unconfirmed (open risk), so only the top-level instance
URL is ever exposed.
"""
enabled: bool = False
@@ -110,6 +118,29 @@ class WebConfig:
allowed_users: list[str] = field(default_factory=list)
session_secret_cred: str = "WebSessionSecret"
oidc: WebOidcConfig | None = None
gitea_url: str | None = None
def gitea_base_url(web: WebConfig) -> str | None:
"""Effective Gitea link-out URL for the nav, or ``None`` if nothing usable.
An explicit ``web.gitea_url`` always wins. Otherwise, if ``[web.oidc]``
is configured, the URL is derived from the *origin* (scheme + host) of
``oidc.issuer`` -- e.g. issuer ``https://gitea.emulab.it`` (or
``https://gitea.emulab.it/some/path``) resolves to
``https://gitea.emulab.it``. Never fabricates a path (see
:class:`WebConfig`'s docstring on the Actions-run-URL open risk). With
neither set, or an issuer that fails to parse into scheme+host, returns
``None`` -- the nav simply omits the link.
"""
if web.gitea_url:
return web.gitea_url
if web.oidc is None:
return None
parts = urlsplit(web.oidc.issuer)
if not parts.scheme or not parts.netloc:
return None
return f"{parts.scheme}://{parts.netloc}"
@dataclass(frozen=True)
@@ -199,6 +230,7 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
allowed_users = web_section.get("allowed_users")
audit_log = web_section.get("audit_log")
gitea_url = web_section.get("gitea_url")
return WebConfig(
enabled=bool(web_section.get("enabled", False)),
@@ -208,6 +240,7 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
allowed_users=[str(u) for u in allowed_users] if isinstance(allowed_users, list) else [],
session_secret_cred=str(web_section.get("session_secret_cred", "WebSessionSecret")),
oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}),
gitea_url=str(gitea_url) if gitea_url else None,
)
@@ -312,5 +345,6 @@ __all__ = [
"Paths",
"WebConfig",
"WebOidcConfig",
"gitea_base_url",
"load_config",
]
+172 -1
View File
@@ -1,6 +1,7 @@
"""Pure host operations shared by the CLI and (future) web UI.
Disk usage, runner (systemd unit) status, and template backup. Zero
Disk usage, runner (systemd unit) status, template backup, and (Stage B of
the web-UI redesign) live host resource/VMware-module health. Zero
``click``, zero ``print`` — everything here returns a dataclass.
Presentation stays in the CLI command wrappers and, later, the web routes.
@@ -12,12 +13,19 @@ every ``systemctl`` invocation is an argument list, read-only.
``commands/template.py::template_backup`` (timestamped ``7z`` archive under
``<config.paths.root>/backups``) but is a fresh, click-free implementation so
this module has no dependency on the CLI layer.
``host_resources``/``vmware_module_health`` (Stage B) read only ``/proc`` —
dependency-free, no ``psutil``, no ``lsmod`` subprocess — via the
:func:`_read_proc` seam so tests can feed fixture strings without touching a
real filesystem.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
@@ -28,6 +36,10 @@ if TYPE_CHECKING: # pragma: no cover
_SYSTEMCTL_TIMEOUT = 10.0
#: The systemd unit the VMware kernel-module stack lives under (see the
#: "VMware modules vs kernel upgrade" gotcha in AGENTS.md / CLAUDE.md memory).
_VMWARE_HOST_SERVICE = "vmware-host.service"
@dataclass(frozen=True)
class DiskStatus:
@@ -58,6 +70,39 @@ class BackupResult:
error: str | None
@dataclass(frozen=True)
class HostResources:
"""Point-in-time host CPU/memory/uptime/load snapshot (Stage B, B1)."""
cpu_pct: float
ncpu: int
mem_total_gb: float
mem_used_gb: float
uptime_sec: int
load1: float
load5: float
load15: float
@dataclass(frozen=True)
class VmwareModuleHealth:
"""``vmmon``/``vmnet`` kernel-module + ``vmware-host.service`` state (Stage B, B2)."""
vmmon_loaded: bool
vmnet_loaded: bool
host_service_active: bool
host_service_sub_state: str
def _read_proc(path: str) -> str:
"""Read a ``/proc`` pseudo-file. The sole I/O seam :func:`host_resources` and
:func:`vmware_module_health` use -- tests monkeypatch this one function to
feed fixture strings instead of touching a real ``/proc``.
"""
with open(path, encoding="utf-8") as fh:
return fh.read()
def disk_status(path: Path) -> DiskStatus:
"""Report usage for ``path``. Degrades to zeros on ``OSError`` — never raises."""
try:
@@ -109,6 +154,128 @@ def runner_status(service_names: list[str]) -> list[RunnerStatus]:
return out
def _parse_stat_cpu_line(text: str) -> tuple[int, int]:
"""Parse the aggregate ``cpu `` line of ``/proc/stat`` text into ``(idle, total)``.
Column order (kernel docs): user nice system idle iowait irq softirq
steal guest guest_nice. ``idle`` is idle+iowait per the B1 spec; ``total``
is the sum of every column present (tolerates a short/old-kernel line
with fewer columns). Returns ``(0, 0)`` if no ``cpu `` line is found —
the caller guards the resulting division by zero.
"""
for line in text.splitlines():
if line.startswith("cpu "):
values = [int(v) for v in line.split()[1:]]
idle = values[3] + (values[4] if len(values) > 4 else 0)
return idle, sum(values)
return 0, 0
def _parse_meminfo(text: str) -> tuple[int, int]:
"""Parse ``/proc/meminfo`` text into ``(MemTotal_kb, MemAvailable_kb)``."""
total_kb = 0
avail_kb = 0
for line in text.splitlines():
if line.startswith("MemTotal:"):
total_kb = int(line.split()[1])
elif line.startswith("MemAvailable:"):
avail_kb = int(line.split()[1])
return total_kb, avail_kb
def host_resources(*, cpu_sample_ms: int = 150) -> HostResources:
"""Snapshot CPU%/memory/uptime/load average from ``/proc``. Never raises.
CPU% needs **two** ``/proc/stat`` samples ``cpu_sample_ms`` apart (a
single read only gives a since-boot cumulative average, not a current
load) — ``cpu_pct = 100 * (1 - idle_delta / total_delta)``. Degrades to
an all-zero snapshot on ``OSError`` (missing ``/proc``, e.g. non-Linux or
a sandboxed test host) — ``ncpu`` still reports the real
:func:`os.cpu_count` since that call never touches ``/proc``.
"""
ncpu = os.cpu_count() or 1
try:
idle1, total1 = _parse_stat_cpu_line(_read_proc("/proc/stat"))
time.sleep(cpu_sample_ms / 1000.0)
idle2, total2 = _parse_stat_cpu_line(_read_proc("/proc/stat"))
idle_delta = idle2 - idle1
total_delta = total2 - total1
cpu_pct = 100.0 * (1.0 - idle_delta / total_delta) if total_delta > 0 else 0.0
cpu_pct = max(0.0, min(100.0, cpu_pct))
total_kb, avail_kb = _parse_meminfo(_read_proc("/proc/meminfo"))
mem_total_gb = total_kb / (1024.0**2)
mem_used_gb = max(0.0, total_kb - avail_kb) / (1024.0**2)
uptime_sec = int(float(_read_proc("/proc/uptime").split()[0]))
load_parts = _read_proc("/proc/loadavg").split()
load1, load5, load15 = float(load_parts[0]), float(load_parts[1]), float(load_parts[2])
except (OSError, ValueError, IndexError):
# OSError: missing/unreadable /proc (non-Linux, a sandboxed test
# host). ValueError/IndexError: defensive -- a malformed line should
# never crash a dashboard poll either, even though it isn't expected
# in practice on a real Linux host.
return HostResources(
cpu_pct=0.0,
ncpu=ncpu,
mem_total_gb=0.0,
mem_used_gb=0.0,
uptime_sec=0,
load1=0.0,
load5=0.0,
load15=0.0,
)
return HostResources(
cpu_pct=cpu_pct,
ncpu=ncpu,
mem_total_gb=mem_total_gb,
mem_used_gb=mem_used_gb,
uptime_sec=uptime_sec,
load1=load1,
load5=load5,
load15=load15,
)
def vmware_module_health() -> VmwareModuleHealth:
"""``vmmon``/``vmnet`` load state + ``vmware-host.service`` state. Never raises.
Module state comes from ``/proc/modules`` (first whitespace-separated
token of each line is the module name) — dependency-free, no ``lsmod``
subprocess. Service state reuses :func:`runner_status` against
``vmware-host.service`` rather than a second systemctl code path. Ties to
the "VMs won't start after boot" gotcha (vmmon/vmnet unbuilt or unloaded).
"""
vmmon_loaded = False
vmnet_loaded = False
try:
loaded_names = {line.split()[0] for line in _read_proc("/proc/modules").splitlines() if line.strip()}
vmmon_loaded = "vmmon" in loaded_names
vmnet_loaded = "vmnet" in loaded_names
except OSError:
pass
host_service_active = False
host_service_sub_state = "unknown"
try:
statuses = runner_status([_VMWARE_HOST_SERVICE])
if statuses:
host_service_active = statuses[0].active
host_service_sub_state = statuses[0].sub_state
except Exception: # defensive: runner_status already degrades, never raise here either
pass
return VmwareModuleHealth(
vmmon_loaded=vmmon_loaded,
vmnet_loaded=vmnet_loaded,
host_service_active=host_service_active,
host_service_sub_state=host_service_sub_state,
)
def _compress_7z(source: Path, archive: Path) -> None:
"""Create a 7z archive of ``source`` at ``archive`` using compression level 1.
@@ -161,8 +328,12 @@ def backup_template(config: Config, template: str, *, dry_run: bool = False) ->
__all__ = [
"BackupResult",
"DiskStatus",
"HostResources",
"RunnerStatus",
"VmwareModuleHealth",
"backup_template",
"disk_status",
"host_resources",
"runner_status",
"vmware_module_health",
]
+290
View File
@@ -0,0 +1,290 @@
"""Pure host-inventory operations shared by the CLI and (future) web UI.
Template discovery (:func:`list_templates`) and read-only directory listings
(:func:`list_backups`/:func:`list_artifacts`, built on :func:`list_dir`) for
the web dashboard's Stage B "Templates" and "Files" panels. Zero ``click``,
zero ``print`` — everything here returns a dataclass.
**Containment (SECURITY):** :func:`list_dir` refuses to list any ``base``
that does not resolve to ``root`` itself or a real descendant of it — this
holds even though Phase 1 never routes user input into ``base``/``root``
here, matching the containment discipline ``web/routes.py``'s
``_is_contained`` already applies to VM-removal paths. Resolving both sides
before comparing means a symlink or ``..`` segment cannot smuggle a listing
outside ``root``.
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.config import Config
#: TTL (seconds) for the ``dir_bytes`` cache below (B7) — an ``os.walk`` over
#: a multi-GB template dir is too expensive to redo on every 30s dashboard
#: poll.
_DIR_BYTES_TTL_SECONDS = 60.0
#: Module-level cache: ``{resolved-path-str: (mtime_ns, bytes, computed_at)}``.
#: A plain dict is not thread-safe against concurrent writers, but the web
#: dashboard runs as a single-worker uvicorn process (see ``commands/web.py``)
#: so a bare dict is an acceptable simplification here — no lock needed.
_dir_bytes_cache: dict[str, tuple[int, int, float]] = {}
@dataclass(frozen=True)
class FileEntry:
"""A single file's name/size/mtime — used for the backups/artifacts lists."""
name: str
size_bytes: int
mtime: float
@dataclass(frozen=True)
class TemplateInfo:
"""One VM template directory as reported for the dashboard's Templates panel."""
name: str
vmx: Path | None
guest_os: str | None
transport: str
dir_bytes: int
has_vmem: bool
last_backup: FileEntry | None
# ──────────────────────────────────────────────────────────────── templates
def _find_vmx(template_dir: Path) -> Path | None:
"""Return the first ``*.vmx`` file directly under ``template_dir``, if any."""
try:
for entry in template_dir.iterdir():
if entry.is_file() and entry.suffix.lower() == ".vmx":
return entry
except OSError:
return None
return None
def _read_guest_os(vmx: Path) -> str:
"""Best-effort parse of the ``guestOS = "..."`` line from a VMX file.
A small, self-contained copy of ``operations/vms.py``'s private helper of
the same name — each ``operations/*`` module stays independently
importable with no cross-module reach-ins, matching this package's
existing per-module-private-helper convention. Degrades to ``""`` on any
read failure or a missing ``guestOS`` line; never raises.
"""
try:
text = vmx.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
for line in text.splitlines():
if "=" not in line:
continue
key, _, rhs = line.partition("=")
if key.strip().lower() != "guestos":
continue
rhs = rhs.strip()
if rhs.startswith('"') and rhs.endswith('"') and len(rhs) >= 2:
return rhs[1:-1]
return rhs
return ""
def _transport_for(guest_os: str | None) -> str:
"""``"ssh"`` for a Linux-flavoured ``guest_os``, ``"winrm"`` otherwise.
``"unknown"`` when ``guest_os`` is absent — mirrors ``commands/job.py``'s
own ``ubuntu-*`` -> SSH / else -> WinRM transport-selection rule.
"""
if not guest_os:
return "unknown"
lowered = guest_os.lower()
if "ubuntu" in lowered or "linux" in lowered:
return "ssh"
return "winrm"
def _has_vmem(template_dir: Path) -> bool:
"""True iff any ``*.vmem`` sits in ``template_dir`` — a snapshot taken while
the VM was running (see the "Snapshot with VM running" gotcha).
"""
try:
return any(template_dir.glob("*.vmem"))
except OSError:
return False
def _dir_bytes_cached(path: Path) -> int:
"""``os.walk``-summed size of ``path``, cached with a TTL (B7).
A cached entry younger than :data:`_DIR_BYTES_TTL_SECONDS` is reused
verbatim so a 30s dashboard poll never re-walks a multi-GB template
directory on every request. Degrades to ``0`` on any ``OSError`` (missing
dir, permission trouble, a file vanishing mid-walk) rather than raising.
"""
try:
key = str(path.resolve())
except OSError:
return 0
now = time.monotonic()
cached = _dir_bytes_cache.get(key)
if cached is not None:
_mtime_ns, cached_bytes, computed_at = cached
if now - computed_at < _DIR_BYTES_TTL_SECONDS:
return cached_bytes
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
total = 0
try:
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
try:
total += (Path(dirpath) / filename).stat().st_size
except OSError:
continue
except OSError:
total = 0
_dir_bytes_cache[key] = (mtime_ns, total, now)
return total
def _newest_backup(backups_dir: Path, template_name: str) -> FileEntry | None:
"""Newest ``Template_<template_name>_*.7z`` under ``backups_dir``, if any.
Mirrors the archive naming ``operations/host.py::backup_template`` writes
(``Template_{template}_{timestamp}.7z``) so a discovered template always
finds its own backups and nobody else's.
"""
if not backups_dir.is_dir():
return None
try:
matches = list(backups_dir.glob(f"Template_{template_name}_*.7z"))
except OSError:
return None
newest: FileEntry | None = None
for match in matches:
try:
if not match.is_file():
continue
st = match.stat()
except OSError:
continue
if newest is None or st.st_mtime > newest.mtime:
newest = FileEntry(name=match.name, size_bytes=st.st_size, mtime=st.st_mtime)
return newest
def list_templates(config: Config) -> list[TemplateInfo]:
"""Scan ``config.paths.templates``' immediate subdirs into ``TemplateInfo`` rows.
Never raises: a missing/unreadable templates root degrades to ``[]``; a
single bad subdirectory (permission trouble, a race with a concurrent
delete) is skipped rather than aborting the whole scan.
"""
templates_dir = config.paths.templates
if not templates_dir.is_dir():
return []
try:
entries = sorted(templates_dir.iterdir())
except OSError:
return []
backups_dir = config.paths.root / "backups"
out: list[TemplateInfo] = []
for entry in entries:
try:
if not entry.is_dir():
continue
except OSError:
continue
vmx = _find_vmx(entry)
guest_os = (_read_guest_os(vmx) or None) if vmx is not None else None
out.append(
TemplateInfo(
name=entry.name,
vmx=vmx,
guest_os=guest_os,
transport=_transport_for(guest_os),
dir_bytes=_dir_bytes_cached(entry),
has_vmem=_has_vmem(entry),
last_backup=_newest_backup(backups_dir, entry.name),
)
)
return out
# ───────────────────────────────────────────────────────────── directory listings
def list_dir(base: Path, *, root: Path, pattern: str = "*") -> list[FileEntry]:
"""Files directly under ``base`` matching ``pattern``, newest-first.
SECURITY: refuses to list anything unless ``base`` resolves to ``root``
itself or a real descendant of it (see the module docstring) — a
``base`` outside ``root``, including via a symlink, returns ``[]``
exactly like a missing ``base``. Never raises.
"""
try:
resolved_root = root.resolve()
resolved_base = base.resolve()
except OSError:
return []
contained = resolved_base == resolved_root or resolved_root in resolved_base.parents
if not contained or not resolved_base.is_dir():
return []
out: list[FileEntry] = []
try:
for entry in resolved_base.glob(pattern):
try:
if not entry.is_file():
continue
st = entry.stat()
except OSError:
continue
out.append(FileEntry(name=entry.name, size_bytes=st.st_size, mtime=st.st_mtime))
except OSError:
return []
out.sort(key=lambda fe: fe.mtime, reverse=True)
return out
def list_backups(config: Config) -> list[FileEntry]:
"""Backup archives under ``<config.paths.root>/backups`` (see ``backup_template``)."""
return list_dir(config.paths.root / "backups", root=config.paths.root, pattern="*.7z")
def list_artifacts(config: Config) -> list[FileEntry]:
"""Files directly under ``config.paths.artifacts``."""
return list_dir(config.paths.artifacts, root=config.paths.root, pattern="*")
__all__ = [
"FileEntry",
"TemplateInfo",
"list_artifacts",
"list_backups",
"list_dir",
"list_templates",
]
+70
View File
@@ -60,6 +60,21 @@ class RemovalResult:
error: str | None
@dataclass(frozen=True)
class ParsedClone:
"""Best-effort decomposition of a ``Clone_<job_id>_<started>_<suffix>`` name.
``job_id`` and ``started`` degrade to ``None`` independently -- a garbled
timestamp does not blank out an otherwise-recoverable ``job_id`` and vice
versa. There is deliberately no ``repo`` field: the clone name (see
:func:`~ci_orchestrator.commands.job._make_clone_name`) never carries the
repo, so a parser here cannot invent one.
"""
job_id: str | None
started: datetime | None
# ────────────────────────────────────────────────────────────────── helpers
@@ -105,6 +120,59 @@ def _read_guest_os(vmx: Path) -> str:
return ""
# ───────────────────────────────────────────────────────── clone-name parsing
#: Clone names are always ``Clone_<job_id>_<YYYYmmdd>_<HHMMSS>_<suffix>``
#: (see ``commands/job.py::_make_clone_name``). ``job_id`` may itself contain
#: underscores, which is why this can't be a plain ``str.split("_")`` unpack
#: -- the date/time/suffix are anchored from the *right* instead.
_CLONE_PREFIX = "Clone_"
_DATE_LEN = 8 # YYYYmmdd
_TIME_LEN = 6 # HHMMSS
def parse_clone_name(name: str) -> ParsedClone:
"""Invert :func:`~ci_orchestrator.commands.job._make_clone_name`.
Strips the leading ``Clone_`` and the trailing ``_<suffix>``; the two
underscore-joined fields remaining before the suffix are the
``YYYYmmdd``/``HHMMSS`` halves of the timestamp, and everything left
between the prefix and the timestamp is ``job_id`` (re-joined with
``_`` if it contained any itself).
Never raises: a name that doesn't start with ``Clone_``, has too few
``_``-separated parts to hold a date+time+suffix, or has a
non-numeric/wrong-length date or time degrades the affected field(s) to
``None`` rather than raising. An empty ``job_id`` (nothing between the
prefix and the timestamp) is also reported as ``None``, not ``""``.
"""
if not name.startswith(_CLONE_PREFIX):
return ParsedClone(job_id=None, started=None)
parts = name[len(_CLONE_PREFIX) :].split("_")
# Need at least date + time + suffix, even with an empty job_id.
if len(parts) < 3:
return ParsedClone(job_id=None, started=None)
date_str, time_str = parts[-3], parts[-2]
job_id_parts = parts[:-3]
job_id = "_".join(job_id_parts) if job_id_parts else None
started: datetime | None = None
if (
len(date_str) == _DATE_LEN
and date_str.isdigit()
and len(time_str) == _TIME_LEN
and time_str.isdigit()
):
try:
started = datetime.strptime(f"{date_str}_{time_str}", "%Y%m%d_%H%M%S")
except ValueError:
started = None
return ParsedClone(job_id=job_id, started=started)
# ─────────────────────────────────────────────────────────────── live VMs
@@ -327,10 +395,12 @@ def reconcile_ip_leases(
__all__ = [
"LiveVm",
"Orphan",
"ParsedClone",
"RemovalResult",
"cleanup_orphans",
"list_live_vms",
"list_orphans",
"parse_clone_name",
"reconcile_ip_leases",
"remove_vm",
]
+274 -8
View File
@@ -30,6 +30,7 @@ never 500s. Tests override it via ``app.dependency_overrides[get_backend]``.
from __future__ import annotations
import secrets
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -41,9 +42,27 @@ from starlette.middleware.sessions import SessionMiddleware
from ci_orchestrator.backends import load_backend
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.config import gitea_base_url
from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_status
from ci_orchestrator.operations.host import (
DiskStatus,
HostResources,
RunnerStatus,
VmwareModuleHealth,
disk_status,
host_resources,
runner_status,
vmware_module_health,
)
from ci_orchestrator.operations.inventory import (
FileEntry,
TemplateInfo,
list_artifacts,
list_backups,
list_templates,
)
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
from ci_orchestrator.web import filters
from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user
from ci_orchestrator.web.security import issue_csrf
@@ -57,6 +76,19 @@ _WEB_DIR = Path(__file__).resolve().parent
#: CLAUDE.md's "Phase C"). A module constant per the W4 plan section.
RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"]
#: Disk-usage health thresholds for the status header (A3): above the
#: "critical" percentage -> red; above "elevated" -> amber; else green.
_DISK_PCT_CRITICAL = 85.0
_DISK_PCT_ELEVATED = 70.0
#: CSS class used by ``partials/status_header.html`` for each health level.
#: Health must never be color-only -- the template always pairs this class
#: with a short text label (see :class:`HealthSummary`).
_TILE_OK = "tile-ok"
_TILE_WARN = "tile-warn"
_TILE_BAD = "tile-bad"
_TILE_UNKNOWN = "tile-unknown"
# ─────────────────────────────────────────────────────────────── dependency
@@ -113,6 +145,160 @@ def _fetch_runner_status() -> list[RunnerStatus]:
return []
def _fetch_resources() -> HostResources | None:
"""Host CPU/memory/uptime/load snapshot (B1), or ``None`` if unreadable.
``host_resources`` itself already degrades to an all-zero snapshot on
``OSError`` (never raises) -- this ``except Exception`` is a defensive
second layer so a route can never 500 even on a genuinely unexpected
failure.
"""
try:
return host_resources()
except Exception: # degrade, never 500
return None
def _fetch_vmware_health() -> VmwareModuleHealth | None:
"""``vmmon``/``vmnet``/``vmware-host.service`` health (B2), or ``None`` if unreadable."""
try:
return vmware_module_health()
except Exception: # degrade, never 500
return None
def _fetch_templates(config: Config) -> list[TemplateInfo] | None:
"""Template inventory rows (B3), or ``None`` if the scan itself failed.
Distinct from an empty list (``[]``, a templates dir with nothing in
it) -- ``None`` triggers the "unavailable" notice in ``templates.html``,
``[]`` triggers its plain "no templates found" state.
"""
try:
return list_templates(config)
except Exception: # degrade, never 500
return None
def _fetch_template_names(config: Config) -> list[str]:
"""Template names only, for the B6 backup-form dropdown. Never ``None``."""
rows = _fetch_templates(config)
return [t.name for t in rows] if rows is not None else []
def _fetch_backups(config: Config) -> list[FileEntry] | None:
"""Backup archive listing (B4), or ``None`` if the scan itself failed."""
try:
return list_backups(config)
except Exception: # degrade, never 500
return None
def _fetch_artifacts(config: Config) -> list[FileEntry] | None:
"""Artifact listing (B4), or ``None`` if the scan itself failed."""
try:
return list_artifacts(config)
except Exception: # degrade, never 500
return None
# ────────────────────────────────────────────────────────── status header
@dataclass(frozen=True)
class HealthSummary:
"""Render-ready values for the status-header tiles (A3).
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.
"""
vm_count: int
orphan_count: int
vm_class: str
vm_label: str
runners_active: int
runners_total: int
runner_class: str
runner_label: str
disk_used_pct: float | None
disk_class: str
disk_label: str
cache_bytes: int | None
cache_class: str
cache_label: str
def _health_summary(
vms: list[LiveVm],
*,
backend_available: bool,
cache: CacheStatus | None,
disk: DiskStatus | None,
runners: list[RunnerStatus],
) -> HealthSummary:
"""Derive the A3 status-header tiles from already-fetched dashboard context.
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.
"""
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"
disk_used_pct = disk.used_pct if disk is not None else None
if disk_used_pct is None:
disk_class, disk_label = _TILE_UNKNOWN, "unknown"
elif disk_used_pct > _DISK_PCT_CRITICAL:
disk_class, disk_label = _TILE_BAD, "critical"
elif disk_used_pct > _DISK_PCT_ELEVATED:
disk_class, disk_label = _TILE_WARN, "elevated"
else:
disk_class, disk_label = _TILE_OK, "ok"
cache_bytes = cache.total_bytes 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,
disk_used_pct=disk_used_pct,
disk_class=disk_class,
disk_label=disk_label,
cache_bytes=cache_bytes,
cache_class=cache_class,
cache_label=cache_label,
)
# ────────────────────────────────────────────────────────────────── routes
@@ -124,15 +310,38 @@ def healthz() -> dict[str, str]:
def dashboard(
request: Request, backend: VmBackend | None = Depends(get_backend)
) -> HTMLResponse:
"""Full dashboard: VM live, cache, disk, runner cards."""
"""Full dashboard: VM live, cache, disk, runner, and Stage-B info cards."""
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()
# One template scan feeds both the full Templates-panel rows and the
# backup-form dropdown's names -- no need for _fetch_template_names to
# call list_templates a second time within the same request.
template_rows = _fetch_templates(config)
template_names = [t.name for t in template_rows] if template_rows is not None else []
context = {
"vms": _fetch_live_vms(backend, config),
"backend_available": backend is not None,
"cache": _fetch_cache_status(config),
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"vms": vms,
"backend_available": backend_available,
"cache": cache,
"disk": disk,
"runners": runners,
"resources": _fetch_resources(),
"vmware": _fetch_vmware_health(),
"templates": template_rows,
"template_names": template_names,
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
"health": _health_summary(
vms,
backend_available=backend_available,
cache=cache,
disk=disk,
runners=runners,
),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "dashboard.html", context)
@@ -161,17 +370,51 @@ def cache_partial(request: Request) -> HTMLResponse:
def host_partial(request: Request) -> HTMLResponse:
"""``GET /host`` -- disk + runner status partial."""
"""``GET /host`` -- disk + runner status + template-backup dropdown partial."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"template_names": _fetch_template_names(config),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/host.html", context)
def resources_partial(request: Request) -> HTMLResponse:
"""``GET /resources`` (B5) -- polled every 5s by the dashboard via htmx."""
templates: Jinja2Templates = request.app.state.templates
context = {"resources": _fetch_resources()}
return templates.TemplateResponse(request, "partials/resources.html", context)
def vmware_partial(request: Request) -> HTMLResponse:
"""``GET /vmware`` (B5) -- polled every 15s by the dashboard via htmx."""
templates: Jinja2Templates = request.app.state.templates
context = {"vmware": _fetch_vmware_health()}
return templates.TemplateResponse(request, "partials/vmware.html", context)
def templates_partial(request: Request) -> HTMLResponse:
"""``GET /templates`` (B5) -- polled every 30s by the dashboard via htmx."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {"templates": _fetch_templates(config)}
return templates.TemplateResponse(request, "partials/templates.html", context)
def files_partial(request: Request) -> HTMLResponse:
"""``GET /files`` (B5) -- polled every 30s by the dashboard via htmx."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
}
return templates.TemplateResponse(request, "partials/files.html", context)
# ─────────────────────────────────────────────────────────────────── factory
@@ -195,7 +438,13 @@ def create_app(config: Config) -> FastAPI:
app = FastAPI(title="ci-orchestrator web")
app.state.config = config
app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates"))
filters.register(app.state.templates)
app.state.templates.env.filters["vmx_id"] = encode_vmx_id
# Stage C2: a Jinja *global* (not per-route context) so every template
# that extends base.html -- dashboard, audit, the action-page wrapper --
# picks up the nav's "Gitea ↗" link without each route builder having to
# remember to thread it through.
app.state.templates.env.globals["gitea_url"] = gitea_base_url(config.web)
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
@@ -209,6 +458,18 @@ def create_app(config: Config) -> FastAPI:
app.get("/host", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
host_partial
)
app.get("/resources", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
resources_partial
)
app.get("/vmware", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
vmware_partial
)
app.get("/templates", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
templates_partial
)
app.get("/files", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
files_partial
)
register_action_routes(app)
# SessionMiddleware is ALWAYS installed (W6) -- CSRF needs
@@ -241,8 +502,13 @@ def create_app(config: Config) -> FastAPI:
__all__ = [
"RUNNER_SERVICE_NAMES",
"HealthSummary",
"create_app",
"dashboard",
"files_partial",
"get_backend",
"healthz",
"resources_partial",
"templates_partial",
"vmware_partial",
]
+194
View File
@@ -0,0 +1,194 @@
"""Jinja2 display filters for the web dashboard (Stage A / A2).
Pure functions -- no I/O, no ``click``, no framework imports beyond the
``Jinja2Templates`` type used by :func:`register`. Every filter degrades to
a harmless placeholder (``"-"``) on ``None`` input rather than raising, so a
template can apply them unconditionally to fields that may be absent (a
degraded ``operations/`` fetch, an unset timestamp, ...).
"""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from ci_orchestrator.operations.vms import ParsedClone, parse_clone_name
if TYPE_CHECKING: # pragma: no cover
from fastapi.templating import Jinja2Templates
#: Byte-count thresholds for :func:`humanbytes`, largest first. Base-1024
#: (binary) math, but labelled "KB/MB/GB" rather than "KiB/MiB/GiB" --
#: matches the informal convention most dashboards use.
_BYTE_UNITS: list[tuple[str, int]] = [
("GB", 1024**3),
("MB", 1024**2),
("KB", 1024),
]
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 3600
_SECONDS_PER_DAY = 86400
def humanbytes(n: int | float) -> str:
"""Render a byte count as ``"11.8 MB"``-style text (base-1024, decimal display).
Whole-byte counts under 1 KB print with no decimal (``"500 B"``);
negative input keeps a leading ``"-"`` on the formatted magnitude.
"""
value = float(n)
sign = "-" if value < 0 else ""
value = abs(value)
for label, factor in _BYTE_UNITS:
if value >= factor:
return f"{sign}{value / factor:.1f} {label}"
if value == int(value):
return f"{sign}{int(value)} B"
return f"{sign}{value:.1f} B"
def duration(seconds: int | float | None) -> str:
"""Render a second count as ``"1h 03m"`` / ``"45s"``-style text; ``None`` -> ``"-"``.
Drops the seconds component once hours are shown (``"1h 03m"``, not
``"1h 03m 03s"``) -- deliberately coarse, this is a dashboard uptime
cell, not a stopwatch.
"""
if seconds is None:
return "-"
total = int(seconds)
sign = "-" if total < 0 else ""
total = abs(total)
if total < _SECONDS_PER_MINUTE:
return f"{sign}{total}s"
if total < _SECONDS_PER_HOUR:
minutes, secs = divmod(total, _SECONDS_PER_MINUTE)
return f"{sign}{minutes}m {secs:02d}s"
hours, remainder = divmod(total, _SECONDS_PER_HOUR)
minutes = remainder // _SECONDS_PER_MINUTE
return f"{sign}{hours}h {minutes:02d}m"
def _parse_dt(value: str | datetime | int | float | None) -> datetime | None:
"""Best-effort parse of an ISO-8601 string, epoch number, or pass-through ``datetime``.
The epoch branch (``int``/``float``, e.g. a raw ``os.stat().st_mtime`` as
stored on ``operations.inventory.FileEntry.mtime``) is interpreted as
UTC. Returns ``None`` on ``None`` input or a string that isn't valid
ISO-8601 -- callers turn that into the ``"-"`` placeholder rather than
raising.
"""
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=UTC)
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def relative_time(value: str | datetime | int | float | None) -> str:
"""Render a past timestamp as ``"2m ago"``-style relative text; ``None`` -> ``"-"``.
A naive ``datetime``/string is compared against naive "now"; an
aware one against aware "now" in the same offset -- either way this
never raises on a tz-naive/aware mismatch elsewhere in the log.
"""
dt = _parse_dt(value)
if dt is None:
return "-"
now = datetime.now(tz=dt.tzinfo) if dt.tzinfo is not None else datetime.now()
seconds = int((now - dt).total_seconds())
if seconds < 0:
seconds = 0 # a clock-skewed "future" timestamp reads as "just now"
if seconds < _SECONDS_PER_MINUTE:
return "just now"
if seconds < _SECONDS_PER_HOUR:
return f"{seconds // _SECONDS_PER_MINUTE}m ago"
if seconds < _SECONDS_PER_DAY:
return f"{seconds // _SECONDS_PER_HOUR}h ago"
return f"{seconds // _SECONDS_PER_DAY}d ago"
def localtime(value: str | datetime | int | float | None) -> str:
"""Render a timestamp in host-local time as ``"YYYY-MM-DD HH:MM"``; ``None`` -> ``"-"``.
An aware ``datetime`` converts to the host's local zone; a naive one is
treated as already-local (``datetime.astimezone()`` does exactly this
for a naive receiver, so one code path covers both).
"""
dt = _parse_dt(value)
if dt is None:
return "-"
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
def shorten_path(p: str | Path, keep: int = 2) -> str:
"""Keep the last ``keep`` path parts, prefixed with ``".../"``.
A path with ``keep`` or fewer parts is returned unshortened (nothing to
elide). ``keep <= 0`` degrades to a bare ``"..."`` rather than raising
or silently returning the whole path (Python's ``parts[-0:]`` is the
*whole* tuple, not empty, so this must be handled explicitly).
"""
path = Path(p)
parts = path.parts
if keep <= 0:
return "..."
if len(parts) <= keep:
return str(path)
return ".../" + "/".join(parts[-keep:])
def thousands(n: int | float) -> str:
"""Render an integer count with thousands separators: ``12345`` -> ``"12,345"``."""
return f"{int(n):,}"
def clone_job(name: str | None) -> ParsedClone:
"""Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1).
Thin wrapper around ``operations.vms.parse_clone_name`` -- the ``None``
guard is defensive (``LiveVm.name`` itself is never ``None``) so this
still degrades to an all-``None`` :class:`ParsedClone` rather than
raising if ever applied to an absent value.
"""
if name is None:
return ParsedClone(job_id=None, started=None)
return parse_clone_name(name)
def register(templates: Jinja2Templates) -> None:
"""Register every filter above on ``templates.env`` under its own name."""
templates.env.filters["humanbytes"] = humanbytes
templates.env.filters["duration"] = duration
templates.env.filters["relative_time"] = relative_time
templates.env.filters["localtime"] = localtime
templates.env.filters["shorten_path"] = shorten_path
templates.env.filters["thousands"] = thousands
templates.env.filters["clone_job"] = clone_job
__all__ = [
"clone_job",
"duration",
"humanbytes",
"localtime",
"register",
"relative_time",
"shorten_path",
"thousands",
]
+70 -8
View File
@@ -18,6 +18,17 @@ outside ``config.paths.build_vms`` -> 400, a fresh ``vm-start.lock`` on
execute -> 409) short-circuit *before* any ``operations/`` call and are
never audited -- they are refusals to act, not actions.
**Rendering (A4)**: every confirm/result/lock-conflict partial is rendered
through :func:`_render_action`, which picks the wrapper based on the
``HX-Request`` request header -- present (htmx's own mutating forms, see
``templates/partials/{vms,cache,host}.html``) gets the bare partial back for
an inline ``hx-swap`` into ``#action-slot``; absent (a plain, no-JS form
POST -- the same forms' ``method``/``action`` fallback) gets the identical
partial body wrapped in ``templates/action_page.html`` so a full-page
navigation still lands on a themed page instead of unstyled bare HTML. The
confirm-gate semantics, CSRF, and audit trail are unchanged either way --
only the response wrapper differs.
In-process only: this module imports ``operations/`` directly and never
shells out to the CLI (see ``CLAUDE.md``).
"""
@@ -28,7 +39,7 @@ import base64
import binascii
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
@@ -37,6 +48,7 @@ from starlette import status
from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit
from ci_orchestrator.operations.cache import purge_cargo_cache
from ci_orchestrator.operations.host import backup_template
from ci_orchestrator.operations.inventory import list_templates
from ci_orchestrator.operations.vms import RemovalResult, cleanup_orphans, remove_vm
from ci_orchestrator.web.app import get_backend
from ci_orchestrator.web.auth import require_user
@@ -142,6 +154,44 @@ def _audit(
append_audit(config.web.audit_log, entry)
def _is_htmx_request(request: Request) -> bool:
"""True iff this request was issued by htmx (``HX-Request: true``).
Absent entirely on a plain, no-JS full-page form navigation -- every
mutating form keeps its ``method``/``action`` fallback precisely so that
path still POSTs to the same route (see the A4 brief). This header is
the sole signal :func:`_render_action` uses to choose bare-partial vs.
full-page rendering.
"""
return request.headers.get("hx-request", "").lower() == "true"
def _render_action(
request: Request,
partial_template: str,
context: dict[str, Any],
*,
status_code: int = status.HTTP_200_OK,
) -> HTMLResponse:
"""Render an action partial: bare (htmx) or themed-full-page (no-JS) (A4).
Both branches render the exact same ``partial_template``/``context`` --
an htmx request gets it back bare for an inline ``hx-swap``; anything
else gets it wrapped in ``templates/action_page.html`` (which extends
``base.html`` and drops the partial body into ``#action-slot``) so a
plain form POST still lands on a styled page instead of unstyled HTML.
"""
templates: Jinja2Templates = request.app.state.templates
if _is_htmx_request(request):
return templates.TemplateResponse(
request, partial_template, context, status_code=status_code
)
wrapped_context = {**context, "partial_template": partial_template}
return templates.TemplateResponse(
request, "action_page.html", wrapped_context, status_code=status_code
)
def _confirm_partial(
request: Request,
*,
@@ -151,7 +201,6 @@ def _confirm_partial(
action_url: str,
extra_fields: dict[str, str],
) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {
"action_label": action_label,
"target": target,
@@ -160,20 +209,21 @@ def _confirm_partial(
"extra_fields": extra_fields,
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/confirm.html", context)
return _render_action(request, "partials/confirm.html", context)
def _result_partial(request: Request, *, action_label: str, ok: bool, detail: str) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {"action_label": action_label, "ok": ok, "detail": detail}
return templates.TemplateResponse(request, "partials/result.html", context)
return _render_action(request, "partials/result.html", context)
def _lock_conflict_partial(request: Request, *, action_label: str, target: str) -> HTMLResponse:
templates: Jinja2Templates = request.app.state.templates
context = {"action_label": action_label, "target": target}
return templates.TemplateResponse(
request, "partials/lock_conflict.html", context, status_code=status.HTTP_409_CONFLICT
return _render_action(
request,
"partials/lock_conflict.html",
context,
status_code=status.HTTP_409_CONFLICT,
)
@@ -366,6 +416,18 @@ async def host_backup(
template = _form_str(form, "template")
if not template:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="template is required")
# B6: the submitted name must be one of the templates this host actually
# discovers -- rejects an arbitrary path reaching backup_template() via a
# tampered/hand-crafted form post (the dashboard's own <select> only ever
# offers these names). A pure validation refusal, so it is not audited --
# see the module docstring's "refusals to act, not actions" rule.
known_templates = {t.name for t in list_templates(config)}
if template not in known_templates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="template is not a recognized template name",
)
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
try:
+135 -20
View File
@@ -1,23 +1,27 @@
/* ci-orchestrator web dashboard — dark, Gitea-style theme (W7a).
/* ci-orchestrator web dashboard — dark, Gitea-style theme (W7a, retoned Stage A).
*
* Hand-written, no external framework. Design tokens per the W7 plan
* (plans/web-ui-management-plan.md, section W7a). Everything under this
* directory is served locally (`/static/app.css`) — the host LAN may be
* offline, so no `@import url(...)` to a CDN, no remote fonts.
* Hand-written, no external framework. Design tokens now mirror Gitea's
* real `gitea-auto` dark palette (Stage A / A1) rather than the placeholder
* green-accent values W7a shipped with. Everything under this directory is
* served locally (`/static/app.css`) — the host LAN may be offline, so no
* `@import url(...)` to a CDN, no remote fonts.
*/
:root {
--bg: #16191d;
--panel: #1c2126;
--panel-2: #22272e;
--border: #30363d;
--text: #d1d5da;
--text-dim: #8b949e;
--accent: #3fb950;
--accent-hover: #46c359;
--bg: #1e1f20;
--panel: #161718;
--panel-2: #2e3033;
--nav-bg: #18191b;
--card-header-bg: #1b1c1e;
--border: #3f4248;
--text: #d2d4d8;
--text-dim: #c0c2c7;
--accent: #4183c4;
--accent-hover: #5a9bd4;
--success: #3fb950;
--danger: #f85149;
--warn: #d29922;
--link: #58a6ff;
--link: #2f81f7;
--radius: 6px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -55,7 +59,7 @@ code, .mono {
/* ── navbar ───────────────────────────────────────────────────────────── */
.navbar {
background: var(--panel);
background: var(--nav-bg);
border-bottom: 1px solid var(--border);
}
@@ -94,6 +98,80 @@ code, .mono {
.content { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
/* Feedback region for htmx-swapped confirm/result/lock-conflict partials
* (A4). Empty at rest -- occupies no visible space until an action fills
* it, so it never pushes the grid around on a normal page load. */
#action-slot:empty { display: none; }
#action-slot:not(:empty) { margin-bottom: 1.25rem; }
/* ── status header (A3) ──────────────────────────────────────────────── */
.status-header {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.stat-tile {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1rem;
}
.stat-tile > .icon { flex-shrink: 0; margin-top: 0.15rem; color: var(--text-dim); }
.stat-tile-body { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; flex: 1; }
.stat-tile-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-dim);
}
.stat-tile-value { font-size: 1.15rem; font-weight: 600; color: var(--text); }
.stat-tile-sub { font-size: 0.8rem; font-weight: 400; color: var(--warn); }
.stat-tile-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Health must never rely on color alone -- the border accent + icon +
* label all move together, so a color-blind viewer still reads the state
* from the text/icon. */
.tile-ok { border-left-color: var(--success); }
.tile-ok .stat-tile-status { color: var(--success); }
.tile-warn { border-left-color: var(--warn); }
.tile-warn .stat-tile-status { color: var(--warn); }
.tile-bad { border-left-color: var(--danger); }
.tile-bad .stat-tile-status { color: var(--danger); }
.tile-unknown { border-left-color: var(--border); }
.tile-unknown .stat-tile-status { color: var(--text-dim); }
.gauge {
width: 100%;
height: 6px;
background: var(--panel-2);
border-radius: 999px;
overflow: hidden;
margin: 0.15rem 0;
}
.gauge-fill { height: 100%; background: var(--success); }
.tile-warn .gauge-fill { background: var(--warn); }
.tile-bad .gauge-fill { background: var(--danger); }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
@@ -116,13 +194,18 @@ code, .mono {
}
.card h2 {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
background: var(--card-header-bg);
border-bottom: 1px solid var(--border);
padding-bottom: 0.6rem;
margin-bottom: 0.9rem;
border-radius: var(--radius) var(--radius) 0 0;
margin: -1.25rem -1.25rem 0.9rem;
padding: 0.75rem 1.25rem 0.6rem;
}
.card h3 { font-size: 0.95rem; color: var(--text); }
@@ -155,6 +238,11 @@ tbody tr:hover { background: var(--panel-2); }
tr.row-orphan td { color: var(--warn); }
tr.row-alert td { color: var(--danger); }
/* ── audit filter (C3) ────────────────────────────────────────────────── */
.audit-filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.9rem; }
.audit-filter input[type="text"] { min-width: 260px; flex: 1; max-width: 420px; }
/* ── status badges ────────────────────────────────────────────────────── */
.badge {
@@ -168,7 +256,7 @@ tr.row-alert td { color: var(--danger); }
white-space: nowrap;
}
.badge-success { background: rgba(63, 185, 80, 0.15); color: var(--accent); border: 1px solid rgba(63, 185, 80, 0.4); }
.badge-success { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.4); }
.badge-danger { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.4); }
.badge-warn { background: rgba(210, 153, 34, 0.15); color: var(--warn); border: 1px solid rgba(210, 153, 34, 0.4); }
@@ -192,7 +280,7 @@ tr.row-alert td { color: var(--danger); }
.btn:hover { background: var(--border); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: #04170a; border-color: var(--accent); }
.btn-primary { background: var(--accent); color: #ffffff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
@@ -204,7 +292,7 @@ form { margin-top: 1rem; display: flex; flex-wrap: wrap; align-items: center; ga
label { font-size: 0.85rem; color: var(--text-dim); display: inline-flex; align-items: center; gap: 0.4rem; }
input[type="text"], input[type="number"] {
input[type="text"], input[type="number"], select {
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
@@ -214,6 +302,12 @@ input[type="text"], input[type="number"] {
font-family: inherit;
}
/* One-line field description above a form control (B6 backup dropdown). */
.field-hint { font-size: 0.8rem; color: var(--text-dim); margin: 0 0 0.5rem; }
/* Muted inline text (e.g. a relative-time suffix next to a file name). */
.text-dim { color: var(--text-dim); }
/* ── notices / confirm / result panels ───────────────────────────────── */
.notice {
@@ -236,6 +330,26 @@ input[type="text"], input[type="number"] {
.result-ok { border-color: rgba(63, 185, 80, 0.4); background: rgba(63, 185, 80, 0.08); }
.result-error { border-color: rgba(248, 81, 73, 0.4); background: rgba(248, 81, 73, 0.08); }
/* ── icon sprite (A6) ─────────────────────────────────────────────────── */
/* Sprite itself (partials/icons.html) is visually hidden; only <use>
* references render. currentColor lets every icon pick up whatever text
* color its container already has -- no per-icon color rules needed. */
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: none;
stroke: currentColor;
flex-shrink: 0;
}
.card h2 .icon,
.stat-tile > .icon {
width: 1.1em;
height: 1.1em;
}
/* ── accessibility ────────────────────────────────────────────────────── */
:focus-visible {
@@ -259,4 +373,5 @@ input[type="text"], input[type="number"] {
.navbar-inner { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
form { flex-direction: column; align-items: flex-start; }
dl { grid-template-columns: 1fr; }
.status-header { grid-template-columns: 1fr; }
}
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{#
Themed full-page wrapper for a mutating-action response (A4). Rendered
only for a plain, no-JS form POST (no ``HX-Request`` header) -- an htmx
request gets ``partial_template`` back bare instead (see
``routes.py::_render_action``). Overrides ``base.html``'s default content
block entirely (no ``{{ super() }}``) so there is exactly one
``#action-slot`` on the page, with the actual confirm/result/lock-conflict
partial dropped inside it.
#}
{% block title %}{{ action_label | default("Action") }} - ci-orchestrator{% endblock %}
{% block content %}
<div id="action-slot">
{% include partial_template %}
</div>
{% endblock %}
+7 -1
View File
@@ -8,12 +8,16 @@
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
{% include "partials/icons.html" %}
{% set logged_in_user = request.session.get("user") %}
<nav class="navbar">
<div class="navbar-inner">
<a class="navbar-brand" href="/">ci-orchestrator</a>
<div class="navbar-links">
<a href="/audit">Audit log</a>
{% if gitea_url %}
<a href="{{ gitea_url }}" target="_blank" rel="noopener">Gitea <svg class="icon" aria-hidden="true"><use href="#i-external"></use></svg></a>
{% endif %}
{% if logged_in_user %}
<span class="navbar-user" aria-label="Logged in as {{ logged_in_user }}">{{ logged_in_user }}</span>
<a class="navbar-logout" href="/logout" aria-label="Log out">Logout</a>
@@ -24,7 +28,9 @@
</div>
</nav>
<main class="content">
{% block content %}{% endblock %}
{% block content %}
<div id="action-slot"></div>
{% endblock %}
</main>
</body>
</html>
@@ -3,9 +3,15 @@
{% block title %}Dashboard - ci-orchestrator{% endblock %}
{% block content %}
{{ super() }}
{% include "partials/status_header.html" %}
<div class="dashboard-grid">
{% include "partials/vms.html" %}
{% include "partials/cache.html" %}
{% include "partials/host.html" %}
{% include "partials/resources.html" %}
{% include "partials/vmware.html" %}
{% include "partials/templates.html" %}
{% include "partials/files.html" %}
</div>
{% endblock %}
@@ -4,8 +4,14 @@
{% block content %}
<section class="card" id="audit-card">
<h2>Audit log</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>Audit log</h2>
{% if entries %}
<div class="audit-filter">
<label for="audit-filter-input">Filter</label>
<input type="text" id="audit-filter-input" autocomplete="off"
placeholder="Filter by user, action, target, detail..."
aria-controls="audit-rows">
</div>
<div class="table-wrap">
<table>
<thead>
@@ -18,12 +24,17 @@
<th>Detail</th>
</tr>
</thead>
<tbody>
<tbody id="audit-rows">
{% for e in entries %}
{% set icon_id = "i-server" if e.action.startswith("vm.")
else "i-file" if e.action.startswith("cache.")
else "i-box" if e.action.startswith("host.")
else "i-gear" if e.action.startswith("service.")
else "i-alert" %}
<tr class="{{ 'row-alert' if e.result == 'error' else '' }}">
<td>{{ e.ts }}</td>
<td title="{{ e.ts | localtime }}">{{ e.ts | relative_time }}</td>
<td>{{ e.user }}</td>
<td>{{ e.action }}</td>
<td><svg class="icon" aria-hidden="true"><use href="#{{ icon_id }}"></use></svg> {{ e.action }}</td>
<td><code>{{ e.target }}</code></td>
<td>
{% if e.result == "ok" %}
@@ -44,4 +55,27 @@
<p>No audit entries yet.</p>
{% endif %}
</section>
{% if entries %}
{#
Client-side-only filter (C3): no CSP header is set anywhere in this app
(see web/app.py / security.py), so a plain inline <script> is safe here --
no backend round-trip needed for a hide/show over rows already on the
page. Case-insensitive substring match over each row's full text.
#}
<script>
(function () {
var input = document.getElementById("audit-filter-input");
var rows = document.getElementById("audit-rows");
if (!input || !rows) { return; }
input.addEventListener("input", function () {
var q = input.value.trim().toLowerCase();
var trs = rows.getElementsByTagName("tr");
for (var i = 0; i < trs.length; i++) {
var text = trs[i].textContent.toLowerCase();
trs[i].style.display = (q === "" || text.indexOf(q) !== -1) ? "" : "none";
}
});
})();
</script>
{% endif %}
{% endblock %}
@@ -1,10 +1,10 @@
<section class="card" id="cache-card" hx-get="/cache" hx-trigger="every 5s" hx-swap="outerHTML">
<h2>Cargo cache</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Cargo cache</h2>
{% if cache %}
<dl>
<dt>Root</dt><dd><code>{{ cache.root }}</code></dd>
<dt>Total size</dt><dd>{{ cache.total_bytes }} bytes</dd>
<dt>Entries</dt><dd>{{ cache.entry_count }}</dd>
<dt>Total size</dt><dd>{{ cache.total_bytes | humanbytes }}</dd>
<dt>Entries</dt><dd>{{ cache.entry_count | thousands }}</dd>
<dt>Oldest entry age</dt>
<dd>{{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}</dd>
<dt>Retention</dt><dd>{{ cache.retention_days }} day(s)</dd>
@@ -12,7 +12,8 @@
{% else %}
<p class="notice notice-warn" role="alert">Cargo cache status unavailable.</p>
{% endif %}
<form method="post" action="/cache/purge" id="cache-purge-form" aria-label="Purge cargo cache">
<form method="post" action="/cache/purge" id="cache-purge-form" aria-label="Purge cargo cache"
hx-post="/cache/purge" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Purge cargo cache">Purge cache</button>
</form>
@@ -2,7 +2,7 @@
<h3>Confirm: {{ action_label }}</h3>
<p>Target: <code>{{ target }}</code></p>
<p>{{ summary }}</p>
<form method="post" action="{{ action_url }}">
<form method="post" action="{{ action_url }}" hx-post="{{ action_url }}" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="stage" value="execute">
{% for key, value in extra_fields.items() %}
@@ -0,0 +1,49 @@
<div class="contents" id="files-partial" hx-get="/files" hx-trigger="every 30s" hx-swap="outerHTML">
<section class="card" id="backups-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Template backups</h2>
{% if backups is none %}
<p class="notice notice-warn" role="alert">Backup listing unavailable.</p>
{% elif backups %}
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Size</th><th>Created</th></tr></thead>
<tbody>
{% for entry in backups %}
<tr>
<td><code>{{ entry.name }}</code></td>
<td>{{ entry.size_bytes | humanbytes }}</td>
<td>{{ entry.mtime | relative_time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No backups found.</p>
{% endif %}
</section>
<section class="card" id="artifacts-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Artifacts</h2>
{% if artifacts is none %}
<p class="notice notice-warn" role="alert">Artifact listing unavailable.</p>
{% elif artifacts %}
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Size</th><th>Created</th></tr></thead>
<tbody>
{% for entry in artifacts %}
<tr>
<td><code>{{ entry.name }}</code></td>
<td>{{ entry.size_bytes | humanbytes }}</td>
<td>{{ entry.mtime | relative_time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No artifacts found.</p>
{% endif %}
</section>
</div>
@@ -1,6 +1,6 @@
<div class="contents" id="host-partial" hx-get="/host" hx-trigger="every 5s" hx-swap="outerHTML">
<section class="card" id="disk-card">
<h2>Disk</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-disk"></use></svg>Disk</h2>
{% if disk %}
<dl>
<dt>Path</dt><dd><code>{{ disk.path }}</code></dd>
@@ -14,7 +14,7 @@
</section>
<section class="card" id="runner-card">
<h2>Runners</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Runners</h2>
{% if runners %}
<div class="table-wrap">
<table>
@@ -48,11 +48,17 @@
</section>
<section class="card" id="backup-card">
<h2>Template backup</h2>
<form method="post" action="/host/backup" id="host-backup-form" aria-label="Backup template">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Template backup</h2>
<p class="field-hint">7z archive of the template dir under &lt;root&gt;/backups</p>
<form method="post" action="/host/backup" id="host-backup-form" aria-label="Backup template"
hx-post="/host/backup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label for="host-backup-template">Template</label>
<input type="text" id="host-backup-template" name="template" required>
<select id="host-backup-template" name="template" required hx-preserve="true">
{% for name in template_names %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-danger" aria-label="Backup template">Backup template</button>
</form>
</section>
@@ -0,0 +1,61 @@
{#
Inline SVG icon sprite (A6). One copy per page (included once from
base.html), visually hidden -- every icon elsewhere is a
<svg class="icon"><use href="#i-...">. No external requests: this is the
entire icon set, vendored as markup, not a font/CDN.
#}
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:none"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<symbol id="i-server" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="7" rx="1.5"></rect>
<rect x="2" y="14" width="20" height="7" rx="1.5"></rect>
<line x1="6" y1="6.5" x2="6.01" y2="6.5"></line>
<line x1="6" y1="17.5" x2="6.01" y2="17.5"></line>
</symbol>
<symbol id="i-disk" viewBox="0 0 24 24">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"></path>
<path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
</symbol>
<symbol id="i-cpu" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1.5"></rect>
<rect x="10" y="1.5" width="4" height="3"></rect>
<rect x="10" y="19.5" width="4" height="3"></rect>
<rect x="1.5" y="10" width="3" height="4"></rect>
<rect x="19.5" y="10" width="3" height="4"></rect>
</symbol>
<symbol id="i-box" viewBox="0 0 24 24">
<path d="M21 8l-9-5-9 5 9 5 9-5z"></path>
<path d="M3 8v8l9 5 9-5V8"></path>
<line x1="12" y1="13" x2="12" y2="21"></line>
</symbol>
<symbol id="i-gear" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>
</symbol>
<symbol id="i-file" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"></path>
<path d="M14 2v6h6"></path>
</symbol>
<symbol id="i-external" viewBox="0 0 24 24">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path>
<path d="M15 3h6v6"></path>
<path d="M10 14L21 3"></path>
</symbol>
<symbol id="i-check" viewBox="0 0 24 24">
<path d="M20 6L9 17l-5-5"></path>
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</symbol>
</svg>
@@ -0,0 +1,21 @@
<section class="card" id="resources-card" hx-get="/resources" hx-trigger="every 5s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>Host resources</h2>
{% if resources %}
<dl>
<dt>CPU</dt>
<dd>
{{ "%.1f"|format(resources.cpu_pct) }}% across {{ resources.ncpu }} core(s)
<div class="gauge" role="img" aria-label="CPU {{ '%.1f'|format(resources.cpu_pct) }} percent used">
<div class="gauge-fill" style="width: {{ resources.cpu_pct }}%"></div>
</div>
</dd>
<dt>Memory</dt>
<dd>{{ "%.1f"|format(resources.mem_used_gb) }} / {{ "%.1f"|format(resources.mem_total_gb) }} GB</dd>
<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>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Host resource status unavailable.</p>
{% endif %}
</section>
@@ -0,0 +1,69 @@
{#
Status header (A3): 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.
#}
<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.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 used</span>
<span class="stat-tile-value">
{{ "%.1f"|format(health.disk_used_pct) ~ "%" if health.disk_used_pct is not none else "-" }}
</span>
<div class="gauge" role="img"
aria-label="Disk {{ '%.1f'|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 size</span>
<span class="stat-tile-value">
{{ health.cache_bytes | humanbytes if health.cache_bytes is not none else "-" }}
</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>
@@ -0,0 +1,49 @@
<section class="card" id="templates-card" hx-get="/templates" hx-trigger="every 30s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Templates</h2>
{% if templates is none %}
<p class="notice notice-warn" role="alert">Template inventory unavailable.</p>
{% elif templates %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Guest OS</th>
<th>Transport</th>
<th>Size</th>
<th>Snapshot</th>
<th>Last backup</th>
</tr>
</thead>
<tbody>
{% for tmpl in templates %}
<tr class="{{ 'row-alert' if tmpl.has_vmem else '' }}">
<td>{{ tmpl.name }}</td>
<td>{{ tmpl.guest_os or "-" }}</td>
<td>{{ tmpl.transport }}</td>
<td>{{ tmpl.dir_bytes | humanbytes }}</td>
<td>
{% if tmpl.has_vmem %}
<span class="badge badge-warn" title="Snapshot captured running RAM">
<svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>running-RAM snapshot
</span>
{% else %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>clean</span>
{% endif %}
</td>
<td>
{% if tmpl.last_backup %}
{{ tmpl.last_backup.name }} <span class="text-dim">({{ tmpl.last_backup.mtime | relative_time }})</span>
{% else %}
&mdash;
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No templates found under the templates directory.</p>
{% endif %}
</section>
@@ -1,5 +1,5 @@
<section class="card" id="vms-card" hx-get="/vms" hx-trigger="every 5s" hx-swap="outerHTML">
<h2>Live VMs</h2>
<h2><svg class="icon" aria-hidden="true"><use href="#i-server"></use></svg>Live VMs</h2>
{% if not backend_available %}
<p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p>
{% endif %}
@@ -9,22 +9,30 @@
<thead>
<tr>
<th>Name</th>
<th>Job</th>
<th>VMX</th>
<th>IP</th>
<th>Guest OS</th>
<th>Uptime (s)</th>
<th>Uptime</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for vm in vms %}
{% set parsed = vm.name | clone_job %}
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
<td>{{ vm.name }}</td>
<td>
{{ parsed.job_id or "—" }}
{% if parsed.started %}
<br><span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
{% endif %}
</td>
<td><code>{{ vm.vmx }}</code></td>
<td>{{ vm.ip or "-" }}</td>
<td>{{ vm.guest_os or "-" }}</td>
<td>{{ vm.uptime_sec if vm.uptime_sec is not none else "-" }}</td>
<td>{{ vm.uptime_sec | duration }}</td>
<td>
{% if vm.is_orphan %}
<span class="badge badge-warn">orphan</span>
@@ -33,7 +41,8 @@
{% endif %}
</td>
<td>
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove">
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Remove VM {{ vm.name }}">Remove</button>
</form>
@@ -46,10 +55,11 @@
{% else %}
<p>No VMs currently running.</p>
{% endif %}
<form method="post" action="/vms/cleanup" id="vms-cleanup-form" aria-label="Cleanup orphaned VMs">
<form method="post" action="/vms/cleanup" id="vms-cleanup-form" aria-label="Cleanup orphaned VMs"
hx-post="/vms/cleanup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label for="vms-cleanup-max-age">Max age (h)</label>
<input type="number" id="vms-cleanup-max-age" name="max_age_hours" value="4" min="0">
<input type="number" id="vms-cleanup-max-age" name="max_age_hours" value="4" min="0" hx-preserve="true">
<button type="submit" class="btn btn-danger" aria-label="Cleanup orphaned VMs older than the given age">Cleanup orphans</button>
</form>
</section>
@@ -0,0 +1,34 @@
<section class="card" id="vmware-card" hx-get="/vmware" hx-trigger="every 15s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>VMware modules</h2>
{% if vmware %}
<dl>
<dt>vmmon</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 %}
</dd>
<dt>vmnet</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 %}
</dd>
<dt>vmware-host.service</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 %}
</section>