diff --git a/src_docs/source/conf.py b/src_docs/source/conf.py
index a8c2765ea..cffe1fa75 100644
--- a/src_docs/source/conf.py
+++ b/src_docs/source/conf.py
@@ -106,7 +106,8 @@
"223086153-522289f3-9902-4f63-ad7b-a7d9c5789db0.png"
)
-html_extra_path = ["CNAME"]
+# Copied as is into the site root, so the dashboard page is served at /dashboard/.
+html_extra_path = ["CNAME", "extra"]
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
diff --git a/src_docs/source/extra/dashboard/dashboard.css b/src_docs/source/extra/dashboard/dashboard.css
new file mode 100644
index 000000000..46b51d8e8
--- /dev/null
+++ b/src_docs/source/extra/dashboard/dashboard.css
@@ -0,0 +1,195 @@
+/* Layout: three columns on wide screens. Left rail = filters and test-run list (sticky), centre = volume tiles and trend charts, right rail = impact (issues), releases tested, notes. Stacks on narrow screens. No status colours: the page shows volume and impact only. */
+:root {
+ --bg: #f3f5f8;
+ --panel: #ffffff;
+ --rail: #eef1f5;
+ --fg: #182230;
+ --muted: #5a6577;
+ --line: #dfe4eb;
+ --accent: #2a55c9;
+ --upgrade: #7a4cc2;
+ --teal: #12857a;
+ --amber: #b26a00;
+ --soft: #e8eefc;
+ --note-bg: #fff6e0;
+ --note-fg: #6b4e00;
+ --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-num: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #0f131a; --panel: #18202a; --rail: #141a23; --fg: #e5e9f0; --muted: #98a2b3; --line: #2a3340;
+ --accent: #7d9bff; --upgrade: #b38cf0; --teal: #4cc7b8; --amber: #f0b454; --soft: #1d2a48;
+ --note-bg: #2e2612; --note-fg: #f0d58a; color-scheme: dark;
+ }
+}
+:root[data-theme="dark"] {
+ --bg: #0f131a; --panel: #18202a; --rail: #141a23; --fg: #e5e9f0; --muted: #98a2b3; --line: #2a3340;
+ --accent: #7d9bff; --upgrade: #b38cf0; --teal: #4cc7b8; --amber: #f0b454; --soft: #1d2a48;
+ --note-bg: #2e2612; --note-fg: #f0d58a; color-scheme: dark;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 var(--font-ui); }
+
+.shell {
+ display: grid;
+ grid-template-columns: 250px minmax(0, 1fr) 300px;
+ grid-template-areas: "left main right";
+ gap: 20px;
+ max-width: 1640px;
+ margin: 0 auto;
+ padding-inline: 16px;
+ padding-block: 16px 32px;
+ align-items: start;
+}
+.left { grid-area: left; }
+.main { grid-area: main; display: grid; gap: 16px; min-width: 0; }
+.right { grid-area: right; display: grid; gap: 16px; min-width: 0; }
+@media (max-width: 1240px) {
+ .shell { grid-template-columns: 240px minmax(0, 1fr); grid-template-areas: "left main" "left right"; }
+ .right { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
+}
+@media (max-width: 820px) {
+ .shell { grid-template-columns: minmax(0, 1fr); grid-template-areas: "left" "main" "right"; }
+ .left { position: static !important; max-height: none !important; }
+}
+
+/* Left rail */
+.left {
+ position: sticky;
+ top: calc(env(safe-area-inset-top, 0px) + 16px);
+ max-height: calc(100vh - 32px);
+ overflow-y: auto;
+ background: var(--rail);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 16px;
+ display: grid;
+ gap: 18px;
+}
+.brand { display: grid; gap: 2px; }
+.brand b { font-size: 16px; }
+.brand span { color: var(--muted); font-size: 12px; }
+.ctl { display: grid; gap: 6px; }
+.ctl-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; }
+.seg { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: var(--panel); }
+.seg button { font: inherit; font-size: 13px; color: var(--fg); background: transparent; border: 0; border-right: 1px solid var(--line); padding: 6px 4px; cursor: pointer; }
+.seg button:last-child { border-right: 0; }
+.seg button[aria-pressed="true"] { background: var(--accent); color: var(--bg); }
+/* Five buttons on two full rows: three across, then two across. */
+.seg.wrap { grid-auto-flow: row; grid-template-columns: repeat(6, minmax(0, 1fr)); }
+.seg.wrap button { white-space: nowrap; grid-column: span 2; }
+.seg.wrap button:nth-child(n+4) { grid-column: span 3; border-top: 1px solid var(--line); }
+.seg.wrap button:nth-child(3) { border-right: 0; }
+button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.runlist { display: grid; gap: 2px; list-style: none; margin: 0; padding: 0; }
+.runlist button {
+ width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; gap: 8px; align-items: center;
+ font: inherit; font-size: 14px; color: var(--fg); background: transparent; border: 0; border-radius: 6px;
+ padding: 6px 8px; cursor: pointer; text-align: left;
+}
+.runlist button:hover { background: var(--panel); }
+.runlist button[aria-pressed="true"] { background: var(--panel); box-shadow: inset 3px 0 0 var(--accent); }
+.runlist .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.runlist .ct { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+.kind { width: 8px; height: 8px; border-radius: 2px; background: var(--muted); }
+.kind.regression { background: var(--accent); }
+.kind.upgrade { background: var(--upgrade); }
+.kind.node-sync { background: var(--teal); }
+.kind.db-sync { background: var(--amber); }
+.kind.local { background: transparent; border: 1.5px dashed var(--muted); }
+
+/* Main */
+.head h1 { margin: 0; font-size: 26px; letter-spacing: -0.01em; text-wrap: balance; }
+.head p { margin: 4px 0 0; color: var(--muted); }
+h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; }
+.hero { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; background: var(--soft); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; }
+@media (max-width: 700px) { .hero { grid-template-columns: minmax(0, 1fr); } }
+.hero .label { color: var(--muted); }
+.hero .value { font-size: 34px; font-weight: 700; }
+.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
+@media (max-width: 1000px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.tile, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
+.tile { padding: 14px 16px; }
+.panel { padding: 16px; }
+.label { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
+.value { display: block; font-size: 28px; font-weight: 650; font-variant-numeric: tabular-nums; }
+.hint { display: block; color: var(--muted); font-size: 12px; }
+.hint.all { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--line); color: var(--fg); }
+.about-tests { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }
+@media (max-width: 900px) { .about-tests { grid-template-columns: minmax(0, 1fr); } }
+.about-tests p { margin: 0; font-size: 14px; }
+.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+@media (max-width: 1000px) { .row2 { grid-template-columns: 1fr; } }
+svg { display: block; width: 100%; height: auto; }
+.axis { stroke: var(--line); }
+.grid { stroke: var(--line); stroke-dasharray: 2 3; }
+.tick { fill: var(--muted); font: 11px var(--font-ui); }
+.regression { fill: var(--accent); background: var(--accent); }
+.upgrade { fill: var(--upgrade); background: var(--upgrade); }
+.node-sync { fill: var(--teal); background: var(--teal); }
+.db-sync { fill: var(--amber); background: var(--amber); }
+.tests { fill: var(--teal); background: var(--teal); }
+.area { fill: var(--teal); fill-opacity: 0.16; }
+.lineS { fill: none; stroke: var(--teal); stroke-width: 2.2; }
+.lineA { fill: none; stroke: var(--accent); stroke-width: 2.2; }
+.endpt { fill: var(--teal); }
+.endptA { fill: var(--accent); }
+.empty { fill: var(--muted); font: 13px var(--font-ui); }
+.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; margin: 0 0 8px; padding: 0; color: var(--muted); font-size: 13px; }
+.sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
+.table-wrap { overflow-x: auto; }
+table { width: 100%; border-collapse: collapse; font-size: 14px; }
+th, td { padding: 7px 8px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; vertical-align: middle; }
+th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
+td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+td.mono { font-family: var(--font-num); font-size: 13px; }
+td.none { color: var(--muted); text-align: center; padding: 18px; }
+.tag { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
+.tag.t-regression { border-color: var(--accent); color: var(--accent); }
+.tag.t-upgrade { border-color: var(--upgrade); color: var(--upgrade); }
+.tag.t-node-sync { border-color: var(--teal); color: var(--teal); }
+.tag.t-db-sync { border-color: var(--amber); color: var(--amber); }
+.tag.release { border-color: var(--fg); color: var(--fg); margin-left: 6px; }
+.tag.local { border-style: dashed; margin-left: 6px; }
+.steps { color: var(--muted); font-size: 12px; margin-left: 6px; }
+
+/* Right rail */
+.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; display: grid; gap: 10px; min-width: 0; }
+.card h3 { margin: 0; font-size: 13px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
+.impact-panel { display: grid; gap: 14px; border-left: 4px solid var(--teal); }
+.impact-head { display: flex; flex-wrap: wrap; gap: 4px 16px; align-items: baseline; justify-content: space-between; }
+.impact-head h2 { margin: 0; font-size: 17px; }
+.impact { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
+@media (max-width: 700px) { .impact { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.impact div { display: grid; gap: 2px; }
+.impact b { font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--teal); }
+.impact span { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
+.comps { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.comps li { display: grid; grid-template-columns: 150px minmax(0, 1fr) 40px; gap: 8px; align-items: center; font-size: 13px; }
+.comps .bar { display: block; height: 10px; border-radius: 3px; background: var(--teal); }
+.comps .n { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
+.issue-link { color: var(--fg); text-decoration: none; }
+.issue-link:hover { text-decoration: underline; }
+.issue-link .ref { color: var(--muted); font-family: var(--font-num); font-size: 12px; margin-right: 6px; }
+.tag.t-bug { border-color: var(--teal); color: var(--teal); }
+.state { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
+.state.resolved { border-color: var(--teal); color: var(--teal); }
+.releases { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.releases li { display: flex; justify-content: space-between; gap: 8px; font-size: 14px; }
+.releases .sub { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+.about dl { margin: 0; display: grid; gap: 6px; font-size: 13px; }
+.about dt { font-weight: 600; }
+.about dd { margin: 0; color: var(--muted); }
+.about a { color: var(--accent); }
+.foot { color: var(--muted); font-size: 12px; margin: 0; }
+[hidden] { display: none !important; }
+.stale { color: var(--muted); }
+
+/* Sync tests panel */
+.sync-tiles { margin: 12px 0; }
+.sync-ctl { max-width: 420px; margin: 0 0 12px; }
+.sync-charts { margin-bottom: 0; }
+.mainnet { fill: var(--accent); background: var(--accent); }
+.preview { fill: var(--teal); background: var(--teal); }
+.preprod { fill: var(--upgrade); background: var(--upgrade); }
diff --git a/src_docs/source/extra/dashboard/dashboard.js b/src_docs/source/extra/dashboard/dashboard.js
new file mode 100644
index 000000000..381b31f6f
--- /dev/null
+++ b/src_docs/source/extra/dashboard/dashboard.js
@@ -0,0 +1,477 @@
+"use strict";
+
+(function () {
+ const SOURCE = document.querySelector('meta[name="dashboard-data"]').content;
+ const REFRESH_MS = 60000;
+ const DAY_MS = 86400000;
+ const LATEST_ROWS = 12;
+ const THEME_KEY = "cardano-test-runs-theme";
+ const SYNC_TYPES = ["node-sync", "db-sync"];
+ const TYPE_LABELS = { regression: "Regression", upgrade: "Upgrade", "node-sync": "Node sync", "db-sync": "db-sync" };
+ const SVG = "http://www.w3.org/2000/svg";
+
+ let data = null;
+ let days = 30;
+ let kind = "all";
+ let source = "all";
+ let network = "all";
+ let only = null;
+
+ // Small DOM helpers. Data is only ever set as text, never as markup.
+ const svgEl = (tag, attrs, text) => {
+ const n = document.createElementNS(SVG, tag);
+ for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, v);
+ if (text !== undefined) n.textContent = text;
+ return n;
+ };
+ const h = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined) n.textContent = text;
+ return n;
+ };
+ const byId = (id) => document.getElementById(id);
+ const fmt = (n) => Math.round(n).toLocaleString("en-US");
+ const compact = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e4 ? `${(n / 1e3).toFixed(0)}k` : fmt(n));
+ const dur = (s) => {
+ const hh = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ return hh ? `${fmt(hh)}h ${m}m` : `${m}m`;
+ };
+ const stamp = (iso) => iso.slice(0, 16).replace("T", " ");
+ const dateOf = (ms) => new Date(ms).toISOString().slice(0, 10);
+
+ function valid(doc) {
+ return Boolean(doc) && doc.schema === 1 && typeof doc.generated_at === "string" && doc.all_time && doc.all_time.by_source &&
+ ["test_runs", "daily", "latest", "releases"].every((k) => Array.isArray(doc[k]));
+ }
+
+ // A testrun is a name plus its source: a local "nightly" is not the CI "nightly".
+ const keyOf = (e) => `${e.name}|${e.source}`;
+ function typeOf(name) {
+ const t = data.test_runs.find((r) => r.name === name);
+ return t ? t.type : "regression";
+ }
+ const isRelease = (name) => data.test_runs.some((r) => r.name === name && r.release);
+ const kindMatch = (e) => kind === "all" || typeOf(e.name) === kind;
+ const sourceMatch = (e) => source === "all" || e.source === source;
+ const filterMatch = (e) => kindMatch(e) && sourceMatch(e) && (!only || keyOf(e) === only);
+
+ function niceMax(v) {
+ const p = Math.pow(10, Math.floor(Math.log10(Math.max(v, 1))));
+ for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p;
+ return 10 * p;
+ }
+
+ const W = 560;
+ const H = 220;
+ const PAD = { t: 10, r: 10, b: 24, l: 52 };
+ const PW = W - PAD.l - PAD.r;
+ const PH = H - PAD.t - PAD.b;
+
+ function axes(svg, rows, lo, hi) {
+ for (const f of [0, 0.5, 1]) {
+ const y = PAD.t + PH - f * PH;
+ svg.appendChild(svgEl("line", { class: f ? "grid" : "axis", x1: PAD.l, x2: W - PAD.r, y1: y, y2: y }));
+ svg.appendChild(svgEl("text", { class: "tick", x: PAD.l - 6, y: y + 4, "text-anchor": "end" }, compact(lo + (hi - lo) * f)));
+ }
+ const step = PW / rows.length;
+ const every = Math.ceil(rows.length / 6);
+ rows.forEach((r, i) => {
+ if (i % every === 0 || i === rows.length - 1) {
+ svg.appendChild(svgEl("text", { class: "tick", x: PAD.l + i * step + step / 2, y: H - 6, "text-anchor": "middle" }, r.label || r.date.slice(5)));
+ }
+ });
+ return step;
+ }
+
+ function empty(svg) {
+ svg.appendChild(svgEl("text", { class: "empty", x: W / 2, y: H / 2, "text-anchor": "middle" }, "No runs in this window"));
+ }
+
+ function bars(container, rows, series, unit) {
+ const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}` });
+ const totals = rows.map((r) => series.reduce((s, k) => s + r[k], 0));
+ const max = niceMax(Math.max(...totals));
+ const step = axes(svg, rows, 0, max);
+ const bw = Math.max(1, step * 0.72);
+ rows.forEach((r, i) => {
+ const x = PAD.l + i * step + (step - bw) / 2;
+ let y = PAD.t + PH;
+ for (const k of series) {
+ const hh = (r[k] / max) * PH;
+ if (hh <= 0) continue;
+ y -= hh;
+ const rect = svgEl("rect", { class: k, x, y, width: bw, height: hh });
+ rect.appendChild(svgEl("title", {}, `${r.date}: ${fmt(r[k])} ${unit(k)}`));
+ svg.appendChild(rect);
+ }
+ });
+ if (totals.every((t) => t === 0)) empty(svg);
+ container.replaceChildren(svg);
+ }
+
+ function line(container, rows, key, opts) {
+ const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}` });
+ const vals = rows.map((r) => r[key]).filter((v) => v !== null);
+ if (!vals.length) {
+ axes(svg, rows, 0, 1);
+ empty(svg);
+ container.replaceChildren(svg);
+ return;
+ }
+ const lo = opts.zero ? 0 : Math.floor(Math.min(...vals) * 0.98);
+ const hi = Math.max(niceMax(Math.max(...vals)), lo + 1);
+ const step = axes(svg, rows, lo, hi);
+ const pts = [];
+ rows.forEach((r, i) => {
+ if (r[key] !== null) pts.push([PAD.l + i * step + step / 2, PAD.t + PH - ((r[key] - lo) / (hi - lo)) * PH]);
+ });
+ if (opts.area) {
+ const d = `M${pts[0][0]},${PAD.t + PH} ` + pts.map((p) => `L${p[0]},${p[1]}`).join(" ") + ` L${pts[pts.length - 1][0]},${PAD.t + PH} Z`;
+ svg.appendChild(svgEl("path", { class: "area", d }));
+ }
+ svg.appendChild(svgEl("polyline", { class: opts.cls, points: pts.map((p) => p.join(",")).join(" ") }));
+ const last = pts[pts.length - 1];
+ svg.appendChild(svgEl("circle", { class: opts.dot, cx: last[0], cy: last[1], r: 4 }));
+ svg.appendChild(svgEl("text", { class: "tick", x: last[0] - 6, y: last[1] - 8, "text-anchor": "end" }, compact(vals[vals.length - 1])));
+ container.replaceChildren(svg);
+ }
+
+ function typeCell(e, steps) {
+ const c = h("td");
+ const t = typeOf(e.name);
+ c.append(h("span", `tag t-${t}`, TYPE_LABELS[t] || t));
+ if (steps > 1) c.append(h("span", "steps", `${steps} steps`));
+ if (e.source === "local") c.append(h("span", "tag local", "Local"));
+ else if (isRelease(e.name)) c.append(h("span", "tag release", "Release"));
+ return c;
+ }
+
+ function noRows(cols) {
+ const tr = h("tr");
+ const c = h("td", "none", "No runs in this window");
+ c.colSpan = cols;
+ tr.append(c);
+ return [tr];
+ }
+
+ function renderRunList(windowDaily) {
+ const counts = new Map();
+ for (const d of windowDaily) {
+ if (kindMatch(d) && sourceMatch(d)) counts.set(keyOf(d), (counts.get(keyOf(d)) || 0) + d.runs);
+ }
+ if (only && !counts.has(only)) only = null;
+ const total = [...counts.values()].reduce((s, n) => s + n, 0);
+ const split = (key) => { const i = key.lastIndexOf("|"); return { name: key.slice(0, i), source: key.slice(i + 1) }; };
+ const order = (k) => { const e = split(k); return [e.source === "local", isRelease(e.name), e.name]; };
+ const keys = [...counts.keys()].sort((a, b) => {
+ const x = order(a);
+ const y = order(b);
+ return (x[0] - y[0]) || (x[1] - y[1]) || x[2].localeCompare(y[2]);
+ });
+ const items = [["All test runs", null, total, null], ...keys.map((k) => [split(k).name, k, counts.get(k), split(k)])];
+ byId("runlist").replaceChildren(...items.map(([label, value, n, e]) => {
+ const li = h("li");
+ const b = h("button");
+ b.type = "button";
+ b.setAttribute("aria-pressed", String(only === value));
+ const kindCls = e ? (e.source === "local" ? "kind local" : `kind ${typeOf(e.name)}`) : "kind";
+ b.append(h("span", kindCls), h("span", "nm", e && e.source === "local" ? `${label} (local)` : label), h("span", "ct", fmt(n)));
+ b.addEventListener("click", () => { only = value; render(); });
+ li.append(b);
+ return li;
+ }));
+ }
+
+ function renderImpact(impact) {
+ const panel = byId("impact");
+ if (!impact) {
+ panel.hidden = true;
+ return;
+ }
+ panel.hidden = false;
+ byId("i-found").textContent = fmt(impact.found);
+ byId("i-bugs").textContent = fmt(impact.bugs);
+ byId("i-open").textContent = fmt(impact.open);
+ byId("i-comp").textContent = fmt(impact.components.length);
+
+ const maxComp = Math.max(1, ...impact.components.map((c) => c.count));
+ byId("comps").replaceChildren(...impact.components.map((c) => {
+ const li = h("li");
+ const bar = h("span", "bar");
+ bar.style.width = `${(c.count / maxComp) * 100}%`;
+ const track = h("span");
+ track.append(bar);
+ li.append(h("span", "mono", c.repo), track, h("span", "n", fmt(c.count)));
+ return li;
+ }));
+
+ // One point per month from the first issue to the data date; the total only rises.
+ const rows = [];
+ if (impact.months.length) {
+ const perMonth = new Map(impact.months.map((m) => [m.month, m.found]));
+ const end = data.generated_at.slice(0, 7);
+ let [y, m] = impact.months[0].month.split("-").map(Number);
+ let found = 0;
+ for (;;) {
+ const key = `${y}-${String(m).padStart(2, "0")}`;
+ found += perMonth.get(key) || 0;
+ rows.push({ date: `${key}-01`, label: String(y), cumul: found });
+ if (key >= end) break;
+ m += 1;
+ if (m > 12) { m = 1; y += 1; }
+ }
+ }
+ if (rows.length) line(byId("chart-issues"), rows, "cumul", { cls: "lineS", dot: "endpt", area: true, zero: true });
+ else byId("chart-issues").replaceChildren();
+
+ byId("issues").replaceChildren(...impact.latest.map((i) => {
+ const tr = h("tr");
+ const a = h("a", "issue-link");
+ a.href = `https://github.com/${encodeURIComponent(impact.owner)}/${encodeURIComponent(i.repo)}/issues/${Number(i.number)}`;
+ a.rel = "noopener";
+ a.append(h("span", "ref", `#${Number(i.number)}`), document.createTextNode(i.title));
+ const link = h("td");
+ link.append(a);
+ const type = h("td");
+ type.append(h("span", i.bug ? "tag t-bug" : "tag", i.bug ? "Bug" : "Issue"));
+ const state = h("td");
+ state.append(h("span", "state", i.state === "open" ? "Open" : "Closed"));
+ tr.append(h("td", "mono", i.opened), link, h("td", "mono", i.repo), type, state);
+ return tr;
+ }));
+ }
+
+ function renderSync(start) {
+ const panel = byId("sync");
+ const sync = data.sync;
+ const usable = sync && Array.isArray(sync.runs);
+ const anySync = data.test_runs.some((e) => SYNC_TYPES.includes(e.type));
+ const onlyType = only ? typeOf(only.slice(0, only.lastIndexOf("|"))) : null;
+ const hidden = !usable || !anySync || (kind !== "all" && !SYNC_TYPES.includes(kind)) || (only && !SYNC_TYPES.includes(onlyType));
+ panel.hidden = hidden;
+ if (hidden) return;
+ const typeOfRun = (r) => (r.kind === "dbsync" ? "db-sync" : "node-sync");
+ const nameOf = (r) => `sync-${r.kind}-${r.network}`;
+ const runs = sync.runs.filter((r) => r.started.slice(0, 10) >= start && sourceMatch(r)
+ && (network === "all" || r.network === network)
+ && (kind === "all" || typeOfRun(r) === kind)
+ && (!only || keyOf({ name: nameOf(r), source: r.source }) === only));
+ const count = (list, f) => list.filter(f).length;
+ const seconds = (list) => list.reduce((s, r) => s + r.seconds, 0);
+ const nodeRuns = count(runs, (r) => r.kind === "node");
+ const dbsyncRuns = count(runs, (r) => r.kind === "dbsync");
+ const mainnet = runs.filter((r) => r.network === "mainnet");
+ const byNetwork = new Map();
+ for (const r of runs) if (r.network !== "mainnet") byNetwork.set(r.network, (byNetwork.get(r.network) || 0) + 1);
+
+ byId("y-runs").textContent = fmt(runs.length);
+ byId("y-runs-hint").textContent = `${fmt(nodeRuns)} node sync · ${fmt(dbsyncRuns)} db-sync`;
+ byId("y-time").textContent = dur(seconds(runs));
+ byId("y-time-hint").textContent = `${dur(seconds(runs.filter((r) => r.kind === "node")))} node · ${dur(seconds(runs.filter((r) => r.kind === "dbsync")))} db-sync`;
+ byId("y-mainnet").textContent = fmt(mainnet.length);
+ byId("y-mainnet-hint").textContent = [...byNetwork].map(([n, c]) => `${fmt(c)} ${n}`).join(" · ") || "no other network";
+ const latestMainnet = mainnet.find((r) => r.kind === "node" && r.completed);
+ byId("y-latest").textContent = latestMainnet ? dur(latestMainnet.seconds) : "–";
+ byId("y-latest-hint").textContent = latestMainnet ? `cardano-node ${latestMainnet.node || "?"} · ${latestMainnet.started.slice(0, 10)}` : "no mainnet node sync in this window";
+
+ // All-time figures under the same filters, from the per test run totals.
+ const allTime = data.test_runs.filter((e) => SYNC_TYPES.includes(e.type) && filterMatch(e)
+ && (network === "all" || e.name.endsWith(`-${network}`)));
+ const atSum = (list, k) => list.reduce((s, e) => s + e[k], 0);
+ byId("y-runs-all").textContent = `${fmt(atSum(allTime, "runs"))} all time`;
+ byId("y-time-all").textContent = `${dur(atSum(allTime, "seconds"))} all time`;
+ byId("y-mainnet-all").textContent = `${fmt(atSum(allTime.filter((e) => e.name.endsWith("-mainnet")), "runs"))} all time`;
+ byId("y-latest-all").textContent = `${fmt(atSum(allTime.filter((e) => e.type === "node-sync"), "runs"))} node syncs all time`;
+
+ const ref = Date.parse(data.generated_at.slice(0, 10));
+ const perDay = [];
+ for (let i = days - 1; i >= 0; i -= 1) {
+ const date = dateOf(ref - i * DAY_MS);
+ const today = runs.filter((r) => r.started.slice(0, 10) === date);
+ const hours = (net) => today.filter((r) => r.network === net).reduce((s, r) => s + r.seconds, 0) / 3600;
+ perDay.push({ date, mainnet: hours("mainnet"), preview: hours("preview"), preprod: hours("preprod") });
+ }
+ bars(byId("chart-sync-hours"), perDay, ["mainnet", "preview", "preprod"], (k) => `${k} sync hours`);
+ const trend = mainnet.filter((r) => r.kind === "node" && r.completed).reverse().map((r) => ({ date: r.started.slice(0, 10), hours: r.seconds / 3600 }));
+ line(byId("chart-sync-trend"), trend.length ? trend : [{ date: dateOf(ref), hours: null }], "hours", { cls: "lineS", dot: "endpt", area: true, zero: true });
+
+ }
+
+ function renderStatic() {
+ const releases = data.releases.map((r) => {
+ const li = h("li");
+ const left = h("span");
+ left.append(h("b", "", r.version ? `cardano-node ${r.version}` : r.name));
+ li.append(left, h("span", "sub", `${fmt(r.runs)} run${r.runs === 1 ? "" : "s"} · ${fmt(r.tests)} tests · ${r.last.slice(0, 10)}`));
+ return li;
+ });
+ byId("releases").replaceChildren(...(releases.length ? releases : [h("li", "sub", "No release runs yet")]));
+ renderImpact(data.impact);
+ }
+
+ function render() {
+ if (!data) return;
+ const ref = Date.parse(data.generated_at.slice(0, 10));
+ const start = dateOf(ref - (days - 1) * DAY_MS);
+ const windowDaily = data.daily.filter((d) => d.date >= start);
+ renderRunList(windowDaily);
+ const daily = windowDaily.filter(filterMatch);
+
+ const onlyLabel = only ? only.replace(/\|ci$/, "").replace(/\|local$/, " (local)") : null;
+ byId("heading").textContent = onlyLabel || (kind === "all" ? "Cardano testing at a glance" : `${TYPE_LABELS[kind]} testing`);
+ // The "What we test" cards follow the selected type, or the selected test run's type.
+ const shownType = only ? typeOf(only.slice(0, only.lastIndexOf("|"))) : kind;
+ for (const card of document.querySelectorAll("[data-about]")) {
+ card.hidden = shownType !== "all" && !card.dataset.about.split(" ").includes(shownType);
+ }
+ byId("subheading").textContent = `Last ${days} days · ${source === "all" ? "CI and local runs" : source === "ci" ? "CI runs" : "local runs"}`;
+
+ const sum = (list, k) => list.reduce((s, d) => s + d[k], 0);
+ const runs = sum(daily, "runs");
+ const byType = (type) => sum(daily.filter((d) => typeOf(d.name) === type), "runs");
+ const localRuns = sum(daily.filter((d) => d.source === "local"), "runs");
+ byId("t-runs").textContent = fmt(runs);
+ byId("t-runs-hint").textContent = `${fmt(byType("regression"))} regression · ${fmt(byType("upgrade"))} upgrade · ${fmt(byType("node-sync"))} node sync · ${fmt(byType("db-sync"))} db-sync · ${fmt(localRuns)} local`;
+ byId("t-cases").textContent = fmt(sum(daily, "tests"));
+ byId("t-time").textContent = dur(sum(daily, "seconds"));
+
+ const invocations = sum(daily, "invocations");
+ byId("t-cli").textContent = fmt(invocations);
+ byId("t-cli-hint").textContent = `${fmt(sum(daily, "submits"))} transactions submitted`;
+
+ // All-time figures for the same filters (test type, source, test run).
+ const allTime = data.test_runs.filter(filterMatch);
+ const atSum = (k) => allTime.reduce((s, t) => s + t[k], 0);
+ byId("t-runs-all").textContent = `${fmt(atSum("runs"))} all time`;
+ byId("t-cases-all").textContent = `${fmt(atSum("tests"))} all time`;
+ byId("t-time-all").textContent = `${dur(atSum("seconds"))} all time`;
+ byId("t-cli-all").textContent = `${fmt(atSum("invocations"))} all time · ${fmt(atSum("submits"))} submitted`;
+
+ const allTimeTests = atSum("tests");
+ let running = allTimeTests - sum(daily, "tests");
+ const rows = [];
+ for (let i = days - 1; i >= 0; i -= 1) {
+ const date = dateOf(ref - i * DAY_MS);
+ const today = daily.filter((d) => d.date === date);
+ const tests = sum(today, "tests");
+ running += tests;
+ const suiteCandidates = today.filter((d) => d.source === "ci" && typeOf(d.name) === "regression" && !isRelease(d.name) && d.runs > 0);
+ rows.push({
+ date,
+ regression: sum(today.filter((d) => typeOf(d.name) === "regression"), "runs"),
+ upgrade: sum(today.filter((d) => typeOf(d.name) === "upgrade"), "runs"),
+ "node-sync": sum(today.filter((d) => typeOf(d.name) === "node-sync"), "runs"),
+ "db-sync": sum(today.filter((d) => typeOf(d.name) === "db-sync"), "runs"),
+ tests,
+ cumul: running,
+ suite: suiteCandidates.length ? Math.max(...suiteCandidates.map((d) => d.tests / d.runs)) : null,
+ });
+ }
+ bars(byId("chart-runs"), rows, ["regression", "upgrade", "node-sync", "db-sync"], (k) => `${TYPE_LABELS[k].toLowerCase()} runs`);
+ bars(byId("chart-tests"), rows, ["tests"], () => "tests");
+ line(byId("chart-cumul"), rows, "cumul", { cls: "lineA", dot: "endptA", area: false });
+ line(byId("chart-suite"), rows, "suite", { cls: "lineS", dot: "endpt", area: true });
+
+ const groups = new Map();
+ for (const d of daily) {
+ const g = groups.get(keyOf(d)) || { e: d, runs: 0, tests: 0, seconds: 0 };
+ g.runs += d.runs;
+ g.tests += d.tests;
+ g.seconds += d.seconds;
+ groups.set(keyOf(d), g);
+ }
+ const lastRun = (key) => (data.test_runs.find((t) => keyOf(t) === key) || {}).last || "";
+ const byName = [...groups.entries()]
+ .sort((a, b) => ((a[1].e.source === "local") - (b[1].e.source === "local")) || (isRelease(a[1].e.name) - isRelease(b[1].e.name)) || b[1].runs - a[1].runs)
+ .map(([key, g]) => {
+ const tr = h("tr");
+ tr.append(h("td", "", g.e.name), typeCell(g.e, 1), h("td", "num", fmt(g.runs)), h("td", "num", fmt(g.tests)),
+ h("td", "num", dur(g.seconds)), h("td", "num", fmt(g.tests / g.runs)), h("td", "mono", stamp(lastRun(key))));
+ return tr;
+ });
+ byId("by-name").replaceChildren(...(byName.length ? byName : noRows(7)));
+
+ const latest = data.latest
+ .filter((r) => r.started.slice(0, 10) >= start && filterMatch(r))
+ .slice(0, LATEST_ROWS)
+ .map((r) => {
+ const tr = h("tr");
+ const versions = [r.node, r.db_sync ? `db-sync ${r.db_sync}` : null].filter(Boolean).join(" · ") || "–";
+ tr.append(h("td", "mono", stamp(r.started)), h("td", "", r.name), typeCell(r, r.steps),
+ h("td", "mono", versions), h("td", "num", fmt(r.tests)), h("td", "num", dur(r.seconds)));
+ return tr;
+ });
+ byId("latest").replaceChildren(...(latest.length ? latest : noRows(6)));
+ renderSync(start);
+ renderUpdated();
+ }
+
+ function renderUpdated(problem) {
+ if (!data) {
+ byId("updated").textContent = problem || "Loading…";
+ return;
+ }
+ const minutes = Math.max(0, Math.round((Date.now() - Date.parse(data.generated_at)) / 60000));
+ const ago = minutes < 1 ? "just now" : minutes < 120 ? `${minutes} minutes ago` : `${Math.round(minutes / 60)} hours ago`;
+ byId("updated").textContent = `Updated ${ago}${problem ? ` · ${problem}` : ""}`;
+ }
+
+ async function load() {
+ try {
+ const response = await fetch(SOURCE, { cache: "no-cache" });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const doc = await response.json();
+ if (!valid(doc)) throw new Error("unexpected data");
+ const changed = !data || doc.generated_at !== data.generated_at;
+ data = doc;
+ if (changed) {
+ renderStatic();
+ render();
+ } else {
+ renderUpdated();
+ }
+ } catch (err) {
+ // Keep the last good data on screen and try again on the next poll.
+ renderUpdated(`could not refresh (${err.message})`);
+ }
+ }
+
+ function wire(selector, attr, set) {
+ const buttons = document.querySelectorAll(selector);
+ for (const b of buttons) {
+ b.addEventListener("click", () => {
+ for (const o of buttons) o.setAttribute("aria-pressed", String(o === b));
+ set(b.dataset[attr]);
+ render();
+ });
+ }
+ }
+
+ // Theme: "system" follows the operating system; light and dark override it.
+ const themeButtons = document.querySelectorAll("[data-theme-choice]");
+ function applyTheme(choice) {
+ if (choice === "light" || choice === "dark") document.documentElement.setAttribute("data-theme", choice);
+ else document.documentElement.removeAttribute("data-theme");
+ for (const b of themeButtons) b.setAttribute("aria-pressed", String(b.dataset.themeChoice === choice));
+ }
+ let saved = "system";
+ try { saved = localStorage.getItem(THEME_KEY) || "system"; } catch (e) { /* storage blocked */ }
+ applyTheme(saved);
+ for (const b of themeButtons) {
+ b.addEventListener("click", () => {
+ applyTheme(b.dataset.themeChoice);
+ try { localStorage.setItem(THEME_KEY, b.dataset.themeChoice); } catch (e) { /* storage blocked */ }
+ });
+ }
+
+ wire("[data-days]", "days", (v) => { days = Number(v); });
+ wire("[data-kind]", "kind", (v) => { kind = v; });
+ wire("[data-source]", "source", (v) => { source = v; });
+ wire("[data-network]", "network", (v) => { network = v; });
+
+ load();
+ setInterval(() => { if (document.visibilityState === "visible") load(); }, REFRESH_MS);
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") load(); });
+})();
diff --git a/src_docs/source/extra/dashboard/index.html b/src_docs/source/extra/dashboard/index.html
new file mode 100644
index 000000000..8da9ce451
--- /dev/null
+++ b/src_docs/source/extra/dashboard/index.html
@@ -0,0 +1,268 @@
+
+
+
+
+
+ Cardano Test Runs
+
+
+
+
+
+
+
+ Cardano test runs System-level testing
+
+
+
Time window
+
+ 7 d
+ 30 d
+ 90 d
+
+
+
+
+
Test type
+
+ All
+ Regression
+ Upgrade
+ Node sync
+ db-sync
+
+
+
+
+
Source
+
+ All
+ CI
+ Local
+
+
+
+
+
+
+
Theme
+
+ System
+ Light
+ Dark
+
+
+
+
+
+
+
Cardano testing at a glance
+
System-level tests run against Cardano node builds and releases.
+
+
+
+ Test runs –
+ Tests executed – skipped tests not counted
+ Test time – wall clock, summed
+ CLI invocations –
+
+
+
+
+
Regression testing
+
End-to-end tests of cardano-node and cardano-cli on a local Cardano testnet. They run every night against the latest node from the master branch, in several setups, and on demand for each node release.
+
+
+
Upgrade testing
+
One run in three steps. Step 1 starts a cluster on the released node and runs the tests. Step 2 upgrades all nodes but one pool, so old and new versions run side by side, and runs the tests again. Step 3 completes the upgrade, with a hard fork when one is due, and runs them a third time.
+
+
+
Sync testing
+
A full sync of a Cardano network from genesis on one machine, with the time per era, memory and CPU recorded. Node sync runs the node alone. db-sync also runs cardano-db-sync against that node, starting once the node has reached the Shelley era, and syncs the database as well.
+
+
+
+
+
+
Sync tests
+
+
+
+ Sync runs –
+ Sync time –
+ Mainnet syncs –
+ Latest mainnet sync –
+
+
+
Network
+
+ All
+ Mainnet
+ Preview
+ Preprod
+
+
+
+
+
Sync hours per day
+
+ Mainnet
+ Preview
+ Preprod
+
+
+
+
+
Mainnet sync time per run
+
+
+
+
+
+
+
+
+
Issues found by our testing
+
+
+
+
– issues found
+
– of them bugs
+
– still open
+
– components
+
+
+
+
Issues found, running total
+
+
+
+
+
+
+
+
+ Opened
+ Issue
+ Component
+ Type
+ State
+
+
+
+
+
+
+
+
+
+ Test runs per day
+
+ Regression
+ Upgrade
+ Node sync
+ db-sync
+
+
+
+
+ Tests executed per day
+
+
+
+
+
+
+
+ Tests executed, running total
+
+
+
+ Size of the regression suite
+
+
+
+
+
+
+ Testing by test run
+
+
+
+
+ Test run
+ Type
+ Runs
+ Tests executed
+ Test time
+ Tests per run
+ Last run (UTC)
+
+
+
+
+
+
+
+
+ Latest runs
+
+
+
+
+ Started (UTC)
+ Test run
+ Type
+ Versions
+ Tests executed
+ Duration
+
+
+
+
+
+
+
+
+
+
+
+
+ About these numbers
+
+
Regression The system-level test suite against a local Cardano testnet, run nightly and for node releases.
+
Upgrade One run in three steps across a node upgrade. It counts as one run; its tests are the sum of the three steps.
+
Tests executed Every test that ran, in every run and step. Skipped tests are not counted.
+
CLI invocations How many times the tests ran a cardano-cli command, and how many transactions they submitted.
+
Sync tests One run is one full sync of a network, by the node alone or with db-sync. Its test time is the sync time. It counts in the numbers above like any other run.
+
+
+
+
+
+ Open source
+
+
+
+
+
+
+
+
+
+
+
+
+
+