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
+59 -1
View File
@@ -9,7 +9,7 @@ coverage for ``list_live_vms`` and ``remove_vm``.
from __future__ import annotations
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -18,13 +18,16 @@ import pytest
import ci_orchestrator.operations.vms as vms_module
from ci_orchestrator.backends.errors import BackendOperationFailed
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.commands.job import _make_clone_name
from ci_orchestrator.config import BackendConfig, Config, Paths
from ci_orchestrator.operations.vms import (
Orphan,
ParsedClone,
RemovalResult,
cleanup_orphans,
list_live_vms,
list_orphans,
parse_clone_name,
remove_vm,
)
@@ -506,3 +509,58 @@ def test_reconcile_ip_leases_unreadable_base_is_noop(tmp_path: Path) -> None:
leases = json.loads((tmp_path / "ip-pool.json").read_text())
assert leases["10.0.0.1"] == "clone-dead"
# ── parse_clone_name (Stage C1) ───────────────────────────────────────────
def test_parse_clone_name_round_trips_real_make_clone_name() -> None:
"""A real ``_make_clone_name`` output must invert cleanly."""
before = datetime.now(tz=UTC).replace(microsecond=0)
name = _make_clone_name("build-42")
after = datetime.now(tz=UTC).replace(microsecond=0)
parsed = parse_clone_name(name)
assert parsed.job_id == "build-42"
assert parsed.started is not None
# started is a naive datetime built from UTC wall-clock digits -- compare
# against naive bounds taken from an aware UTC "now" either side of the call.
assert before.replace(tzinfo=None) <= parsed.started <= after.replace(tzinfo=None) + timedelta(seconds=1)
def test_parse_clone_name_job_id_containing_underscore() -> None:
parsed = parse_clone_name("Clone_my_job_42_20250101_123456_abc123")
assert parsed.job_id == "my_job_42"
assert parsed.started == datetime(2025, 1, 1, 12, 34, 56)
def test_parse_clone_name_non_clone_prefix_degrades_both_to_none() -> None:
assert parse_clone_name("SomeOtherVMName") == ParsedClone(job_id=None, started=None)
def test_parse_clone_name_too_few_parts_degrades_both_to_none() -> None:
# No room for date + time + suffix at all.
assert parse_clone_name("Clone_short") == ParsedClone(job_id=None, started=None)
def test_parse_clone_name_garbage_timestamp_degrades_started_only() -> None:
parsed = parse_clone_name("Clone_myjob_notadate_notatime_abc123")
assert parsed.job_id == "myjob"
assert parsed.started is None
def test_parse_clone_name_empty_job_id_degrades_to_none() -> None:
# No text between "Clone_" and the timestamp.
parsed = parse_clone_name("Clone_20250101_123456_abc123")
assert parsed.job_id is None
assert parsed.started == datetime(2025, 1, 1, 12, 34, 56)
def test_parse_clone_name_wrong_length_date_degrades_started_only() -> None:
parsed = parse_clone_name("Clone_job1_2025010_123456_abc123")
assert parsed.started is None