Restyles every template with a hand-written dark Gitea-style theme (web/static/app.css, ~270 lines: badges, buttons, cards, focus rings, prefers-reduced-motion) and completes the htmx polling wiring for all four dashboard cards. Adds the repeatable setup-web-linux.sh installer (idempotent: pip install '[web]' non-editable, render ci-web.service, template [web]/[web.oidc] into config.toml, prompt for keyring secrets, print Gitea OIDC registration steps, enable/restart the unit) plus deploy/systemd/ci-web.service and docs/WEB-UI.md.
9.8 KiB
Web UI — Management Dashboard
Server-rendered dashboard (ci web serve) for the local CI/CD system: live
VMs, cargo cache, disk, and act_runner status, plus confirm-gated destructive
actions (VM remove/cleanup, cache purge, template backup) with an audit
trail. In-process only — it imports operations/ and backends.load_backend
directly and never shells out to the CLI.
See plans/web-ui-management-plan.md (steps W0–W7) for the design history
and config.example.toml's [web] / [web.oidc] section for every
config key referenced below.
1. Prerequisites
- The Linux host already bootstrapped by
setup-host-linux.sh(ci-runnerservice user,/opt/ci/venv,/var/lib/ci/config.toml). - A reachable Gitea instance to authenticate against (OIDC/OAuth2) — only required if you intend to bind the dashboard on the LAN; a loopback-only install can skip Gitea entirely (see §5, "open mode").
- Outbound network from the host to Gitea's
/.well-known/openid-configurationendpoint, if OIDC is enabled.
Nothing here needs internet access — ci_orchestrator[web]'s dependencies
(FastAPI, Jinja2, Authlib, uvicorn, ...) install from the same package index
already used for the core install, and every frontend asset
(web/static/app.css, web/static/htmx.min.js) is vendored in the repo, no
CDN.
2. Running the installer
cd /opt/ci/local-ci-cd-system
sudo bash setup-web-linux.sh --host 127.0.0.1 # loopback-only, no OIDC needed
# or, once OIDC is registered (see §3):
sudo bash setup-web-linux.sh --host 192.168.1.50 # LAN
setup-web-linux.sh is idempotent — every mutating step checks its own
state first and skips (with a message) when already satisfied, so re-running
it (e.g. after git pull + a code change) is always safe:
pip install '<repo>[web]'into/opt/ci/venv, non-editable — perCLAUDE.md, the production venv can't resolve editable installs whenact_runner/systemd launches Python from a transient working directory.- Renders
deploy/systemd/ci-web.serviceinto/etc/systemd/system/, substituting the bind IP you passed via--host. - Appends a commented
[web]/[web.oidc]template to/var/lib/ci/config.tomlif no such section exists yet — it never overwrites an existing one, and never writes a secret value. - Prompts (interactively, one at a time) to store
WebSessionSecretandWebOidcClientSecretin the keyring, if they aren't already set. The secret is only ever typed at the terminal prompt — the installer never accepts it as a command-line argument and never echoes or logs it. - Prints the manual Gitea OIDC application registration steps (see §3).
systemctl daemon-reloadthen enables + starts (or restarts, if already running)ci-web.service.
Run setup-web-linux.sh --help for the full flag list (--skip-pip,
--skip-secrets, --config-path).
After step 3/4, edit /var/lib/ci/config.toml: uncomment the appended
[web] / [web.oidc] block, fill in issuer, client_id,
allowed_users, then restart the service (systemctl restart ci-web.service).
3. Gitea OAuth2 application registration
The dashboard authenticates against Gitea via OIDC (Authlib + Gitea's
/.well-known/openid-configuration discovery document). Register the
application before enabling [web.oidc]:
- In Gitea: Settings → Applications → "Manage OAuth2 Applications" → Create a new OAuth2 Application.
- Application Name: anything recognizable, e.g.
ci-web. - Redirect URI:
http://<LAN-IP>:8800/auth/callback— this must match[web.oidc].redirect_urlexactly (scheme, host, port, and path). A mismatch is the #1 cause of a login redirect loop (see §6). - Gitea shows a Client ID and Client Secret exactly once:
- Client ID →
[web.oidc].client_idinconfig.toml(not a secret, plain text is fine). - Client Secret → never put it in
config.toml. Store it in the keyring instead:sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \ --target WebOidcClientSecret --user web-oidc-clientconfig.tomlonly ever holds the keyring key name (client_secret_cred = "WebOidcClientSecret"), never the value.
- Client ID →
- Add the Gitea username(s) allowed to log in to
[web].allowed_users. The allowlist is fail-closed: an empty list denies everyone, it never means "allow all".
4. Operating
# Start / stop / restart
sudo systemctl start ci-web.service
sudo systemctl stop ci-web.service
sudo systemctl restart ci-web.service # after a config.toml edit or a code upgrade
# Status
systemctl status ci-web.service
# Logs (uvicorn access log + app stdout/stderr go to the journal)
journalctl -u ci-web.service -f
journalctl -u ci-web.service -n 100 --no-pager
# Health check (unauthenticated, always 200)
curl -s http://127.0.0.1:8800/healthz
Once code changes: sudo bash setup-web-linux.sh --host <same-as-before>
reinstalls the [web] extra and restarts the service in one step (steps
2–6 no-op if nothing else changed).
5. Firewall / security
- Bind only a specific LAN IP, never
0.0.0.0. Neitherci web servenorsetup-web-linux.shwill ever write0.0.0.0— the installer prompts for (or accepts via--host) one explicit address, defaulting to127.0.0.1. - Open mode (no
[web.oidc]configured) is localhost-only, enforced at startup.ci web serverefuses to start if you bind a non-loopback address while[web.oidc]is absent — an unauthenticated dashboard that can destroy VMs and purge caches must never be reachable from the LAN. Configure[web.oidc]first, or bind127.0.0.1. - Every mutating route (VM remove/cleanup, cache purge, template backup) is behind a synchronizer-token CSRF check and a two-step confirm gate (a preview/dry-run, then an explicit "Confirm" that actually executes) — there is no one-click destroy.
- Every mutating action appends exactly one entry to the audit log
(
[web].audit_log, default/var/lib/ci/logs/web-audit.jsonl), viewable at/auditin the dashboard. - If you run a host firewall (ufw/nftables/iptables), only open the chosen
port (default
8800) to the specific LAN/VLAN that needs dashboard access, not the whole network.
6. Keyring / headless note
ci-web.service runs as ci-runner under systemd — there is no desktop
session and no D-Bus, so the GNOME Secret Service backend (secret-tool)
does not work here. This host uses keyrings.alt.file.PlaintextKeyring
instead (already the credential backend used for guest/Gitea credentials —
see plans/archived/2026-05-23/B3-closeout.md):
- Config:
/home/ci-runner/.local/share/python_keyring/keyringrc.cfg([backend] default-keyring=keyrings.alt.file.PlaintextKeyring) - Store:
/home/ci-runner/.local/share/python_keyring/keyring_pass.cfg— plain text, readable only byci-runner(file permissions), no D-Bus session required. Verified to work both fromsudo -u ci-runner ...and from inside a plain systemd service. - This is why
ci-web.service's hardening usesProtectHome=read-onlyand notProtectHome=yes/true: the latter hides/homeentirely from the unit's mount namespace, which would break every OIDC/session secret lookup and make login fail even though the keyring itself is fine.read-onlykeeps/home/ci-runnerreadable (the app never writes to the keyring at request time — secrets are set once, out-of-band, byci creds set) while still blocking writes. WebSessionSecret/WebOidcClientSecretare keyring key names inconfig.toml(session_secret_cred,client_secret_cred) — the actual values live only in the keyring, set viaci creds set --target <name> --user <label>(interactive prompt,hide_input+ confirmation; never a CLI argument, never logged).
7. Troubleshooting
- Login loop (bounces back to
/loginafter authenticating with Gitea) - The registered Redirect URI in Gitea does not exactly match
[web.oidc].redirect_urlinconfig.toml— check scheme (httpvshttps), host/IP, port, and the/auth/callbackpath character-for-character. - 403 after a successful Gitea login
- Your Gitea username is not in
[web].allowed_users(or the list is empty — the allowlist is fail-closed by design). Add it and restart the service. - **"keyring key '...' not found -- run
ci creds set ..." in the journal / - on
/login** - The session secret or the OIDC client secret has not been stored yet
(or was stored for a different Unix user than
ci-runner, or under a different_credname than the one inconfig.toml). Re-run:sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \ --target WebSessionSecret --user web-sessionsubstituting the exact key name printed in the error. See §6 for why this must run as
ci-runner, not as your own login. - Dashboard is blank / cards show "unavailable"
- The VM backend (
vmrun) orshutil.disk_usage/systemctlcould not be reached from the host — the dashboard degrades a card to an "unavailable" notice instead of a 500. Checkvmware-host.serviceis up (systemctl is-active vmware-host.service) and that/healthzstill returns200. - **
ci web serverefuses to start with "refusing to bind ... without - authentication"**
- You passed a non-loopback
--host(or[web].bind_hostinconfig.toml) while[web.oidc]is unset. This is the guard described in §5 — configure[web.oidc](see §3) or bind127.0.0.1. - Confirm/Cleanup/Purge form returns 403
- The CSRF token expired (the session cookie was cleared, e.g. by
/logout, or the browser tab was left open across a service restart that rotated the session secret in open mode). Reload the page and retry — the confirm form always mints a fresh token.