igneum/tools/workers/shape.js
igneum-labs 942a58a0ef Worker dashboard: collector on igneum-build-1, Mac pusher, workers.html next to the fleet page (the project lead, 6 October 2026)
tools/workers/collect.mjs runs every 30 s on the box (infra/build-server/workers/igneum-workers.{service,timer},
install.sh) and writes /srv/workers/workers.json from the machine itself: /proc/stat deltas per core, meminfo, df on
/srv, net bytes, hwmon temperatures, the flock state of /srv/builds/_locks, cargo processes with worktree, target and
start time, flock waiters, /srv/builds/_log/builds.jsonl (the format agreed with the build-server agent), sccache
--show-stats, headline.json. tools/workers/push.mjs (launchd every 60 s) adds the Mac's with-lock slots and waiters
and the two PCs' relay job states from the intake, drops them on the box so its file is whole, merges the box's file
and writes the fleet folder's workers.json; it deploys only when the live copy is over 6 min old, otherwise the
fleet orchestrator's 5-minute deploy carries it. The page (tools/workers/page/workers.html, shape.js) tries
https://build.igneum.network/workers.json first and falls back to the folder copy; core strip, arcs, now building,
queue, recently done with closing lines, headline timings, analytics; UK time with UTC tooltips; phone width.
node --test tools/workers/test: 13 tests over /proc/stat, lock dir, JSONL and sccache fixtures and the page shaping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:59:07 +00:00

99 lines
7.6 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', check: 'check', prove: 'prove', pow: 'pow', other: 'other' };
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 (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 }; });
}