Files
cc-ci/dashboard/dashboard.py
autonomic-bot 2cede01ed7 style(1b): auto-format + lint-clean the whole codebase (RL1)
Mechanical, semantics-preserving cleanup so the codebase passes the new lint stage:
- ruff format: all 32 Python files (wraps long signatures, normalizes quotes/blank lines).
- nixpkgs-fmt: modules/drone-runner.nix.
- shfmt (-i 2 -ci): scripts/*.sh.

Lint fixes (reviewed, behavior-preserving — no test weakened):
- ruff SIM105: try/except-pass -> contextlib.suppress (abra.py app_config rm; lifecycle.py janitor).
- ruff SIM115: open().read() -> with open() (run_recipe_ci.py redaction-values + gitea-token).
- statix: merge repeated sops `secrets.*` keys into one `secrets = { ... }` (comments kept);
  empty fn pattern `{ ... }:` -> `_:` (packages.nix).
- deadnix: drop unused lambda args (flake `self`; configuration.nix `lib`; overlay `final` -> `_`).

Verified on cc-ci: `scripts/lint.sh` -> lint: PASS; nixosConfigurations.cc-ci evaluates;
all Python byte-compiles. The deployed bridge/dashboard/runner source changes hash (reformat),
so cc-ci will be rebuilt to the new closure in W2 before the cold D1-D10 re-verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:52:05 +01:00

197 lines
7.0 KiB
Python

#!/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/<recipe>.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'<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>'
)
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>"""
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"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img">
<rect width="{lw}" height="20" fill="#555"/><rect x="{lw}" width="{mw}" height="20" fill="{color}"/>
<g fill="#fff" font-family="Verdana,sans-serif" font-size="11">
<text x="6" y="14">{html.escape(label)}</text>
<text x="{lw + 6}" y="14">{html.escape(msg)}</text></g></svg>"""
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()