UI for projects, templates, attributes and maturity (RBT-WP-0008-T08); stop a test from writing runs into the repo
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 6s
Work Records / validate (push) Has been cancelled

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 13:12:47 +02:00
parent 3470552789
commit eacbf8a21d
9 changed files with 560 additions and 12 deletions

View file

@ -11,6 +11,8 @@ 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 .attrs import Registry
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}}
@ -19,7 +21,7 @@ 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}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top}summary{cursor:pointer}.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}"""
button,input,select{font:inherit;padding:.25rem .5rem}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top}summary{cursor:pointer}.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}table{border-collapse:collapse;width:100%;margin:.5rem 0}td,th{border-bottom:1px solid #8884;padding:.3rem .5rem;text-align:left;vertical-align:top}"""
def page(title: str, body: str) -> bytes:
@ -52,9 +54,17 @@ def score_line(c: dict) -> str:
class App:
def __init__(self, roots: list[Path]):
self.roots = [Path(r) for r in roots]
self.token = secrets.token_urlsafe(16)
self.packs = [P.load(p) for r in roots for p in P.discover(r)]
def registry(self) -> Registry:
"""Fresh view of projects, attribute sets, templates and packs (files may have changed)."""
return Registry(self.roots)
def pack_index(self, pack_id: str) -> int | None:
return next((i for i, p in enumerate(self.packs) if p.id == pack_id), None)
def hidden(self) -> str:
return f"<input type=hidden name=_t value='{self.token}'>"
@ -71,10 +81,9 @@ class App:
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> "
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> {UP.maturity_pill(p)} "
f"<span class=note>{e(score_line(R.scorecard(p)))}</span></li>")
return page("Runbooks", f"<h1>Runbooks</h1><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
return page("Runbooks", UP.index_sections(self) + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
def pack_page(self, i: int) -> bytes:
p = self.packs[i]; d = p.data
@ -88,7 +97,7 @@ class App:
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><p class=note>{e(score_line(R.scorecard(p)))}</p>"
f"{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, 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>")
@ -172,8 +181,16 @@ class App:
def run_page(self, i: int, rid: str, msg: str = "") -> bytes:
p = self.packs[i]; r = R.load(p, rid); d = p.data
if p.spec != "runbook-pack/v0.1":
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=bad>This run uses {e(p.spec)}. The UI cannot walk "
"v0.2 runs yet (RBT-WP-0004); the engine and CLI can.</p><p><a href='/'>← Back</a></p>")
ctx = r.data.get("context") or {}
where = ""
if ctx.get("project"):
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
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>"]
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"]
if msg:
out.append(f"<p class=bad>{e(msg)}</p>")
blocked = False
@ -280,6 +297,14 @@ def make_handler(app: App, port_holder: list):
return self._send(app.pack_page(int(parts[1])))
if parts[0] == "run":
return self._send(app.run_page(int(parts[1]), parts[2]))
if parts[0] == "project" and len(parts) == 2:
body = UP.project_page(app, parts[1])
if body:
return self._send(body)
if parts[0] == "template" and len(parts) == 2:
body = UP.template_page(app, parts[1])
if body:
return self._send(body)
except (IndexError, ValueError, R.RunError):
pass
self._send(page("Not found", "<p>Not found.</p>"), 404)
@ -294,6 +319,26 @@ def make_handler(app: App, port_holder: list):
parts = [x for x in urlparse(self.path).path.split("/") if x]
msg = ""
try:
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)
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]
params = {k[2:]: v for k, v in form.items() if k.startswith("p_")}
@ -327,6 +372,12 @@ def make_handler(app: App, port_holder: list):
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"<p class=bad>{e(msg)}</p>"), 400)
if parts[0] == "template":
return self._send(UP.template_page(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
if parts[0] == "pack":
return self._send(app.pack_page(int(parts[1])).replace(b"<h1>", f"<p class=bad>{e(msg)}</p><h1>".encode(), 1), 400)
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)