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 = ( + '' +) + + +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'
{html.escape(r["version"])}no recipe runs yet
' + inner = ( + f"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.
{html.escape(r["ref"]) or "—"}{html.escape(r['version'])}Latest !testme run per enrolled recipe. Per-run logs live in Drone.
-Auto-refreshes every 30s.
| Recipe | Status | Ref | Last run | Run |
|---|
← all recipes · every !testme run, newest first.
| Run | Status | Level | Version | " + "When | Card |
|---|