// Data shaping for the worker dashboard page (workers.html loads it as a module; node --test imports the same file). // Pure functions over workers.json: the analytics strip, the timeline bars, the time formatting. No DOM here. export const KIND_LABEL = { 'node-linux': 'node Linux', 'node-windows': 'node Windows', app: 'app', 'app-windows': 'app Windows', suite: 'suite', bench: 'bench', gate: 'gate', check: 'check', prove: 'prove', pow: 'pow', other: 'other' }; /// The scheduling class line for a card: "nice 10 · 32 cores · -j 32" or '' when the line carries none (7 October 2026 classes). export const classLine = b => [Number.isFinite(b && b.nice) ? `nice ${b.nice}` : '', Number.isFinite(b && b.cores) ? `${b.cores === 96 ? 'all 96' : 'last ' + b.cores} cores` : '', Number.isFinite(b && b.jobs) ? `-j ${b.jobs}` : '', b && b.priority === 'gate' ? 'gate priority' : ''].filter(Boolean).join(' · '); export const kindLabel = k => KIND_LABEL[k] || k || 'other'; export function quantile(sorted, q) { if (!sorted.length) return null; const pos = (sorted.length - 1) * q, lo = Math.floor(pos), hi = Math.ceil(pos); return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); } /// Median and p90 duration per kind over finished builds (failed ones included: a failed build still cost its time). export function durationByKind(builds) { const by = new Map(); for (const b of builds || []) { if (!Number.isFinite(b.secs)) continue; const k = b.kind || 'other'; if (!by.has(k)) by.set(k, []); by.get(k).push(b.secs); } const out = []; for (const [kind, arr] of by) { arr.sort((a, b) => a - b); out.push({ kind, label: kindLabel(kind), n: arr.length, median_s: quantile(arr, 0.5), p90_s: quantile(arr, 0.9), min_s: arr[0], max_s: arr[arr.length - 1] }); } return out.sort((a, b) => b.n - a.n || a.kind.localeCompare(b.kind)); } /// Builds that ENDED in each hour of `day` (UTC date string), 24 buckets of { hour, n, ok, failed }. export function buildsPerHour(builds, day) { const buckets = Array.from({ length: 24 }, (_, hour) => ({ hour, n: 0, ok: 0, failed: 0 })); for (const b of builds || []) { const t = b.end || b.start; if (!t || !String(t).startsWith(day)) continue; const h = Number(String(t).slice(11, 13)); if (!(h >= 0 && h < 24)) continue; buckets[h].n++; if (b.ok) buckets[h].ok++; else buckets[h].failed++; } return buckets; } /// sccache hit rate per build in time order, for the sparkline: [{ at, pct, kind }]. export function hitRateSeries(builds) { return (builds || []).filter(b => b.sccache && Number.isFinite(b.sccache.hit_rate_pct)).map(b => ({ at: b.end || b.start, pct: b.sccache.hit_rate_pct, kind: b.kind })).sort((a, b) => String(a.at).localeCompare(String(b.at))); } /// Box against Mac for the same kind: the box's median from the log, the Mac's from `baselines` ({ kind: { secs, source } }). export function compareKinds(builds, baselines) { const box = new Map(durationByKind(builds).map(d => [d.kind, d])); const kinds = new Set([...box.keys(), ...Object.keys(baselines || {})]); return [...kinds].map(kind => { const b = box.get(kind); const m = baselines && baselines[kind]; const ratio = b && m && m.secs ? Math.round((m.secs / b.median_s) * 10) / 10 : null; return { kind, label: kindLabel(kind), box_s: b ? b.median_s : null, box_n: b ? b.n : 0, mac_s: m ? m.secs : null, mac_source: m ? m.source || null : null, speedup: ratio }; }).filter(r => r.box_s !== null || r.mac_s !== null).sort((a, b) => (b.box_n - a.box_n) || a.kind.localeCompare(b.kind)); } /// The timeline: newest first, each with a bar width as a share of the longest, and the lines to show on click. export function timeline(builds, { limit = 40 } = {}) { const rows = (builds || []).slice().sort((a, b) => String(b.end || b.start || '').localeCompare(String(a.end || a.start || ''))).slice(0, limit); const max = Math.max(60, ...rows.map(r => Number(r.secs) || 0)); return rows.map(r => ({ ...r, bar_pct: Math.max(1.5, Math.round(((Number(r.secs) || 0) / max) * 1000) / 10), closing: closingLines(r), })); } /// What a click on a done build shows: the facts the log carries, one per line. export function closingLines(b) { const out = []; if (b.command) out.push(`$ ${b.command}`); if (b.worktree) out.push(`${b.worktree}/${b.crate || ''}${b.branch ? ` (${b.branch}${b.sha ? ' ' + String(b.sha).slice(0, 8) : ''})` : ''}`); out.push(`exit ${b.exit ?? '?'} after ${fmtDur(b.secs)}${b.wait_s ? `, waited ${fmtDur(b.wait_s)} for a slot` : ''}${b.slot !== undefined && b.slot !== null ? ` on build-${b.slot}` : ''}`); if (classLine(b)) out.push(`class: ${classLine(b)}`); if (b.compiles !== undefined) out.push(`${b.compiles} compiles, sccache ${b.sccache && b.sccache.hit_rate_pct !== null ? b.sccache.hit_rate_pct + '% hits' : 'no stats'} (${b.sccache ? `${b.sccache.hits} hit, ${b.sccache.misses} miss` : ''})`); if (Array.isArray(b.load_end) && b.load_end.length) out.push(`load at the end ${b.load_end.join(' ')}`); for (const a of b.artefacts || []) out.push(`${a.path} ${fmtBytes(a.bytes)} sha256 ${String(a.sha256 || '').slice(0, 16)}…`); if (b.agent) out.push(`agent ${b.agent}`); return out; } export const fmtDur = s => { s = Math.max(0, Math.round(Number(s) || 0)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60; return h ? `${h} h ${String(m).padStart(2, '0')} min` : m ? `${m} min ${String(r).padStart(2, '0')} s` : `${r} s`; }; export const fmtDurShort = s => { s = Math.max(0, Math.round(Number(s) || 0)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${m}:${String(r).padStart(2, '0')}`; }; export const fmtBytes = n => { n = Number(n) || 0; if (n < 1024) return `${n} B`; if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`; if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`; return `${(n / 1073741824).toFixed(2)} GB`; }; export const fmtBps = n => { n = Number(n) || 0; if (n < 1e6) return `${(n / 1e3).toFixed(0)} kb/s`; if (n < 1e9) return `${(n / 1e6).toFixed(1)} Mb/s`; return `${(n / 1e9).toFixed(2)} Gb/s`; }; export const fmtUptime = s => { s = Number(s) || 0; const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d ? `${d} d ${h} h` : h ? `${h} h ${m} min` : `${m} min`; }; /// UK local time for display with the UTC in the title; `tz` defaults to Europe/London. export function ukTime(isoStr, { tz = 'Europe/London', withDate = false } = {}) { if (!isoStr) return { text: '', utc: '' }; const d = new Date(isoStr); if (Number.isNaN(d.getTime())) return { text: String(isoStr), utc: '' }; const opts = { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false, ...(withDate ? { day: '2-digit', month: 'short' } : {}) }; let text; try { text = new Intl.DateTimeFormat('en-GB', opts).format(d); } catch { text = d.toISOString().slice(11, 16); } return { text, utc: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' }; } /// "x min ago" for stamps. export const ago = (isoStr, now = Date.now()) => { if (!isoStr) return ''; const s = Math.max(0, Math.round((now - Date.parse(isoStr)) / 1000)); return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.floor(s / 60)} min ago` : `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min ago`; }; /// The headline timings: the three the build-server agent measures with the Mac baseline beside each. export function headline(h) { const rows = [ { key: 'clean_node', label: 'Clean node build', what: 'cold target dir, Linux node' }, { key: 'incremental_under_load', label: 'Incremental under load', what: 'one file changed while another build runs' }, { key: 'windows_cross', label: 'Windows cross', what: 'igneumd.exe and igneum-miner.exe' }, ]; return rows.map(r => { const v = (h && h[r.key]) || {}; return { ...r, box_s: Number.isFinite(v.box_s) ? v.box_s : null, mac_s: Number.isFinite(v.mac_s) ? v.mac_s : null, pc_s: Number.isFinite(v.pc_s) ? v.pc_s : null, note: v.note || null, measured_at: v.measured_at || null, speedup: Number.isFinite(v.box_s) && Number.isFinite(v.mac_s) && v.box_s > 0 ? Math.round((v.mac_s / v.box_s) * 10) / 10 : null }; }); }