feat(3 U4): YunoHost-style dashboard grid — per-recipe level badge + status + version + app screenshot thumbnail + per-recipe /recipe/<name> history; reads results.json artifacts (R5); 9 dashboard unit tests
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
autonomic-bot
2026-05-31 09:51:55 +00:00
parent 67ed6bf2d6
commit e1d837ee97
2 changed files with 339 additions and 46 deletions

View File

@ -50,6 +50,9 @@ def _read(path):
DRONE_TOKEN = _read(os.environ["DRONE_TOKEN_FILE"])
_CACHE = {"ts": 0.0, "recipes": []}
# Raw custom builds (newest-first), cached so the overview AND the per-recipe history page share one
# Drone fetch within CACHE_TTL (U4 history reads the same list latest_per_recipe groups from).
_BUILDS = {"ts": 0.0, "builds": []}
_COLORS = {
"success": "#3fb950",
@ -60,11 +63,42 @@ _COLORS = {
"killed": "#8b949e",
}
# Level → colour ramp, kept in sync with runner/harness/card.py LEVEL_COLOR (the dashboard is a
# standalone stdlib service that doesn't import the runner harness, so the small map is duplicated).
_LEVEL_COLOR = {
0: "#e5534b", 1: "#e0823d", 2: "#e0823d", 3: "#d9b343",
4: "#a0b93f", 5: "#57ab5a", 6: "#3fb950",
}
def level_color(level):
try:
return _LEVEL_COLOR.get(int(level), "#8b949e")
except (TypeError, ValueError):
return "#8b949e"
def log(*a):
print(*a, file=sys.stderr, flush=True)
def _results_for(number):
"""Read a run's results.json from the bind-mounted runs dir (R5: the grid surfaces the real
level/version/screenshot/flags from the artifact, not just Drone's pass/fail). Traversal-guarded
like serve_run_file; returns {} on any miss so the overview degrades to Drone-only fields."""
if number in (None, ""):
return {}
base = os.path.realpath(CCCI_RUNS_DIR)
real = os.path.realpath(os.path.join(base, str(number), "results.json"))
if not real.startswith(base + os.sep):
return {}
try:
with open(real) as fh:
return json.load(fh)
except (OSError, ValueError):
return {}
def _drone(path):
req = urllib.request.Request(
f"{DRONE_URL}{path}", headers={"Authorization": f"Bearer {DRONE_TOKEN}"}
@ -73,40 +107,74 @@ def _drone(path):
return json.loads(resp.read())
def latest_per_recipe():
"""Latest recipe-CI build per recipe (event=custom builds carry the RECIPE param)."""
def _custom_recipe_builds():
"""All event=custom recipe-CI builds (newest first), each carrying a real RECIPE param. The
cc-ci repo's own name isn't a recipe under test (e.g. an Adversary `!testme` on the cc-ci PR) so
it's filtered out. Cached (CACHE_TTL) and shared by the overview + history. None on fetch error."""
now = time.time()
if now - _BUILDS["ts"] <= CACHE_TTL and _BUILDS["builds"]:
return _BUILDS["builds"]
try:
builds = _drone(f"/api/repos/{CI_REPO}/builds?per_page=100")
except (urllib.error.URLError, OSError, ValueError) as e:
log("drone fetch failed", e)
return None
latest = {}
own = CI_REPO.rsplit("/", 1)[-1]
out = []
for b in builds or []:
if b.get("event") != "custom":
continue
recipe = (b.get("params") or {}).get("RECIPE")
if not recipe:
if not recipe or recipe == own:
continue
# The cc-ci repo's own name isn't a recipe under test (e.g. an Adversary !testme on the
# cc-ci PR); don't list it as a recipe row.
if recipe == CI_REPO.rsplit("/", 1)[-1]:
continue
if recipe not in latest or b.get("number", 0) > latest[recipe].get("number", 0):
out.append(b)
out.sort(key=lambda b: b.get("number", 0), reverse=True)
_BUILDS["builds"] = out
_BUILDS["ts"] = now
return out
def _build_row(b):
"""Project a Drone build (+ its results.json artifact, if present) into a display row. The level/
version/screenshot/flags come from the run's results.json so the grid mirrors the real artifact
(R5/cardinal: never greener than the run); they're absent until U0+ artifacts exist for a run."""
ref = (b.get("params") or {}).get("REF") or ""
res = _results_for(b.get("number"))
return {
"recipe": (b.get("params") or {}).get("RECIPE"),
"status": b.get("status", "unknown"),
"number": b.get("number"),
"ref": ref[:8],
"version": res.get("version") or ref[:12] or "",
"level": res.get("level"),
"level_cap_reason": res.get("level_cap_reason") or "",
"has_screenshot": bool(res.get("screenshot")),
"flags": res.get("flags") or {},
"finished": b.get("finished") or 0,
"url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}",
}
def latest_per_recipe():
"""Latest recipe-CI build per recipe, augmented from results.json (R5). None on fetch error."""
builds = _custom_recipe_builds()
if builds is None:
return None
latest = {}
for b in builds: # newest-first → first seen per recipe is the latest
recipe = (b.get("params") or {}).get("RECIPE")
if recipe not in latest:
latest[recipe] = b
rows = []
for recipe, b in sorted(latest.items()):
ref = (b.get("params") or {}).get("REF") or ""
rows.append(
{
"recipe": recipe,
"status": b.get("status", "unknown"),
"number": b.get("number"),
"ref": ref[:8],
"finished": b.get("finished") or 0,
"url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}",
}
)
return rows
return [_build_row(latest[r]) for r in sorted(latest)]
def history_for(recipe):
"""All runs for one recipe (newest first), augmented from results.json — the per-recipe history
page (R5 'link to history'). [] if none / None on fetch error."""
builds = _custom_recipe_builds()
if builds is None:
return None
return [_build_row(b) for b in builds if (b.get("params") or {}).get("RECIPE") == recipe]
def recipes_cached():
@ -132,35 +200,130 @@ def _ago(ts):
return f"{d // 86400}d ago"
_PAGE_CSS = """
body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;margin:0;padding:0}
.wrap{max-width:1100px;margin:0 auto;padding:1.5rem 1rem 3rem}
h1{font-size:1.5rem;margin:.2rem 0;display:flex;align-items:center;gap:.5rem}
a{color:#58a6ff;text-decoration:none} a:hover{text-decoration:underline}
.sub{color:#8b949e;font-size:.9rem;margin:.3rem 0 1.2rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem}
.card{background:#161b22;border:1px solid #21262d;border-radius:.6rem;overflow:hidden;display:flex;flex-direction:column}
.shot{position:relative;display:block;height:140px;background:#0d1117 center/cover no-repeat;border-bottom:1px solid #21262d}
.shot .ph{display:flex;height:100%;align-items:center;justify-content:center;color:#484f58;font-size:.8rem}
.lvl{position:absolute;top:.5rem;right:.5rem;color:#fff;font-weight:700;font-size:.8rem;padding:.15rem .5rem;border-radius:.5rem;box-shadow:0 1px 3px #0008}
.body{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.4rem;flex:1}
.name{font-weight:700;font-size:1.05rem;color:#e6edf3}
.row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.82rem}
.pill{color:#fff;padding:.08rem .5rem;border-radius:.5rem;font-size:.75rem;font-weight:600}
.cap{color:#8b949e;font-size:.75rem}
code{background:#0d1117;border:1px solid #21262d;border-radius:.3rem;padding:0 .3rem;font-size:.78rem;color:#c9d1d9}
.flags{display:flex;gap:.4rem;font-size:.72rem;color:#8b949e}
.foot{margin-top:auto;display:flex;justify-content:space-between;font-size:.8rem;padding-top:.3rem;border-top:1px solid #21262d}
table{border-collapse:collapse;width:100%;margin-top:1rem}
th,td{text-align:left;padding:.5rem .7rem;border-bottom:1px solid #21262d;font-size:.88rem}
th{color:#8b949e;font-weight:600;font-size:.8rem;text-transform:uppercase}
.flower{flex:0 0 auto}
"""
# Inline sunflower (matches the summary card; no emoji font dependency in the page header).
_FLOWER = (
'<svg class="flower" width="26" height="26" viewBox="0 0 28 28">'
'<g fill="#f0b429">'
+ "".join(
f'<ellipse cx="14" cy="5.5" rx="2.6" ry="5.5" transform="rotate({a} 14 14)"/>'
for a in range(0, 360, 45)
)
+ '</g><circle cx="14" cy="14" r="5" fill="#7a4f1d"/></svg>'
)
def _level_pill(level):
"""The big corner LEVEL badge (R5). '' (grey) when no results.json level yet."""
if level is None:
return '<span class="lvl" style="background:#8b949e">level —</span>'
return f'<span class="lvl" style="background:{level_color(level)}">level {int(level)}</span>'
def _flags_html(flags):
out = []
if flags.get("clean_teardown"):
out.append('<span title="clean teardown">✔ teardown</span>')
if flags.get("no_secret_leak"):
out.append('<span title="no secret leak">✔ no-leak</span>')
return f'<div class="flags">{"".join(out)}</div>' if out else ""
def _card(r):
color = _COLORS.get(r["status"], "#8b949e")
num = r["number"]
run_url = html.escape(r["url"])
# Screenshot thumbnail (clickable → full summary card). Placeholder when no screenshot captured.
if r["has_screenshot"]:
shot = (
f'<a class="shot" href="/runs/{num}/summary.png" '
f'style="background-image:url(/runs/{num}/screenshot.png)" '
f'title="view summary card"><span>{_level_pill(r["level"])}</span></a>'
)
else:
shot = (
f'<a class="shot" href="{run_url}" title="open run">'
f'<span class="ph">no screenshot</span>{_level_pill(r["level"])}</a>'
)
cap = f'<div class="cap">{html.escape(r["level_cap_reason"])}</div>' if r["level_cap_reason"] else ""
return (
f'<div class="card">{shot}<div class="body">'
f'<div class="name">{html.escape(r["recipe"])}</div>'
f'<div class="row"><span class="pill" style="background:{color}">{html.escape(r["status"])}</span>'
f'<code>{html.escape(r["version"])}</code></div>'
f"{cap}{_flags_html(r['flags'])}"
f'<div class="foot"><a href="{run_url}">run #{num} · {_ago(r["finished"])}</a>'
f'<a href="/recipe/{html.escape(r["recipe"])}">history →</a></div>'
f"</div></div>"
)
def _page(title, inner):
return (
f'<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>'
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
f'<meta http-equiv="refresh" content="30"><style>{_PAGE_CSS}</style></head>'
f'<body><div class="wrap">{inner}</div></body></html>'
)
def render_overview(rows):
cards = "\n".join(_card(r) for r in rows) or '<p class="sub">no recipe runs yet</p>'
inner = (
f"<h1>{_FLOWER} cc-ci — Co-op Cloud recipe CI</h1>"
'<p class="sub">Latest <code>!testme</code> run per enrolled recipe — level, status, version, '
"app screenshot. Click a card for its summary card; “history” for past runs. "
"Auto-refreshes every 30s.</p>"
f'<div class="grid">{cards}</div>'
)
return _page("cc-ci — Co-op Cloud recipe CI", inner)
def render_history(recipe, rows):
trs = []
for r in rows:
color = _COLORS.get(r["status"], "#8b949e")
lvl = "" if r["level"] is None else f'<b style="color:{level_color(r["level"])}">L{int(r["level"])}</b>'
shot = f'<a href="/runs/{r["number"]}/summary.png">card</a>' if r["has_screenshot"] else ""
trs.append(
f'<tr><td><b>{html.escape(r["recipe"])}</b></td>'
f'<td><span class="badge" style="background:{color}">{html.escape(r["status"])}</span></td>'
f'<td><code>{html.escape(r["ref"]) or ""}</code></td>'
f'<td>{_ago(r["finished"])}</td>'
f'<td><a href="{html.escape(r["url"])}">run #{r["number"]}</a></td></tr>'
f'<tr><td><a href="{html.escape(r["url"])}">#{r["number"]}</a></td>'
f'<td><span class="pill" style="background:{color}">{html.escape(r["status"])}</span></td>'
f"<td>{lvl}</td><td><code>{html.escape(r['version'])}</code></td>"
f'<td>{_ago(r["finished"])}</td><td>{shot}</td></tr>'
)
body = "\n".join(trs) or '<tr><td colspan="5">no recipe runs yet</td></tr>'
return f"""<!doctype html><html><head><meta charset="utf-8">
<title>cc-ci — Co-op Cloud recipe CI</title>
<meta http-equiv="refresh" content="30">
<style>
body{{font-family:system-ui,sans-serif;background:#0d1117;color:#c9d1d9;margin:2rem auto;max-width:900px;padding:0 1rem}}
h1{{font-size:1.4rem}} a{{color:#58a6ff}} table{{border-collapse:collapse;width:100%;margin-top:1rem}}
th,td{{text-align:left;padding:.5rem .75rem;border-bottom:1px solid #21262d}}
th{{color:#8b949e;font-weight:600;font-size:.85rem;text-transform:uppercase}}
.badge{{color:#fff;padding:.1rem .5rem;border-radius:.5rem;font-size:.8rem;font-weight:600}}
.sub{{color:#8b949e;font-size:.85rem}}
</style></head><body>
<h1>cc-ci — Co-op Cloud recipe CI</h1>
<p class="sub">Latest <code>!testme</code> run per enrolled recipe. Per-run logs live in Drone.
Auto-refreshes every 30s.</p>
<table><thead><tr><th>Recipe</th><th>Status</th><th>Ref</th><th>Last run</th><th>Run</th></tr></thead>
<tbody>{body}</tbody></table>
</body></html>"""
body = "\n".join(trs) or '<tr><td colspan="6">no runs for this recipe yet</td></tr>'
inner = (
f'<h1>{_FLOWER} {html.escape(recipe)} — run history</h1>'
'<p class="sub"><a href="/">← all recipes</a> · every <code>!testme</code> run, newest first.</p>'
"<table><thead><tr><th>Run</th><th>Status</th><th>Level</th><th>Version</th>"
"<th>When</th><th>Card</th></tr></thead><tbody>"
f"{body}</tbody></table>"
)
return _page(f"{recipe} — cc-ci history", inner)
def render_badge(recipe, status):
@ -211,6 +374,12 @@ class Handler(BaseHTTPRequestHandler):
if got is not None:
return 200, got[1], got[0]
return 404, "not found", "text/plain"
if path.startswith("/recipe/"):
recipe = path[len("/recipe/") :]
if _RUN_ID_RE.match(recipe):
rows = history_for(recipe) or []
return 200, render_history(recipe, rows), "text/html; charset=utf-8"
return 404, "not found", "text/plain"
if path == "/":
return 200, render_overview(recipes_cached()), "text/html; charset=utf-8"
return 404, "not found", "text/plain"

View File

@ -0,0 +1,124 @@
"""Phase 3 U4 — dashboard YunoHost-style grid + per-recipe history (pure-render + helpers).
The dashboard reads a Drone admin token at import; point DRONE_TOKEN_FILE at a temp file so the
module imports without the real secret. All tests here are pure (no network): they exercise the
rendering + results.json projection, asserting the grid/history mirror the artifact and never present
a run greener than its data (R5 / cardinal guardrail)."""
from __future__ import annotations
import json
import os
import sys
import tempfile
_tok = tempfile.NamedTemporaryFile("w", delete=False, suffix=".tok")
_tok.write("test-token")
_tok.close()
os.environ["DRONE_TOKEN_FILE"] = _tok.name
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "dashboard"))
import dashboard # noqa: E402
def _row(**kw):
base = {
"recipe": "custom-html", "status": "success", "number": 4, "ref": "db9a9502",
"version": "db9a95024e9d", "level": 4, "level_cap_reason": "L5 integration N/A",
"has_screenshot": True, "flags": {"clean_teardown": True, "no_secret_leak": True},
"finished": 0, "url": "https://drone.x/cc-ci/4",
}
base.update(kw)
return base
def test_level_color_ramp_and_fallback():
assert dashboard.level_color(0) == "#e5534b"
assert dashboard.level_color(6) == "#3fb950"
assert dashboard.level_color(4) == "#a0b93f"
assert dashboard.level_color(99) == "#8b949e"
assert dashboard.level_color(None) == "#8b949e"
def test_overview_grid_mirrors_results():
out = dashboard.render_overview([_row()])
assert "custom-html" in out
assert "level 4" in out # the corner level pill
assert dashboard.level_color(4) in out # coloured by level
assert "db9a95024e9d" in out # version from results.json
assert "/runs/4/screenshot.png" in out # thumbnail
assert "/runs/4/summary.png" in out # links to full card
assert "/recipe/custom-html" in out # history link
assert "✔ teardown" in out and "✔ no-leak" in out
def test_overview_never_greener_than_data():
# A failed run at level 0 must show level 0 + the failure pill — never a green/high level.
out = dashboard.render_overview([_row(status="failure", level=0, has_screenshot=False,
flags={}, level_cap_reason="L1 install FAILED")])
assert "level 0" in out
assert dashboard.level_color(0) in out # red
assert dashboard._COLORS["failure"] in out
assert "level 4" not in out and "level 5" not in out and "level 6" not in out
assert "no screenshot" in out # placeholder, no broken image
def test_level_pill_unknown_when_no_results():
assert "level —" in dashboard._level_pill(None)
assert "#8b949e" in dashboard._level_pill(None)
def test_history_table_lists_runs():
out = dashboard.render_history("custom-html", [_row(number=4), _row(number=3, level=2)])
assert "custom-html — run history" in out
assert "#4" in out and "#3" in out
assert "L4" in out and "L2" in out
assert "← all recipes" in out
assert "/runs/4/summary.png" in out # per-run card link
def test_history_empty():
out = dashboard.render_history("hedgedoc", [])
assert "no runs for this recipe yet" in out
def test_build_row_projects_results(monkeypatch):
monkeypatch.setattr(dashboard, "_results_for", lambda n: {
"version": "1.2.3", "level": 2, "level_cap_reason": "cap",
"screenshot": "screenshot.png", "flags": {"clean_teardown": True},
})
b = {"number": 7, "status": "success", "event": "custom",
"params": {"RECIPE": "n8n", "REF": "abcdef1234567890"}, "finished": 10}
r = dashboard._build_row(b)
assert r["recipe"] == "n8n" and r["number"] == 7
assert r["level"] == 2 and r["version"] == "1.2.3"
assert r["has_screenshot"] is True
assert r["url"].endswith("/cc-ci/7")
def test_build_row_degrades_without_results(monkeypatch):
# No results.json (e.g. an old run): grid still renders from Drone fields, level absent.
monkeypatch.setattr(dashboard, "_results_for", lambda n: {})
b = {"number": 9, "status": "running", "event": "custom",
"params": {"RECIPE": "ghost", "REF": "deadbeefcafe1234567890"}, "finished": 0}
r = dashboard._build_row(b)
assert r["level"] is None and r["has_screenshot"] is False
assert r["version"] == "deadbeefcafe" # ref[:12] fallback
# render must not crash or claim a level
assert "level —" in dashboard.render_overview([r])
def test_results_for_traversal_guarded():
with tempfile.TemporaryDirectory() as d:
os.makedirs(os.path.join(d, "5"))
with open(os.path.join(d, "5", "results.json"), "w") as f:
json.dump({"level": 3}, f)
orig = dashboard.CCCI_RUNS_DIR
dashboard.CCCI_RUNS_DIR = d
try:
assert dashboard._results_for("5") == {"level": 3}
assert dashboard._results_for("../../etc") == {} # traversal rejected
assert dashboard._results_for("nonexist") == {} # missing → {}
assert dashboard._results_for("") == {}
finally:
dashboard.CCCI_RUNS_DIR = orig