Add local web UI for native packs (RBT-WP-0004-T02)
Some checks failed
Work Records / validate (push) Has been cancelled
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 00:45:20 +02:00
parent 6056710e8e
commit 9cbdc11f5e
5 changed files with 282 additions and 4 deletions

View file

@ -14,8 +14,11 @@ python3 -m rtut list <repo-root> # id, engine, exercise status, title
python3 -m rtut show <pack.yaml> # steps with owners and done-when
python3 -m rtut render <pack.yaml> <step> -p name=value
python3 -m rtut launch <pack.yaml> # legacy-command packs: start the existing console
python3 -m rtut ui <repo-root> # local web UI on 127.0.0.1:8877: walk a pack, record a run
python3 -m rtut run new|start|verify|done|finish <pack.yaml> ... # the same run record from the terminal
make test
```
Status: the specification, validator and legacy launch work; run state, evidence
and the UI are planned (`workplans/RBT-WP-0003` and `RBT-WP-0004`).
Status: specification, validator, legacy launch, run records with verification and
exercise-status diff, and a first local web UI work. Terminal walk-through mode and
the console migration are open (`workplans/`).

View file

@ -72,6 +72,9 @@ def main(argv: list[str] | None = None) -> int:
sp.add_argument("--yes", action="store_true", help="confirm running a verify command")
sp.add_argument("--pass", dest="passed", action="store_true", help="manual check: operator confirms it passed")
sp.add_argument("--apply", action="store_true", help="finish: write the exercise-status change")
sp = sub.add_parser("ui", help="serve the local UI on 127.0.0.1")
sp.add_argument("roots", nargs="*", default=["."])
sp.add_argument("--port", type=int, default=8877)
sp = sub.add_parser("launch"); sp.add_argument("manifest")
sp.add_argument("--timeout", type=float, default=30.0)
sp = sub.add_parser("show"); sp.add_argument("manifest")
@ -79,6 +82,15 @@ def main(argv: list[str] | None = None) -> int:
sp.add_argument("--param", "-p", action="append", default=[], help="name=value")
a = ap.parse_args(argv)
if a.cmd == "ui":
from . import ui
srv = ui.serve([Path(r) for r in a.roots], a.port)
print(f"http://127.0.0.1:{a.port} (Ctrl-C stops)")
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return 0
if a.cmd in ("list", "validate"):
packs = _packs(a.root)
if not packs:

195
rtut/ui.py Normal file
View file

@ -0,0 +1,195 @@
"""Local web UI: pick a pack, fill parameters, walk the steps, record evidence.
Localhost only. Server-rendered forms; the only script is a copy button.
"""
from __future__ import annotations
import html
import secrets
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from . import pack as P, run as R
CSS = """body{font:15px/1.5 system-ui,sans-serif;max-width:52rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fff}
@media(prefers-color-scheme:dark){body{color:#eee;background:#111}pre,input,select{background:#1b1b1b;color:#eee}}
h1,h2{font-weight:600}a{color:inherit}hr{border:0;border-top:1px solid #8886}
.eyebrow{font:12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em}
.step{border:1px solid #8886;padding:.8rem 1rem;margin:.8rem 0}.step.running{border-color:currentColor}
.pill{font:12px ui-monospace,monospace;border:1px solid #8886;padding:0 .4rem}
pre{border:1px solid #8886;padding:.5rem;overflow:auto;margin:.4rem 0}
button,input,select{font:inherit;padding:.25rem .5rem}.note{opacity:.75}.bad{font-weight:600}"""
def page(title: str, body: str) -> bytes:
return (f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>"
f"<title>{html.escape(title)}</title><style>{CSS}</style>"
f"<p class=eyebrow><a href='/'>runbook-tutorials</a></p>{body}"
"<script>document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>"
"navigator.clipboard.writeText(b.previousElementSibling.textContent))</script>").encode()
def e(s) -> str:
return html.escape(str(s))
class App:
def __init__(self, roots: list[Path]):
self.token = secrets.token_urlsafe(16)
self.packs = [P.load(p) for r in roots for p in P.discover(r)]
def hidden(self) -> str:
return f"<input type=hidden name=_t value='{self.token}'>"
def index(self) -> bytes:
rows = []
for i, p in enumerate(self.packs):
d = p.data
if not p.ok:
rows.append(f"<li class=bad>{e(p.id)}: invalid ({e(p.errors[0])})</li>")
elif d["engine"] != "native":
rows.append(f"<li>{e(d['title'])} <span class=pill>legacy console</span> "
f"<span class=note>start with: python -m rtut launch {e(p.path)}</span></li>")
else:
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> "
f"<span class=pill>{e(d['exercise_status'])}</span></li>")
return page("Runbooks", f"<h1>Runbooks</h1><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
def pack_page(self, i: int) -> bytes:
p = self.packs[i]; d = p.data
prereq = "".join(f"<li>[{e(x['owner'])}] {e(x['text'])}</li>" for x in d.get("prerequisites", []))
fields = "".join(
f"<p><label>{e(x['label'])}<br>"
+ (f"<select name='p_{x['id']}'>" + "".join(
f"<option{' selected' if o == x.get('default') else ''}>{e(o)}</option>" for o in x["values"]) + "</select>"
if x.get("values") else f"<input name='p_{x['id']}' value='{e(x.get('default', ''))}'>")
+ f"</label><br><span class=note>{e(x.get('help', ''))} Never enter secrets.</span></p>"
for x in d.get("parameters", []))
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
f"<p>{e(d.get('outcome', ''))}</p><h2>Prerequisites</h2><ul>{prereq}</ul>"
f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}"
"<p><label>Your name (one word)<br><input name=operator required></label></p>"
"<button>Start run</button></form>")
def run_page(self, i: int, rid: str, msg: str = "") -> bytes:
p = self.packs[i]; r = R.load(p, rid); d = p.data
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}</p>"]
if msg:
out.append(f"<p class=bad>{e(msg)}</p>")
blocked = False
for s in d["steps"]:
st = r.state(s["id"]); state = st["state"]
act = ""
cmd = R.command_for(r, s["id"])
block = f"<pre>{e(cmd)}</pre><button type=button data-copy>Copy</button>" if cmd else ""
url = f"/run/{i}/{e(rid)}/{e(s['id'])}"
v = st.get("verification")
if state == "todo" and not blocked:
act = f"<form method=post action='{url}/start'>{self.hidden()}<button>Start</button></form>"
elif state == "running":
ver = s["verify"]
if ver.get("expect", "manual") == "manual":
act = (f"<form method=post action='{url}/verify'>{self.hidden()}<label><input type=checkbox name=ok value=1> "
f"I confirm: {e(ver['done_when'])}</label> <button>Record check</button></form>")
else:
act = (f"<form method=post action='{url}/verify'>{self.hidden()}<input type=hidden name=yes value=1>"
f"<p class=note>Runs: <code>{e(R.render(ver['command'], r.data['params'], p))}</code></p>"
f"<button>Run check</button></form>")
if v:
act += f"<p>{'✔ passed' if v['passed'] else '✘ failed'}</p>"
if v and v["passed"]:
ev = "".join(f"<label>{e(n)} <input name='e_{e(n)}' required></label> " for n in s.get("evidence", []))
act += f"<form method=post action='{url}/done'>{self.hidden()}{ev}<button>Mark done</button></form>"
act += (f"<form method=post action='{url}/fail'>{self.hidden()}<input name=note placeholder='what went wrong (no secrets)'> "
"<button>Mark failed</button></form>")
elif state == "failed":
act = f"<p class=bad>Failed: {e(st.get('note', ''))}</p><form method=post action='{url}/retry'>{self.hidden()}<button>Retry</button></form>"
if state not in ("done", "skipped"):
blocked = True
rb = f"<p class=note>Rollback: {e(s['rollback'])}</p>" if s.get("rollback") else ""
out.append(f"<div class='step {state}'><span class=pill>{state}</span> <b>{e(s['title'])}</b> "
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}</span>{block}"
f"<p class=note>Done when: {e(s['verify']['done_when'])}</p>{rb}{act}</div>")
if r.complete:
out.append("<p><b>All steps done.</b> To record this run as exercising the pack, review and apply: "
f"<code>python -m rtut run finish {e(p.path)} {e(rid)} --apply</code></p>")
out.append("<h2>Threat checks</h2><ul>" + "".join(f"<li>{e(t)}</li>" for t in d["threat_checks"]) + "</ul>")
return page(d["title"], "".join(out))
def make_handler(app: App, port_holder: list):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): # quiet
pass
def _send(self, body: bytes, code=200):
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'")
self.end_headers(); self.wfile.write(body)
def _host_ok(self) -> bool:
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost"))
def do_GET(self):
if not self._host_ok():
return self._send(b"forbidden", 403)
parts = [x for x in urlparse(self.path).path.split("/") if x]
try:
if not parts:
return self._send(app.index())
if parts[0] == "pack":
return self._send(app.pack_page(int(parts[1])))
if parts[0] == "run":
return self._send(app.run_page(int(parts[1]), parts[2]))
except (IndexError, ValueError, R.RunError):
pass
self._send(page("Not found", "<p>Not found.</p>"), 404)
def do_POST(self):
if not self._host_ok():
return self._send(b"forbidden", 403)
n = int(self.headers.get("Content-Length", 0))
form = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode()).items()}
if form.get("_t") != app.token:
return self._send(b"bad token", 403)
parts = [x for x in urlparse(self.path).path.split("/") if x]
msg = ""
try:
if parts[:2] == ["run", "new"]:
i = int(parts[2]); p = app.packs[i]
params = {k[2:]: v for k, v in form.items() if k.startswith("p_")}
r = R.create(p, form.get("operator", ""), params)
return self._redirect(f"/run/{i}/{r.data['run_id']}")
i, rid, sid, action = int(parts[1]), parts[2], parts[3], parts[4]
p = app.packs[i]; r = R.load(p, rid)
if action == "start": R.start(r, sid)
elif action == "verify":
R.verify(r, sid, confirm=bool(form.get("yes")), manual_ok=bool(form.get("ok")) if not form.get("yes") else None)
elif action == "done":
R.complete(r, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")})
elif action == "fail": R.fail(r, sid, form.get("note", ""))
elif action == "retry": R.retry(r, sid)
return self._redirect(f"/run/{i}/{rid}")
except (R.RunError, KeyError, ValueError, IndexError) as ex:
msg = str(ex)
try:
if parts[:2] == ["run", "new"]:
return self._send(app.pack_page(int(parts[2])).replace(b"<h1>", f"<p class=bad>{e(msg)}</p><h1>".encode(), 1), 400)
return self._send(app.run_page(int(parts[1]), parts[2], msg), 400)
except Exception:
return self._send(page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
def _redirect(self, to: str):
self.send_response(303); self.send_header("Location", to); self.end_headers()
return H
def serve(roots: list[Path], port: int = 8877) -> ThreadingHTTPServer:
app = App(roots)
srv = ThreadingHTTPServer(("127.0.0.1", port), make_handler(app, []))
srv.app = app # type: ignore[attr-defined]
return srv

68
tests/test_ui.py Normal file
View file

@ -0,0 +1,68 @@
import http.client
import re
import shutil
import threading
from pathlib import Path
from urllib.parse import urlencode
import pytest
from rtut import ui
ROOT = Path(__file__).resolve().parent.parent
@pytest.fixture
def srv(tmp_path):
d = tmp_path / "runbooks" / "hello"; d.mkdir(parents=True)
shutil.copy(ROOT / "runbooks/example-hello/pack.yaml", d / "pack.yaml")
s = ui.serve([tmp_path], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
yield s
s.shutdown()
def req(s, method, path, form=None, host="127.0.0.1"):
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
body = urlencode(form) if form is not None else None
c.request(method, path, body, {"Host": host, "Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse()
return r.status, r.getheader("Location"), r.read().decode()
def test_index_lists_pack(srv):
st, _, body = req(srv, "GET", "/")
assert st == 200 and "Example - check a host answers" in body
def test_foreign_host_and_missing_token_refused(srv):
assert req(srv, "GET", "/", host="evil.example")[0] == 403
assert req(srv, "POST", "/run/new/0", {"operator": "bernd"})[0] == 403
def test_walk_a_run(srv):
t = srv.app.token
st, loc, _ = req(srv, "POST", "/run/new/0", {"_t": t, "operator": "bernd", "p_host": "localhost", "p_count": "1"})
assert st == 303 and loc.startswith("/run/0/")
assert req(srv, "POST", loc + "/ping/start", {"_t": t})[0] == 303
st, _, body = req(srv, "GET", loc)
assert "ping -c 1 localhost" in body and "Run check" in body
req(srv, "POST", loc + "/ping/verify", {"_t": t, "yes": "1"})
st, _, body = req(srv, "GET", loc)
if "passed" in body and "failed" not in body.split("Threat")[0].replace("Mark failed", ""):
assert req(srv, "POST", loc + "/ping/done", {"_t": t})[0] == 303
assert "All steps done" in req(srv, "GET", loc)[2]
def test_unsafe_parameter_rejected_and_escaped(srv):
t = srv.app.token
st, _, body = req(srv, "POST", "/run/new/0", {"_t": t, "operator": "bernd", "p_host": "<script>x;"})
assert st == 400 and "<script>x" not in body
def test_secret_in_note_refused(srv):
t = srv.app.token
_, loc, _ = req(srv, "POST", "/run/new/0", {"_t": t, "operator": "bernd"})
req(srv, "POST", loc + "/ping/start", {"_t": t})
st, _, body = req(srv, "POST", loc + "/ping/fail", {"_t": t, "note": "hvs.ABCDEFGHIJKLMN"})
assert st == 400 and "secret-looking" in body

View file

@ -4,7 +4,7 @@ type: workplan
title: "Local runbook UI"
domain: infotech
repo: runbook-tutorials
status: ready
status: active
flavor: implementation
owner: claude
topic_slug: stack
@ -37,7 +37,7 @@ approval phrase) and what to change. Output: a short UX note in `docs/`.
```task
id: RBT-WP-0004-T02
status: todo
status: done
priority: high
state_hub_task_id: "e15c22b6-60eb-5a54-9d22-b268897fcf4d"
```