App shell with sidebar and theme switch; attribute-set management; project rename; new pages for projects, runbooks and activity
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
a6ee6a0a6e
commit
6579618737
9 changed files with 663 additions and 13 deletions
|
|
@ -132,6 +132,12 @@ Every attribute has:
|
|||
|
||||
Values are never secret (`secret` is not allowed; secret markers are refused). Only references to secrets are allowed.
|
||||
|
||||
### Managing attribute sets in the UI
|
||||
|
||||
The **Attribute sets** section lists every set with where it lives, what it extends and who reuses it (projects, templates, other sets). A set's
|
||||
page shows what it resolves to, with the source of each value and a *modified* mark where a later layer replaced one. Sets stored in a repository
|
||||
can be created and edited there; your own `user-attributes` set is edited on the settings page. Deleting sets is not offered yet.
|
||||
|
||||
### The active user: `user-attributes`
|
||||
|
||||
One attribute set lives in the app rather than in any project: `user-attributes`, holding the active user's `operator` (one word) and an
|
||||
|
|
|
|||
|
|
@ -144,3 +144,88 @@ def check_value(pack: P.Pack, name: str, value) -> str | None:
|
|||
except (ValueError, KeyError) as e:
|
||||
return str(e)
|
||||
return None
|
||||
|
||||
|
||||
# ---- managing attribute sets ---------------------------------------------------------------------------------------
|
||||
|
||||
import re as _re
|
||||
|
||||
SET_ID = _re.compile(r"[a-z0-9]+([.-][a-z0-9]+)*")
|
||||
ATTR_NAME = _re.compile(r"[a-z][a-z0-9_]*")
|
||||
|
||||
|
||||
def used_by(reg: Registry, set_id: str) -> dict[str, list[str]]:
|
||||
"""Who reuses an attribute set: projects and templates (also through targets), and sets that extend it."""
|
||||
out = {"projects": [], "templates": [], "sets": []}
|
||||
for kind, docs in (("projects", reg.projects), ("templates", reg.templates)):
|
||||
for did, d in docs.items():
|
||||
refs = list(d.data.get("attribute_sets", []))
|
||||
for t in d.data.get("targets", {}).values():
|
||||
refs += t.get("attribute_sets", [])
|
||||
if set_id in refs:
|
||||
out[kind].append(did)
|
||||
for sid, d in reg.sets.items():
|
||||
if sid != set_id and set_id in d.data.get("extends", []):
|
||||
out["sets"].append(sid)
|
||||
return out
|
||||
|
||||
|
||||
def _would_cycle(reg: Registry, set_id: str, extends: list[str]) -> bool:
|
||||
def reaches(start: str, seen: set) -> bool:
|
||||
if start == set_id:
|
||||
return True
|
||||
if start in seen or start not in reg.sets:
|
||||
return False
|
||||
seen.add(start)
|
||||
return any(reaches(x, seen) for x in reg.sets[start].data.get("extends", []))
|
||||
return any(reaches(x, set()) for x in extends)
|
||||
|
||||
|
||||
def save_set(reg: Registry, root: Path, set_id: str, title: str, extends: list[str],
|
||||
attributes: dict[str, dict], existing_path: Path | None = None) -> Path:
|
||||
"""Write an attribute set file. `attributes` maps name -> {value, requirement, note} (any part may be empty).
|
||||
|
||||
Validates the id, title, names, secrets, that extended sets exist and that nothing becomes circular.
|
||||
"""
|
||||
if not SET_ID.fullmatch(set_id or ""):
|
||||
raise ValueError("the id is lower-case letters, digits, dots and hyphens")
|
||||
if len((title or "").strip()) < 3:
|
||||
raise ValueError("the title needs at least 3 characters")
|
||||
extends = [x for x in extends if x]
|
||||
for x in extends:
|
||||
if x == set_id:
|
||||
raise ValueError("a set cannot extend itself")
|
||||
if x not in reg.sets:
|
||||
raise ValueError(f"unknown attribute set {x} in extends")
|
||||
if _would_cycle(reg, set_id, extends):
|
||||
raise ValueError("that extends list would make the sets circular")
|
||||
out: dict = {}
|
||||
for name, a in attributes.items():
|
||||
if not ATTR_NAME.fullmatch(name):
|
||||
raise ValueError(f"bad attribute name {name!r}: lower-case letters, digits and underscores, starting with a letter")
|
||||
value = a.get("value")
|
||||
value = None if value is None or str(value).strip() == "" else str(value).strip()
|
||||
req = a.get("requirement") or None
|
||||
if req not in (None, "required", "optional"):
|
||||
raise ValueError(f"{name}: requirement is required or optional")
|
||||
note = (a.get("note") or "").strip() or None
|
||||
if value is None and req is None and note is None:
|
||||
raise ValueError(f"{name}: give a value, a requirement or a note")
|
||||
for text in (value or "", note or ""):
|
||||
if SECRET_RE.search(text):
|
||||
raise ValueError(f"{name}: looks like a secret; attribute sets are never secret")
|
||||
entry = {k: v for k, v in (("value", value), ("requirement", req), ("note", note)) if v is not None}
|
||||
out[name] = entry["value"] if list(entry) == ["value"] else entry
|
||||
if SECRET_RE.search(title):
|
||||
raise ValueError("the title looks like a secret")
|
||||
doc: dict = {"spec": "attribute-set/v0.1", "id": set_id, "title": title.strip()}
|
||||
if extends:
|
||||
doc["extends"] = extends
|
||||
doc["attributes"] = out
|
||||
errs = _schema_errors(doc, "attribute-set.schema.json")
|
||||
if errs:
|
||||
raise ValueError(errs[0])
|
||||
path = existing_path or (Path(root) / "attribute-sets" / f"{set_id}.yaml")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(doc, sort_keys=False))
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -295,3 +295,27 @@ def set_attributes(reg: Registry, doc: Doc, values: dict[str, str]) -> Path:
|
|||
text["attributes"] = attrs
|
||||
doc.path.write_text(yaml.safe_dump(text, sort_keys=False))
|
||||
return doc.path
|
||||
|
||||
|
||||
def update_project(doc: Doc, title: str, description: str = "", goal: str = "") -> Path:
|
||||
"""Rename a project and edit its description and goal, in its own file."""
|
||||
if doc.data.get("spec") != "project/v0.1":
|
||||
raise ValueError("only a project can be edited this way")
|
||||
from .pack import SECRET_RE
|
||||
title = title.strip()
|
||||
if len(title) < 3:
|
||||
raise ValueError("the project name needs at least 3 characters")
|
||||
for label, text in (("name", title), ("description", description), ("goal", goal)):
|
||||
if SECRET_RE.search(text):
|
||||
raise ValueError(f"the {label} looks like a secret; projects are never secret")
|
||||
data = yaml.safe_load(doc.path.read_text())
|
||||
data["title"] = title
|
||||
for key, text in (("description", description), ("goal", goal)):
|
||||
if text.strip():
|
||||
data[key] = text.strip()
|
||||
else:
|
||||
data.pop(key, None)
|
||||
ordered = {k: data[k] for k in ("spec", "id", "title", "description", "goal") if k in data}
|
||||
ordered.update({k: v for k, v in data.items() if k not in ordered})
|
||||
doc.path.write_text(yaml.safe_dump(ordered, sort_keys=False))
|
||||
return doc.path
|
||||
|
|
|
|||
109
rtut/ui.py
109
rtut/ui.py
|
|
@ -14,13 +14,19 @@ 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 . import ui_attrs as UA
|
||||
from .attrs import Registry
|
||||
|
||||
LIGHT = "--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--side:#f3f3ef"
|
||||
DARK = "--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72;--side:#0e0e0d"
|
||||
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}}
|
||||
:root{%s;--radius:6px;color-scheme:light}
|
||||
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){%s;color-scheme:dark}}
|
||||
:root[data-theme=dark]{%s;color-scheme:dark}
|
||||
:root[data-theme=light]{color-scheme:light}
|
||||
""" % (LIGHT, DARK, DARK) + """
|
||||
*{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)}
|
||||
body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;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}
|
||||
|
|
@ -42,6 +48,7 @@ table{border-collapse:collapse;width:100%;margin:.5rem 0;background:var(--card);
|
|||
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%}
|
||||
table.edit input,table.edit select{width:100%}
|
||||
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}
|
||||
|
|
@ -70,16 +77,47 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
|
|||
.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}
|
||||
#current{scroll-margin-top:1rem}
|
||||
.app{display:grid;grid-template-columns:15.5rem minmax(0,1fr);min-height:100vh}
|
||||
main{max-width:58rem;width:100%;padding:1.8rem 1.6rem 4rem}
|
||||
.side{position:sticky;top:0;align-self:start;height:100vh;display:flex;flex-direction:column;gap:.2rem;padding:1.1rem .8rem;background:var(--side);border-right:1px solid var(--line)}
|
||||
.brand{font:12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;text-decoration:none;padding:.3rem .6rem 1rem;color:var(--fg)}
|
||||
.side nav{display:flex;flex-direction:column;gap:.1rem}
|
||||
.side a[data-nav]{display:block;padding:.4rem .7rem;border-radius:var(--radius);text-decoration:none;color:var(--mut)}
|
||||
.side a[data-nav]:hover{background:var(--hi);color:#141414}
|
||||
.side a[data-nav][aria-current=page]{background:var(--card);color:var(--fg);font-weight:650;box-shadow:inset 3px 0 0 var(--fg)}
|
||||
.side-foot{margin-top:auto;display:flex;flex-direction:column;gap:.5rem;border-top:1px solid var(--line);padding-top:.8rem}
|
||||
.side-foot a.me{display:flex;gap:.5rem;align-items:center}
|
||||
.avatar{display:inline-grid;place-items:center;width:1.7rem;height:1.7rem;border-radius:99px;background:var(--fg);color:var(--bg);font:12px ui-monospace,monospace}
|
||||
.side-foot button{background:var(--card);color:var(--fg);border-color:var(--line);font-size:.85rem}
|
||||
@media(max-width:52rem){.app{grid-template-columns:1fr}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:.6rem .8rem}
|
||||
.brand{padding:.3rem .6rem}.side nav{flex-direction:row;flex-wrap:wrap}.side-foot{margin:0 0 0 auto;border:0;padding:0;flex-direction:row;align-items:center}main{padding:1.2rem .9rem 3rem}}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}}
|
||||
@media print{.side,button,.tabs,form{display:none}.app{display:block}.step,.stage{break-inside:avoid}}
|
||||
"""
|
||||
|
||||
|
||||
NAV = (("Home", "/"), ("Projects", "/projects"), ("Runbooks", "/runbooks"), ("Attribute sets", "/attribute-sets"), ("Activity", "/activity"))
|
||||
|
||||
# applied before the page paints, so a saved theme never flashes; ?theme=dark|light|system also sets it
|
||||
THEME_INIT = ("(function(){try{var q=new URLSearchParams(location.search).get('theme');"
|
||||
"if(q==='dark'||q==='light')localStorage.setItem('rtut-theme',q);else if(q==='system')localStorage.removeItem('rtut-theme');"
|
||||
"var t=localStorage.getItem('rtut-theme');"
|
||||
"if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);"
|
||||
"else document.documentElement.removeAttribute('data-theme')}catch(x){}})()")
|
||||
|
||||
|
||||
def page(title: str, body: str) -> bytes:
|
||||
return (f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>"
|
||||
f"<title>{html.escape(title)}</title><style>{CSS}</style>"
|
||||
f"<p class=eyebrow><a href='/'>runbook-tutorials</a></p>{body}"
|
||||
u = USER.current()
|
||||
label = u["display_name"] or u["operator"]
|
||||
nav = "".join(f"<a href='{h}' data-nav>{html.escape(t)}</a>" for t, h in NAV)
|
||||
side = (f"<aside class=side><a class=brand href='/'>runbook-tutorials</a><nav>{nav}</nav>"
|
||||
f"<div class=side-foot><a class=me href='/user' data-nav title='Your settings'>"
|
||||
f"<span class=avatar>{html.escape(label[:1].upper())}</span><span>{html.escape(label)}</span></a>"
|
||||
"<button type=button data-theme-toggle title='Switch between system, light and dark'>Theme: system</button></div></aside>")
|
||||
return (f"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
|
||||
f"<title>{html.escape(title)}</title><style>{CSS}</style><script>{THEME_INIT}</script></head>"
|
||||
f"<body><div class=app>{side}<main>{body}</main></div>"
|
||||
"<script>"
|
||||
"document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>"
|
||||
"navigator.clipboard.writeText(b.previousElementSibling.textContent));"
|
||||
|
|
@ -87,9 +125,17 @@ def page(title: str, body: str) -> bytes:
|
|||
"document.querySelectorAll('[data-fail]').forEach(b=>b.addEventListener('click',ev=>{"
|
||||
"const f=b.closest('form'),n=f.querySelector('[name=note]'),w=f.querySelector('[data-warn]');"
|
||||
"if(n&&!n.value.trim()&&!b.dataset.warned){ev.preventDefault();b.dataset.warned='1';w.hidden=false;n.focus();}}));"
|
||||
# sidebar: mark where we are; theme switch cycles system -> light -> dark
|
||||
"const p=location.pathname;document.querySelectorAll('a[data-nav]').forEach(a=>{const h=a.getAttribute('href');"
|
||||
"if(h==='/'?p==='/':p===h||p.startsWith(h+'/'))a.setAttribute('aria-current','page')});"
|
||||
"const tb=document.querySelector('[data-theme-toggle]');const cur=()=>{try{return localStorage.getItem('rtut-theme')||'system'}catch(x){return 'system'}};"
|
||||
"const show=()=>{if(tb)tb.textContent='Theme: '+cur()};show();"
|
||||
"if(tb)tb.onclick=()=>{const n={system:'light',light:'dark',dark:'system'}[cur()];"
|
||||
"try{n==='system'?localStorage.removeItem('rtut-theme'):localStorage.setItem('rtut-theme',n)}catch(x){};"
|
||||
"if(n==='system')document.documentElement.removeAttribute('data-theme');else document.documentElement.setAttribute('data-theme',n);show()};"
|
||||
# keep the operator on the current step after every action
|
||||
"const c=document.getElementById(location.hash==='#feedback'?'feedback':location.hash==='#close'?'close':'current');if(c)c.scrollIntoView({block:'center'});"
|
||||
"</script>").encode()
|
||||
"</script></body></html>").encode()
|
||||
|
||||
|
||||
def e(s) -> str:
|
||||
|
|
@ -130,15 +176,16 @@ 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) -> str:
|
||||
def activities_html(self, limit: int = 12, level: str = "h2") -> 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>"
|
||||
return f"<{level}>Latest activities</{level}><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 <span class=note style='font-weight:400;font-size:.85rem'>(times in UTC)</span></h2><ul class=feed>{rows}</ul>"
|
||||
return (f"<{level}>Latest activities <span class=note style='font-weight:400;font-size:.85rem'>(times in UTC)</span></{level}>"
|
||||
f"<ul class=feed>{rows}</ul>")
|
||||
|
||||
def user_page(self, msg: str = "") -> bytes:
|
||||
u = USER.current()
|
||||
|
|
@ -161,6 +208,18 @@ 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())
|
||||
|
||||
def runbooks_page(self) -> bytes:
|
||||
return page("Runbooks", self.runbooks_html("h1"))
|
||||
|
||||
def projects_page(self) -> bytes:
|
||||
return page("Projects", UP.index_sections(self))
|
||||
|
||||
def activity_page(self) -> bytes:
|
||||
return page("Activity", self.activities_html(100, "h1"))
|
||||
|
||||
def runbooks_html(self, level: str = "h2") -> str:
|
||||
rows = []
|
||||
for i, p in enumerate(self.packs):
|
||||
d = p.data
|
||||
|
|
@ -172,7 +231,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) + self.activities_html() + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
return f"<{level}>Runbooks</{level}><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>"
|
||||
|
||||
def pack_page(self, i: int) -> bytes:
|
||||
p = self.packs[i]; d = p.data
|
||||
|
|
@ -407,6 +466,18 @@ def make_handler(app: App, port_holder: list):
|
|||
return self._send(app.pack_page(int(parts[1])))
|
||||
if parts == ["user"]:
|
||||
return self._send(app.user_page())
|
||||
if parts == ["projects"]:
|
||||
return self._send(app.projects_page())
|
||||
if parts == ["runbooks"]:
|
||||
return self._send(app.runbooks_page())
|
||||
if parts == ["activity"]:
|
||||
return self._send(app.activity_page())
|
||||
if parts == ["attribute-sets"]:
|
||||
return self._send(UA.sets_page(app))
|
||||
if len(parts) == 2 and parts[0] == "attribute-set":
|
||||
body = UA.set_page(app, parts[1])
|
||||
if body:
|
||||
return self._send(body)
|
||||
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"))
|
||||
|
|
@ -437,6 +508,13 @@ def make_handler(app: App, port_holder: list):
|
|||
form["operator"] = app.operator() # the active user, unless a form names someone
|
||||
if not form.get("by", "").strip():
|
||||
form["by"] = app.operator()
|
||||
if parts == ["attribute-sets", "new"] or (len(parts) == 2 and parts[0] == "attribute-set"):
|
||||
try:
|
||||
return self._redirect(UA.handle_post(app, parts, form))
|
||||
except (ValueError, KeyError) as ex:
|
||||
if parts[0] == "attribute-set" and len(parts) == 2:
|
||||
return self._send(UA.set_page(app, parts[1], str(ex)) or page("Not found", "<p>Not found.</p>"), 400)
|
||||
return self._send(UA.sets_page(app, str(ex)), 400)
|
||||
if parts == ["user"]:
|
||||
try:
|
||||
USER.save(form.get("operator", ""), form.get("display_name", ""))
|
||||
|
|
@ -455,6 +533,11 @@ def make_handler(app: App, port_holder: list):
|
|||
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] == "edit":
|
||||
from . import project as J
|
||||
doc = app.registry().projects[parts[1]]
|
||||
J.update_project(doc, form.get("title", ""), form.get("description", ""), form.get("goal", ""))
|
||||
return self._redirect(f"/project/{parts[1]}")
|
||||
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]]
|
||||
|
|
|
|||
170
rtut/ui_attrs.py
Normal file
170
rtut/ui_attrs.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""UI pages for attribute sets: list, inspect (with what they resolve to and who reuses them), create and edit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from pathlib import Path
|
||||
|
||||
from . import user as USER
|
||||
from .attrs import fold, save_set, set_layers, used_by
|
||||
|
||||
EXTRA_ROWS = 3
|
||||
|
||||
|
||||
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 _is_app_level(doc) -> bool:
|
||||
return Path(doc.path) == USER.user_file()
|
||||
|
||||
|
||||
def _where(app, doc) -> str:
|
||||
if _is_app_level(doc):
|
||||
return "app (you)"
|
||||
for r in app.roots:
|
||||
try:
|
||||
return f"{r.name}/{Path(doc.path).relative_to(r)}"
|
||||
except ValueError:
|
||||
continue
|
||||
return str(doc.path)
|
||||
|
||||
|
||||
def _usage(app, reg, sid: str) -> str:
|
||||
u = used_by(reg, sid)
|
||||
parts = []
|
||||
parts += [f"<a href='/project/{e(p)}'>{e(p)}</a>" for p in u["projects"]]
|
||||
parts += [f"<a href='/template/{e(t)}'>{e(t)}</a> <span class=note>(template)</span>" for t in u["templates"]]
|
||||
parts += [f"<a href='/attribute-set/{e(x)}'>{e(x)}</a> <span class=note>(extends it)</span>" for x in u["sets"]]
|
||||
return ", ".join(parts) or "<span class=note>nothing yet</span>"
|
||||
|
||||
|
||||
def sets_page(app, msg: str = "") -> bytes:
|
||||
from .ui import page
|
||||
reg = app.registry()
|
||||
rows = ""
|
||||
for sid, doc in sorted(reg.sets.items()):
|
||||
n = len(doc.data.get("attributes", {})) if doc.ok else 0
|
||||
state = "" if doc.ok else f" <span class=bad>invalid: {e(doc.errors[0])}</span>"
|
||||
ext = ", ".join(f"<a href='/attribute-set/{e(x)}'>{e(x)}</a>" for x in doc.data.get("extends", [])) or "-"
|
||||
rows += (f"<tr><td><a href='/attribute-set/{e(sid)}'>{e(doc.data.get('title', sid))}</a>{state}<br><span class=note>{e(sid)}</span></td>"
|
||||
f"<td>{e(_where(app, doc))}</td><td>{ext}</td><td>{n}</td><td>{_usage(app, reg, sid)}</td></tr>")
|
||||
roots = "".join(f"<option value='{i}'>{e(r.name)} ({e(r)})</option>" for i, r in enumerate(app.roots))
|
||||
extends_help = ", ".join(sorted(reg.sets)) or "none yet"
|
||||
body = ("<h1>Attribute sets</h1>"
|
||||
"<p class=note>Named, reusable, non-secret facts. Runbooks receive them as inputs, projects and templates compose them, and one set "
|
||||
"can extend another (later layers win, and every value keeps its source). Nothing here may be a secret.</p>"
|
||||
+ (f"<p class=bad>{e(msg)}</p>" if msg else "")
|
||||
+ ("<table><tr><th>Set</th><th>Where it lives</th><th>Extends</th><th>Attributes</th><th>Reused by</th></tr>" + rows + "</table>"
|
||||
if rows else "<p class=note>No attribute sets yet.</p>")
|
||||
+ "<details class=override><summary>New attribute set</summary>"
|
||||
f"<form method=post action='/attribute-sets/new'>{app.hidden()}"
|
||||
"<p><label>Id<br><input name=set_id required pattern='[a-z0-9]+([.-][a-z0-9]+)*' placeholder='lower-case, digits, dots, hyphens'></label></p>"
|
||||
"<p><label>Title<br><input name=title required minlength=3 class=grow style='width:100%'></label></p>"
|
||||
f"<p><label>Stored in repository<br><select name=root>{roots}</select></label></p>"
|
||||
f"<p><label>Extends (ids, comma separated)<br><input name=extends class=grow style='width:100%' placeholder='available: {e(extends_help)}'></label></p>"
|
||||
"<button>Create</button></form></details>")
|
||||
return page("Attribute sets", body)
|
||||
|
||||
|
||||
def set_page(app, sid: str, msg: str = "") -> bytes | None:
|
||||
from .ui import page
|
||||
reg = app.registry()
|
||||
doc = reg.sets.get(sid)
|
||||
if doc is None:
|
||||
return None
|
||||
d = doc.data
|
||||
out = [f"<h1>{e(d.get('title', sid))}</h1><p>{pill('app' if _is_app_level(doc) else 'repository')} <code>{e(sid)}</code> "
|
||||
f"<span class=note>{e(_where(app, doc))}</span></p>"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
if not doc.ok:
|
||||
out.append("<p class=bad>This set is invalid:</p><ul>" + "".join(f"<li>{e(x)}</li>" for x in doc.errors) + "</ul>")
|
||||
return page(d.get("title", sid), "".join(out))
|
||||
out.append(f"<p><b>Reused by:</b> {_usage(app, reg, sid)}</p>")
|
||||
try:
|
||||
folded = fold(set_layers(reg, sid))
|
||||
except ValueError as ex:
|
||||
out.append(f"<p class=bad>{e(ex)}</p>")
|
||||
folded = {}
|
||||
rows = ""
|
||||
for name, a in folded.items():
|
||||
val = "" if a["value"] is None else a["value"]
|
||||
rows += (f"<tr><td><code>{e(name)}</code></td><td>{e(val) if val != '' else '<span class=note>(no value)</span>'}</td>"
|
||||
f"<td>{e(a['requirement'] or '-')}</td><td>{e(a.get('note', ''))}</td><td class=note>{e(a['source'] or '-')}"
|
||||
f"{' ' + pill('modified') if a['modified'] else ''}</td></tr>")
|
||||
out.append("<h2>What this set resolves to</h2><p class=note>Including everything it extends; the source shows which set gave each value.</p>"
|
||||
"<table><tr><th>Attribute</th><th>Value</th><th>Requirement</th><th>Note</th><th>Source</th></tr>" + rows + "</table>")
|
||||
if _is_app_level(doc):
|
||||
out.append("<p class=note>This is <b>your</b> set: it follows you across every project. Change it on "
|
||||
"<a href='/user'>your settings page</a>. A repository may define its own set with the same id, which then wins.</p>")
|
||||
return page(d.get("title", sid), "".join(out))
|
||||
own = d.get("attributes", {})
|
||||
trs = ""
|
||||
from .attrs import normalize
|
||||
for i, (name, a) in enumerate(normalize(own).items()):
|
||||
req = a.get("requirement") or ""
|
||||
opts = "".join(f"<option value='{o}'{' selected' if o == req else ''}>{o or '(none)'}</option>" for o in ("", "required", "optional"))
|
||||
trs += (f"<tr><td><input name=n{i} value='{e(name)}' readonly size=14></td>"
|
||||
f"<td><input name=v{i} value='{e('' if a['value'] is None else a['value'])}'></td><td><select name=r{i}>{opts}</select></td>"
|
||||
f"<td><input name=t{i} value='{e(a.get('note') or '')}'></td><td><label><input type=checkbox name=x{i} value=1> remove</label></td></tr>")
|
||||
base = len(own)
|
||||
for j in range(EXTRA_ROWS):
|
||||
i = base + j
|
||||
trs += (f"<tr><td><input name=n{i} placeholder='new_attribute' size=14 pattern='[a-z][a-z0-9_]*'></td><td><input name=v{i}></td>"
|
||||
f"<td><select name=r{i}><option value=''>(none)</option><option>required</option><option>optional</option></select></td>"
|
||||
f"<td><input name=t{i}></td><td></td></tr>")
|
||||
warn = ""
|
||||
u = used_by(reg, sid)
|
||||
if u["projects"] or u["templates"] or u["sets"]:
|
||||
warn = ("<p class=note>Other things reuse this set. Changing a value changes what their runbooks receive from now on; "
|
||||
"runs that already started keep the values they started with.</p>")
|
||||
out.append("<h2>Edit</h2>" + warn +
|
||||
f"<form method=post action='/attribute-set/{e(sid)}'>{app.hidden()}"
|
||||
f"<p><label>Title<br><input name=title value='{e(d.get('title', ''))}' required minlength=3 class=grow style='width:100%'></label></p>"
|
||||
f"<p><label>Extends (ids, comma separated)<br><input name=extends value='{e(', '.join(d.get('extends', [])))}' class=grow style='width:100%'></label></p>"
|
||||
f"<input type=hidden name=rows value={base + EXTRA_ROWS}>"
|
||||
"<table class=edit><tr><th>Attribute</th><th>Value</th><th>Requirement</th><th>Note</th><th></th></tr>" + trs + "</table>"
|
||||
"<p class=note>A value can be left empty for a required attribute: it then has to be given by whoever uses the set. "
|
||||
"Never enter secrets.</p><button>Save</button></form>")
|
||||
return page(d.get("title", sid), "".join(out))
|
||||
|
||||
|
||||
def _split(text: str) -> list[str]:
|
||||
return [x for x in text.replace(",", " ").split() if x]
|
||||
|
||||
|
||||
def handle_post(app, parts: list[str], form: dict) -> str:
|
||||
"""Create or edit an attribute set. Returns the URL to go to; raises ValueError with the reason if refused."""
|
||||
reg = app.registry()
|
||||
if parts == ["attribute-sets", "new"]:
|
||||
try:
|
||||
root = app.roots[int(form.get("root", "0"))]
|
||||
except (ValueError, IndexError):
|
||||
raise ValueError("choose a repository")
|
||||
sid = form.get("set_id", "").strip()
|
||||
if sid in reg.sets:
|
||||
raise ValueError(f"an attribute set {sid} already exists")
|
||||
save_set(reg, root, sid, form.get("title", ""), _split(form.get("extends", "")), {})
|
||||
return f"/attribute-set/{sid}"
|
||||
if len(parts) == 2 and parts[0] == "attribute-set":
|
||||
sid = parts[1]
|
||||
doc = reg.sets.get(sid)
|
||||
if doc is None:
|
||||
raise KeyError(sid)
|
||||
if _is_app_level(doc):
|
||||
raise ValueError("this is your app-level set: change it on your settings page")
|
||||
attrs: dict = {}
|
||||
for i in range(int(form.get("rows", "0") or 0)):
|
||||
name = form.get(f"n{i}", "").strip()
|
||||
if not name or form.get(f"x{i}"):
|
||||
continue
|
||||
if name in attrs:
|
||||
raise ValueError(f"{name} appears twice")
|
||||
attrs[name] = {"value": form.get(f"v{i}", ""), "requirement": form.get(f"r{i}", ""), "note": form.get(f"t{i}", "")}
|
||||
save_set(reg, Path(doc.path).parent.parent, sid, form.get("title", ""), _split(form.get("extends", "")), attrs, existing_path=Path(doc.path))
|
||||
return f"/attribute-set/{sid}"
|
||||
raise ValueError("unknown action")
|
||||
|
|
@ -120,6 +120,12 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
|
|||
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"]:
|
||||
|
|
|
|||
|
|
@ -249,3 +249,67 @@ def test_project_without_a_given_name_uses_its_id_never_the_templates_title(repo
|
|||
J.instantiate(reg, "lighthouse-watch", "ab", "staging")
|
||||
with pytest.raises(ValueError, match="secret"):
|
||||
J.instantiate(reg, "lighthouse-watch", "leaky-one", "staging", description="token hvs.ABCDEFGHIJKLMN")
|
||||
|
||||
|
||||
# ---- managing attribute sets and editing projects --------------------------------------------------------------------
|
||||
|
||||
def test_used_by_finds_projects_templates_targets_and_extending_sets(repo):
|
||||
from rtut.attrs import used_by
|
||||
reg = project(repo) # north-watch uses stormy-coast, which extends harbour-defaults
|
||||
u = used_by(reg, "stormy-coast")
|
||||
assert u == {"projects": ["north-watch"], "templates": [], "sets": []}
|
||||
u = used_by(reg, "harbour-defaults")
|
||||
assert u["templates"] == ["lighthouse-watch"] and u["sets"] == ["stormy-coast"]
|
||||
|
||||
|
||||
def test_save_set_writes_validates_and_normalizes(repo):
|
||||
from rtut.attrs import save_set
|
||||
reg = Registry([repo])
|
||||
path = save_set(reg, repo, "team-defaults", "Team defaults", ["harbour-defaults"],
|
||||
{"lamp": {"value": "electric"}, "keeper": {"requirement": "required", "note": "who"}, "empty_note": {"note": "later"}})
|
||||
made = yaml.safe_load(path.read_text())
|
||||
assert path == repo / "attribute-sets/team-defaults.yaml" and made["extends"] == ["harbour-defaults"]
|
||||
assert made["attributes"]["lamp"] == "electric" # a lone value is written as a scalar
|
||||
assert made["attributes"]["keeper"] == {"requirement": "required", "note": "who"}
|
||||
reg = Registry([repo])
|
||||
assert reg.sets["team-defaults"].ok and fold(set_layers(reg, "team-defaults"))["lamp"]["value"] == "electric"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs,frag", [
|
||||
({"set_id": "Bad Id"}, "lower-case"), ({"title": "ab"}, "3 characters"), ({"extends": ["ghost"]}, "unknown attribute set"),
|
||||
({"extends": ["team-x"]}, "itself"), ({"attributes": {"Bad-Name": {"value": "x"}}}, "bad attribute name"),
|
||||
({"attributes": {"a": {"value": "hvs.ABCDEFGHIJKLMN"}}}, "secret"), ({"attributes": {"a": {"requirement": "maybe", "value": "x"}}}, "requirement"),
|
||||
({"attributes": {"a": {}}}, "give a value"),
|
||||
])
|
||||
def test_save_set_refusals(repo, kwargs, frag):
|
||||
from rtut.attrs import save_set
|
||||
args = {"set_id": "team-x", "title": "Team x", "extends": [], "attributes": {"a": {"value": "1"}}, **kwargs}
|
||||
with pytest.raises(ValueError, match=frag):
|
||||
save_set(Registry([repo]), repo, **args)
|
||||
assert not (repo / "attribute-sets/team-x.yaml").exists()
|
||||
|
||||
|
||||
def test_save_set_refuses_a_cycle(repo):
|
||||
from rtut.attrs import save_set
|
||||
reg = Registry([repo])
|
||||
with pytest.raises(ValueError, match="circular"):
|
||||
save_set(reg, repo, "harbour-defaults", "Harbour defaults", ["stormy-coast"], {"lamp": {"value": "x"}}, reg.sets["harbour-defaults"].path)
|
||||
|
||||
|
||||
def test_update_project_renames_and_keeps_everything_else(repo):
|
||||
reg = project(repo)
|
||||
doc = reg.projects["north-watch"]
|
||||
before = yaml.safe_load(doc.path.read_text())
|
||||
J.update_project(doc, "North harbour watch", "Our harbour", "Both lights burn")
|
||||
after = yaml.safe_load(doc.path.read_text())
|
||||
assert after["title"] == "North harbour watch" and after["description"] == "Our harbour" and after["goal"] == "Both lights burn"
|
||||
assert after["runbooks"] == before["runbooks"] and after["attributes"] == before["attributes"] and after["id"] == "north-watch"
|
||||
assert list(after)[:5] == ["spec", "id", "title", "description", "goal"]
|
||||
J.update_project(Registry([repo]).projects["north-watch"], "North harbour watch", "", "")
|
||||
assert "description" not in yaml.safe_load(doc.path.read_text())
|
||||
for bad, frag in (("ab", "3 characters"), ("x" * 5 + "hvs.ABCDEFGHIJKLMN", "secret")):
|
||||
with pytest.raises(ValueError, match=frag):
|
||||
J.update_project(Registry([repo]).projects["north-watch"], bad)
|
||||
reg = Registry([repo])
|
||||
with pytest.raises(ValueError, match="only a project"):
|
||||
J.update_project(reg.templates["lighthouse-watch"], "Renamed template")
|
||||
|
|
|
|||
197
tests/test_ui_shell.py
Normal file
197
tests/test_ui_shell.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import http.client
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import ui, user
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def write(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def srv(tmp_path):
|
||||
shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", tmp_path / "runbooks/show/pack.yaml") if (tmp_path / "runbooks/show").mkdir(parents=True) is None else None
|
||||
for f in (ROOT / "attribute-sets").glob("*.yaml"):
|
||||
write(tmp_path / "attribute-sets" / f.name, yaml.safe_load(f.read_text()))
|
||||
(tmp_path / "project-templates").mkdir()
|
||||
shutil.copy(ROOT / "project-templates/lighthouse-watch.yaml", tmp_path / "project-templates/lighthouse-watch.yaml")
|
||||
write(tmp_path / "projects/watch/project.yaml", {
|
||||
"spec": "project/v0.1", "id": "watch", "title": "Watch project", "description": "Old description", "environment": "rehearsal",
|
||||
"attribute_sets": ["stormy-coast", "user-attributes"], "attributes": {"logdir": "/tmp/rtut-shell-x", "keeper": "Ada"},
|
||||
"runbooks": [{"pack": "rtut.showcase-lighthouse"}]})
|
||||
s = ui.serve([tmp_path], 0)
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
s.repo = tmp_path
|
||||
yield s
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def req(s, method, path, form=None):
|
||||
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
|
||||
body = urlencode(form) if form is not None else None
|
||||
c.request(method, path, body, {"Host": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"})
|
||||
r = c.getresponse()
|
||||
return r.status, r.getheader("Location"), r.read().decode()
|
||||
|
||||
|
||||
def post(s, path, **form):
|
||||
return req(s, "POST", path, {"_t": s.app.token, **form})
|
||||
|
||||
|
||||
# ---- shell: sidebar, user, theme ---------------------------------------------------------------------------------
|
||||
|
||||
PAGES = ["/", "/projects", "/runbooks", "/activity", "/attribute-sets", "/user", "/pack/0", "/project/watch",
|
||||
"/template/lighthouse-watch", "/attribute-set/harbour-defaults"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_every_page_has_the_sidebar_with_sections_user_and_theme_switch(srv, path):
|
||||
st, _, body = req(srv, "GET", path)
|
||||
assert st == 200
|
||||
assert "<aside class=side>" in body and "<a class=brand href='/'>runbook-tutorials</a>" in body
|
||||
for href, label in (("/", "Home"), ("/projects", "Projects"), ("/runbooks", "Runbooks"), ("/attribute-sets", "Attribute sets"), ("/activity", "Activity")):
|
||||
assert f"<a href='{href}' data-nav>{label}</a>" in body
|
||||
assert "href='/user' data-nav" in body and "tester-login" in body # who you are, linking to your settings
|
||||
assert "data-theme-toggle" in body and "Theme: system" in body
|
||||
|
||||
|
||||
def test_the_theme_can_be_chosen_and_is_remembered_and_dark_styles_exist(srv):
|
||||
_, _, body = req(srv, "GET", "/")
|
||||
assert ":root[data-theme=dark]" in body and "prefers-color-scheme:dark" in body and ":root:not([data-theme=light])" in body
|
||||
head = body.split("</head>")[0]
|
||||
assert "localStorage.getItem('rtut-theme')" in head and "URLSearchParams" in head # applied before painting; ?theme=dark also works
|
||||
assert "system:'light',light:'dark',dark:'system'" in body # the button cycles system, light, dark
|
||||
assert "aria-current" in body # the current section is marked
|
||||
|
||||
|
||||
def test_the_sidebar_shows_the_saved_name(srv):
|
||||
post(srv, "/user", operator="bernd", display_name="Bernd W.")
|
||||
_, _, body = req(srv, "GET", "/projects")
|
||||
assert "<span class=avatar>B</span><span>Bernd W.</span>" in body
|
||||
|
||||
|
||||
def test_sections_are_separate_pages(srv):
|
||||
_, _, body = req(srv, "GET", "/projects")
|
||||
assert "<h1>Projects</h1>" in body and "Project templates" in body and "<h2>Runbooks</h2>" not in body and "<h1>Runbooks</h1>" not in body
|
||||
_, _, body = req(srv, "GET", "/runbooks")
|
||||
assert "<h1>Runbooks</h1>" in body and "Showcase: a night at the lighthouse" in body and "Project templates" not in body
|
||||
_, _, body = req(srv, "GET", "/activity")
|
||||
assert "<h1>Latest activities</h1>" in body and "Nothing has happened yet" in body
|
||||
assert post(srv, "/run/new/0", operator="bernd", p_logdir="/tmp/rtut-shell-act")[0] == 303
|
||||
_, _, body = req(srv, "GET", "/activity")
|
||||
assert "<h1>Latest activities" in body and "(times in UTC)" in body and "started a run" in body
|
||||
_, _, body = req(srv, "GET", "/")
|
||||
assert "<h1>Projects</h1>" in body and "Latest activities" in body and "<h2>Runbooks</h2>" in body # Home shows all three
|
||||
|
||||
|
||||
# ---- attribute sets ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_attribute_set_list_shows_where_they_live_extends_and_who_reuses_them(srv):
|
||||
_, _, body = req(srv, "GET", "/attribute-sets")
|
||||
assert "<h1>Attribute sets</h1>" in body and "harbour-defaults" in body and "stormy-coast" in body
|
||||
assert "app (you)" in body and "user-attributes" in body # the app-level set is listed with the others
|
||||
assert "<a href='/project/watch'>watch</a>" in body and "(extends it)" in body and "(template)" in body
|
||||
assert "New attribute set" in body and "action='/attribute-sets/new'" in body
|
||||
|
||||
|
||||
def test_attribute_set_page_resolves_layers_with_sources_and_offers_editing(srv):
|
||||
_, _, body = req(srv, "GET", "/attribute-set/stormy-coast")
|
||||
assert "What this set resolves to" in body and "set:harbour-defaults" in body and "set:stormy-coast" in body
|
||||
assert ">modified<" in body # lamp/signal replaced a base value
|
||||
assert "<b>Reused by:</b> <a href='/project/watch'>watch</a>" in body and "Other things reuse this set" in body
|
||||
assert "<h2>Edit</h2>" in body and "name=n0 value='weather'" in body and "name=n3 placeholder='new_attribute'" in body # own rows + three empty ones
|
||||
assert "name=extends value='harbour-defaults'" in body
|
||||
assert req(srv, "GET", "/attribute-set/nope")[0] == 404
|
||||
|
||||
|
||||
def test_the_users_own_set_is_shown_read_only_with_a_link_to_settings(srv):
|
||||
_, _, body = req(srv, "GET", "/attribute-set/user-attributes")
|
||||
assert "tester-login" in body and "<a href='/user'>your settings page</a>" in body and "<h2>Edit</h2>" not in body
|
||||
st, _, err = post(srv, "/attribute-set/user-attributes", title="Hacked", rows="0")
|
||||
assert st == 400 and "settings page" in err
|
||||
|
||||
|
||||
def test_editing_a_set_changes_values_adds_and_removes_attributes(srv):
|
||||
path = srv.repo / "attribute-sets/stormy-coast.yaml"
|
||||
st, loc, _ = post(srv, "/attribute-set/stormy-coast", title="Stormy coast", extends="harbour-defaults", rows="6",
|
||||
n0="weather", v0="fog", r0="", t0="", n1="signal", v1="flags", r1="", t1="", x1="1", # remove signal
|
||||
n2="lamp", v2="electric", r2="optional", t2="the lamp",
|
||||
n3="cape", v3="Cape Wrath", r3="", t3="") # a new attribute
|
||||
assert st == 303 and loc == "/attribute-set/stormy-coast"
|
||||
made = yaml.safe_load(path.read_text())
|
||||
assert made["attributes"] == {"weather": "fog", "lamp": {"value": "electric", "requirement": "optional", "note": "the lamp"}, "cape": "Cape Wrath"}
|
||||
_, _, body = req(srv, "GET", "/attribute-set/stormy-coast")
|
||||
assert "Cape Wrath" in body and "the lamp" in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fields,frag", [
|
||||
({"title": "ab"}, "3 characters"),
|
||||
({"extends": "ghost"}, "unknown attribute set"),
|
||||
({"n0": "Bad-Name", "v0": "x"}, "bad attribute name"),
|
||||
({"n0": "a", "v0": "hvs.ABCDEFGHIJKLMN"}, "secret"),
|
||||
({"n0": "a", "v0": "1", "n1": "a", "v1": "2"}, "twice"),
|
||||
({"extends": "stormy-coast"}, "itself"),
|
||||
])
|
||||
def test_edit_refusals_change_nothing_and_say_why(srv, fields, frag):
|
||||
path = srv.repo / "attribute-sets/stormy-coast.yaml"
|
||||
before = path.read_text()
|
||||
form = {"title": "Stormy coast", "extends": "harbour-defaults", "rows": "2", "n0": "weather", "v0": "storm", **fields}
|
||||
st, _, body = post(srv, "/attribute-set/stormy-coast", **form)
|
||||
assert st == 400 and frag in body and path.read_text() == before
|
||||
|
||||
|
||||
def test_a_cycle_is_refused(srv):
|
||||
st, _, body = post(srv, "/attribute-set/harbour-defaults", title="Harbour defaults", extends="stormy-coast", rows="1", n0="lamp", v0="x")
|
||||
assert st == 400 and "circular" in body
|
||||
|
||||
|
||||
def test_new_attribute_set_is_created_in_the_chosen_repository(srv):
|
||||
st, loc, _ = post(srv, "/attribute-sets/new", set_id="team-defaults", title="Team defaults", root="0", extends="harbour-defaults")
|
||||
assert st == 303 and loc == "/attribute-set/team-defaults"
|
||||
made = yaml.safe_load((srv.repo / "attribute-sets/team-defaults.yaml").read_text())
|
||||
assert made["extends"] == ["harbour-defaults"] and made["attributes"] == {}
|
||||
assert "Team defaults" in req(srv, "GET", "/attribute-sets")[2]
|
||||
st, _, err = post(srv, "/attribute-sets/new", set_id="team-defaults", title="Again", root="0")
|
||||
assert st == 400 and "already exists" in err
|
||||
assert post(srv, "/attribute-sets/new", set_id="x-set", title="X set", root="9")[0] == 400
|
||||
|
||||
|
||||
def test_attribute_pages_escape_user_text_and_need_the_token(srv):
|
||||
post(srv, "/attribute-set/harbour-defaults", title="Harbour defaults", extends="", rows="1", n0="lamp", v0="paraffin", t0="<script>alert(1)</script>")
|
||||
_, _, body = req(srv, "GET", "/attribute-set/harbour-defaults")
|
||||
assert "<script>alert(1)" not in body and "<script>alert(1)" in body
|
||||
assert req(srv, "POST", "/attribute-set/harbour-defaults", {"title": "x"})[0] == 403
|
||||
assert req(srv, "POST", "/attribute-sets/new", {"set_id": "z-set"})[0] == 403
|
||||
|
||||
|
||||
# ---- editing a project ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_a_project_can_be_renamed_and_described_from_its_page(srv):
|
||||
_, _, body = req(srv, "GET", "/project/watch")
|
||||
assert "Edit name, description and goal" in body and "name=title value='Watch project'" in body
|
||||
st, loc, _ = post(srv, "/project/watch/edit", title="North harbour watch", description="Our harbour", goal="Both lights burn")
|
||||
assert st == 303 and loc == "/project/watch"
|
||||
_, _, body = req(srv, "GET", "/project/watch")
|
||||
assert "<h1>North harbour watch</h1>" in body and "Our harbour" in body and "<b>Goal:</b> Both lights burn" in body
|
||||
assert "North harbour watch" in req(srv, "GET", "/projects")[2]
|
||||
made = yaml.safe_load((srv.repo / "projects/watch/project.yaml").read_text())
|
||||
assert made["id"] == "watch" and made["attributes"]["keeper"] == "Ada" and made["runbooks"] # nothing else changed
|
||||
|
||||
|
||||
def test_project_edit_refusals(srv):
|
||||
path = srv.repo / "projects/watch/project.yaml"
|
||||
before = path.read_text()
|
||||
st, _, body = post(srv, "/project/watch/edit", title="ab")
|
||||
assert st == 400 and "3 characters" in body
|
||||
st, _, body = post(srv, "/project/watch/edit", title="Fine name", description="hvs.ABCDEFGHIJKLMN")
|
||||
assert st == 400 and "secret" in body and path.read_text() == before
|
||||
assert req(srv, "POST", "/project/watch/edit", {"title": "Fine name"})[0] == 403
|
||||
|
|
@ -230,6 +230,21 @@ can only be changed with an override and are recorded as `changed-after-use`, sa
|
|||
opened and downloaded, with safety rules; (f) run start, finish and duration, per-step duration, and the average duration of successful
|
||||
runs next to the score. Documented in `docs/run-spec.md` and `docs/maturity-and-projects.md`.
|
||||
|
||||
## App shell, theme, attribute-set management and project editing
|
||||
|
||||
```task
|
||||
id: RBT-WP-0004-T15
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Done 2026-09-29 from Bernd's feedback (no way to switch theme, no navigation to user settings or attribute sets): a sidebar shell on every
|
||||
page (Home, Projects, Runbooks, Attribute sets, Activity; you and a theme switch at the bottom; a top bar on narrow screens); a theme
|
||||
switch that cycles system, light and dark and remembers the choice (`?theme=dark` also sets it); dedicated Projects, Runbooks and Activity
|
||||
pages; attribute sets listed, inspected with their resolved values and sources, created and edited (values, requirement, note, extends;
|
||||
secrets, bad names, unknown and circular `extends` refused, a refused save changes nothing); the project name, description and goal
|
||||
editable from the project page. Bernd confirmed the theme and navigation. Not done: deleting an attribute set, editing templates.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Bernd can complete the SSH tutorial pack in the UI without pasting outputs to an agent.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue