feat: per-worker utilization visualization with phase bars (Stage 4)

Replace the basic canvas bar chart on the web overview with rich worker
cards showing:
- Stacked phase-timing bars (processing/delivery/spawns/overhead)
- Load percentage derived from tick timing active ratio
- Inline sparklines from history (message rate trends)
- Actor count, messages processed, mailbox depth stats
- Color legend for phase identification

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
Claude 2026-02-13 08:56:30 +00:00
parent 41930ef8f4
commit 6cfafa04b7

View file

@ -77,6 +77,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
.chart-panel { grid-row: span 2; } .chart-panel { grid-row: span 2; }
canvas#workerChart { width: 100%; height: 200px; } canvas#workerChart { width: 100%; height: 200px; }
.worker-cards { display: flex; flex-direction: column; gap: 6px; }
.worker-card {
display: flex; align-items: center; gap: 10px;
background: #1c1f2e; border-radius: 4px; padding: 6px 10px;
}
.worker-card .wc-id { font-weight: 700; min-width: 32px; }
.worker-card .wc-bar-wrap { flex: 1; height: 14px; background: #0f1117; border-radius: 2px; overflow: hidden; display: flex; }
.worker-card .wc-bar-seg { height: 100%; }
.worker-card .wc-stats { font-size: 11px; color: #888; min-width: 200px; text-align: right; }
.worker-card .wc-spark { display: inline-flex; gap: 4px; margin-left: 6px; }
.actor-table-wrap { max-height: 200px; overflow-y: auto; } .actor-table-wrap { max-height: 200px; overflow-y: auto; }
.actor-table-wrap table { width: 100%; border-collapse: collapse; } .actor-table-wrap table { width: 100%; border-collapse: collapse; }
.actor-table-wrap th, .actor-table-wrap td { .actor-table-wrap th, .actor-table-wrap td {
@ -156,9 +167,14 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
<div class="grid"> <div class="grid">
<div class="panel chart-panel"> <div class="panel chart-panel">
<h2>Worker Distribution</h2> <h2>Worker Utilization</h2>
<canvas id="workerChart"></canvas> <div id="workerCards" class="worker-cards"></div>
<div id="workerLegend" style="margin-top:8px;font-size:11px;color:#888;"></div> <div style="margin-top:6px;font-size:10px;color:#555;">
<span style="color:#4caf50;">\u25A0</span> processing
<span style="color:#2196f3;">\u25A0</span> delivery
<span style="color:#00bcd4;">\u25A0</span> spawns
<span style="color:#f44336;">\u25A0</span> overhead
</div>
</div> </div>
<div class="panel"> <div class="panel">
@ -243,48 +259,83 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
} }
setInterval(updateUptime, 1000); setInterval(updateUptime, 1000);
var canvas = document.getElementById('workerChart');
var ctx = canvas.getContext('2d');
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63']; var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
function drawWorkerChart(workers) { var phaseColors = ['#4caf50', '#2196f3', '#00bcd4', '#f44336'];
var dpr = window.devicePixelRatio || 1; // Group tick phases: processing=2, delivery=1+4, spawns=0+3, overhead=5
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
var W = rect.width, H = rect.height;
ctx.clearRect(0, 0, W, H);
if (!workers || workers.length === 0) return; function computePhases(timings) {
if (!timings || timings.length === 0) return [0.25, 0.25, 0.25, 0.25];
var sums = [0,0,0,0,0,0];
var active = 0;
for (var i = 0; i < timings.length; i++) {
var t = timings[i];
if (t.did_work) active++;
for (var p = 0; p < 6 && p < t.phase_us.length; p++) sums[p] += t.phase_us[p];
}
var total = sums.reduce(function(a,b) { return a+b; }, 0);
if (total === 0) return [0.25, 0.25, 0.25, 0.25];
var processing = sums[2] / total;
var delivery = (sums[1] + sums[4]) / total;
var spawns = (sums[0] + sums[3]) / total;
var overhead = sums[5] / total;
var load = timings.length > 0 ? active / timings.length : 0;
return { fracs: [processing, delivery, spawns, overhead], load: load };
}
var maxActors = Math.max(1, Math.max.apply(null, workers.map(function(w) { return w.num_actors; }))); function renderWorkerCards(data) {
var barW = Math.max(8, Math.floor((W - 40) / workers.length) - 6); var container = document.getElementById('workerCards');
var chartH = H - 30; if (!data.workers) return;
container.innerHTML = '';
workers.forEach(function(w, i) { data.workers.forEach(function(w, idx) {
var x = 20 + i * (barW + 6); var timings = data.tick_timings ? data.tick_timings[idx] : null;
var h = (w.num_actors / maxActors) * (chartH * 0.45); var phases = computePhases(timings);
ctx.fillStyle = colors[i % colors.length]; var load = phases.load || 0;
ctx.globalAlpha = 0.8; var fracs = phases.fracs || [0.25, 0.25, 0.25, 0.25];
ctx.fillRect(x, chartH * 0.5 - h, barW, h);
var mh = Math.min(w.mailbox_depth * 2, chartH * 0.4); var card = document.createElement('div');
ctx.globalAlpha = 0.4; card.className = 'worker-card';
ctx.fillRect(x, chartH * 0.55, barW, mh);
ctx.globalAlpha = 1; // ID
ctx.fillStyle = '#888'; var idSpan = document.createElement('span');
ctx.font = '10px monospace'; idSpan.className = 'wc-id';
ctx.textAlign = 'center'; idSpan.style.color = colors[w.id % colors.length];
ctx.fillText('W' + w.id, x + barW / 2, H - 2); idSpan.textContent = 'W' + w.id;
card.appendChild(idSpan);
// Phase bar
var barWrap = document.createElement('span');
barWrap.className = 'wc-bar-wrap';
var filledPct = Math.round(load * 100);
for (var p = 0; p < 4; p++) {
var seg = document.createElement('span');
seg.className = 'wc-bar-seg';
seg.style.width = (fracs[p] * filledPct) + '%';
seg.style.background = phaseColors[p];
barWrap.appendChild(seg);
}
card.appendChild(barWrap);
// Sparklines
var sparkWrap = document.createElement('span');
sparkWrap.className = 'wc-spark';
var wh = workerHistory[w.id];
if (wh) {
sparkWrap.innerHTML = renderSparklineSvg(wh.message_rates, 60, 14, '#4caf50');
}
card.appendChild(sparkWrap);
// Stats
var statsSpan = document.createElement('span');
statsSpan.className = 'wc-stats';
statsSpan.textContent = w.num_actors + ' actors ' +
w.messages_processed.toLocaleString() + ' msgs mbox ' + w.mailbox_depth +
' ' + Math.round(load * 100) + '%';
card.appendChild(statsSpan);
container.appendChild(card);
}); });
var legend = document.getElementById('workerLegend');
legend.innerHTML = workers.map(function(w, i) {
return '<span style="color:' + colors[i % colors.length] + '">W' + w.id +
': ' + w.num_actors + ' actors, ' + w.messages_processed + ' msgs, mbox ' + w.mailbox_depth + '</span>';
}).join(' &nbsp;|&nbsp; ');
} }
function updateStats(data) { function updateStats(data) {
@ -301,7 +352,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
document.getElementById('statWorkers').textContent = data.num_workers || 0; document.getElementById('statWorkers').textContent = data.num_workers || 0;
document.getElementById('statMailbox').textContent = totalMailbox; document.getElementById('statMailbox').textContent = totalMailbox;
drawWorkerChart(data.workers); renderWorkerCards(data);
var tbody = document.getElementById('actorTableBody'); var tbody = document.getElementById('actorTableBody');
tbody.innerHTML = ''; tbody.innerHTML = '';