# 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 - [x] A1. Swap CSS tokens to real Gitea dark palette (accent → blue `#4183c4`) - [x] A2. `web/filters.py` — number/format Jinja filters + registration in `app.py` - [x] A3. Status header partial (`partials/status_header.html`) + health thresholds - [x] A4. Fix unthemed action pages (htmx swap + full-page `HX-Request` fallback) - [x] A5. Fix polling-wipes-form-input bug (`hx-preserve` on inputs) - [x] A6. Inline SVG icon sprite (`partials/icons.html`) + apply to card headers - [x] A7. Stage A tests green · ruff · mypy --strict · coverage ≥90 - [x] **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 - [x] B1. `operations/host.py` — `host_resources()` (/proc, 2-sample CPU%) - [x] B2. `operations/host.py` — `vmware_module_health()` (lsmod + vmware-host.service) - [x] B3. `operations/inventory.py` — `list_templates()` (+ `.vmem` health, last backup) - [x] B4. `operations/inventory.py` — `list_backups()` / `list_artifacts()` (path-contained) - [x] B5. GET partials + routes: `/resources`, `/vmware`, `/templates`, `/files` - [x] B6. Backup card → template **dropdown** (validated against `list_templates`) - [x] B7. Slow-poll / on-demand refresh for expensive panels (dir-size cache) - [x] B8. Stage B tests green · ruff · mypy --strict · coverage ≥90 - [x] **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 - [x] C1. `parse_clone_name()` + `repo/job` (job_id + started) column in VM table - [x] C2. Gitea base URL in config/context; nav `Gitea ↗` + contextual links - [x] C3. Audit page restyle (relative time, action icons, simple filter) - [x] C4. Stage C tests green · ruff · mypy --strict · coverage ≥90 - [x] **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) - [x] D1. `operations/services.py` — allow-list, `service_states()`, `control_service()` - [x] D2. POST `/services/{service_id}/{action}` — confirm + CSRF + audit + refusals - [x] D3. `ci-web` self-restart via detached `systemd-run` (response returns first) - [x] D4. Services panel partial (state-aware buttons, active-VM warning on stop) - [x] D5. `sudoers.d/ci-web` (exact-command NOPASSWD) + installer `visudo -c` validate - [x] D6. Config `[web].managed_services` override + `config.example.toml` + `WEB-UI.md` - [x] D7. Security regression tests (unknown id → 400 & systemctl never called; etc.) - [x] D8. Stage D tests green · ruff · mypy --strict · coverage ≥90 - [x] **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 | `web/routes.py` (existing split, keep it) | ### 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 #4183c4 ``` ### 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: ```python 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 `