clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / site / assets / insights.ts
insights.ts 25.2K
1// The insights page's charts (site/_includes/insights.njk): stat tiles and2// SVG charts drawn from insights.json (lib/insights.ts), which forge lake3// writes next to the page. Each chart has a tooltip on hover and on keyboard4// focus (arrow keys move it), and a table with the same numbers.5(() => {6 const found = document.querySelector<HTMLElement>('.insights[data-insights]');7 if (!found) return;8 const root: HTMLElement = found;9 const forgeWide = root.dataset.forge === '1';10 const NS = 'http://www.w3.org/2000/svg';11 const DAY = 864e5;1213 // [repo, id, created, ended, how, revisions, approvals]14 type ReviewRow = [string, number, number | null, number | null, string | null, number, number];15 interface Data {16 generated: string;17 from: number | null;18 totals: Record<string, number | null>;19 builds: [number, number][];20 pushes: [string, number][];21 reviews: ReviewRow[];22 ci: [number, number | null, number][];23 repos?: [string, number, number, number | null, number, number, number, number | null][];24 // [name, unit, [[commit time, value, commit, subject]...]], a repo's only.25 metrics?: [string, string | null, [number, number, string, string][]][];26 }2728 // ---------------------------------------------------------------- formats2930 const r1 = (n: number): string => (Math.round(n * 10) / 10).toString();31 const secs = (s: number): string => (s < 10 ? `${Math.round(s * 100) / 100}s` : s < 60 ? `${r1(s)}s` : s < 3600 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${r1(s / 3600)}h`);32 const hours = (h: number): string => (h < 1 ? `${Math.round(h * 60)}m` : h < 48 ? `${r1(h)}h` : `${r1(h / 24)}d`);33 const count = (n: number): string => (n >= 1e4 ? `${Math.round(n / 1e3)}K` : n >= 1e3 ? `${r1(n / 1e3)}K` : String(n));34 // A metric's value: whole numbers grouped, others to three significant digits.35 const num = (v: number): string => (Number.isInteger(v) ? v.toLocaleString() : Math.abs(v) >= 1000 ? Math.round(v).toLocaleString() : String(Number(v.toPrecision(3))));36 const plural = (n: number, w: string): string => `${count(n)} ${w}${n === 1 ? '' : 's'}`;37 const when = (ms: number): string => new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });38 const dayOf = (ms: number): string => new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });39 const dayLong = (ms: number): string => new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });4041 // ---------------------------------------------------------------- dom4243 const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?: string): HTMLElementTagNameMap[K] => {44 const e = document.createElement(tag);45 if (cls) e.className = cls;46 if (text != null) e.textContent = text;47 return e;48 };49 const svgEl = (tag: string, attrs: Record<string, string | number>, parent?: Element): SVGElement => {50 const e = document.createElementNS(NS, tag);51 for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));52 parent?.appendChild(e);53 return e;54 };5556 // Round numbers from 0 to at least max, about n steps.57 function ticks(max: number, n = 4): number[] {58 if (!(max > 0)) return [0, 1];59 const raw = max / n;60 const mag = 10 ** Math.floor(Math.log10(raw));61 const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;62 const out: number[] = [];63 for (let v = 0; v < max + step; v += step) {64 out.push(Number(v.toPrecision(12)));65 if (v >= max) break;66 }67 return out;68 }6970 // Times for the x axis between a and b, about n of them, on day (or hour) boundaries.71 function timeTicks(a: number, b: number, n: number): { at: number; label: string }[] {72 const span = Math.max(b - a, 1);73 const steps = [3600e3, 3 * 3600e3, 6 * 3600e3, 12 * 3600e3, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY, 91 * DAY, 182 * DAY, 365 * DAY];74 const step = steps.find((s) => span / s <= n) ?? 365 * DAY;75 const out: { at: number; label: string }[] = [];76 let t = Math.ceil(a / step) * step;77 if (step >= DAY) t = Math.ceil(a / DAY) * DAY;78 for (; t <= b; t += step) {79 const d = new Date(t);80 out.push({ at: t, label: step < DAY ? d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : step >= 182 * DAY ? String(d.getUTCFullYear()) : dayOf(t) });81 }82 return out;83 }8485 // ---------------------------------------------------------------- charts8687 interface Mark {88 cx: number;89 cy: number;90 // Columns: the band the pointer selects them by.91 x0?: number;92 x1?: number;93 els: SVGElement[];94 head: string;95 rows: [string, string, string?][];96 }9798 const H = 190;99 const PAD = { top: 14, right: 12, bottom: 24, left: 44 };100101 interface Frame {102 svg: SVGElement;103 w: number;104 y: (v: number) => number;105 plotW: number;106 }107108 // An SVG with y gridlines and labels from 0 to the top tick; x is the caller's.109 function frame(plot: HTMLElement, yMax: number, yFmt: (v: number) => string): Frame {110 const w = Math.max(280, plot.clientWidth || 440);111 const svg = svgEl('svg', { viewBox: `0 0 ${w} ${H}`, height: H, role: 'img' });112 const ts = ticks(yMax);113 const top = ts.at(-1)!;114 const plotH = H - PAD.top - PAD.bottom;115 const y = (v: number): number => PAD.top + plotH - (v / top) * plotH;116 for (const t of ts) {117 svgEl('line', { class: t === 0 ? 'base' : 'grid', x1: PAD.left, x2: w - PAD.right, y1: Math.round(y(t)) + 0.5, y2: Math.round(y(t)) + 0.5 }, svg);118 const label = svgEl('text', { class: 'tick', x: PAD.left - 6, y: y(t) + 4, 'text-anchor': 'end' }, svg);119 label.textContent = yFmt(t);120 }121 plot.replaceChildren(svg);122 return { svg, w, y, plotW: w - PAD.left - PAD.right };123 }124125 function xLabels(f: Frame, list: { x: number; label: string }[]): void {126 let last = -Infinity;127 for (const { x, label } of list) {128 // Skip a label that would run into the one before it.129 if (x - last < label.length * 6.5 + 8) continue;130 last = x;131 const t = svgEl('text', { class: 'tick', x, y: H - 7, 'text-anchor': 'middle' }, f.svg);132 t.textContent = label;133 }134 }135136 // Hover and keyboard: the nearest mark (by its band, or by distance) gets137 // a tooltip and lifts; dots get a vertical hairline.138 const listening = new WeakMap<HTMLElement, AbortController>();139 function interact(plot: HTMLElement, f: Frame, marks: Mark[], hair: boolean): void {140 // A chart drawn again (a resize) drops the last drawing's listeners.141 listening.get(plot)?.abort();142 const ac = new AbortController();143 listening.set(plot, ac);144 const signal = ac.signal;145 if (!marks.length) return;146 plot.tabIndex = 0;147 plot.setAttribute('aria-label', `${marks.length} values; arrow keys step through them`);148 const tip = el('div', 'tip');149 tip.hidden = true;150 plot.appendChild(tip);151 const line = hair ? svgEl('line', { class: 'hair', y1: PAD.top, y2: H - PAD.bottom, visibility: 'hidden' }, f.svg) : null;152 let at = -1;153 const show = (i: number): void => {154 if (at >= 0) for (const e of marks[at].els) e.classList.remove('on');155 at = i;156 if (i < 0) {157 tip.hidden = true;158 line?.setAttribute('visibility', 'hidden');159 return;160 }161 const m = marks[i];162 for (const e of m.els) e.classList.add('on');163 tip.replaceChildren();164 tip.appendChild(el('div', 'desc', m.head));165 // The value leads, its label follows; a short line keys the series.166 for (const [value, label, cls] of m.rows) {167 const row = el('div');168 if (cls) {169 const k = el('span', 'k');170 k.style.background = cls === 'fail' ? 'var(--viz-fail)' : 'var(--viz-1)';171 row.appendChild(k);172 }173 row.append(el('b', '', value), ` ${label}`);174 tip.appendChild(row);175 }176 tip.hidden = false;177 const scale = plot.clientWidth / f.w;178 const px = m.cx * scale;179 const tw = tip.offsetWidth;180 tip.style.left = `${Math.min(Math.max(0, px - tw / 2), plot.clientWidth - tw)}px`;181 tip.style.top = `${Math.max(0, m.cy * scale - tip.offsetHeight - 12)}px`;182 if (line) {183 line.setAttribute('x1', String(m.cx));184 line.setAttribute('x2', String(m.cx));185 line.setAttribute('visibility', 'visible');186 }187 };188 plot.addEventListener('pointermove', (e) => {189 const box = f.svg.getBoundingClientRect();190 const x = ((e.clientX - box.left) / box.width) * f.w;191 const y = ((e.clientY - box.top) / box.height) * H;192 let best = -1;193 let bd = Infinity;194 marks.forEach((m, i) => {195 const d = m.x0 != null ? (x >= m.x0 && x <= m.x1! ? 0 : Math.min(Math.abs(x - m.x0), Math.abs(x - m.x1!))) : Math.hypot(x - m.cx, (y - m.cy) / 3);196 if (d < bd) [bd, best] = [d, i];197 });198 if (best !== at) show(best);199 }, { signal });200 plot.addEventListener('pointerleave', () => show(-1), { signal });201 plot.addEventListener('blur', () => show(-1), { signal });202 plot.addEventListener('focus', () => show(at >= 0 ? at : marks.length - 1), { signal });203 plot.addEventListener('keydown', (e) => {204 if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {205 e.preventDefault();206 show(Math.min(marks.length - 1, Math.max(0, (at < 0 ? marks.length : at) + (e.key === 'ArrowRight' ? 1 : -1))));207 } else if (e.key === 'Escape') show(-1);208 }, { signal });209 }210211 interface Dot {212 t: number;213 v: number;214 fail?: boolean;215 head: string;216 rows: [string, string, string?][];217 }218219 // Dots over time, one per fact. The last one carries its value. A line220 // joins them, oldest first, for a metric's values commit by commit.221 function dots(plot: HTMLElement, list: Dot[], yFmt: (v: number) => string, joined = false): void {222 const max = Math.max(...list.map((d) => d.v));223 const f = frame(plot, max, yFmt);224 const a = Math.min(...list.map((d) => d.t));225 const b = Math.max(...list.map((d) => d.t));226 const pad = (b - a) * 0.03 || 3600e3;227 const x = (t: number): number => PAD.left + ((t - (a - pad)) / (b - a + 2 * pad)) * f.plotW;228 xLabels(f, timeTicks(a - pad, b + pad, Math.max(2, Math.floor(f.plotW / 90))).map((k) => ({ x: x(k.at), label: k.label })));229 if (joined && list.length > 1) {230 const by = [...list].sort((p, q) => p.t - q.t);231 svgEl('path', { class: 'line', d: by.map((d, i) => `${i ? 'L' : 'M'}${x(d.t).toFixed(1)},${f.y(d.v).toFixed(1)}`).join('') }, f.svg);232 }233 const marks: Mark[] = list.map((d) => {234 const c = svgEl('circle', { class: `dot ${d.fail ? 'fail' : 's1'}`, cx: x(d.t), cy: f.y(d.v), r: 4 }, f.svg);235 return { cx: x(d.t), cy: f.y(d.v), els: [c], head: d.head, rows: d.rows };236 });237 const last = list.reduce((p, d) => (d.t >= p.t ? d : p), list[0]);238 const lbl = svgEl('text', { class: 'mark-label', x: Math.min(x(last.t) + 7, f.w - 2), y: f.y(last.v) - 7, 'text-anchor': x(last.t) > f.w - 60 ? 'end' : 'start' }, f.svg);239 lbl.textContent = yFmt(last.v);240 interact(plot, f, marks, true);241 }242243 interface Column {244 label: string;245 head: string;246 segs: { v: number; cls: string; name: string }[];247 }248249 // A column's path: square at the baseline, 4px round corners on top.250 const colPath = (x: number, y: number, w: number, h: number, round: boolean): string => {251 const r = round ? Math.min(4, w / 2, h) : 0;252 return `M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z`;253 };254255 // Columns, stacked when they have more than one segment, with a 2px gap256 // between segments. The tallest carries its total.257 function columns(plot: HTMLElement, cols: Column[], yFmt: (v: number) => string, every = 1): void {258 const total = (c: Column): number => c.segs.reduce((s, x) => s + x.v, 0);259 const max = Math.max(...cols.map(total));260 const f = frame(plot, max, yFmt);261 const band = f.plotW / cols.length;262 const w = Math.max(1, Math.min(24, band * 0.7));263 const marks: Mark[] = [];264 const labels: { x: number; label: string }[] = [];265 let tallest: { x: number; y: number; v: number } | null = null;266 cols.forEach((c, i) => {267 const cx = PAD.left + band * i + band / 2;268 const els: SVGElement[] = [];269 let base = f.y(0);270 const shown = c.segs.filter((s) => s.v > 0);271 shown.forEach((s, j) => {272 const h = f.y(0) - f.y(s.v) - (j > 0 ? 2 : 0);273 if (h <= 0) return;274 const y = base - (j > 0 ? 2 : 0) - h;275 els.push(svgEl('path', { class: s.cls, d: colPath(cx - w / 2, y, w, h, j === shown.length - 1) }, f.svg));276 base = y;277 });278 if (i % every === 0) labels.push({ x: cx, label: c.label });279 const t = total(c);280 if (!tallest || t > tallest.v) tallest = { x: cx, y: base, v: t };281 marks.push({ cx, cy: Math.min(base, f.y(0) - 4), x0: PAD.left + band * i, x1: PAD.left + band * (i + 1), els, head: c.head, rows: c.segs.map((s) => [count(s.v), s.name, c.segs.length > 1 ? s.cls : undefined]) });282 });283 xLabels(f, labels);284 if (tallest && (tallest as { v: number }).v > 0) {285 const { x, y, v } = tallest as { x: number; y: number; v: number };286 const t = svgEl('text', { class: 'mark-label', x, y: y - 5, 'text-anchor': 'middle' }, f.svg);287 t.textContent = yFmt(v);288 }289 interact(plot, f, marks, false);290 }291292 // ---------------------------------------------------------------- buckets293294 // Days for a short span, weeks (from Monday) for up to two years, else months; UTC.295 function buckets(times: number[]): { unit: 'day' | 'week' | 'month'; keys: number[]; key: (t: number) => number } {296 const a = Math.min(...times);297 const b = Math.max(...times);298 const span = (b - a) / DAY;299 const unit = span <= 62 ? 'day' : span <= 730 ? 'week' : 'month';300 const key = (t: number): number => {301 const d = new Date(Math.floor(t / DAY) * DAY);302 if (unit === 'week') return d.getTime() - ((d.getUTCDay() + 6) % 7) * DAY;303 if (unit === 'month') return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);304 return d.getTime();305 };306 const keys: number[] = [];307 for (let k = key(a); k <= key(b); ) {308 keys.push(k);309 const d = new Date(k);310 k = unit === 'day' ? k + DAY : unit === 'week' ? k + 7 * DAY : Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1);311 }312 return { unit, keys, key };313 }314315 const bucketLabel = (unit: string, k: number): string => (unit === 'month' ? new Date(k).toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' }) : dayOf(k));316317 // ---------------------------------------------------------------- tables318319 function table(fig: HTMLElement, head: string[], rows: () => string[][]): void {320 const d = fig.querySelector<HTMLDetailsElement>('details.table');321 if (!d || d.dataset.ready) return;322 d.dataset.ready = '1';323 d.addEventListener('toggle', () => {324 if (!d.open || d.querySelector('table')) return;325 const t = el('table');326 const tr = el('tr');327 for (const h of head) tr.appendChild(el('th', '', h));328 t.appendChild(el('thead')).appendChild(tr);329 const body = t.appendChild(el('tbody'));330 for (const r of rows()) {331 const row = el('tr');332 for (const c of r) row.appendChild(el('td', '', c));333 body.appendChild(row);334 }335 d.appendChild(el('div', 'scroll')).appendChild(t);336 });337 }338339 function empty(fig: HTMLElement, text: string): void {340 fig.querySelector('.plot')?.replaceChildren(el('div', 'empty-chart', text));341 fig.querySelector('details.table')?.remove();342 }343344 function legend(fig: HTMLElement, items: [string, string][]): void {345 const l = el('div', 'legend');346 for (const [cls, name] of items) {347 const s = el('span');348 const k = el('i');349 k.style.background = cls === 'fail' ? 'var(--viz-fail)' : 'var(--viz-1)';350 s.append(k, name);351 l.appendChild(s);352 }353 fig.querySelector('.plot')?.after(l);354 }355356 // ---------------------------------------------------------------- the page357358 const charts: (() => void)[] = [];359 const figure = (name: string): HTMLElement | null => root.querySelector<HTMLElement>(`figure[data-chart="${name}"]`);360 const chart = (name: string, draw: (fig: HTMLElement, plot: HTMLElement) => void): void => {361 const fig = figure(name);362 const plot = fig?.querySelector<HTMLElement>('.plot');363 if (!fig || !plot) return;364 const run = (): void => draw(fig, plot);365 charts.push(run);366 run();367 };368 const reviewName = (r: ReviewRow): string => (forgeWide ? `${r[0]} #${r[1]}` : `#${r[1]}`);369370 function tiles(d: Data): void {371 const t = d.totals;372 const box = root.querySelector('#ins-kpis');373 if (!box) return;374 const tile = (label: string, value: string, sub: string): void => {375 const k = el('div', 'kpi');376 k.append(el('div', 'label', label), el('div', 'value', value), el('div', 'sub', sub));377 box.appendChild(k);378 };379 const n = (k: string): number => t[k] ?? 0;380 tile('pushes', count(n('pushes')), d.from ? `since ${dayLong(d.from)}` : 'none yet');381 tile('builds', count(n('builds')), t.build_ms_median != null ? `median ${secs(t.build_ms_median / 1000)}` : 'none yet');382 tile('reviews landed', `${count(n('landed'))} of ${count(n('reviews'))}`, t.land_hours_median != null ? `median ${hours(t.land_hours_median)} to land` : `${count(n('closed'))} closed`);383 tile('revisions', t.revisions_median != null ? r1(t.revisions_median) : '-', 'per review, median');384 tile('ci passed', n('ci') ? `${Math.round((100 * n('ci_pass')) / n('ci'))}%` : '-', n('ci') ? `${n('ci_pass')} of ${plural(n('ci'), 'run')}${t.ci_seconds_median != null ? `, median ${secs(t.ci_seconds_median)}` : ''}` : 'no attestations yet');385 }386387 function draw(d: Data): void {388 const meta = root.querySelector('#ins-meta');389 if (meta) meta.textContent = `From the forge's lake, as of ${when(Date.parse(d.generated))}.`;390 tiles(d);391392 chart('builds', (fig, plot) => {393 if (!d.builds.length) return empty(fig, 'No builds yet.');394 dots(plot, d.builds.map(([t, ms]) => ({ t, v: ms / 1000, head: when(t), rows: [[secs(ms / 1000), 'to build']] })), (v) => secs(v));395 table(fig, ['built', 'took'], () => [...d.builds].reverse().map(([t, ms]) => [when(t), secs(ms / 1000)]));396 });397398 chart('pushes', (fig, plot) => {399 if (!d.pushes.length) return empty(fig, 'No pushes yet.');400 const days = d.pushes.map(([day, n]) => [Date.parse(`${day}T00:00:00Z`), n] as [number, number]);401 const bk = buckets(days.map(([t]) => t));402 const sums = new Map<number, number>();403 for (const [t, n] of days) sums.set(bk.key(t), (sums.get(bk.key(t)) ?? 0) + n);404 for (const s of fig.querySelectorAll('.ins-bucket')) s.textContent = bk.unit;405 const every = Math.ceil(bk.keys.length / Math.max(2, Math.floor((plot.clientWidth || 440) / 70)));406 columns(plot, bk.keys.map((k) => ({ label: bucketLabel(bk.unit, k), head: `${bk.unit === 'week' ? 'week of ' : ''}${bk.unit === 'month' ? bucketLabel('month', k) : dayLong(k)}`, segs: [{ v: sums.get(k) ?? 0, cls: 's1', name: 'pushes' }] })), (v) => count(v), every);407 table(fig, [bk.unit === 'week' ? 'week of' : bk.unit === 'month' ? 'month' : 'day', 'pushes'], () => bk.keys.filter((k) => sums.get(k)).reverse().map((k) => [dayLong(k), String(sums.get(k))]));408 });409410 chart('land', (fig, plot) => {411 const landed = d.reviews.filter((r) => r[4] === 'landed' && r[2] != null && r[3] != null);412 if (!landed.length) return empty(fig, 'No review has landed yet.');413 const h = (r: ReviewRow): number => Math.max(0, (r[3]! - r[2]!) / 3600e3);414 dots(plot, landed.map((r) => ({ t: r[3]!, v: h(r), head: `${reviewName(r)}, landed ${when(r[3]!)}`, rows: [[hours(h(r)), 'to land'], [String(r[5]), r[5] === 1 ? 'revision' : 'revisions']] })), (v) => hours(v));415 table(fig, ['review', 'landed', 'took', 'revisions'], () => [...landed].reverse().map((r) => [reviewName(r), when(r[3]!), hours(h(r)), String(r[5])]));416 });417418 chart('revisions', (fig, plot) => {419 const revs = d.reviews.filter((r) => r[5] > 0);420 if (!revs.length) return empty(fig, 'No reviews yet.');421 const top = Math.max(...revs.map((r) => r[5]));422 const cap = Math.min(top, 10);423 const n = new Array(cap + 1).fill(0);424 for (const r of revs) n[Math.min(r[5], cap)]++;425 const name = (i: number): string => (i === cap && top > cap ? `${i}+` : String(i));426 columns(plot, n.slice(1).map((v, i) => ({ label: name(i + 1), head: `${name(i + 1)} ${i === 0 ? 'revision' : 'revisions'}`, segs: [{ v, cls: 's1', name: v === 1 ? 'review' : 'reviews' }] })), (v) => count(v));427 table(fig, ['review', 'revisions', 'approvals', 'ended'], () => [...revs].reverse().map((r) => [reviewName(r), String(r[5]), String(r[6]), r[4] ?? 'open']));428 });429430 chart('ci', (fig, plot) => {431 if (!d.ci.length) return empty(fig, 'No CI attestations yet (forge attest).');432 const bk = buckets(d.ci.map(([t]) => t));433 const pass = new Map<number, number>();434 const fail = new Map<number, number>();435 for (const [t, , p] of d.ci) (p ? pass : fail).set(bk.key(t), ((p ? pass : fail).get(bk.key(t)) ?? 0) + 1);436 for (const s of fig.querySelectorAll('.ins-bucket')) s.textContent = bk.unit;437 const every = Math.ceil(bk.keys.length / Math.max(2, Math.floor((plot.clientWidth || 440) / 70)));438 columns(plot, bk.keys.map((k) => ({ label: bucketLabel(bk.unit, k), head: `${bk.unit === 'week' ? 'week of ' : ''}${dayLong(k)}`, segs: [{ v: pass.get(k) ?? 0, cls: 's1', name: 'passed' }, { v: fail.get(k) ?? 0, cls: 'fail', name: 'failed' }] })), (v) => count(v), every);439 if (!fig.querySelector('.legend')) legend(fig, [['s1', 'passed'], ['fail', 'failed']]);440 table(fig, [bk.unit === 'week' ? 'week of' : bk.unit === 'month' ? 'month' : 'day', 'passed', 'failed'], () => bk.keys.filter((k) => pass.get(k) || fail.get(k)).reverse().map((k) => [dayLong(k), String(pass.get(k) ?? 0), String(fail.get(k) ?? 0)]));441 });442443 chart('ciTime', (fig, plot) => {444 const runs = d.ci.filter(([, s]) => s != null);445 if (!runs.length) return empty(fig, 'No CI attestations yet (forge attest).');446 dots(plot, runs.map(([t, s, p]) => ({ t, v: s!, fail: !p, head: when(t), rows: [[secs(s!), p ? 'passed' : 'failed', p ? 's1' : 'fail']] })), (v) => secs(v));447 if (!fig.querySelector('.legend')) legend(fig, [['s1', 'passed'], ['fail', 'failed']]);448 table(fig, ['started', 'took', 'result'], () => [...runs].reverse().map(([t, s, p]) => [when(t), secs(s!), p ? 'passed' : 'failed']));449 });450451 // A chart a metric, made here since the page doesn't know their names.452 const box = root.querySelector<HTMLElement>('#ins-metrics');453 const grid = box?.querySelector('.charts');454 if (box && grid && d.metrics?.length) {455 box.hidden = false;456 for (const [name, unit, pts] of d.metrics) {457 const fig = el('figure', 'panel');458 fig.dataset.chart = `metric:${name}`;459 const head = el('figcaption', 'panel-head');460 head.append(el('h3', 'mono', name), el('span', 'grow', unit ? `in ${unit}, ` : ''));461 head.lastElementChild!.append(plural(pts.length, 'commit'));462 const det = el('details', 'table');463 det.appendChild(el('summary', '', 'table'));464 fig.append(head, el('div', 'plot'), det);465 grid.appendChild(fig);466 const fmt = (v: number): string => num(v) + (unit ? ` ${unit}` : '');467 chart(`metric:${name}`, (f, plot) => {468 dots(plot, pts.map(([t, v, sha, subj]) => ({ t, v, head: `${sha.slice(0, 7)} ${subj}`, rows: [[fmt(v), name], [when(t), 'committed']] })), num, true);469 table(f, ['commit', 'subject', 'committed', name], () => [...pts].reverse().map(([t, v, sha, subj]) => [sha.slice(0, 7), subj, when(t), fmt(v)]));470 });471 }472 }473474 const repos = root.querySelector('#ins-repos tbody');475 if (repos && d.repos) {476 for (const [slug, pushes, builds, buildMs, landed, ci, pass, last] of d.repos) {477 const tr = el('tr');478 const a = el('a');479 a.href = `/${slug.split('/').map(encodeURIComponent).join('/')}/insights/`;480 const [o, n] = slug.split('/');481 a.append(el('span', 'desc', `${o} /`), ' ', el('b', '', n));482 tr.appendChild(el('td', 'nowrap')).appendChild(a);483 for (const v of [count(pushes), count(builds), buildMs != null ? secs(buildMs / 1000) : '', count(landed), ci ? `${pass} of ${ci}` : '']) tr.appendChild(el('td', 'num', v));484 tr.appendChild(el('td', 'date', last ? dayLong(last) : ''));485 repos.appendChild(tr);486 }487 }488489 // Charts are drawn at their width; draw again when it changes.490 let frameId = 0;491 let width = root.clientWidth;492 new ResizeObserver(() => {493 if (root.clientWidth === width) return;494 width = root.clientWidth;495 cancelAnimationFrame(frameId);496 frameId = requestAnimationFrame(() => charts.forEach((c) => c()));497 }).observe(root);498 }499500 fetch(root.dataset.insights!)501 .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))))502 .then((d: Data) => draw(d))503 .catch(() => {504 const meta = root.querySelector('#ins-meta');505 if (meta) meta.textContent = 'No insights yet: forge lake writes them after the next build, or run it on the server.';506 for (const fig of root.querySelectorAll<HTMLElement>('figure')) empty(fig, 'Nothing yet.');507 });508})();