swactor/crates/runtime-dashboard/static/index.html
Developer 548396ba4a feat: in-browser runtime, WebSocket gateway, dashboard improvements
- NodeAddr abstraction replacing SocketAddr across distribution crate (~30 files)
- Core wasm32 compatibility: web-time, cfg-gated threads, wire encoding extraction
- WebSocket transport (browser) with GatewayControl protocol
- WebSocket gateway (native) with session routing and control protocol
- BrowserRuntime API: JS actor support, connect/spawn/send/tick
- Dashboard: extracted HTML to static files, gossip protocol panel with
  membership event log, visual fixes, click-to-filter, worker-colored rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:51:36 +00:00

380 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime Dashboard</title>
<link rel="stylesheet" href="/css/shared.css">
<style>
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 12px; padding: 12px;
}
.chart-panel { grid-row: span 2; }
canvas#workerChart { width: 100%; height: 200px; }
#workerLegend { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.actor-table-wrap { max-height: 200px; overflow-y: auto; }
.actor-table-wrap table { width: 100%; border-collapse: collapse; }
.actor-table-wrap th, .actor-table-wrap td {
padding: 4px 8px; text-align: left; border-bottom: 1px solid #2a2d3e; font-size: 12px;
}
.actor-table-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
.log-panel { grid-column: 1 / -1; }
.log-wrap { max-height: 300px; overflow-y: auto; }
.log-wrap table { width: 100%; border-collapse: collapse; }
.log-wrap th, .log-wrap td {
padding: 3px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
white-space: nowrap;
}
.log-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
.log-wrap td.msg { white-space: normal; word-break: break-all; max-width: 400px; }
.worker-detail-panel { grid-column: 1 / -1; }
.worker-detail-scroll { max-height: 360px; overflow-y: auto; }
.worker-group { margin-bottom: 8px; border: 1px solid #2a2d3e; border-radius: 4px; overflow: hidden; }
.worker-group-header {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 12px; background: #1c1f2e; cursor: pointer; user-select: none;
}
.worker-group-header:hover { background: #22253a; }
.worker-group-header .wid { font-weight: 700; }
.worker-group-header .summary { color: #888; font-size: 11px; }
.worker-group-header .toggle { color: #555; font-size: 14px; }
.worker-group-body { display: none; }
.worker-group.open .worker-group-body { display: block; }
.worker-group-body table { width: 100%; border-collapse: collapse; }
.worker-group-body th, .worker-group-body td {
padding: 3px 10px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
}
.worker-group-body th { color: #888; font-weight: 500; background: #161822; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>
Swactor Runtime Dashboard
<span id="statusDot" class="status-dot"></span>
<span id="replayBadge" class="replay-badge">REPLAY</span>
</h1>
<nav class="nav-links">
<a href="/" class="nav-link active">Overview</a>
<a href="/actors" class="nav-link">Actors</a>
<a href="/distribution" class="nav-link">Distribution</a>
</nav>
</div>
<div class="header-right">
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
<span id="uptimeLabel" style="color:#888;font-size:12px;"></span>
</div>
</div>
<div id="progressBarWrap" class="progress-bar-wrap">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="grid">
<div class="panel chart-panel">
<h2>Worker Distribution</h2>
<canvas id="workerChart"></canvas>
<div id="workerLegend" style="margin-top:8px;font-size:11px;color:#888;"></div>
</div>
<div class="panel">
<h2>Stats</h2>
<div class="stats-cards">
<div class="stat-card"><div class="value" id="statActors">0</div><div class="label">Actors</div></div>
<div class="stat-card"><div class="value" id="statMessages">0</div><div class="label">Messages</div></div>
<div class="stat-card"><div class="value" id="statWorkers">0</div><div class="label">Workers</div></div>
<div class="stat-card"><div class="value" id="statMailbox">0</div><div class="label">Mailbox</div></div>
</div>
</div>
<div class="panel">
<h2>Actors</h2>
<div class="actor-table-wrap">
<table>
<thead><tr><th>Address</th><th>Worker</th></tr></thead>
<tbody id="actorTableBody"></tbody>
</table>
</div>
</div>
<div class="panel worker-detail-panel">
<h2>Worker Details</h2>
<div class="worker-detail-scroll" id="workerDetailContainer"></div>
</div>
<div class="panel log-panel">
<h2>Activity Log <span id="logCount" style="color:#555;font-weight:400;"></span></h2>
<div class="log-wrap" id="logWrap">
<table>
<thead><tr><th>Seq</th><th>Time</th><th>Level</th><th>Worker</th><th>Message</th><th>Fields</th></tr></thead>
<tbody id="logTableBody"></tbody>
</table>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script>
(function() {
var logRowCount = 0;
var MAX_LOG_ROWS = 2000;
var workerOpenState = {}; // persistent open/close state for worker groups
var workerNodes = {}; // persistent wid → {div, summaryEl, bodyEl, toggleEl}
var lastStatsJSON = ''; // fingerprint for skipping redundant redraws
var canvas = document.getElementById('workerChart');
var ctx = canvas.getContext('2d');
function drawWorkerChart(workers) {
var dpr = window.devicePixelRatio || 1;
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;
var maxActors = Math.max(1, Math.max.apply(null, workers.map(function(w) { return w.num_actors; })));
var barW = Math.max(8, Math.floor((W - 40) / workers.length) - 6);
var chartH = H - 30;
workers.forEach(function(w, i) {
var x = 20 + i * (barW + 6);
var h = (w.num_actors / maxActors) * (chartH * 0.45);
ctx.fillStyle = colors[i % colors.length];
ctx.globalAlpha = 0.8;
ctx.fillRect(x, chartH * 0.5 - h, barW, h);
var mh = Math.min(w.mailbox_depth * 2, chartH * 0.4);
ctx.globalAlpha = 0.4;
ctx.fillRect(x, chartH * 0.55, barW, mh);
ctx.globalAlpha = 1;
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('W' + w.id, x + barW / 2, H - 2);
});
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) {
trackUptime(data);
// Skip full rebuild if data hasn't changed
var json = JSON.stringify(data);
if (json === lastStatsJSON) return;
lastStatsJSON = json;
var totalActors = data.actors ? data.actors.length : 0;
var totalMsgs = data.workers ? data.workers.reduce(function(s, w) { return s + w.messages_processed; }, 0) : 0;
var totalMailbox = data.workers ? data.workers.reduce(function(s, w) { return s + w.mailbox_depth; }, 0) : 0;
document.getElementById('statActors').textContent = totalActors;
document.getElementById('statMessages').textContent = totalMsgs.toLocaleString();
document.getElementById('statWorkers').textContent = data.num_workers || 0;
document.getElementById('statMailbox').textContent = totalMailbox;
drawWorkerChart(data.workers);
updateActorTable(data.actors);
updateWorkerDetails(data);
}
// ── Differential actor table update ──────────────────────
var actorRows = []; // pool of <tr> elements
function updateActorTable(actors) {
var tbody = document.getElementById('actorTableBody');
if (!actors) actors = [];
var shown = actors.slice(0, 200);
var needOverflow = actors.length > 200;
var totalNeeded = shown.length + (needOverflow ? 1 : 0);
// Remove excess rows
while (actorRows.length > totalNeeded) {
var old = actorRows.pop();
tbody.removeChild(old);
}
// Update or create rows
for (var i = 0; i < shown.length; i++) {
var addr = shown[i][0];
var wid = shown[i][1];
var hex = addrToHex(addr);
var html = '<td style="color:#aaa;font-size:11px;">' + hex + '</td><td>W' + wid + '</td>';
if (i < actorRows.length) {
actorRows[i].innerHTML = html;
} else {
var tr = document.createElement('tr');
tr.innerHTML = html;
tbody.appendChild(tr);
actorRows.push(tr);
}
}
// Overflow row
if (needOverflow) {
var idx = shown.length;
var html2 = '<td colspan="2" style="color:#555;">... and ' + (actors.length - 200) + ' more</td>';
if (idx < actorRows.length) {
actorRows[idx].innerHTML = html2;
} else {
var tr2 = document.createElement('tr');
tr2.innerHTML = html2;
tbody.appendChild(tr2);
actorRows.push(tr2);
}
}
}
// ── In-place worker details update ───────────────────────
function updateWorkerDetails(data) {
var container = document.getElementById('workerDetailContainer');
if (!data.workers) return;
var groups = {};
data.workers.forEach(function(w) { groups[w.id] = { info: w, actors: [] }; });
if (data.actor_details) {
data.actor_details.forEach(function(a) {
if (groups[a.worker_id]) groups[a.worker_id].actors.push(a);
});
}
var wids = Object.keys(groups).sort(function(a, b) { return a - b; });
// Remove nodes for workers that no longer exist
var widsSet = {};
for (var i = 0; i < wids.length; i++) widsSet[wids[i]] = true;
for (var key in workerNodes) {
if (!widsSet[key]) {
container.removeChild(workerNodes[key].div);
delete workerNodes[key];
delete workerOpenState[key];
}
}
// Update or create nodes for each worker
for (var i = 0; i < wids.length; i++) {
var wid = wids[i];
var g = groups[wid];
var node = workerNodes[wid];
// Build the summary and body HTML
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
var summaryHtml = g.actors.length + ' actors, ' +
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml;
var bodyHtml;
if (g.actors.length === 0) {
bodyHtml = '<div style="padding:6px 12px;color:#555;">No actors</div>';
} else {
var rows = '';
g.actors.forEach(function(a) {
var hex = addrToHex(a.address);
var hasMsg = !!a.last_msg_type;
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
rows += '<tr><td style="color:#aaa;">' + hex + '</td>' +
'<td>' + a.mailbox_depth + '</td>' +
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
});
bodyHtml = '<table><thead><tr><th>Address</th><th>Mailbox</th><th>Last Message</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
if (node) {
// Update existing node in place — header and click handler stay stable
node.summaryEl.innerHTML = summaryHtml;
node.bodyEl.innerHTML = bodyHtml;
} else {
// Create new node
var isOpen = !!workerOpenState[wid];
var div = document.createElement('div');
div.className = 'worker-group' + (isOpen ? ' open' : '');
var hdr = document.createElement('div');
hdr.className = 'worker-group-header';
hdr.innerHTML =
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
'<span class="summary">' + summaryHtml + '</span>' +
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
var summaryEl = hdr.querySelector('.summary');
var toggleEl = hdr.querySelector('.toggle');
hdr.onclick = (function(w, d, t) { return function() {
workerOpenState[w] = !workerOpenState[w];
d.classList.toggle('open');
t.textContent = d.classList.contains('open') ? '\u25BC' : '\u25B6';
}; })(wid, div, toggleEl);
div.appendChild(hdr);
var body = document.createElement('div');
body.className = 'worker-group-body';
body.innerHTML = bodyHtml;
div.appendChild(body);
container.appendChild(div);
workerNodes[wid] = { div: div, summaryEl: summaryEl, bodyEl: body, toggleEl: toggleEl };
}
}
}
function addLogEvents(events) {
var tbody = document.getElementById('logTableBody');
var wrap = document.getElementById('logWrap');
var wasAtBottom = wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 20;
events.forEach(function(ev) {
if (logRowCount >= MAX_LOG_ROWS) {
tbody.removeChild(tbody.firstChild);
logRowCount--;
}
var tr = document.createElement('tr');
var ts = new Date(ev.timestamp_ms);
var timeStr = ts.toLocaleTimeString() + '.' + String(ts.getMilliseconds()).padStart(3, '0');
var wid = ev.worker_id !== null && ev.worker_id !== undefined ? 'W' + ev.worker_id : '-';
var fieldsStr = Object.keys(ev.fields).length > 0 ? JSON.stringify(ev.fields) : '';
tr.innerHTML =
'<td>' + ev.seq + '</td>' +
'<td>' + timeStr + '</td>' +
'<td class="level-' + ev.level + '">' + ev.level + '</td>' +
'<td>' + wid + '</td>' +
'<td class="msg">' + escapeHtml(ev.message) + '</td>' +
'<td style="color:#555;max-width:300px;overflow:hidden;text-overflow:ellipsis;">' + escapeHtml(fieldsStr) + '</td>';
tbody.appendChild(tr);
logRowCount++;
});
document.getElementById('logCount').textContent = '(' + logRowCount + ')';
if (wasAtBottom) {
wrap.scrollTop = wrap.scrollHeight;
}
}
createSSE({
stats: function(data) { updateStats(data); },
activity: function(data) { addLogEvents(data); }
});
})();
</script>
</body>
</html>