M8/D7: results dashboard — overview + SVG badges at ci.commoninternet.net
All checks were successful
continuous-integration/drone/push Build is passing

Stdlib HTTP service (like the bridge): polls the Drone API for recipe-CI builds (event=custom),
groups latest-run-per-recipe, renders a YunoHost-CI-like overview table with pass/fail/running
badges + links to the canonical Drone run, plus /badge/<recipe>.svg. Nix-built OCI image, swarm
service on proxy, traefik Host(ci.commoninternet.net) (the bridge's /hook rule stays higher
priority by length). Reuses the Drone token (read-only). Reconcile oneshot like bridge/drone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 07:17:12 +01:00
parent 8b4dc16227
commit 60d917646b
3 changed files with 269 additions and 0 deletions

182
dashboard/dashboard.py Normal file
View File

@ -0,0 +1,182 @@
#!/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
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()

View File

@ -13,6 +13,7 @@
../../modules/drone.nix
../../modules/drone-runner.nix
../../modules/bridge.nix
../../modules/dashboard.nix
../../modules/backupbot.nix
../../modules/harness.nix
];

86
modules/dashboard.nix Normal file
View File

@ -0,0 +1,86 @@
# Results dashboard (§4.5, D7): the YunoHost-CI-like overview at ci.commoninternet.net. Reads the
# Drone API (read-only) and renders latest-run-per-recipe + SVG badges. Packaged as a Nix-built OCI
# image and run as a swarm service on `proxy`, routed by traefik at Host(ci.commoninternet.net) — the
# comment-bridge's Host && PathPrefix(`/hook`) rule is longer, so /hook still wins (priority by rule
# length). Deployed by an idempotent-reconcile oneshot (same pattern as bridge/drone).
{ pkgs, ... }:
let
dashApp = pkgs.runCommand "cc-ci-dashboard-app" { } ''
mkdir -p $out/app
cp ${../dashboard/dashboard.py} $out/app/dashboard.py
'';
image = pkgs.dockerTools.buildLayeredImage {
name = "cc-ci-dashboard";
tag = "latest";
contents = [ pkgs.python3 pkgs.cacert dashApp ];
config = {
Cmd = [ "${pkgs.python3}/bin/python3" "/app/dashboard.py" ];
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
ExposedPorts = { "8080/tcp" = { }; };
};
};
stack = pkgs.writeText "cc-ci-dashboard-stack.yml" ''
version: "3.8"
services:
app:
image: cc-ci-dashboard:latest
environment:
- DRONE_URL=https://drone.ci.commoninternet.net
- CI_REPO=recipe-maintainers/cc-ci
- DASH_LISTEN=0.0.0.0:8080
- DRONE_TOKEN_FILE=/run/secrets/drone_token
secrets:
- drone_token
networks:
- proxy
deploy:
replicas: 1
restart_policy:
condition: any
labels:
- "traefik.enable=true"
- "traefik.http.services.ccci-dashboard.loadbalancer.server.port=8080"
- "traefik.http.routers.ccci-dashboard.rule=Host(`ci.commoninternet.net`)"
- "traefik.http.routers.ccci-dashboard.entrypoints=web-secure"
- "traefik.http.routers.ccci-dashboard.tls=true"
networks:
proxy:
external: true
secrets:
drone_token:
external: true
name: cc_ci_dashboard_drone_token_v1
'';
reconcile = pkgs.writeShellApplication {
name = "cc-ci-reconcile-dashboard";
runtimeInputs = with pkgs; [ docker coreutils ];
text = ''
if [ ! -r /run/secrets/bridge_drone_token ]; then
echo "FATAL: /run/secrets/bridge_drone_token missing (rebuild ordering?)" >&2
exit 1
fi
docker load -i ${image}
# Dashboard reads the Drone API read-only; reuse the same Drone token value as the bridge.
docker secret inspect cc_ci_dashboard_drone_token_v1 >/dev/null 2>&1 \
|| docker secret create cc_ci_dashboard_drone_token_v1 /run/secrets/bridge_drone_token >/dev/null
docker stack deploy --detach=true -c ${stack} ccci-dashboard
'';
};
in
{
systemd.services.deploy-dashboard = {
description = "Reconcile the cc-ci results dashboard (overview + badges) swarm service";
after = [ "deploy-proxy.service" "swarm-init.service" "docker.service" "network-online.target" ];
requires = [ "swarm-init.service" "docker.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${reconcile}/bin/cc-ci-reconcile-dashboard";
};
};
}