Files
local-ci-cd-system/plans/web-ui-redesign-plan.md
T
Simone e56eae95c7 feat(web): service management panel over a sudo allow-list (stage D)
Adds operations/services.py: a fixed ManagedService allow-list (act-runner, act-runner-rust, vmware-host, ci-web) with find_service/service_states/control_service. control_service builds an argument list only (never shell=True), validates the action against ALLOWED_ACTIONS (ValueError->400), refuses stop on restart-only services (ci-web, allow_stop=False) without building a command, and defers ci-web's own restart via 'sudo -n systemd-run --on-active=2 systemctl restart ci-web.service' so the HTTP response returns first.

Route POST /services/{service_id}/{action} in web/routes.py mirrors vms_remove: require_user + require_csrf + two-step confirm gate + one audit entry per stage; unknown id / bad action / disallowed-stop short-circuit to 400 before any subprocess or audit; the vmware-host confirm step warns with the active-VM count. New /services read partial with state-aware Start/Stop/Restart buttons, polled every 15s.

deploy/sudoers.d/ci-web grants ci-runner exact-command NOPASSWD for exactly these nine systemctl lines plus the systemd-run deferral — no wildcards, no bare systemctl, no ci-web stop; setup-web-linux.sh installs it mode 0440 and validates with 'visudo -cf', rolling back on failure. Config gains [web].managed_services override; docs/WEB-UI.md documents the panel + security model.

Gates: 1044 tests pass (46 new security/unit tests), coverage 94.20%, ruff + mypy --strict clean. Not yet deployed: sudoers install + prod-venv reinstall + ci-web restart are a human privilege-boundary step.
2026-07-24 12:11:36 +02:00

24 KiB
Raw Blame History

Web UI Redesign — Implementation Plan (Phase 1)

Status: Stages A–D COMPLETE + all gates approved (1044 tests pass, cov 94.20%, ruff+mypy clean). Committed on branch feat/web-ui-redesign. Remaining: human deploy of Stage D — pip install . into prod venv, install deploy/sudoers.d/ci-web (visudo -c), restart ci-web.service (privilege-boundary change, not auto-applied). Owner / executor: Sonnet (implementation) under Opus supervision (review gate after every stage). Scope: Presentation redesign + new read-only panels + service management. No time-series store, no light theme, no rebuild of Gitea job/log views. Target files root: src/ci_orchestrator/web/, src/ci_orchestrator/operations/.

This plan is written to be executed by a Sonnet-class model. Each stage is self-contained, ends with a hard STOP — supervisor review gate, and lists exact files, signatures, tests, and acceptance criteria. Do not start a stage before the previous stage's gate is approved. Keep the checklist below current (tick - [x] as tasks land); the supervisor or a subagent updates it.


Progress checklist (keep this updated)

Legend: [ ] todo · [~] in progress · [x] done · [!] blocked

Stage A — Restyle foundation + bug fixes

  • A1. Swap CSS tokens to real Gitea dark palette (accent → blue #4183c4)
  • A2. web/filters.py — number/format Jinja filters + registration in app.py
  • A3. Status header partial (partials/status_header.html) + health thresholds
  • A4. Fix unthemed action pages (htmx swap + full-page HX-Request fallback)
  • A5. Fix polling-wipes-form-input bug (hx-preserve on inputs)
  • A6. Inline SVG icon sprite (partials/icons.html) + apply to card headers
  • A7. Stage A tests green · ruff · mypy --strict · coverage ≥90
  • A-GATE — supervisor review (Opus: gates green 895 pass / cov 94.15% / ruff+mypy clean; theming fix + action-slot verified)

Stage B — Read-only panels

  • B1. operations/host.py — host_resources() (/proc, 2-sample CPU%)
  • B2. operations/host.py — vmware_module_health() (lsmod + vmware-host.service)
  • B3. operations/inventory.py — list_templates() (+ .vmem health, last backup)
  • B4. operations/inventory.py — list_backups() / list_artifacts() (path-contained)
  • B5. GET partials + routes: /resources, /vmware, /templates, /files
  • B6. Backup card → template dropdown (validated against list_templates)
  • B7. Slow-poll / on-demand refresh for expensive panels (dir-size cache)
  • B8. Stage B tests green · ruff · mypy --strict · coverage ≥90
  • B-GATE — supervisor review (Opus: gates green 958 pass / cov 94.10% / ruff+mypy clean; containment + 2-sample CPU + backup-name 400 verified)
  • C1. parse_clone_name() + repo/job (job_id + started) column in VM table
  • C2. Gitea base URL in config/context; nav Gitea ↗ + contextual links
  • C3. Audit page restyle (relative time, action icons, simple filter)
  • C4. Stage C tests green · ruff · mypy --strict · coverage ≥90
  • C-GATE — supervisor review (Opus: gates green 989 pass / cov 94.12% / ruff+mypy clean; subagent cut off mid-C2 by session limit — Opus finished wiring + fixed 2 left-over test bugs: empty-VM header assert, OIDC keyring stub)

Stage D — Service management (SECURITY-CRITICAL, last)

  • D1. operations/services.py — allow-list, service_states(), control_service()
  • D2. POST /services/{service_id}/{action} — confirm + CSRF + audit + refusals
  • D3. ci-web self-restart via detached systemd-run (response returns first)
  • D4. Services panel partial (state-aware buttons, active-VM warning on stop)
  • D5. sudoers.d/ci-web (exact-command NOPASSWD) + installer visudo -c validate
  • D6. Config [web].managed_services override + config.example.toml + WEB-UI.md
  • D7. Security regression tests (unknown id → 400 & systemctl never called; etc.)
  • D8. Stage D tests green · ruff · mypy --strict · coverage ≥90
  • D-GATE — supervisor review + deploy note (Opus: gates green 1044 pass / cov 94.20% / ruff+mypy clean; verified control_service arg-list+allow-list+stop-refusal+no-shell, route 400-before-subprocess for all 3 refusals + CSRF-first + confirm-gate, sudoers exact-command ↔ argv match with no ci-web stop, 46 security/unit tests, no live-host execution. DEPLOY (human step, not done): on merge → pip install . into prod venv, install deploy/sudoers.d/ci-web to /etc/sudoers.d/ (mode 0440, visudo -c), sudo systemctl restart ci-web.service.)

Locked design decisions

Decision Value
Theme Dark only, mirroring Gitea gitea-auto dark values
CSS approach Hand-written, extend existing tokens. No framework, no CDN (LAN offline)
Data over time None — all panels point-in-time snapshots
Job history / logs Not rebuilt — link out to Gitea
New runtime dep None — host metrics via /proc, not psutil
Service control Full start/stop/restart over a fixed allow-list
Read routes web/app.py · Mutating routes

Real Gitea dark palette (source: gitea.emulab.it theme gitea-auto)

--color-primary        #4183c4   (accent — replaces green #3fb950)
--color-primary-contrast #ffffff
--color-body           #1e1f20   (page bg)
--color-box-body       #161718   (card bg)
--color-box-header     #1b1c1e   (card header bg)
--color-nav-bg         #18191b   (navbar bg)
--color-secondary-bg   #2e3033   (input / subtle panel)
--color-text           #d2d4d8
--color-text-light     #c0c2c7   (dim)
--color-secondary      #3f4248   (border-ish)
--color-success-text   #3fb950   --color-error-text #f85149   --color-warning-text #d29922
--color-info-text      #2f81f7

Health thresholds (status header + row colors)

  • Disk used_pct > 85 → red, > 70 → amber, else green.
  • Any runner unit not active → red.
  • Orphan VM present → amber.
  • VMware vmmon or vmnet not loaded, or vmware-host.service not active → red.

Architecture changes (file map)

New files:

  • web/filters.py — Jinja formatting filters (pure functions, unit-tested).
  • operations/services.py — managed-service allow-list + status + control.
  • operations/inventory.py — template discovery + directory listing (path-contained).
  • web/templates/partials/: status_header.html, resources.html, vmware.html, templates.html, files.html, services.html, icons.html.

Edited files:

  • web/static/app.css — token swap + new components (gauge, stat tile, service controls).
  • web/app.py — register filters; new GET routes/partials; status-header context.
  • web/routes.py — service-control POST; backup route → dropdown validation.
  • operations/host.py — add host_resources(), vmware_module_health().
  • web/templates/base.html — nav Gitea ↗; #action-slot.
  • web/templates/partials/{vms,cache,host,audit,confirm,result}.html — restyle + fixes.
  • config.py / config.example.toml — [web].gitea_url, [web].managed_services.
  • docs/WEB-UI.md — sudoers, service panel, new panels.

Isolation rule (why the split): operations/host.py already carries disk+runner+backup. Adding resources+modules+templates+files+service-control would bloat it past what fits in one reviewer's head. Keep host-level probes in host.py, inventory/listing in inventory.py, service control in services.py. Each is independently testable and imported by both CLI and web.


Stage A — Restyle foundation + bug fixes

Goal: the dashboard looks professional and the two known UI bugs die. No new data sources. This stage alone fixes "look grezzo / colpo d'occhio / layout piatto" and the unthemed-action-page complaint.

A1 — CSS tokens

Edit web/static/app.css :root: replace the palette with the Gitea dark values above (accent #4183c4; --bg→#1e1f20; --panel→#161718; --panel-2→#2e3033; --border→#3f4248; --text→#d2d4d8; --text-dim→#c0c2c7; --accent→#4183c4; --accent-hover a lighter blue; keep --danger/--warn as-is). Update .btn-primary contrast text to #ffffff. No structural HTML change in A1.

A2 — Jinja filters (web/filters.py)

Pure functions + a register(templates: Jinja2Templates) helper called from app.py build_app. Signatures:

def humanbytes(n: int | float) -> str          # 12_345_678 -> "11.8 MB"
def duration(seconds: int | float | None) -> str # 3783 -> "1h 03m"; None -> "-"
def relative_time(value: str | datetime | None) -> str  # "2m ago"; None -> "-"
def localtime(value: str | datetime | None) -> str      # ISO -> host-local "YYYY-MM-DD HH:MM"
def shorten_path(p: str | Path, keep: int = 2) -> str    # ".../Templates/LinuxBuild2404"
def thousands(n: int | float) -> str            # 812 -> "812"; 12345 -> "12,345"

Apply in templates: cache.html (total_bytes|humanbytes, entry_count|thousands), vms.html (uptime_sec|duration), host.html (disk GB formatting tidy), audit.html (ts|relative_time with ts|localtime in title=).

A3 — Status header (partials/status_header.html)

A row of stat tiles fed from the existing dashboard context (no new fetch): VM count (+orphan count), runner active/total, disk used_pct gauge, cache size. Apply health-threshold classes. Included at top of dashboard.html, above .dashboard-grid. Add a _health_summary(...) helper in app.py that derives the tile values/colors from already-fetched context (keep it pure).

A4 — Fix unthemed action pages

Root cause: mutating <form method="post"> do full-page navigation and the routes return bare partials (confirm/result/lock_conflict.html) with no base.html wrapper. Fix both paths:

  1. Convert the mutating forms to htmx (hx-post, hx-target="#action-slot", hx-swap="innerHTML"). Confirm→execute stays two-step but renders inline.
  2. In the render helpers (_confirm_partial, _result_partial, _lock_conflict_partial), when request.headers.get("HX-Request") is absent, render the partial wrapped in a themed full page (a new action_page.html that extends base.html and drops the partial into #action-slot). This keeps no-JS browsers themed too. Add <div id="action-slot"></div> near the top of base.html content block.

A5 — Polling wipes form input

vms.html cleanup form (max_age_hours) and the backup form live inside cards that self-replace every 5s via hx-swap="outerHTML" → typed values vanish. Add hx-preserve="true" + stable id to those inputs, or move the forms into a non-polled sibling container. Prefer hx-preserve (smaller diff).

A6 — Icon sprite (partials/icons.html)

One inline SVG <symbol> sprite (server, disk, cpu, box/template, gear/service, file, external-link, check, alert). Reference via <svg class="icon"><use href="#i-…"></use></svg>. No external requests. Add .icon sizing to CSS.

A7 — Verify

python -m pytest tests/python -q --cov=ci_orchestrator --cov-fail-under=90
python -m ruff check src tests/python
python -m mypy --strict src

New tests: tests/python/test_web_filters.py (each filter, incl. None/edge), test_web_theming.py (HX-Request present → bare partial; absent → contains <link rel="stylesheet").

A-GATE

STOP. Show the supervisor the rendered dashboard (describe or screenshot) and the green test/lint/type output. Do not proceed to Stage B until approved.


Stage B — Read-only panels

Goal: four new information panels. All degrade to an "unavailable" notice on any error (never 500), matching the existing _fetch_* pattern in app.py.

B1 — host_resources() (operations/host.py)

@dataclass(frozen=True)
class HostResources:
    cpu_pct: float          # 0..100
    ncpu: int
    mem_total_gb: float
    mem_used_gb: float
    uptime_sec: int
    load1: float
    load5: float
    load15: float

def host_resources(*, cpu_sample_ms: int = 150) -> HostResources: ...

CPU% requires two /proc/stat samples cpu_sample_ms apart; compute 1 - idle_delta/total_delta. Read /proc/meminfo (MemTotal, MemAvailable → used = total-available), /proc/uptime (field 0), /proc/loadavg, os.cpu_count(). Degrade to zeros on OSError (never raise).

B2 — vmware_module_health() (operations/host.py)

@dataclass(frozen=True)
class VmwareModuleHealth:
    vmmon_loaded: bool
    vmnet_loaded: bool
    host_service_active: bool
    host_service_sub_state: str

vmmon/vmnet via reading /proc/modules (dependency-free; no lsmod subprocess needed) or lsmod if simpler — prefer /proc/modules. Service state reuses the existing runner_status(["vmware-host.service"]) machinery. Ties to the documented "VMs won't start" gotcha.

B3 — list_templates() (operations/inventory.py)

@dataclass(frozen=True)
class TemplateInfo:
    name: str
    vmx: Path | None
    guest_os: str | None       # parsed from vmx guestOS
    snapshot: str | None       # best-effort; may be None
    transport: str             # "ssh" | "winrm" | "unknown" (from guest_os)
    dir_bytes: int
    has_vmem: bool             # True -> snapshot captured running RAM (warn)
    last_backup: FileEntry | None

def list_templates(config: Config) -> list[TemplateInfo]: ...

Scan config.paths.templates subdirs. guest_os from the .vmx guestOS line; transport = ssh if ubuntu/linux else winrm. has_vmem = any *.vmem in the dir. dir_bytes via os.walk sum (cached — see B7). last_backup = newest Template_<name>_*.7z under <root>/backups.

B4 — Directory listings (operations/inventory.py)

@dataclass(frozen=True)
class FileEntry:
    name: str
    size_bytes: int
    mtime: float

def list_dir(base: Path, *, root: Path, pattern: str = "*") -> list[FileEntry]: ...
def list_backups(config: Config) -> list[FileEntry]: ...
def list_artifacts(config: Config) -> list[FileEntry]: ...

Containment (security): list_dir must resolve base and assert it is root or under root (base.resolve().is_relative_to(root.resolve())); never follow symlinks that escape root; return [] if base missing. No user input reaches these in Phase 1 (fixed dirs), but enforce anyway.

B5 — Routes + partials

In app.py add GET routes (behind require_user), each rendering its partial: /resources→resources.html, /vmware→vmware.html, /templates→templates.html, /files→files.html. Add matching _fetch_* wrappers (try/except → None/[]). Include panels in dashboard.html.

B6 — Backup dropdown

Replace the free-text template input in the backup form with a <select> populated from list_templates() names. In routes.py host_backup, validate the submitted template is in the discovered set → else 400 (no arbitrary path to backup_template). Add a one-line description above the control ("7z archive of the template dir under <root>/backups").

B7 — Poll cadence / caching

resources may poll 5s (cheap). vmware poll 15s. templates/files poll 30s or render once with a manual "Refresh" button (hx-get on click). Template dir_bytes (os.walk over multi-GB dirs) must be cached with a short TTL (e.g. module-level {path: (mtime_ns, bytes, ts)}, recompute if older than 60s) so a 30s poll doesn't walk the tree every time.

B8 — Verify (same commands as A7)

New tests: test_operations_inventory.py (tmpdir templates incl. .vmem present/absent, containment reject ../escape and out-of-root symlink), test_host_resources.py (feed fixture /proc strings via a small reader seam; two-sample CPU math). Coverage ≥90.

B-GATE

STOP for supervisor review.


C1 — Clone-name parsing

@dataclass(frozen=True)
class ParsedClone:
    job_id: str | None
    started: datetime | None

def parse_clone_name(name: str) -> ParsedClone: ...

Format is Clone_<job_id>_<YYYYmmdd_HHMMSS>_<suffix> (see commands/job.py::_make_clone_name). Repo is not in the name — do not invent it. VM table gains a Job column showing job_id (+ started relative time in a tooltip). Put the helper in operations/vms.py (shared) and expose ParsedClone on the render-ready LiveVm or compute in the view layer.

Config: add optional [web].gitea_url; default = origin of [web.oidc].issuer (https://gitea.emulab.it). Expose in template context. base.html nav gets Gitea ↗ (opens new tab, rel="noopener"). If job_id looks like a Gitea run id, link the Job cell to <gitea_url> (top-level; do not fabricate a run-URL path unless the exact Actions URL scheme is confirmed with the supervisor first).

C3 — Audit page polish

Restyle audit.html: ts|relative_time (full ts in title), an action icon per row (reuse sprite), result badge (already present), and a simple client-side text filter over rows (a text input + tiny inline JS, or an hx-get with a query param — prefer no-JS hx-get?q= if cheap). "Basta un bel log": keep it clean, monospace targets, zebra rows optional.

C4 — Verify + C-GATE (STOP).


Stage D — Service management (SECURITY-CRITICAL — do last)

Treat this stage with the same rigor as the existing mutating routes. Read the routes.py module docstring first; mirror its confirm-gate + CSRF + audit shape exactly.

D1 — operations/services.py

@dataclass(frozen=True)
class ManagedService:
    id: str            # stable slug, e.g. "act-runner"
    unit: str          # "act-runner.service"
    label: str
    allow_stop: bool   # ci-web -> False (restart only; stop would lock out the UI)

MANAGED_SERVICES: tuple[ManagedService, ...] = (
    ManagedService("act-runner", "act-runner.service", "Runner", True),
    ManagedService("act-runner-rust", "act-runner-rust.service", "Runner (Rust)", True),
    ManagedService("vmware-host", "vmware-host.service", "VMware host", True),
    ManagedService("ci-web", "ci-web.service", "Web UI", False),
)
ALLOWED_ACTIONS = ("start", "stop", "restart")

@dataclass(frozen=True)
class ServiceState:
    service: ManagedService
    active: bool
    sub_state: str

@dataclass(frozen=True)
class ServiceActionResult:
    service_id: str
    action: str
    ok: bool
    detail: str

def find_service(service_id: str) -> ManagedService | None: ...
def service_states(services: tuple[ManagedService, ...] = MANAGED_SERVICES) -> list[ServiceState]: ...
def control_service(service: ManagedService, action: str, *,
                    dry_run: bool = False) -> ServiceActionResult: ...

control_service rules:

  • action must be in ALLOWED_ACTIONS else raise ValueError (caller maps to 400).
  • action == "stop" and not service.allow_stop → refuse (400).
  • Build an argument list ["sudo", "-n", "systemctl", action, service.unit] — never a shell string, never a user-supplied unit name. service comes only from find_service, which only returns allow-list members.
  • dry_run returns the would-run command without executing.
  • Timeout + check=False; non-zero → ok=False with stderr tail in detail.

D2 — Route POST /services/{service_id}/{action}

In routes.py, mirror vms_remove: require_user + require_csrf + two-stage confirm gate + exactly one AuditEntry per stage (action="service.<action>", target=unit). service_id → find_service; unknown → 400 before any subprocess. action not allowed → 400. Register in register_routes.

D3 — ci-web self-restart

Restarting ci-web.service from within its own worker kills the response mid-flight. For service.id == "ci-web" and action == "restart", run a detached deferred restart so the HTTP response returns first: ["sudo","-n","systemd-run","--on-active=2","systemctl","restart","ci-web.service"]. Audit as dry-run→ok with detail "scheduled in 2s". Document that the UI will briefly drop.

D4 — Services panel (partials/services.html)

Table of service_states(): label, unit, active badge, sub_state, and state-aware action buttons (hide "start" when active, hide "stop" when inactive or allow_stop=False). Stop/restart of vmware-host.service confirm step shows "N active VMs — this interrupts running jobs" (count from the VM context). Buttons are htmx → #action-slot (same pattern as Stage A4).

D5 — sudoers + installer

Create deploy/sudoers.d/ci-web (or wherever the systemd installer lives — find it first; referenced by docs/WEB-UI.md / the "systemd installer" commit):

# Exact-command NOPASSWD — no wildcards, no bare `systemctl`.
Cmnd_Alias CI_WEB_SVC = \
  /usr/bin/systemctl start act-runner.service, /usr/bin/systemctl stop act-runner.service, /usr/bin/systemctl restart act-runner.service, \
  /usr/bin/systemctl start act-runner-rust.service, /usr/bin/systemctl stop act-runner-rust.service, /usr/bin/systemctl restart act-runner-rust.service, \
  /usr/bin/systemctl start vmware-host.service, /usr/bin/systemctl stop vmware-host.service, /usr/bin/systemctl restart vmware-host.service, \
  /usr/bin/systemd-run --on-active=2 systemctl restart ci-web.service
ci-runner ALL=(root) NOPASSWD: CI_WEB_SVC

Installer must (a) write this to /etc/sudoers.d/ci-web mode 0440, (b) run visudo -cf /etc/sudoers.d/ci-web and abort/rollback on non-zero (a broken sudoers file can lock out sudo). Verify the real systemctl/systemd-run absolute paths on the host (command -v) — adjust if not /usr/bin. Note: ci-web has no stop entry (restart-only by design).

D6 — Config + docs

config.py: optional [web].managed_services to override the default allow-list (list of {id, unit, label, allow_stop}); default = MANAGED_SERVICES. Update config.example.toml and docs/WEB-UI.md (new panels, sudoers install, self-restart caveat, security model).

D7 — Security regression tests (test_web_services_security.py)

  • Unknown service_id → 400 and control_service/subprocess never called (assert via mock).
  • action not in ALLOWED_ACTIONS → 400, no subprocess.
  • stop on ci-web (allow_stop=False) → refused, no subprocess.
  • Missing/invalid CSRF → 403 (reuse existing security test pattern).
  • control_service builds an arg list (never shell=True); ci-web restart uses systemd-run.
  • Confirm-gate: POST without stage=execute → dry-run + confirm partial, no real systemctl.

D8 — Verify + D-GATE

Full suite green, coverage ≥90, ruff, mypy --strict. Supervisor review + deploy note: after merge, re-run pip install . into the production venv, install the sudoers file, sudo systemctl restart ci-web.service. This stage changes privilege boundaries — supervisor confirms the sudoers file on the live host with visudo -c before enabling the panel.


Cross-cutting rules (all stages)

  • Never subprocess.run(..., shell=True); always arg lists. No user input in argv.
  • Every route stays behind require_user; every mutating POST behind require_csrf + confirm gate + audit.
  • Read routes must never 500 on a backend/probe hiccup — degrade to an "unavailable" notice.
  • click.echo only in commands/*; operations/* and web/* return dataclasses / render — no print.
  • Match existing code style, comment density, and the operations/ "pure logic, zero click" boundary.
  • Coverage gate 90% is non-negotiable; add tests in the same stage as the code.

Non-goals (explicit)

  • No light theme. No prefers-color-scheme.
  • No metrics-over-time, sparklines, or sampler/store (that would be a separate Phase 2 spec).
  • No file download (Phase 1 lists only).
  • No reimplementation of Gitea job/run/log views — link out only.
  • No new mutating VM actions beyond existing remove/cleanup (service control is the only new mutation).

Open risks / de-risk notes

  • Gitea run-URL scheme (C2): confirm the exact Actions URL path before linking a Job cell to a specific run; default to a top-level Gitea link.
  • systemd unit names (D): act-runner.service / act-runner-rust.service are from web/app.py::RUNNER_SERVICE_NAMES; confirm they exist on the live host (systemctl status) before wiring buttons.
  • systemctl/systemd-run paths (D5): confirm absolute paths on host; sudoers matches literal paths.
  • CPU% correctness (B1): must use two /proc/stat samples — single-read % is a classic bug.