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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 <root>/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 %}
|
||||
—
|
||||
{% 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>
|
||||
Reference in New Issue
Block a user