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 <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:
parent
e30d81c5b6
commit
86ac0bebbe
4 changed files with 124 additions and 35 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
53
rtut/ui.py
53
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"<!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()
|
||||
"<script>"
|
||||
"document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>"
|
||||
"navigator.clipboard.writeText(b.previousElementSibling.textContent));"
|
||||
# first click on 'Mark failed' without a comment only warns; a second click fails the step without one
|
||||
"document.querySelectorAll('[data-fail]').forEach(b=>b.addEventListener('click',ev=>{"
|
||||
"const f=b.closest('form'),n=f.querySelector('[name=note]'),w=f.querySelector('[data-warn]');"
|
||||
"if(n&&!n.value.trim()&&!b.dataset.warned){ev.preventDefault();b.dataset.warned='1';w.hidden=false;n.focus();}}));"
|
||||
# keep the operator on the current step after every action
|
||||
"const c=document.getElementById('current');if(c)c.scrollIntoView({block:'center'});"
|
||||
"</script>").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"<p class=eyebrow>{done_n} of {total} steps done</p>"
|
||||
f"<progress max={total} value={done_n} style='width:100%'></progress>")
|
||||
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"<pre>{e(cmd)}</pre><button type=button data-copy>Copy</button>" if cmd else ""
|
||||
block = ""
|
||||
if cmd and state == "running": # the command and Copy appear only once the step is started
|
||||
block = f"<pre>{e(cmd)}</pre><button type=button data-copy>Copy</button>"
|
||||
elif cmd and state == "done":
|
||||
block = f"<details><summary class=note>Command</summary><pre>{e(cmd)}</pre></details>"
|
||||
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 += "<p><b>✔ Check passed.</b> Press <b>Mark done</b> to lock it in; the next step opens after that.</p>"
|
||||
else:
|
||||
act += "<p class=bad>✘ Check not passed. Fix the cause, then check again, or mark the step failed.</p>"
|
||||
ev = ""
|
||||
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>")
|
||||
ev = "".join(f"<label>{e(x)} <input name='e_{e(x)}' required></label> " for x in s.get("evidence", []))
|
||||
ev = f"<p>{ev}</p>" if ev else ""
|
||||
done_btn = f"<button formaction='{url}/done'>Mark done</button> " if v and v["passed"] else ""
|
||||
act += (f"<form method=post action='{url}/fail'>{self.hidden()}{ev}<p class=row>{done_btn}"
|
||||
f"<button formaction='{url}/fail' formnovalidate data-fail>Mark failed</button> "
|
||||
"<input name=note class=grow placeholder='comment (what happened, what went wrong; no secrets)'>"
|
||||
"</p><p class='note bad' data-warn hidden>Add a comment about what went wrong, or click "
|
||||
"<b>Mark failed</b> again to fail without one.</p></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>"
|
||||
act = f"<p class=bad>Failed: {e(st.get('note') or '(no comment)')}</p><form method=post action='{url}/retry'>{self.hidden()}<button>Retry</button></form>"
|
||||
elif state == "done" and st.get("note"):
|
||||
act = f"<p class=note>Comment: {e(st['note'])}</p>"
|
||||
if state not in ("done", "skipped") and not blocked:
|
||||
blocked = True
|
||||
waiting_for = f"step {n}, {s['title']}"
|
||||
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>{n}. {e(s['title'])}</b> "
|
||||
rb = f"<p class=note>Rollback: {e(s['rollback'])}</p>" if s.get("rollback") and state in ("running", "failed") else ""
|
||||
anchor = " id=current" if s["id"] == current else ""
|
||||
out.append(f"<div{anchor} class='step {state}'><span class=pill>{state}</span> <b>{n}. {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:
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "<button type=button data-copy>" not in body and "<pre>" not in body # nothing started yet
|
||||
req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "<button type=button data-copy>" in body # started: command and Copy appear
|
||||
assert "<pre>printf" in body
|
||||
req(srv, "POST", base + "/read-the-sign/verify", {"_t": t, "ok": "1"})
|
||||
req(srv, "POST", base + "/read-the-sign/done", {"_t": t, "note": "read it twice"})
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "<button type=button data-copy>" not in body # done: no copy button
|
||||
assert "<details>" in body and "Comment: read it twice" in body # command kept as a record, comment shown
|
||||
|
||||
|
||||
def test_action_buttons_left_of_the_comment(srv):
|
||||
t, loc, _ = _showcase_run(srv)
|
||||
base = loc.split("#")[0]
|
||||
req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
|
||||
req(srv, "POST", base + "/read-the-sign/verify", {"_t": t, "ok": "1"})
|
||||
_, _, body = req(srv, "GET", base)
|
||||
row = body[body.index("<p class=row>"):]
|
||||
assert row.index("Mark done") < row.index("Mark failed") < row.index("name=note")
|
||||
|
||||
|
||||
def test_fail_without_comment_warns_first_then_is_allowed(srv):
|
||||
t, loc, _ = _showcase_run(srv)
|
||||
base = loc.split("#")[0]
|
||||
req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "data-fail" in body and "data-warn hidden" in body and "dataset.warned" in body # client-side first-click warning
|
||||
st, _, _ = req(srv, "POST", base + "/read-the-sign/fail", {"_t": t, "note": ""}) # the second click submits
|
||||
assert st == 303
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "(no comment)" in body and "Retry" in body
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ state_hub_task_id: "d635f972-0377-5581-a8fe-c01ef89559cd"
|
|||
Done 2026-09-29 against the lighthouse showcase (tested over HTTP; a browser pass by Bernd is still the real check). Defects found in review: a ticked manual check shows "passed" while the step stays
|
||||
`running`, and unticking changes nothing; blocked steps show no Start button and no
|
||||
reason. Make the cycle explicit (check, record, mark done), and show what a blocked
|
||||
step is waiting for.
|
||||
step is waiting for. Follow-up done 2026-09-29 from Bernd's first hands-on: the command and
|
||||
Copy button appear only once a step is started (done steps keep the command as a record);
|
||||
"Mark done" and "Mark failed" sit left of the comment field; a first "Mark failed" without a
|
||||
comment only warns, a second click fails without one; every action returns to the current
|
||||
step instead of the top of the page.
|
||||
|
||||
## Questionnaire flow on the v0.2 model
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue