"""Local web UI: pick a pack, fill parameters, walk the steps, record evidence. Localhost only. Server-rendered forms; the only script is small: copy button, fail-comment warning, scroll to current step. """ 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 from . import ui_projects as UP from . import ui_v2 as UI2 from .attrs import Registry CSS = """ :root{--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--radius:6px} @media(prefers-color-scheme:dark){:root{--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72}} *{box-sizing:border-box} body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;max-width:56rem;margin:2rem auto;padding:0 1.2rem;color:var(--fg);background:var(--bg)} h1{font-size:1.6rem;font-weight:650;letter-spacing:-.01em;margin:.4rem 0 .6rem} h2{font-size:1.1rem;font-weight:650;margin:1.8rem 0 .5rem;padding-bottom:.25rem;border-bottom:1px solid var(--line)} h3{font-size:.95rem;margin:1.2rem 0 .3rem} a{color:inherit;text-underline-offset:.15em}a:hover{background:var(--hi);color:#141414} hr{border:0;border-top:1px solid var(--line)} code,pre{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace} pre{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:.6rem .8rem;overflow:auto;margin:.5rem 0} .eyebrow{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin:.2rem 0} .note{color:var(--mut)}.bad{color:var(--bad);font-weight:600} progress{height:.45rem;border:0;border-radius:99px;overflow:hidden;background:var(--line);accent-color:var(--fg)} progress::-webkit-progress-bar{background:var(--line)}progress::-webkit-progress-value{background:var(--fg)} button,input,select,textarea{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:.3rem .6rem} button{cursor:pointer;background:var(--fg);color:var(--bg);border-color:var(--fg)} button:hover{background:var(--hi);color:#141414;border-color:var(--hi)} button[name=ok][value="0"],button[data-fail],button[type=button]{background:var(--card);color:var(--fg);border-color:var(--line)} button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:2px solid var(--hi);outline-offset:2px} label{display:inline-block}fieldset{border:1px solid var(--line);border-radius:var(--radius);margin:.8rem 0;padding:.4rem .9rem} table{border-collapse:collapse;width:100%;margin:.5rem 0;background:var(--card);border:1px solid var(--line);border-radius:var(--radius)} td,th{border-bottom:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}th{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)} tr:last-child td{border-bottom:0} table.data td:first-child{width:42%} td label{display:block} td input:not([type=checkbox]):not([type=hidden]):not([type=radio]){width:100%} .row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none} summary{cursor:pointer}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top} .step,.stage,.panel,.banner,.questions,details.framing,details.override{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:.7rem 1rem;margin:.7rem 0} .stage{padding:.8rem 1.1rem;background:transparent}.stage>summary{font-size:1.02rem} .stage .step,.stage .questions,.stage .stage{background:var(--card)} .step.running{border-color:var(--fg);box-shadow:0 0 0 1px var(--fg)}.step.failed{border-color:var(--bad)}.step.done,.step.pushed{opacity:.9} .step.gate{border-style:dashed}.step.gate.passed{border-style:solid} details.framing{background:transparent;border-left:3px solid var(--warn)}details.override{background:transparent;border-style:dashed;margin:.5rem 0} .panel{background:transparent}.panel[data-s=auto]{border-color:var(--fg)} .banner{background:var(--hi);color:#141414;border-color:var(--hi)}.banner a{text-decoration-thickness:2px}.banner .note{color:#3d3a10} .banner .chip{background:rgba(255,255,255,.55);color:#141414;border-color:rgba(0,0,0,.2)} .questions{background:transparent;border-left:3px solid var(--fg)} .tabs{display:flex;gap:.2rem;margin:1rem 0;border-bottom:1px solid var(--line)} .tab{padding:.4rem .9rem;text-decoration:none;border:1px solid transparent;border-bottom:0;border-radius:var(--radius) var(--radius) 0 0;color:var(--mut)} .tab.on{color:var(--fg);border-color:var(--line);background:var(--card);font-weight:650;margin-bottom:-1px} .chip{display:inline-block;font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:99px;padding:0 .55rem;margin:.1rem .1rem;text-decoration:none;color:var(--mut)} a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{background:var(--fg);color:var(--bg);border-color:var(--fg)} .chip[data-s=irreversible]{color:var(--bad);border-color:var(--bad)} .pill{font:11.5px ui-monospace,monospace;border:1px solid var(--line);border-radius:99px;padding:.05rem .55rem;white-space:nowrap;color:var(--mut);background:var(--card)} .pill[data-s=done],.pill[data-s=passed],.pill[data-s=ok],.pill[data-s=complete],.pill[data-s=succeeded],.pill[data-s=applied]{color:var(--ok);border-color:var(--ok)} .pill[data-s=running],.pill[data-s="in progress"],.pill[data-s=applying],.pill[data-s=auto],.pill[data-s=auto-confirm]{background:var(--hi);color:#141414;border-color:var(--hi)} .pill[data-s=failed],.pill[data-s=err],.pill[data-s=invalid]{color:var(--bad);border-color:var(--bad)} .pill[data-s=pushed],.pill[data-s=waived],.pill[data-s=stale],.pill[data-s=override],.pill[data-s=push-through],.pill[data-s=waiver],.pill[data-s=modified],.pill[data-s=abandoned]{color:var(--warn);border-color:var(--warn)} .pill[data-s=nil],.pill[data-s=todo],.pill[data-s=open],.pill[data-s=planned],.pill[data-s="not checked"],.pill[data-s=not-applied]{color:var(--mut);border-style:dashed} .checks{list-style:none;padding:0;margin:.4rem 0}.checks li{padding:.35rem 0;border-bottom:1px dashed var(--line)}.checks li:last-child{border:0} #current{scroll-margin-top:1rem}details>summary{list-style-position:outside} @media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}} @media print{button,.tabs,form{display:none}.step,.stage{break-inside:avoid}} """ def page(title: str, body: str) -> bytes: return (f"" f"
{UP.pill('v0.2')} {UP.maturity_pill(p)}
{e(d.get('outcome', ''))}
" f"{e(score_line(R.scorecard(p)))}
" f"
{e(x.get('help', ''))} Never enter secrets.
{e(d['exercise_status'])}
" f"{e(d.get('outcome', ''))}
{e(score_line(R.scorecard(p)))}
" f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}{e(head + c['output'][:4000])}captured {e(c['at'][:19])} ({e(c['source'])})
" if finished or state == "todo": return f"Run finished: {e(outcome)}{note}
" if outcome == "succeeded": if r.data.get("exercise_recorded"): box += f"Pack recorded as {e(r.data['exercise_recorded'])}. Commit the change in the owning repository.
" else: old, new = R.exercise_update(r) line = new.split("exercise_status:", 1)[1].split("\n", 1)[0].strip() box += (f"Current pack status: {e(p.data['exercise_status'])}. "
f"Recording this run would set it to {e(line)} in {e(p.path)} "
"(a reviewable edit; commit it in the owning repository).
Finishing records the outcome in the pack's score. A finished run is read-only. " "Unfinished runs count as stalled, and as abandoned after a week.
" f"Thank you. Recorded: " f"{face} {e(fb['comment'] or '(no comment)')}
" "" f"{e(d['exercise_status'])}
" f"run {e(rid)} · operator {e(r.data['operator'])}{where}
"] if msg: out.append(f"{e(msg)}
") blocked = False waiting_for = "" finished = bool(r.data.get("outcome")) total = len(d["steps"]) 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 = "" 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"{e(cmd)}" # kept as a record; no Copy once done
url = f"/run/{i}/{e(rid)}/{e(s['id'])}"
v = st.get("verification")
if state == "todo" and not blocked:
act = f""
elif state == "todo":
act = f"Waiting for: {e(waiting_for)}
" elif state == "running": ver = s["verify"] if ver.get("expect", "manual") == "manual": act = (f"") else: act = (f"") if not v: act = "1. Run the command, then check → 2. Mark done to lock it in
" + act elif v["passed"]: 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 x in s.get("evidence", [])) ev = f"{ev}
" if ev else "" done_btn = f" " if v and v["passed"] else "" act += (f"") elif state == "failed": act = f"Failed: {e(st.get('note') or '(no comment)')}
" elif state == "done" and st.get("note"): act = f"Comment: {e(st['note'])}
" if finished and state != "done": act = "" # a finished run is read-only 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") and state in ("running", "failed") else "" anchor = " id=current" if s["id"] == current else "" fold = "" if state in ("done", "skipped") else " open" # finished steps start folded; the triangle toggles out.append(f"Done when: {e(s['verify']['done_when'])}
{rb}{act}All steps done. To record this run as exercising the pack, review and apply: "
f"python -m rtut run finish {e(p.path)} {e(rid)} --apply
Not found.
"), 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[0] == "run" and len(parts) >= 4 and parts[1].isdigit() and app.packs[int(parts[1])].spec == "runbook-pack/v0.2": return self._redirect(UI2.handle_post(app, int(parts[1]), parts[2], parts[3:], form)) if len(parts) == 3 and parts[0] == "pack" and parts[2] == "review": from . import maturity as M i = int(parts[1]) try: M.add_review(app.packs[i], form.get("by", ""), form.get("note", "")) except M.ReviewError as ex: raise ValueError(str(ex)) return self._redirect(f"/pack/{i}#maturity") if len(parts) == 3 and parts[0] == "project" and parts[2] in ("attributes", "run"): from . import project as J reg = app.registry(); doc = reg.projects[parts[1]] if parts[2] == "attributes": J.set_attributes(reg, doc, {k[2:]: v for k, v in form.items() if k.startswith("a_")}) return self._redirect(f"/project/{parts[1]}") r = J.start_run(reg, doc, form.get("pack", ""), form.get("operator", ""), form.get("target") or None, form.get("mode", "manual")) i = app.pack_index(form.get("pack", "")) return self._redirect(f"/run/{i}/{r.data['run_id']}#current") if len(parts) == 3 and parts[0] == "template" and parts[2] == "new": pid = UP.create_from_template(app, parts[1], form) return self._redirect(f"/project/{pid}") if parts[:2] == ["run", "new"]: i = int(parts[2]); p = app.packs[i] if p.spec == "runbook-pack/v0.2": r = UI2.create_run(app, i, form) return self._redirect(f"/run/{i}/{r.data['run_id']}#current") 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']}#current") if len(parts) == 4 and parts[0] == "run" and parts[3] == "feedback": i, rid = int(parts[1]), parts[2] R.add_feedback(R.load(app.packs[i], rid), form.get("rating", ""), form.get("comment", "")) return self._redirect(f"/run/{i}/{rid}#feedback") if len(parts) == 4 and parts[0] == "run" and parts[3] in ("finalize", "exercise"): i, rid = int(parts[1]), parts[2] r = R.load(app.packs[i], rid) if parts[3] == "finalize": R.finalize(r, form.get("outcome", ""), form.get("note", "")) else: R.apply_exercise(r) app.packs[i] = P.load(app.packs[i].path) # pick up the new exercise status return self._redirect(f"/run/{i}/{rid}#close") 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_")}, form.get("note", "")) elif action == "fail": R.fail(r, sid, form.get("note", "")) elif action == "capture": R.add_capture(r, sid, form.get("output", ""), form.get("command", "")) elif action == "verify-pasted": R.verify_pasted(r, sid, form.get("output", ""), form.get("command", "")) elif action == "retry": R.retry(r, sid) return self._redirect(f"/run/{i}/{rid}#current") except (R.RunError, KeyError, ValueError, IndexError) as ex: msg = str(ex) try: if parts[0] == "project": return self._send(UP.project_page(app, parts[1], msg) or page("Error", f"{e(msg)}
"), 400) if parts[0] == "template": return self._send(UP.template_page(app, parts[1], msg) or page("Error", f"{e(msg)}
"), 400) if parts[0] == "pack": return self._send(app.pack_page(int(parts[1])).replace(b"{e(msg)}
{e(msg)}
{e(msg)}
"), 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