From 86ac0bebbe44bd162f332b7a01f2a9bc4990d2c6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 11:01:00 +0200 Subject: [PATCH] UI: show command only when a step is started, buttons left of comment, fail-comment warning, keep focus on current step Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477 --- rtut/run.py | 4 +- rtut/ui.py | 53 +++++++++---- tests/test_ui.py | 96 ++++++++++++++++++----- workplans/RBT-WP-0004-local-runbook-ui.md | 6 +- 4 files changed, 124 insertions(+), 35 deletions(-) diff --git a/rtut/run.py b/rtut/run.py index 9e2b10e..29a822d 100644 --- a/rtut/run.py +++ b/rtut/run.py @@ -173,7 +173,7 @@ def verify(run: Run, step_id: str, confirm: bool = False, manual_ok: bool | None return result -def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None) -> None: +def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None, note: str = "") -> None: sd = run.step_def(step_id) st = run.state(step_id) if st["state"] != "running": @@ -185,6 +185,8 @@ def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None) -> if missing: raise RunError(f"evidence required: {', '.join(missing)}") st["evidence"] = {k: _clean(str(v), f"evidence {k}") for k, v in evidence.items()} + if note.strip(): + st["note"] = _clean(note.strip(), "note") st.update(state="done", finished=_now()) run.save() diff --git a/rtut/ui.py b/rtut/ui.py index dbf0668..69f1131 100644 --- a/rtut/ui.py +++ b/rtut/ui.py @@ -1,6 +1,6 @@ """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. +Localhost only. Server-rendered forms; the only script is small: copy button, fail-comment warning, scroll to current step. """ from __future__ import annotations @@ -19,15 +19,23 @@ h1,h2{font-weight:600}a{color:inherit}hr{border:0;border-top:1px solid #8886} .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}""" +button,input,select{font:inherit;padding:.25rem .5rem}.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}#current{scroll-margin-top:1rem}.note{opacity:.75}.bad{font-weight:600}""" def page(title: str, body: str) -> bytes: return (f"" f"{html.escape(title)}" f"

runbook-tutorials

{body}" - "").encode() + "").encode() def e(s) -> str: @@ -87,11 +95,18 @@ class App: done_n = sum(1 for x in d["steps"] if r.state(x["id"])["state"] in ("done", "skipped")) out.append(f"

{done_n} of {total} steps done

" f"") + current = next((x["id"] for x in d["steps"] if r.state(x["id"])["state"] == "running"), None) + if current is None: + current = next((x["id"] for x in d["steps"] if r.state(x["id"])["state"] in ("todo", "failed")), None) for n, s in enumerate(d["steps"], 1): st = r.state(s["id"]); state = st["state"] act = "" cmd = R.command_for(r, s["id"]) - block = f"
{e(cmd)}
" if cmd else "" + block = "" + if cmd and state == "running": # the command and Copy appear only once the step is started + block = f"
{e(cmd)}
" + elif cmd and state == "done": + block = f"
Command
{e(cmd)}
" url = f"/run/{i}/{e(rid)}/{e(s['id'])}" v = st.get("verification") if state == "todo" and not blocked: @@ -114,18 +129,26 @@ class App: act += "

✔ Check passed. Press Mark done to lock it in; the next step opens after that.

" else: act += "

✘ Check not passed. Fix the cause, then check again, or mark the step failed.

" + ev = "" if v and v["passed"]: - ev = "".join(f" " for n in s.get("evidence", [])) - act += f"
{self.hidden()}{ev}
" - act += (f"
{self.hidden()} " - "
") + ev = "".join(f" " for x in s.get("evidence", [])) + ev = f"

{ev}

" if ev else "" + done_btn = f" " if v and v["passed"] else "" + act += (f"
{self.hidden()}{ev}

{done_btn}" + f" " + "" + "

") elif state == "failed": - act = f"

Failed: {e(st.get('note', ''))}

{self.hidden()}
" + act = f"

Failed: {e(st.get('note') or '(no comment)')}

{self.hidden()}
" + elif state == "done" and st.get("note"): + act = f"

Comment: {e(st['note'])}

" if state not in ("done", "skipped") and not blocked: blocked = True waiting_for = f"step {n}, {s['title']}" - rb = f"

Rollback: {e(s['rollback'])}

" if s.get("rollback") else "" - out.append(f"
{state} {n}. {e(s['title'])} " + rb = f"

Rollback: {e(s['rollback'])}

" if s.get("rollback") and state in ("running", "failed") else "" + anchor = " id=current" if s["id"] == current else "" + out.append(f"{state} {n}. {e(s['title'])} " f"[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}{block}" f"

Done when: {e(s['verify']['done_when'])}

{rb}{act}
") if r.complete: @@ -178,17 +201,17 @@ def make_handler(app: App, port_holder: list): 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']}") + return self._redirect(f"/run/{i}/{r.data['run_id']}#current") 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=(form.get("ok") == "1") 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_")}) + R.complete(r, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")}, form.get("note", "")) elif action == "fail": R.fail(r, sid, form.get("note", "")) elif action == "retry": R.retry(r, sid) - return self._redirect(f"/run/{i}/{rid}") + return self._redirect(f"/run/{i}/{rid}#current") except (R.RunError, KeyError, ValueError, IndexError) as ex: msg = str(ex) try: diff --git a/tests/test_ui.py b/tests/test_ui.py index 22401af..318fb8c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -43,15 +43,15 @@ def test_foreign_host_and_missing_token_refused(srv): 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 st == 303 and loc.startswith("/run/0/") and loc.endswith("#current") + assert req(srv, "POST", loc.split("#")[0] + "/ping/start", {"_t": t})[0] == 303 + st, _, body = req(srv, "GET", loc.split("#")[0]) 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) + req(srv, "POST", loc.split("#")[0] + "/ping/verify", {"_t": t, "yes": "1"}) + st, _, body = req(srv, "GET", loc.split("#")[0]) 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] + assert req(srv, "POST", loc.split("#")[0] + "/ping/done", {"_t": t})[0] == 303 + assert "All steps done" in req(srv, "GET", loc.split("#")[0])[2] def test_unsafe_parameter_rejected_and_escaped(srv): @@ -63,8 +63,8 @@ def test_unsafe_parameter_rejected_and_escaped(srv): 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"}) + req(srv, "POST", loc.split("#")[0] + "/ping/start", {"_t": t}) + st, _, body = req(srv, "POST", loc.split("#")[0] + "/ping/fail", {"_t": t, "note": "hvs.ABCDEFGHIJKLMN"}) assert st == 400 and "secret-looking" in body @@ -79,18 +79,78 @@ def test_manual_check_cycle_and_blocked_reason(srv): srv.app.packs.append(P.load(d / "pack.yaml")) i = len(srv.app.packs) - 1 _, loc, _ = req(srv, "POST", f"/run/new/{i}", {"_t": t, "operator": "bernd"}) - _, _, body = req(srv, "GET", loc) + _, _, body = req(srv, "GET", loc.split("#")[0]) assert "0 of 8 steps done" in body and "1. Read the poem" in body assert "Waiting for: step 1" in body # blocked steps explain themselves - req(srv, "POST", loc + "/read-the-sign/start", {"_t": t}) - _, _, body = req(srv, "GET", loc) + req(srv, "POST", loc.split("#")[0] + "/read-the-sign/start", {"_t": t}) + _, _, body = req(srv, "GET", loc.split("#")[0]) assert "Yes, it is" in body and "Not yet" in body and "/read-the-sign/done" not in body - req(srv, "POST", loc + "/read-the-sign/verify", {"_t": t, "ok": "0"}) - _, _, body = req(srv, "GET", loc) + req(srv, "POST", loc.split("#")[0] + "/read-the-sign/verify", {"_t": t, "ok": "0"}) + _, _, body = req(srv, "GET", loc.split("#")[0]) assert "Check not passed" in body and "/read-the-sign/done" not in body - req(srv, "POST", loc + "/read-the-sign/verify", {"_t": t, "ok": "1"}) - _, _, body = req(srv, "GET", loc) + req(srv, "POST", loc.split("#")[0] + "/read-the-sign/verify", {"_t": t, "ok": "1"}) + _, _, body = req(srv, "GET", loc.split("#")[0]) assert "Check passed" in body and "/read-the-sign/done" in body - req(srv, "POST", loc + "/read-the-sign/done", {"_t": t}) - _, _, body = req(srv, "GET", loc) + req(srv, "POST", loc.split("#")[0] + "/read-the-sign/done", {"_t": t}) + _, _, body = req(srv, "GET", loc.split("#")[0]) assert "1 of 8 steps done" in body and "Start this step" in body + + +def _showcase_run(srv): + import shutil + from rtut import pack as P + t = srv.app.token + d = Path(srv.app.packs[0].path).parents[2] / "runbooks" / "show2"; d.mkdir(parents=True) + shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", d / "pack.yaml") + srv.app.packs.append(P.load(d / "pack.yaml")) + i = len(srv.app.packs) - 1 + st, loc, _ = req(srv, "POST", f"/run/new/{i}", {"_t": t, "operator": "bernd"}) + return t, loc, st + + +def test_redirects_keep_focus_on_current_step(srv): + t, loc, _ = _showcase_run(srv) + assert loc.endswith("#current") + base = loc.split("#")[0] + st, redirect, _ = req(srv, "POST", base + "/read-the-sign/start", {"_t": t}) + assert st == 303 and redirect.endswith("#current") + _, _, body = req(srv, "GET", base) + assert body.count("id=current") == 1 and "scrollIntoView" in body + + +def test_command_and_copy_appear_only_when_step_is_started(srv): + t, loc, _ = _showcase_run(srv) + base = loc.split("#")[0] + _, _, body = req(srv, "GET", base) + assert "