- 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>
380 lines
14 KiB
HTML
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(' | ');
|
|
}
|
|
|
|
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>
|