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.
24 KiB
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 inapp.py - A3. Status header partial (
partials/status_header.html) + health thresholds - A4. Fix unthemed action pages (htmx swap + full-page
HX-Requestfallback) - A5. Fix polling-wipes-form-input bug (
hx-preserveon 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()(+.vmemhealth, 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)
Stage C — VM enrichment + Gitea link-out + audit polish
- 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-webself-restart via detachedsystemd-run(response returns first) - D4. Services panel partial (state-aware buttons, active-VM warning on stop)
- D5.
sudoers.d/ci-web(exact-command NOPASSWD) + installervisudo -cvalidate - D6. Config
[web].managed_servicesoverride +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, installdeploy/sudoers.d/ci-webto /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
vmmonorvmnetnot loaded, orvmware-host.servicenot 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— addhost_resources(),vmware_module_health().web/templates/base.html— navGitea ↗;#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:
- Convert the mutating forms to htmx (
hx-post,hx-target="#action-slot",hx-swap="innerHTML"). Confirm→execute stays two-step but renders inline. - In the render helpers (
_confirm_partial,_result_partial,_lock_conflict_partial), whenrequest.headers.get("HX-Request")is absent, render the partial wrapped in a themed full page (a newaction_page.htmlthatextends base.htmland drops the partial into#action-slot). This keeps no-JS browsers themed too. Add<div id="action-slot"></div>near the top ofbase.htmlcontent 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.
Stage C — VM enrichment + Gitea link-out + audit polish
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.
C2 — Gitea link-out
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:
actionmust be inALLOWED_ACTIONSelse raiseValueError(caller maps to 400).action == "stop"andnot 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.servicecomes only fromfind_service, which only returns allow-list members. dry_runreturns the would-run command without executing.- Timeout +
check=False; non-zero →ok=Falsewith stderr tail indetail.
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 andcontrol_service/subprocess never called (assert via mock). actionnot inALLOWED_ACTIONS→ 400, no subprocess.stoponci-web(allow_stop=False) → refused, no subprocess.- Missing/invalid CSRF → 403 (reuse existing security test pattern).
control_servicebuilds an arg list (nevershell=True);ci-webrestart usessystemd-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 behindrequire_csrf+ confirm gate + audit. - Read routes must never 500 on a backend/probe hiccup — degrade to an "unavailable" notice.
click.echoonly incommands/*;operations/*andweb/*return dataclasses / render — noprint.- 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.serviceare fromweb/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/statsamples — single-read % is a classic bug.