Merge feature/workbench-w4-resources into unify/skills-20261112

Conflicts combined W1 (Inbox leaves the Stream for Tasks, Waits on Operator) with W4 (Resources tab): five tabs with Resources, Stream keeps only the Firewall, the inbox hash keeps its redirect; index.html.j2 includes both diffs.js and resources.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:26:29 +00:00
commit 515fe61009
21 files changed
+1937 -18

No files matched your search

+6
View File
@@ -406,6 +406,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
{% include "style-hierarchy.css" %}
{% include "style-resources.css" %}
{% include "style-inbox.css" %}
{% include "style-ui.css" %}
@@ -463,6 +465,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "diffs.js" %}
{% include "resources.js" %}
{% include "nav.js" %}
{% include "ancestry.js" %}
@@ -578,6 +582,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-alerts.js" %}
{% include "page-resources.js" %}
{% include "page-rules.js" %}
{% include "page-keyboard.js" %}
+2 -1
View File
@@ -1,6 +1,6 @@
const Nav = (() => {
"use strict";
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"], ["resources", "Resources"]];
const SUBS = {stream: [["rules", "Firewall"]]};
const PANES = {inbox: ["tasks", "operator"]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
@@ -10,6 +10,7 @@ const Nav = (() => {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", gantt: "bi-calendar-range", burn: "bi-graph-down-arrow",
resources: "bi-speedometer2",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
+174
View File
@@ -0,0 +1,174 @@
const RESOURCE = {left: 46, right: 10, top: 6, bottom: 6, axis: 18, poll: 5000, stale: 60, tones: 6, hour: 3600, narrow: 600};
const RESOURCE_PANELS = [["disks", "bi-hdd-stack", "Disks"], ["host", "bi-cpu", "CPU, RAM and swap"], ["gpu", "bi-gpu-card", "GPU and VRAM"], ["tokens", "bi-coin", "Tokens per agent"]];
const RESOURCE_ZONES = {idle: "Idle: below 30 %", saturated: "Saturated: above 90 %"};
const RESOURCE_MARKS = {
wait: ["#bi-hourglass-split", (mark) => `${mark.peak} jobs waited while CPU and RAM stayed under 50 %`],
asleep: ["#bi-moon", (mark) => `${mark.name} asleep`],
unknown: ["#bi-question-circle", (mark) => `${mark.name} unknown: no reading`],
};
const resourceView = {data: null, dom: null, busy: false, key: "", at: 0, timer: 0};
const resourceKey = () => JSON.stringify([ui.preset, ui.from, ui.to, ui.asOf]);
const compactCount = (value) => (value >= 1e6 ? `${fixed(value / 1e6)}M` : value >= 1e3 ? `${fixed(value / 1e3)}k` : String(Math.round(value)));
const resourceLabel = (line) => (line.kind === "vram" ? `${line.name} VRAM` : line.name);
function resourcesScreen() {
const count = h("span", {class: "count", title: "Samples", "aria-label": "Samples"}, "0");
const panels = RESOURCE_PANELS.map(([kind, name, label]) => {
const legend = h("div", {class: "res-legend"});
const plot = h("div", {class: "res-plot"});
const panel = h("section", {class: `card res-panel res-${kind}`, "data-panel": kind, "aria-label": label},
h("div", {class: "res-head"}, h("span", {class: "res-title", title: label, "aria-label": label}, icon(name)), legend), plot);
return {kind, label, legend, plot, panel};
});
resourceView.dom = {count, panels};
if (!resourceView.timer) {
resourceView.timer = window.setInterval(pollResources, RESOURCE.poll);
window.addEventListener("resize", drawResources);
}
window.setTimeout(() => {
drawResources();
fetchResources();
}, 0);
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchResources, "Reload: read the sampler files again");
return h("div", {class: "graph resources"},
h("div", {class: "card controls"}, h("div", {class: "toolbar", role: "toolbar", "aria-label": "Resources"}, reload, count)),
h("div", {class: "res-panels"}, panels.map((entry) => entry.panel)));
}
function pollResources() {
const dom = resourceView.dom;
if (!dom || !dom.count.isConnected || document.hidden || resourceView.busy) {
return;
}
if (resourceKey() !== resourceView.key || nowSeconds() - resourceView.at >= RESOURCE.stale) {
fetchResources();
}
}
async function fetchResources() {
if (resourceView.busy) {
return;
}
resourceView.busy = true;
resourceView.key = resourceKey();
try {
const response = await fetch(`/api/resources?${windowQuery()}`, {cache: "no-store"});
if (response.ok) {
resourceView.data = await response.json();
}
} catch {
ui.offline = true;
}
resourceView.at = nowSeconds();
resourceView.busy = false;
drawResources();
}
function resourceFrame(entry, span, size) {
const axis = entry.kind === "tokens";
const bottom = size.height - (axis ? RESOURCE.axis : RESOURCE.bottom);
const x = (t) => RESOURCE.left + ((t - span.from) / (span.to - span.from)) * (size.width - RESOURCE.left - RESOURCE.right);
const svg = s("svg", {class: `chart res-chart res-${entry.kind}`, "data-panel": entry.kind, "data-from": span.from, "data-to": span.to, viewBox: `0 0 ${size.width} ${size.height}`, role: "img", "aria-label": entry.label});
const ticks = size.width < RESOURCE.narrow ? 2 : 4;
for (let index = 0; index <= ticks; index += 1) {
const t = span.from + ((span.to - span.from) * index) / ticks;
svg.append(s("line", {class: "grid", x1: fixed(x(t)), x2: fixed(x(t)), y1: RESOURCE.top, y2: fixed(bottom)}));
if (axis) {
svg.append(s("text", {class: "axis", x: fixed(x(t)), y: size.height - 4, "text-anchor": index === 0 ? "start" : index === ticks ? "end" : "middle"}, (span.to - span.from > DAY ? absolute : clock).format(stampOf(t))));
}
}
return {svg, x, top: RESOURCE.top, bottom, right: size.width - RESOURCE.right};
}
function resourceScale(frame, values, label) {
const y = (value) => frame.top + (1 - value / values[values.length - 1]) * (frame.bottom - frame.top);
for (const value of values) {
frame.svg.append(s("line", {class: "grid", x1: RESOURCE.left, x2: fixed(frame.right), y1: fixed(y(value)), y2: fixed(y(value))}));
frame.svg.append(s("text", {class: "axis", x: RESOURCE.left - 6, y: fixed(y(value)), dy: "0.35em", "text-anchor": "end"}, label(value)));
}
return y;
}
function resourceMarks(frame, marks, span) {
for (const mark of marks) {
const left = frame.x(Math.max(mark.from, span.from));
const right = frame.x(Math.min(mark.to, span.to));
if (right <= left) {
continue;
}
const [href, words] = RESOURCE_MARKS[mark.kind];
frame.svg.append(s("rect", {class: `res-mark ${mark.kind}`, "data-from": mark.from, "data-to": mark.to, "data-peak": mark.peak, x: fixed(left), width: fixed(Math.max(right - left, 2)), y: frame.top, height: fixed(frame.bottom - frame.top)}, s("title", {}, words(mark))));
frame.svg.append(s("use", {class: `res-icon ${mark.kind}`, href, x: fixed(left + 2), y: frame.top + 2, width: 11, height: 11}));
}
}
function resourcePercent(entry, list, marks, span, size) {
const frame = resourceFrame(entry, span, size);
const y = resourceScale(frame, [0, 50, 100], (value) => `${value} %`);
for (const zone of Resources.ZONES) {
frame.svg.append(s("rect", {class: `band ${zone.kind}`, "data-zone": zone.kind, x: RESOURCE.left, width: fixed(frame.right - RESOURCE.left), y: fixed(y(zone.to)), height: fixed(y(zone.from) - y(zone.to))}, s("title", {}, RESOURCE_ZONES[zone.kind])));
}
resourceMarks(frame, marks, span);
const tones = new Map();
const legend = [];
for (const line of list) {
if (!tones.has(line.name)) {
tones.set(line.name, tones.size % RESOURCE.tones);
}
const tone = `res-c${tones.get(line.name)} k-${line.kind}`;
const last = [...line.points].reverse().find(([, value]) => value !== null);
const words = `${resourceLabel(line)} ${dash(last === undefined ? null : last[1])} %`;
for (const part of Resources.segments(line.points)) {
const d = part.map(([t, value], index) => `${index ? "L" : "M"}${fixed(frame.x(t))},${fixed(y(Math.min(Math.max(value, 0), 100)))}`).join(" ");
frame.svg.append(s("path", {class: `res-line ${tone}`, "data-series": line.name, "data-kind": line.kind, "data-points": part.length, d: part.length === 1 ? `${d} h1` : d}, s("title", {}, words)));
}
legend.push(h("span", {class: `res-key ${tone}`, "data-series": line.name, "data-kind": line.kind}, words));
}
return {svg: frame.svg, legend};
}
function resourceTokens(entry, stack, span, size) {
const frame = resourceFrame(entry, span, size);
const y = resourceScale(frame, [0, Math.max(stack.top, 1)], compactCount);
const tones = new Map(stack.agents.map((agent, index) => [agent, index % RESOURCE.tones]));
for (const bar of stack.bars) {
const left = frame.x(Math.max(bar.t, span.from));
const right = frame.x(Math.min(bar.t + RESOURCE.hour, span.to));
if (right <= left) {
continue;
}
for (const part of bar.parts) {
const words = `${part.agent} · ${absolute.format(stampOf(bar.t))}: ${part.total} tokens (input ${part.input}, output ${part.output}, cache read ${part.cache_read}, cache write ${part.cache_write}) · ${part.models.join(", ")}`;
frame.svg.append(s("rect", {class: `res-bar res-c${tones.get(part.agent)}`, "data-agent": part.agent, "data-t": bar.t, "data-total": part.total, x: fixed(left), width: fixed(Math.max(right - left - 1, 1)), y: fixed(y(part.to)), height: fixed(Math.max(y(part.from) - y(part.to), 0.5))}, s("title", {}, words)));
}
}
const legend = stack.agents.map((agent) => h("span", {class: `res-key res-c${tones.get(agent)}`, "data-series": agent}, `${agent} ${compactCount(stack.totals[agent])}`));
return {svg: frame.svg, legend};
}
function drawResources() {
const dom = resourceView.dom;
const data = resourceView.data;
if (!dom || !dom.count.isConnected || data === null) {
return;
}
const span = {from: data.since, to: Math.max(data.until, data.since + 60)};
const lines = Resources.lines(data.samples);
const marks = {
disks: [],
host: Resources.waits(data.samples).map((mark) => ({...mark, kind: "wait"})),
gpu: ["asleep", "unknown"].flatMap((kind) => Resources[kind](data.samples).flatMap((gpu) => gpu.spans.map((mark) => ({...mark, kind, name: gpu.name})))),
};
dom.count.textContent = String(data.samples.length);
for (const entry of dom.panels) {
const box = entry.plot.getBoundingClientRect();
const size = {width: Math.round(box.width), height: Math.round(box.height)};
if (size.width < RESOURCE.left + RESOURCE.right + 10 || size.height < RESOURCE.axis + 10) {
entry.plot.replaceChildren();
continue;
}
const drawn = entry.kind === "tokens" ? resourceTokens(entry, Resources.stack(data.tokens), span, size) : resourcePercent(entry, lines[entry.kind], marks[entry.kind], span, size);
entry.plot.replaceChildren(drawn.svg);
entry.legend.replaceChildren(...drawn.legend);
}
}
+2 -2
View File
@@ -55,7 +55,7 @@
}
function screenOf() {
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen]();
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen, resources: resourcesScreen}[ui.screen]();
}
function render(force) {
@@ -73,7 +73,7 @@
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
document.body.classList.toggle("fit", ["tasks", "inbox"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
document.body.classList.toggle("fit", ["tasks", "inbox", "resources"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
drawn.screen = ui.screen;
drawn.sig = sig;
if (g3.host && !g3.host.isConnected) {
+144
View File
@@ -0,0 +1,144 @@
const Resources = (() => {
"use strict";
const IDLE = 30;
const SATURATED = 90;
const CALM = 50;
const GAP = 1800;
const STEP = 60;
const COUNTS = ["input", "output", "cache_read", "cache_write"];
const ZONES = [{kind: "idle", from: 0, to: IDLE}, {kind: "saturated", from: SATURATED, to: 100}];
const finite = (value) => typeof value === "number" && Number.isFinite(value);
const share = (part, whole) => (finite(part) && finite(whole) && whole > 0 ? Math.round((1000 * part) / whole) / 10 : null);
const listOf = (row, key) => (Array.isArray(row[key]) ? row[key].filter((item) => item && typeof item === "object") : []);
const timed = (samples) => samples.filter((row) => row && finite(row.t));
function series(samples, pick) {
const found = new Map();
for (const row of timed(samples)) {
for (const [name, kind, value] of pick(row)) {
const key = `${kind}\u0000${name}`;
if (!found.has(key)) {
found.set(key, {name: String(name), kind, points: []});
}
found.get(key).points.push([row.t, finite(value) ? value : null]);
}
}
return [...found.values()];
}
function lines(samples) {
return {
disks: series(samples, (row) => listOf(row, "disks").map((disk) => [disk.mount, "disk", share(disk.used, disk.total)])),
host: series(samples, (row) => [["CPU", "host", row.cpu], ["RAM", "host", row.ram], ["Swap", "host", row.swap]]),
gpu: series(samples, (row) => listOf(row, "gpus").flatMap((gpu) => [[gpu.name, "util", gpu.asleep ? null : gpu.util], [gpu.name, "vram", gpu.asleep ? null : share(gpu.vram_used, gpu.vram_total)]])),
};
}
function segments(points) {
const found = [];
let current = [];
let last = null;
for (const [t, value] of points) {
if (current.length && (value === null || t - last > GAP)) {
found.push(current);
current = [];
}
if (value !== null) {
current.push([t, value]);
}
last = t;
}
if (current.length) {
found.push(current);
}
return found;
}
function spans(samples, test, value) {
const rows = timed(samples);
const found = [];
let open = null;
rows.forEach((row, index) => {
if (open && index && row.t - rows[index - 1].t > GAP) {
found.push(open);
open = null;
}
if (!test(row)) {
if (open) {
found.push(open);
}
open = null;
return;
}
const next = rows[index + 1];
const end = row.t + (next && next.t - row.t <= GAP ? next.t - row.t : STEP);
const peak = value(row);
open = open ? {from: open.from, to: end, peak: Math.max(open.peak, peak)} : {from: row.t, to: end, peak};
});
if (open) {
found.push(open);
}
return found;
}
function waits(samples) {
const waiting = (row) => (row.queue && finite(row.queue.waiting) ? row.queue.waiting : 0);
return spans(samples, (row) => waiting(row) > 0 && finite(row.cpu) && finite(row.ram) && row.cpu < CALM && row.ram < CALM, waiting);
}
function gpuSpans(samples, test) {
const names = [...new Set(timed(samples).flatMap((row) => listOf(row, "gpus").filter(test).map((gpu) => String(gpu.name))))];
return names.map((name) => ({name, spans: spans(samples, (row) => listOf(row, "gpus").some((gpu) => String(gpu.name) === name && test(gpu)), () => 1)}));
}
const asleep = (samples) => gpuSpans(samples, (gpu) => gpu.asleep === true);
const unknown = (samples) => gpuSpans(samples, (gpu) => gpu.asleep !== true && !finite(gpu.util) && !finite(gpu.vram_used));
function stack(tokens) {
const totals = new Map();
const hours = new Map();
for (const row of tokens) {
if (!row || !finite(row.t) || typeof row.agent !== "string") {
continue;
}
const counts = COUNTS.map((name) => (finite(row[name]) && row[name] > 0 ? row[name] : 0));
const sum = counts.reduce((total, value) => total + value, 0);
if (!totals.has(row.agent)) {
totals.set(row.agent, 0);
}
totals.set(row.agent, totals.get(row.agent) + sum);
if (!hours.has(row.t)) {
hours.set(row.t, new Map());
}
const hour = hours.get(row.t);
if (!hour.has(row.agent)) {
hour.set(row.agent, {agent: row.agent, total: 0, models: [], ...Object.fromEntries(COUNTS.map((name) => [name, 0]))});
}
const entry = hour.get(row.agent);
COUNTS.forEach((name, index) => {
entry[name] += counts[index];
});
entry.total += sum;
if (typeof row.model === "string" && !entry.models.includes(row.model)) {
entry.models.push(row.model);
}
}
const agents = [...totals].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([agent]) => agent);
const bars = [...hours.keys()].sort((a, b) => a - b).map((t) => {
let base = 0;
const parts = agents.filter((agent) => hours.get(t).has(agent)).map((agent) => {
const entry = hours.get(t).get(agent);
const part = {...entry, from: base, to: base + entry.total};
base += entry.total;
return part;
});
return {t, total: base, parts};
});
return {agents, totals: Object.fromEntries(totals), bars, top: Math.max(0, ...bars.map((bar) => bar.total))};
}
return {IDLE, SATURATED, CALM, GAP, COUNTS, ZONES, lines, segments, spans, waits, asleep, unknown, stack};
})();
if (typeof module === "object" && module.exports) {
module.exports = Resources;
}
@@ -0,0 +1,27 @@
body.fit .graph.resources > .res-panels { flex: 1; }
.res-panels { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.res-panel { display: flex; flex: 1 1 0; flex-direction: column; gap: 2px; min-height: 72px; padding: 6px 10px; overflow: hidden; }
.res-head { display: flex; align-items: center; gap: 4px 12px; min-width: 0; overflow: hidden; color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
.res-title { display: inline-flex; color: var(--text); font-size: var(--fs-md); }
.res-legend { display: flex; gap: 4px 12px; min-width: 0; overflow: hidden; font-variant-numeric: tabular-nums; }
.res-key { display: inline-flex; align-items: center; gap: 5px; }
.res-key::before { content: ""; width: 12px; height: 3px; border-radius: 2px; background: var(--c); }
.res-key.k-vram::before { background: repeating-linear-gradient(90deg, var(--c) 0 4px, transparent 4px 6px); }
.res-plot { position: relative; flex: 1; min-height: 0; }
.res-plot > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
svg .band.idle { fill: var(--ok-soft); }
svg .band.saturated { fill: var(--bad-soft); }
svg .res-line { fill: none; stroke: var(--c); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
svg .res-line.k-vram { stroke-dasharray: 5 3; }
svg .res-mark.wait { fill: var(--warn-soft); stroke: var(--warn); stroke-dasharray: 3 3; }
svg .res-mark.asleep { fill: var(--neutral-soft); }
svg .res-mark.unknown { fill: var(--neutral-soft); stroke: var(--muted); stroke-dasharray: 2 3; }
svg .res-icon.wait { fill: var(--warn); }
svg .res-icon.asleep, svg .res-icon.unknown { fill: var(--muted); }
svg .res-bar { fill: var(--c); }
.res-c0 { --c: var(--info); }
.res-c1 { --c: var(--ok); }
.res-c2 { --c: var(--accent); }
.res-c3 { --c: var(--warn); }
.res-c4 { --c: var(--violet); }
.res-c5 { --c: var(--bad); }