M8/D7: results dashboard — overview + SVG badges at ci.commoninternet.net
All checks were successful
continuous-integration/drone/push Build is passing
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:
182
dashboard/dashboard.py
Normal file
182
dashboard/dashboard.py
Normal 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()
|
||||
@ -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
86
modules/dashboard.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user