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 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.

+
+
+ + + + + +
+
+

Test runs per day

+ + +
+
+

Tests executed per day

+ + +
+
+ +
+
+

Tests executed, running total

+ +
+
+

Size of the regression suite

+

Tests per regression run, the largest run of each day. Growth means new coverage.

+ +
+
+ +
+

Testing by test run

+
+ + + + + + + + + + + + + +
Test runTypeRunsTests executedTest timeTests per runLast run (UTC)
+
+
+ +
+

Latest runs

+
+ + + + + + + + + + + + +
Started (UTC)Test runTypeVersionsTests executedDuration
+
+
+
+ + +
+ + +