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
+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>