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