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
274 lines
15 KiB
Python
274 lines
15 KiB
Python
"""UI pages for projects, templates, attributes and maturity. Server-rendered; no script needed."""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
from pathlib import Path
|
|
|
|
from . import maturity as M, project as J, run as R
|
|
from .attrs import fold, set_layers
|
|
|
|
|
|
def e(s) -> str:
|
|
return html.escape(str(s))
|
|
|
|
|
|
def pill(text: str) -> str:
|
|
return f"<span class=pill data-s='{e(text)}'>{e(text)}</span>"
|
|
|
|
|
|
def _runnable_here(pack) -> bool:
|
|
return pack.ok and pack.data.get("engine") == "native"
|
|
|
|
|
|
# ---- maturity ------------------------------------------------------------------------------------------------
|
|
|
|
def maturity_pill(pack) -> str:
|
|
if not pack.ok or pack.data.get("engine") != "native":
|
|
return ""
|
|
m = M.maturity(pack)
|
|
label = m["level"] + (f" (was {m['stale_from']})" if m["stale_from"] else "")
|
|
return pill(label)
|
|
|
|
|
|
def idempotency_text(pack) -> str:
|
|
if pack.spec != "runbook-pack/v0.2":
|
|
return "not declared (v0.1 packs cannot declare it)"
|
|
from .flow import pack_idempotency
|
|
return pack_idempotency(pack.data)
|
|
|
|
|
|
def maturity_block(app, i: int, pack) -> str:
|
|
m = M.maturity(pack)
|
|
ev = m["evidence"]
|
|
revs = "".join(f"<li>{e(r['by'])} · {e(r['at'][:10])} · {'current revision' if r['digest'] == m['digest'] else 'older revision'}"
|
|
f"{' · ' + e(r['note']) if r.get('note') else ''}</li>" for r in M._reviews(pack))
|
|
stale = f" It was <b>{e(m['stale_from'])}</b> before the pack changed." if m["stale_from"] else ""
|
|
return (f"<div id=maturity class=step><h2>Maturity {maturity_pill(pack)}</h2>"
|
|
f"<p class=note>Earned from run history at revision <code>{e(m['digest'])}</code>; independent of any one environment.{stale}</p>"
|
|
f"<p>Reviewed: <b>{'yes' if ev['reviewed'] else 'no'}</b> · clean runs: <b>{ev['clean_runs']}</b> "
|
|
f"(in rehearsal: {ev['rehearsal_runs']}) · environments: {e(', '.join(ev['environments']) or '-')} · "
|
|
f"operators: {e(', '.join(ev['operators']) or '-')}</p>"
|
|
"<p class=note>proven = reviewed + rehearsed + clean in two environments by two operators. Runs without a project "
|
|
"context, and runs with deviations, do not count.</p>"
|
|
f"<p>Idempotency: <b>{e(idempotency_text(pack))}</b></p>"
|
|
f"{'<ul>' + revs + '</ul>' if revs else ''}"
|
|
f"<form method=post action='/pack/{i}/review'>{app.hidden()}{app.who('by')}<p class=row>"
|
|
"<input name=note class=grow placeholder='review note (optional)'> "
|
|
"<button>Record my review of this revision</button></p></form></div>")
|
|
|
|
|
|
# ---- index sections ---------------------------------------------------------------------------------------------
|
|
|
|
def index_sections(app, home: bool = False) -> str:
|
|
reg = app.registry()
|
|
prj = []
|
|
for pid, d in reg.projects.items():
|
|
if not d.ok:
|
|
prj.append(f"<li class=bad>{e(pid)}: invalid ({e(d.errors[0])})</li>")
|
|
continue
|
|
errs = J.validate(reg, d)
|
|
st = pill("invalid") if errs else pill(J.state(reg, d)["state"])
|
|
origin = (f" <span class=note>from template <a href='/template/{e(d.data['template'])}'>{e(d.data['template'])}</a></span>"
|
|
if d.data.get("template") else "")
|
|
desc = f"<br><span class=note>{e(d.data['description'])}</span>" if d.data.get("description") else ""
|
|
prj.append(f"<li><a href='/project/{e(pid)}'>{e(d.data['title'])}</a> {pill(d.data['environment'])} {st}{origin}{desc}</li>")
|
|
tpl = [f"<li><a href='/template/{e(tid)}'>{e(d.data['title'])}</a> "
|
|
f"<span class=note>{e(d.data.get('goal', ''))}</span></li>" if d.ok else f"<li class=bad>{e(tid)}: invalid</li>"
|
|
for tid, d in reg.templates.items()]
|
|
from .ui import heading
|
|
link = "/projects" if home else None
|
|
out = f"{heading('h1', 'Projects', link)}<ul>{''.join(prj) or '<li class=note>No projects yet. Start one from a template.</li>'}</ul>"
|
|
out += f"{heading('h2', 'Project templates', link)}<ul>{''.join(tpl) or '<li class=note>No templates found.</li>'}</ul>"
|
|
return out
|
|
|
|
|
|
# ---- attribute tables ----------------------------------------------------------------------------------------------
|
|
|
|
def attribute_table(rows: list[dict]) -> str:
|
|
body = ""
|
|
for r in rows:
|
|
value = "" if r["value"] is None else str(r["value"])
|
|
if r["value"] is None and "default" in r:
|
|
value = f"(runbook default: {r['default']})"
|
|
cls = " class=bad" if r["state"] == "err" or (r["state"] == "nil" and r["requirement"] == "required" and "default" not in r) else ""
|
|
source = e(r["source"]) if r["source"] else "-"
|
|
body += (f"<tr{cls}><td>{e(r['label'])} <span class=note>({e(r['id'])})</span></td><td>{e(value)}</td>"
|
|
f"<td>{pill(r['state'])}{' ' + pill('modified') if r['modified'] else ''}</td><td>{e(r['requirement'])}</td>"
|
|
f"<td>{source}</td><td>{e(r.get('error', ''))}</td></tr>")
|
|
return ("<table><tr><th>Attribute</th><th>Value</th><th>State</th><th>Requirement</th><th>Source</th><th>Problem</th></tr>"
|
|
f"{body}</table>")
|
|
|
|
|
|
# ---- project page ----------------------------------------------------------------------------------------------------
|
|
|
|
def project_page(app, pid: str, msg: str = "") -> bytes | None:
|
|
from .ui import page
|
|
reg = app.registry()
|
|
doc = reg.projects.get(pid)
|
|
if doc is None:
|
|
return None
|
|
d = doc.data
|
|
out = [f"<h1>{e(d.get('title', pid))}</h1>"]
|
|
if msg:
|
|
out.append(f"<p class=bad>{e(msg)}</p>")
|
|
errs = J.validate(reg, doc)
|
|
if errs:
|
|
out.append("<div class=step><p class=bad>This project has problems:</p><ul>" + "".join(f"<li>{e(x)}</li>" for x in errs) + "</ul></div>")
|
|
return page(d.get("title", pid), "".join(out))
|
|
st = J.state(reg, doc)
|
|
out.append(f"<p>{pill(d['environment'])} {pill(st['state'])}"
|
|
f"{' <span class=note>from template <a href=' + chr(39) + '/template/' + e(d['template']) + chr(39) + '>' + e(d['template']) + '</a></span>' if d.get('template') else ''}</p>")
|
|
if d.get("description"):
|
|
out.append(f"<p>{e(d['description'])}</p>")
|
|
if d.get("goal"):
|
|
out.append(f"<p><b>Goal:</b> {e(d['goal'])}</p>")
|
|
out.append(f"<details class=override><summary>Edit name, description and goal</summary>"
|
|
f"<form method=post action='/project/{e(pid)}/edit'>{app.hidden()}"
|
|
f"<p><label>Project name<br><input name=title value='{e(d['title'])}' required minlength=3 class=grow style='width:100%'></label></p>"
|
|
f"<p><label>Description<br><textarea name=description rows=2 style='width:100%'>{e(d.get('description', ''))}</textarea></label></p>"
|
|
f"<p><label>Goal<br><input name=goal value='{e(d.get('goal', ''))}' class=grow style='width:100%'></label></p>"
|
|
"<p class=note>The project id and its template stay as they are. Never enter secrets.</p><button>Save</button></form></details>")
|
|
# what is still needed
|
|
needs: dict[str, list[str]] = {}
|
|
for n in st["needs"]:
|
|
needs.setdefault(n["attribute"], []).append(n["pack"])
|
|
if needs:
|
|
fields = "".join(f"<p><label>{e(a)}<br><input name='a_{e(a)}'></label> <span class=note>needed by {e(', '.join(packs))}</span></p>"
|
|
for a, packs in needs.items())
|
|
out.append(f"<div class=step><h2>Still needed</h2><p class=note>Required attributes without a value. Never enter secrets.</p>"
|
|
f"<form method=post action='/project/{e(pid)}/attributes'>{app.hidden()}{fields}<button>Save attributes</button></form></div>")
|
|
# members
|
|
rows = ""
|
|
by_pack = {m["pack"]: m for m in st["members"]}
|
|
for spec in d["runbooks"]:
|
|
pack = reg.packs[spec["pack"]]
|
|
ms = next(m for m in st["members"] if m["pack"] == spec["pack"] and m["target"] == spec.get("target"))
|
|
i = app.pack_index(spec["pack"])
|
|
wait = [a for a in spec.get("after", []) if by_pack[a]["status"] != "applied"]
|
|
needed = sorted({n["attribute"] for n in st["needs"] if n["pack"] == spec["pack"]})
|
|
if not _runnable_here(pack):
|
|
action = "<span class=note>cannot be run in this UI yet</span>"
|
|
elif needed:
|
|
action = f"<span class=note>needs: {e(', '.join(needed))}</span>"
|
|
elif wait:
|
|
action = f"<span class=note>waiting for {e(', '.join(wait))}</span>"
|
|
else:
|
|
mode = ("<select name=mode><option>manual</option><option>auto-confirm</option></select> "
|
|
if pack.spec == "runbook-pack/v0.2" else "")
|
|
action = (f"<form method=post action='/project/{e(pid)}/run' class=row>{app.hidden()}"
|
|
f"<input type=hidden name=pack value='{e(spec['pack'])}'><input type=hidden name=target value='{e(spec.get('target') or '')}'>"
|
|
f"<input type=hidden name=operator value='{e(app.operator())}'> {mode}<button>Start run as {e(app.operator())}</button></form>")
|
|
link = f" <a href='/run/{i}/{e(ms['run'])}'>latest run</a>" if ms.get("run") and i is not None else ""
|
|
dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else ""
|
|
rows += (f"<tr><td>{e(pack.data.get('title', pack.id))}<br><span class=note>{e(spec['pack'])}"
|
|
f"{' @' + e(spec['target']) if spec.get('target') else ''}</span></td>"
|
|
f"<td>{pill(ms['status'])}{e(dev)}{link}</td><td>{maturity_pill(pack)}</td><td>{action}</td></tr>")
|
|
out.append("<h2>Runbooks</h2>"
|
|
f"<p class=note>Next: {e(', '.join(st['next']) or 'nothing to start')}</p>"
|
|
"<table><tr><th>Runbook</th><th>Status</th><th>Maturity</th><th></th></tr>" + rows + "</table>")
|
|
from . import ui_projectrun as UPR
|
|
out.append(UPR.project_section(app, reg, doc, st["needs"]))
|
|
# attributes per member
|
|
out.append("<h2>Attributes</h2><p class=note>What each runbook receives from this project, and where each value comes from.</p>")
|
|
for spec in d["runbooks"]:
|
|
res = J.member_attributes(reg, d, spec)
|
|
unused = f"<p class=note>Unused attributes: {e(', '.join(res['unused']))}</p>" if res["unused"] else ""
|
|
out.append(f"<details><summary><b>{e(reg.packs[spec['pack']].data.get('title', spec['pack']))}</b>"
|
|
f"{' @' + e(spec['target']) if spec.get('target') else ''}</summary>{attribute_table(res['rows'])}{unused}</details>")
|
|
if d.get("targets"):
|
|
out.append("<h2>Targets</h2><ul>" + "".join(f"<li><b>{e(t)}</b> {e(v.get('title', ''))}</li>" for t, v in d["targets"].items()) + "</ul>")
|
|
return page(d["title"], "".join(out))
|
|
|
|
|
|
# ---- template page ----------------------------------------------------------------------------------------------------
|
|
|
|
def template_page(app, tid: str, msg: str = "") -> bytes | None:
|
|
from .ui import page
|
|
reg = app.registry()
|
|
doc = reg.templates.get(tid)
|
|
if doc is None:
|
|
return None
|
|
d = doc.data
|
|
out = [f"<h1>{e(d.get('title', tid))}</h1><p>{pill('template')}</p>"]
|
|
if msg:
|
|
out.append(f"<p class=bad>{e(msg)}</p>")
|
|
if not doc.ok:
|
|
out.append("<p class=bad>This template is invalid:</p><ul>" + "".join(f"<li>{e(x)}</li>" for x in doc.errors) + "</ul>")
|
|
return page(d.get("title", tid), "".join(out))
|
|
if d.get("goal"):
|
|
out.append(f"<p>{e(d['goal'])}</p>")
|
|
layers = []
|
|
try:
|
|
for s in d.get("attribute_sets", []):
|
|
layers += set_layers(reg, s)
|
|
except ValueError as ex:
|
|
out.append(f"<p class=bad>{e(ex)}</p>")
|
|
from .attrs import normalize
|
|
layers.append(("template", normalize(d.get("attributes"))))
|
|
folded = fold(layers)
|
|
members = "".join(f"<li>{e(m['pack'])}{' @' + e(m['target']) if m.get('target') else ''}"
|
|
f"{' (after ' + e(', '.join(m['after'])) + ')' if m.get('after') else ''}</li>" for m in d["runbooks"])
|
|
out.append(f"<h2>Runbooks</h2><ul>{members}</ul>")
|
|
fields = ""
|
|
for name, a in folded.items():
|
|
req = a["requirement"] or "optional"
|
|
val = "" if a["value"] is None else a["value"]
|
|
note = f" - {e(a['note'])}" if a.get("note") else ""
|
|
fields += (f"<p><label>{e(name)} {pill(req)}<br><input name='a_{e(name)}' value='{e(val)}'"
|
|
f"{' required' if req == 'required' and not val else ''}></label>"
|
|
f"<span class=note>{note}{' from ' + e(a['source']) if a['source'] else ''}</span></p>")
|
|
envs = "".join(f"<option>{e(x)}</option>" for x in R.ENVIRONMENTS)
|
|
out.append("<h2>Start a project from this template</h2>"
|
|
f"<form method=post action='/template/{e(tid)}/new'>{app.hidden()}"
|
|
"<p><label>Project name<br><input name=title class=grow style='width:100%' minlength=3 required "
|
|
"placeholder='what you call this project'></label></p>"
|
|
"<p><label>Description<br><textarea name=description rows=2 style='width:100%' "
|
|
"placeholder='what this project is (optional)'></textarea></label></p>"
|
|
"<p><label>Goal<br><input name=goal class=grow style='width:100%' "
|
|
"placeholder='what should be true when it is done (optional)'></label></p>"
|
|
"<p><label>Project id<br><input name=project_id pattern='[a-z0-9]+([.-][a-z0-9]+)*' required "
|
|
"placeholder='lower-case, digits, dots, hyphens'></label></p>"
|
|
f"<p><label>Environment<br><select name=environment>{envs}</select></label></p>"
|
|
"<h3>Attributes</h3><p class=note>Values you leave as they are stay defaults; required ones must be given before the "
|
|
f"runbooks can start. Never enter secrets.</p>{fields}<button>Create project</button></form>")
|
|
return page(d["title"], "".join(out))
|
|
|
|
|
|
# ---- actions ------------------------------------------------------------------------------------------------------------------
|
|
|
|
def create_from_template(app, tid: str, form: dict) -> str:
|
|
"""Instantiate a template; returns the new project id."""
|
|
reg = app.registry()
|
|
doc = reg.templates.get(tid)
|
|
if doc is None or not doc.ok:
|
|
raise ValueError(f"no valid template {tid}")
|
|
layers = []
|
|
for s in doc.data.get("attribute_sets", []):
|
|
layers += set_layers(reg, s)
|
|
from .attrs import normalize
|
|
layers.append(("template", normalize(doc.data.get("attributes"))))
|
|
base = fold(layers)
|
|
values = {}
|
|
for k, v in form.items():
|
|
if not k.startswith("a_"):
|
|
continue
|
|
name, v = k[2:], v.strip()
|
|
if v and str(base.get(name, {}).get("value")) != v:
|
|
values[name] = v
|
|
from .pack import SECRET_RE
|
|
for name, v in values.items():
|
|
if SECRET_RE.search(v):
|
|
raise ValueError(f"{name}: value looks like a secret; attributes are never secret")
|
|
pid = form.get("project_id", "").strip()
|
|
if len(form.get("title", "").strip()) < 3:
|
|
raise ValueError("give the project a name of at least 3 characters")
|
|
J.instantiate(reg, tid, pid, form.get("environment", ""), values, title=form.get("title", ""),
|
|
root=doc.path.parent.parent, description=form.get("description", ""), goal=form.get("goal", ""))
|
|
reg = app.registry()
|
|
created = reg.projects[pid]
|
|
errs = J.validate(reg, created)
|
|
if errs:
|
|
created.path.unlink()
|
|
created.path.parent.rmdir()
|
|
raise ValueError(f"project would be invalid: {errs[0]}")
|
|
return pid
|