igneum/tools/workers/shape.js
igneum-labs ee15172f4e Worker dashboard: scheduling classes on the cards (kind, nice, cores), gate-queued marker, wait and gate-pending files
The slot line now ends "; kind=<k> nice=<n> cores=<c>; agent=<a>" (build box scheduling, 69034dc0): lib.mjs lifts the
three into their own fields and keeps the bare label; the old line shape still derives its kind. The collector reads
/srv/builds/_locks/wait-<pid> and gate-pending-<pid> directly (dead pids skipped), marks a gate with priority gate
and sorts it first; the page shows the class line on Now building and Queue cards, a "gate queued" pill, and the class
in the closing lines. JSONL nice, cores, jobs and priority carried through. 14 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 9fce5742a6)
2026-10-07 10:35:07 +00:00

102 lines
8.1 KiB
JavaScript

// 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 }; });
}