The slot line now ends "; kind=<k> nice=<n> cores=<c>; agent=<a>" (build box scheduling,c03d000a): 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 commit9fce5742a6)
102 lines
8.1 KiB
JavaScript
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 }; });
|
|
}
|