Active user attribute set, latest activities, guarded changes to used values, artefact links, durations and averages; irreversible showcase 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
6716cee921
commit
8d62c1fbe8
22 changed files with 1025 additions and 35 deletions
94
rtut/ui.py
94
rtut/ui.py
|
|
@ -13,6 +13,7 @@ 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 . import activity as ACT, user as USER
|
||||
from .attrs import Registry
|
||||
|
||||
CSS = """
|
||||
|
|
@ -65,8 +66,9 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
|
|||
.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=changed-after-use],.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}
|
||||
.feed{list-style:none;padding:0}.feed li{padding:.3rem 0;border-bottom:1px dashed var(--line)}
|
||||
.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}}
|
||||
|
|
@ -98,8 +100,12 @@ SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor",
|
|||
|
||||
|
||||
def score_line(c: dict) -> str:
|
||||
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
|
||||
line = (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
|
||||
f"abandoned {c['abandoned']} · stalled {c['stalled']}")
|
||||
if c.get("avg_seconds") is not None:
|
||||
line += (f" · average {R.fmt_duration(c['avg_seconds'])} per successful run"
|
||||
f" (fastest {R.fmt_duration(c['fastest_seconds'])}, {c['timed_runs']} timed)")
|
||||
return line
|
||||
|
||||
|
||||
class App:
|
||||
|
|
@ -109,8 +115,44 @@ class App:
|
|||
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)
|
||||
"""Fresh view of projects, attribute sets, templates and packs (files may have changed).
|
||||
|
||||
The active user is available to every project as the app-level attribute set `user-attributes`."""
|
||||
return Registry(self.roots, extra_sets={USER.USER_SET_ID: USER.attribute_set()})
|
||||
|
||||
def operator(self) -> str:
|
||||
return USER.current()["operator"]
|
||||
|
||||
def who(self, name: str = "operator") -> str:
|
||||
"""The active user, as a hidden field and a line, instead of asking for a name again."""
|
||||
u = USER.current()
|
||||
label = f"{e(u['display_name'])} ({e(u['operator'])})" if u["display_name"] else e(u["operator"])
|
||||
return (f"<input type=hidden name={name} value='{e(u['operator'])}'>"
|
||||
f"<p class=note>Running as <b>{label}</b> · <a href='/user'>change</a></p>")
|
||||
|
||||
def activities_html(self, limit: int = 12) -> str:
|
||||
events = ACT.latest(self.packs, limit)
|
||||
if not events:
|
||||
return "<h2>Latest activities</h2><p class=note>Nothing has happened yet. Start a run or a project.</p>"
|
||||
rows = "".join(
|
||||
f"<li><span class=note>{e(x['at'][:16].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])} "
|
||||
f"<span class=note>· {e(x['pack'])}{' · project ' + e(x['project']) if x.get('project') else ''}</span>"
|
||||
f"{' · <a href=' + chr(39) + x['link'] + chr(39) + '>open</a>' if x['link'] else ''}</li>" for x in events)
|
||||
return f"<h2>Latest activities</h2><ul class=feed>{rows}</ul>"
|
||||
|
||||
def user_page(self, msg: str = "") -> bytes:
|
||||
u = USER.current()
|
||||
env = ("<p class=note>The operator is set by the <code>RTUT_USER</code> environment variable, which takes precedence over "
|
||||
"anything saved here.</p>" if u["source"] == "environment" else "")
|
||||
src = {"environment": "the environment", "saved": "your saved settings", "login": "your operating-system login"}[u["source"]]
|
||||
return page("You", f"<h1>You</h1>{'<p class=bad>' + e(msg) + '</p>' if msg else ''}"
|
||||
f"<p>Runs, reviews and activity are recorded under <b>{e(u['operator'])}</b>, taken from {src}.</p>{env}"
|
||||
"<p class=note>These are the attributes of the app-level set <code>user-attributes</code>. Every project can reuse it "
|
||||
"(<code>attribute_sets: [user-attributes]</code>). If the UI later sits behind a login, the login fills the same attribute.</p>"
|
||||
f"<form method=post action='/user'>{self.hidden()}"
|
||||
f"<p><label>Operator (one word)<br><input name=operator value='{e(u['operator'])}' required pattern='[A-Za-z0-9][A-Za-z0-9._-]*' maxlength=40></label></p>"
|
||||
f"<p><label>Display name (optional)<br><input name=display_name value='{e(u['display_name'])}' maxlength=80></label></p>"
|
||||
"<button>Save</button></form>")
|
||||
|
||||
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)
|
||||
|
|
@ -130,7 +172,7 @@ class App:
|
|||
else:
|
||||
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", UP.index_sections(self) + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
return page("Runbooks", UP.index_sections(self) + self.activities_html() + 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
|
||||
|
|
@ -152,15 +194,17 @@ class App:
|
|||
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
|
||||
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>")
|
||||
f"{self.who()}<button>Start run</button></form>")
|
||||
|
||||
def runs_list(self, i: int, p) -> str:
|
||||
rows = []
|
||||
for rid in reversed(R.list_runs(p)):
|
||||
r = R.load(p, rid)
|
||||
t = R.run_times(r)
|
||||
took = (f"took {R.fmt_duration(t['elapsed'])}" if t["finished_run"] else f"running {R.fmt_duration(t['elapsed'])}")
|
||||
rows.append(f"<li><a href='/run/{i}/{e(rid)}'>{e(rid)}</a> · {e(r.data['operator'])} · "
|
||||
f"<span class=pill>{e(R.effective_outcome(r))}</span></li>")
|
||||
f"<span class=pill data-s='{e(R.effective_outcome(r))}'>{e(R.effective_outcome(r))}</span> "
|
||||
f"<span class=note>{e(UI2.stamp(t['started']))} · {e(took)}</span></li>")
|
||||
return f"<h2>Runs</h2><ul>{''.join(rows)}</ul>" if rows else ""
|
||||
|
||||
def capture_html(self, url: str, s: dict, st: dict, state: str, finished: bool) -> str:
|
||||
|
|
@ -242,7 +286,9 @@ class App:
|
|||
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'])}{where}</p>"]
|
||||
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"
|
||||
f"<p class=note>{UI2.times_line(r)}</p>"]
|
||||
step_secs = R.run_times(r)["step_seconds"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
blocked = False
|
||||
|
|
@ -310,7 +356,8 @@ class App:
|
|||
fold = "" if state in ("done", "skipped") else " open" # finished steps start folded; the triangle toggles
|
||||
out.append(f"<details{anchor} class='step {state}'{fold}><summary><span class=head>"
|
||||
f"<span><b>{n}. {e(s['title'])}</b> "
|
||||
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}</span></span>"
|
||||
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}"
|
||||
f"{' · took ' + e(R.fmt_duration(step_secs[s['id']])) if s['id'] in step_secs and state == 'done' else ''}</span></span>"
|
||||
f"<span class=pill data-s='{state}'>{state}</span></span></summary>{block}"
|
||||
f"{self.capture_html(url, s, st, state, finished)}"
|
||||
f"<p class=note>Done when: {e(s['verify']['done_when'])}</p>{rb}{act}</details>")
|
||||
|
|
@ -335,6 +382,17 @@ def make_handler(app: App, port_holder: list):
|
|||
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 _send_file(self, code: int, body: bytes, ctype: str, download_name: str | None):
|
||||
"""Artefacts: never rendered as a page (plain text or download), never sniffed, sandboxed."""
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
if download_name:
|
||||
safe = "".join(ch for ch in download_name if ch.isalnum() or ch in "._-") or "artefact"
|
||||
self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"")
|
||||
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"))
|
||||
|
||||
|
|
@ -347,6 +405,12 @@ def make_handler(app: App, port_holder: list):
|
|||
return self._send(app.index())
|
||||
if parts[0] == "pack":
|
||||
return self._send(app.pack_page(int(parts[1])))
|
||||
if parts == ["user"]:
|
||||
return self._send(app.user_page())
|
||||
if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact":
|
||||
status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4])
|
||||
want_dl = bool(parse_qs(urlparse(self.path).query).get("download"))
|
||||
return self._send_file(status, body, ctype, dl or ("artefact-" + parts[4] if want_dl and status == 200 else None))
|
||||
if parts[0] == "run":
|
||||
return self._send(app.run_page(int(parts[1]), parts[2], query=parse_qs(urlparse(self.path).query)))
|
||||
if parts[0] == "project" and len(parts) == 2:
|
||||
|
|
@ -369,6 +433,16 @@ def make_handler(app: App, port_holder: list):
|
|||
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]
|
||||
if not form.get("operator", "").strip():
|
||||
form["operator"] = app.operator() # the active user, unless a form names someone
|
||||
if not form.get("by", "").strip():
|
||||
form["by"] = app.operator()
|
||||
if parts == ["user"]:
|
||||
try:
|
||||
USER.save(form.get("operator", ""), form.get("display_name", ""))
|
||||
except ValueError as ex:
|
||||
return self._send(app.user_page(str(ex)), 400)
|
||||
return self._redirect("/user")
|
||||
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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue