#!/usr/bin/env python3 """cc-ci results dashboard (§4.5, D7). A small stdlib HTTP service served at `ci.commoninternet.net` (root; the comment-bridge keeps the more-specific `/hook` route). It polls the Drone API for the cc-ci repo's recipe-CI builds (event=custom, which carry the RECIPE build param), groups the latest run per recipe, and renders a YunoHost-CI-like overview: a table of recipes with a pass/fail/running status badge, last-tested ref, when, and a link to the canonical Drone run. Also serves an embeddable SVG badge per recipe at `/badge/.svg`. Read-only (Drone API token, never written to the page). Python stdlib only. Config (env): DRONE_URL, CI_REPO, DRONE_TOKEN_FILE, DASH_LISTEN (default 0.0.0.0:8080), POLL_INTERVAL (default 60), CACHE_TTL (default 30). """ import html import json import os import sys import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net") CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") CACHE_TTL = int(os.environ.get("CACHE_TTL", "30")) def _read(path): with open(path) as fh: return fh.read().strip() DRONE_TOKEN = _read(os.environ["DRONE_TOKEN_FILE"]) _CACHE = {"ts": 0.0, "recipes": []} _COLORS = { "success": "#3fb950", "failure": "#f85149", "error": "#f85149", "running": "#d29922", "pending": "#d29922", "killed": "#8b949e", } def log(*a): print(*a, file=sys.stderr, flush=True) def _drone(path): req = urllib.request.Request( f"{DRONE_URL}{path}", headers={"Authorization": f"Bearer {DRONE_TOKEN}"} ) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read()) def latest_per_recipe(): """Latest recipe-CI build per recipe (event=custom builds carry the RECIPE param).""" 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 = {} for b in builds or []: if b.get("event") != "custom": continue recipe = (b.get("params") or {}).get("RECIPE") if not recipe: 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): 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 def recipes_cached(): now = time.time() if now - _CACHE["ts"] > CACHE_TTL: fresh = latest_per_recipe() if fresh is not None: _CACHE["recipes"] = fresh _CACHE["ts"] = now return _CACHE["recipes"] def _ago(ts): if not ts: return "—" d = int(time.time() - ts) if d < 60: return f"{d}s ago" if d < 3600: return f"{d // 60}m ago" if d < 86400: return f"{d // 3600}h ago" return f"{d // 86400}d ago" def render_overview(rows): trs = [] for r in rows: color = _COLORS.get(r["status"], "#8b949e") 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"]}' ) 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
""" def render_badge(recipe, status): color = _COLORS.get(status, "#8b949e") label, msg = "cc-ci", status lw, mw = 44, max(40, 7 * len(msg) + 10) w = lw + mw return f""" {html.escape(label)} {html.escape(msg)}""" class Handler(BaseHTTPRequestHandler): def _send(self, code, body, ctype="text/html; charset=utf-8"): data = body.encode() if isinstance(body, str) else body self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def do_GET(self): path = self.path.split("?")[0].rstrip("/") or "/" if path in ("/healthz", "/dashboard/healthz"): return self._send(200, "ok", "text/plain") if path.startswith("/badge/") and path.endswith(".svg"): recipe = path[len("/badge/") : -len(".svg")] row = next((r for r in recipes_cached() if r["recipe"] == recipe), None) status = row["status"] if row else "unknown" return self._send(200, render_badge(recipe, status), "image/svg+xml") if path == "/": return self._send(200, render_overview(recipes_cached())) return self._send(404, "not found", "text/plain") def log_message(self, *a): pass def main(): host, _, port = os.environ.get("DASH_LISTEN", "0.0.0.0:8080").rpartition(":") srv = ThreadingHTTPServer((host or "0.0.0.0", int(port)), Handler) log(f"dashboard listening on {host or '0.0.0.0'}:{port}") srv.serve_forever() if __name__ == "__main__": main()