Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
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
b7edb8f023
commit
711c4e1149
13 changed files with 381 additions and 42 deletions
60
rtut/ui.py
60
rtut/ui.py
|
|
@ -75,6 +75,7 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
|
|||
.pill[data-s=failed],.pill[data-s=err],.pill[data-s=invalid]{color:var(--bad);border-color:var(--bad)}
|
||||
.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}
|
||||
.hl a{text-decoration:none}.hl a::after{content:" →";opacity:.35;font-weight:400}.hl a:hover::after{opacity:1}
|
||||
.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}
|
||||
|
|
@ -143,6 +144,13 @@ def e(s) -> str:
|
|||
return html.escape(str(s))
|
||||
|
||||
|
||||
def heading(level: str, text: str, href: str | None = None, extra: str = "") -> str:
|
||||
"""A section headline. On the Home page it links to the matching sidebar section, as a shortcut."""
|
||||
if href:
|
||||
return f"<{level} class=hl><a href='{href}'>{html.escape(text)}</a>{extra}</{level}>"
|
||||
return f"<{level}>{html.escape(text)}{extra}</{level}>"
|
||||
|
||||
|
||||
SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor", "🙁", "Poor"))
|
||||
|
||||
|
||||
|
|
@ -177,15 +185,15 @@ class App:
|
|||
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, level: str = "h2") -> str:
|
||||
def activities_html(self, limit: int = 12, level: str = "h2", link: bool = False) -> str:
|
||||
events = ACT.latest(self.packs, limit)
|
||||
if not events:
|
||||
return f"<{level}>Latest activities</{level}><p class=note>Nothing has happened yet. Start a run or a project.</p>"
|
||||
return f"{heading(level, 'Latest activities', '/activity' if link else None)}<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"<{level}>Latest activities <span class=note style='font-weight:400;font-size:.85rem'>(times in UTC)</span></{level}>"
|
||||
return (f"{heading(level, 'Latest activities', '/activity' if link else None, ' <span class=note style=' + chr(39) + 'font-weight:400;font-size:.85rem' + chr(39) + '>(times in UTC)</span>')}"
|
||||
f"<ul class=feed>{rows}</ul>")
|
||||
|
||||
def user_page(self, msg: str = "") -> bytes:
|
||||
|
|
@ -209,7 +217,7 @@ class App:
|
|||
return f"<input type=hidden name=_t value='{self.token}'>"
|
||||
|
||||
def index(self) -> bytes:
|
||||
return page("Home", UP.index_sections(self) + self.activities_html() + self.runbooks_html())
|
||||
return page("Home", UP.index_sections(self, home=True) + self.activities_html(link=True) + self.runbooks_html(link=True))
|
||||
|
||||
def runbooks_page(self) -> bytes:
|
||||
return page("Runbooks", self.runbooks_html("h1"))
|
||||
|
|
@ -220,7 +228,7 @@ class App:
|
|||
def activity_page(self) -> bytes:
|
||||
return page("Activity", self.activities_html(100, "h1"))
|
||||
|
||||
def runbooks_html(self, level: str = "h2") -> str:
|
||||
def runbooks_html(self, level: str = "h2", link: bool = False) -> str:
|
||||
rows = []
|
||||
for i, p in enumerate(self.packs):
|
||||
d = p.data
|
||||
|
|
@ -232,7 +240,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 f"<{level}>Runbooks</{level}><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>"
|
||||
return f"{heading(level, 'Runbooks', '/runbooks' if link else None)}<ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>"
|
||||
|
||||
def pack_page(self, i: int) -> bytes:
|
||||
p = self.packs[i]; d = p.data
|
||||
|
|
@ -291,7 +299,7 @@ class App:
|
|||
outcome = r.data.get("outcome")
|
||||
if outcome:
|
||||
note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else ""
|
||||
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>"
|
||||
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
|
||||
if outcome == "succeeded":
|
||||
if r.data.get("exercise_recorded"):
|
||||
box += f"<p>Pack recorded as <b>{e(r.data['exercise_recorded'])}</b>. Commit the change in the owning repository.</p>"
|
||||
|
|
@ -315,7 +323,7 @@ class App:
|
|||
"<p class=note>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.</p>"
|
||||
f"<form method=post action='/run/{i}/{e(rid)}/finalize'>{self.hidden()}"
|
||||
"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>"
|
||||
f"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>{self.close_feedback_fields(r)}"
|
||||
f"<p class=row>{' '.join(btns)}</p></form></div>")
|
||||
|
||||
def feedback_form(self, i: int, rid: str, r) -> str:
|
||||
|
|
@ -328,14 +336,41 @@ class App:
|
|||
"Never include secrets.</p>"
|
||||
f"<p><textarea name=comment rows=3 style='width:100%' placeholder='optional comment'>"
|
||||
f"{e(fb['comment']) if fb else ''}</textarea></p><p class=row>{btns}</p></form>")
|
||||
if fb: # collected: close the section, suggest the next step
|
||||
if r.data.get("outcome"): # a closed run is locked: show what was said, offer no change
|
||||
if not fb:
|
||||
return ""
|
||||
face = next(f for k, f, _ in SMILEYS if k == fb["rating"])
|
||||
return (f"<div id=feedback class=step><h2>Feedback</h2><p>{face} {e(fb['comment'] or '(no comment)')}</p>"
|
||||
"<p class=note>The run is closed and locked, so its feedback can no longer be changed.</p></div>")
|
||||
if fb: # collected while the run is open: close the section
|
||||
face = next(f for k, f, _ in SMILEYS if k == fb["rating"])
|
||||
return ("<div id=feedback class=step><p><b>Thank you.</b> Recorded: "
|
||||
f"{face} {e(fb['comment'] or '(no comment)')}</p>"
|
||||
"<p><a href='/'>← Back to the runbook list</a></p>"
|
||||
f"<details><summary class=note>Change my feedback</summary>{form}</details></div>")
|
||||
return f"<div id=feedback class=step><h2>How was this runbook?</h2>{form}</div>"
|
||||
|
||||
def close_feedback_fields(self, r) -> str:
|
||||
"""Optional rating and comment inside the closing form: a closed run cannot take feedback afterwards."""
|
||||
if R.latest_feedback(r):
|
||||
return ""
|
||||
faces = " ".join(f"<label title='{e(t)}'><input type=radio name=rating value={k}> <span style='font-size:1.4rem'>{face}</span></label>"
|
||||
for k, face, t in SMILEYS)
|
||||
return ("<fieldset><legend>How was this runbook? <span class=note>(optional; it cannot be added after closing)</span></legend>"
|
||||
f"<p class=row>{faces}</p><textarea name=fb_comment rows=2 style='width:100%' "
|
||||
"placeholder='anything strange or unclear, or an idea to make it better (no secrets)'></textarea></fieldset>")
|
||||
|
||||
def locked_note(self, r) -> str:
|
||||
"""What 'closed' means: the record is sealed and locked. A record changed outside the app is called out."""
|
||||
state = R.seal_state(r)
|
||||
if state == "broken":
|
||||
return ("<p class=bad>⚠ This closed run's record was changed outside the app after it was sealed. "
|
||||
"It no longer counts towards maturity.</p>")
|
||||
base = ("This run is <b>closed and locked</b>: its record is sealed and cannot be changed. "
|
||||
"Start a new run to try again.")
|
||||
if state == "unsealed":
|
||||
base += " (It was closed before sealing existed, so its record carries no seal.)"
|
||||
return f"<p class=note>{base}</p>"
|
||||
|
||||
def run_page(self, i: int, rid: str, msg: str = "", query: dict | None = None) -> bytes:
|
||||
p = self.packs[i]; r = R.load(p, rid); d = p.data
|
||||
if p.spec == "runbook-pack/v0.2":
|
||||
|
|
@ -424,7 +459,7 @@ class App:
|
|||
if r.complete:
|
||||
out.append("<p><b>All steps done.</b> To record this run as exercising the pack, review and apply: "
|
||||
f"<code>python -m rtut run finish {e(p.path)} {e(rid)} --apply</code></p>")
|
||||
if r.complete:
|
||||
if r.complete or r.data.get("outcome") or R.latest_feedback(r):
|
||||
out.append(self.feedback_form(i, rid, r))
|
||||
out.append(self.close_run_form(i, rid, r, p))
|
||||
out.append("<h2>Threat checks</h2><ul>" + "".join(f"<li>{e(t)}</li>" for t in d["threat_checks"]) + "</ul>")
|
||||
|
|
@ -567,7 +602,8 @@ def make_handler(app: App, port_holder: list):
|
|||
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", ""))
|
||||
R.finalize(r, form.get("outcome", ""), form.get("note", ""), rating=form.get("rating") or None,
|
||||
comment=form.get("fb_comment", ""))
|
||||
else:
|
||||
R.apply_exercise(r)
|
||||
app.packs[i] = P.load(app.packs[i].path) # pick up the new exercise status
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue