diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index 9960115..705bdc4 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -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 = ( + '' + '' + + "".join( + f'' + for a in range(0, 360, 45) + ) + + '' +) + + +def _level_pill(level): + """The big corner LEVEL badge (R5). '—' (grey) when no results.json level yet.""" + if level is None: + return 'level —' + return f'level {int(level)}' + + +def _flags_html(flags): + out = [] + if flags.get("clean_teardown"): + out.append('✔ teardown') + if flags.get("no_secret_leak"): + out.append('✔ no-leak') + return f'
{"".join(out)}
' 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'{_level_pill(r["level"])}' + ) + else: + shot = ( + f'' + f'no screenshot{_level_pill(r["level"])}' + ) + cap = f'
{html.escape(r["level_cap_reason"])}
' if r["level_cap_reason"] else "" + return ( + f'
{shot}
' + f'
{html.escape(r["recipe"])}
' + f'
{html.escape(r["status"])}' + f'{html.escape(r["version"])}
' + f"{cap}{_flags_html(r['flags'])}" + f'' + f"
" + ) + + +def _page(title, inner): + return ( + f'{html.escape(title)}' + f'' + f'' + f'
{inner}
' + ) + + def render_overview(rows): + cards = "\n".join(_card(r) for r in rows) or '

no recipe runs yet

' + inner = ( + f"

{_FLOWER} cc-ci — Co-op Cloud recipe CI

" + '

Latest !testme run per enrolled recipe — level, status, version, ' + "app screenshot. Click a card for its summary card; “history” for past runs. " + "Auto-refreshes every 30s.

" + f'
{cards}
' + ) + 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'L{int(r["level"])}' + shot = f'card' if r["has_screenshot"] else "—" trs.append( - f'{html.escape(r["recipe"])}' - f'{html.escape(r["status"])}' - f'{html.escape(r["ref"]) or "—"}' - f'{_ago(r["finished"])}' - f'run #{r["number"]}' + f'#{r["number"]}' + f'{html.escape(r["status"])}' + f"{lvl}{html.escape(r['version'])}" + f'{_ago(r["finished"])}{shot}' ) - body = "\n".join(trs) or 'no recipe runs yet' - return f""" -cc-ci — Co-op Cloud recipe CI - - -

cc-ci — Co-op Cloud recipe CI

-

Latest !testme run per enrolled recipe. Per-run logs live in Drone. -Auto-refreshes every 30s.

- -{body}
RecipeStatusRefLast runRun
-""" + body = "\n".join(trs) or 'no runs for this recipe yet' + inner = ( + f'

{_FLOWER} {html.escape(recipe)} — run history

' + '

← all recipes · every !testme run, newest first.

' + "" + "" + f"{body}
RunStatusLevelVersionWhenCard
" + ) + 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" diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py new file mode 100644 index 0000000..1c7ff49 --- /dev/null +++ b/tests/unit/test_dashboard.py @@ -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