swactor/crates/runtime-dashboard/src/actor_detail_html.rs
Claude 5e3a779cba feat: actor detail drill-down view (Stage 2)
Add dedicated actor detail page accessible from both web and TUI surfaces.

Web:
- /actor/<hex> page with live-updating stats, sparkline charts for message
  rate and mailbox depth, status badge, worker assignment
- Actor addresses in overview and actors pages now link to detail page
- Actors page detail panel links to dedicated detail page

TUI:
- ViewMode::ActorDetail with per-actor sparklines (rate + mailbox)
- Enter on actor row opens detail, Esc returns to previous view
- Per-actor ring buffer history (60 samples) tracked in App state

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00

230 lines
8.2 KiB
Rust

pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Actor Detail — Swactor Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.header-left { display: flex; align-items: center; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.content { padding: 16px 20px; max-width: 900px; }
.breadcrumb { color: #555; font-size: 12px; margin-bottom: 12px; }
.breadcrumb a { color: #888; text-decoration: none; }
.breadcrumb a:hover { color: #e0e0e0; }
.info-card {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 16px; margin-bottom: 12px;
}
.info-row { display: flex; gap: 24px; margin-bottom: 6px; flex-wrap: wrap; }
.info-label { color: #888; font-size: 11px; text-transform: uppercase; }
.info-value { color: #fff; font-weight: 700; font-size: 15px; }
.info-value.healthy { color: #4caf50; }
.info-value.poisoned { color: #f44336; }
.stats-cards {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
margin-bottom: 12px;
}
.stat-card {
background: #161822; border: 1px solid #2a2d3e; border-radius: 4px;
padding: 12px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
.sparkline-panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; margin-bottom: 12px;
}
.sparkline-panel h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.sparkline-panel svg { width: 100%; height: 50px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>
Swactor Runtime Dashboard
<span id="statusDot" class="status-dot"></span>
</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link">Actors</a>
</nav>
</div>
</div>
<div class="content">
<div class="breadcrumb">
<a href="/">Overview</a> / <a href="/actors">Actors</a> / <span id="addrBreadcrumb">—</span>
</div>
<div class="info-card">
<div class="info-row">
<div><div class="info-label">Address</div><div class="info-value" id="addrFull">—</div></div>
<div><div class="info-label">Worker</div><div class="info-value" id="addrWorker">—</div></div>
<div><div class="info-label">Status</div><div class="info-value" id="addrStatus">—</div></div>
</div>
<div class="info-row">
<div><div class="info-label">Last Message Type</div><div class="info-value" id="addrLastMsg" style="color:#4caf50;font-size:13px;">—</div></div>
</div>
</div>
<div class="stats-cards">
<div class="stat-card"><div class="value" id="addrMsgs">0</div><div class="label">Messages</div></div>
<div class="stat-card"><div class="value" id="addrMailbox">0</div><div class="label">Mailbox</div></div>
<div class="stat-card"><div class="value" id="addrRate">0</div><div class="label">Msg/s</div></div>
<div class="stat-card"><div class="value" id="addrWorkerLoad">—</div><div class="label">Worker Load</div></div>
</div>
<div class="sparkline-panel">
<h3>Message Rate</h3>
<svg id="rateSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
</div>
<div class="sparkline-panel">
<h3>Mailbox Depth</h3>
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
</div>
</div>
<script>
(function() {
var targetAddr = '__ACTOR_ADDR__';
var dot = document.getElementById('statusDot');
var history = { rates: [], mailbox: [], prev_msgs: 0 };
function formatAddr(addr) {
if (!addr) return '';
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += ('0' + bytes[i].toString(16)).slice(-2);
}
return hex;
}
function shortAddr(hex) {
return hex.length > 16 ? hex.substring(0, 16) + '\u2026' : hex;
}
function shortTypeName(full) {
if (!full) return '\u2014';
var parts = full.split('::');
return parts[parts.length - 1];
}
function updateSparklineSvg(svgEl, data, color) {
if (!data || data.length < 2) { svgEl.innerHTML = ''; return; }
var max = Math.max.apply(null, data);
if (max === 0) max = 1;
var w = 400, h = 50;
var step = w / (data.length - 1);
var points = data.map(function(v, i) {
return (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 4) - 2).toFixed(1);
}).join(' ');
svgEl.innerHTML = '<polyline fill="none" stroke="' + color + '" stroke-width="2" points="' + points + '"/>';
}
function findActor(stats) {
if (!stats.actor_details) return null;
for (var i = 0; i < stats.actor_details.length; i++) {
var a = stats.actor_details[i];
var hex = formatAddr(a.address);
if (hex === targetAddr || hex.indexOf(targetAddr) === 0) return a;
}
return null;
}
function updateDetail(stats) {
var actor = findActor(stats);
if (!actor) return;
var hex = formatAddr(actor.address);
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
document.getElementById('addrFull').textContent = hex;
document.getElementById('addrWorker').textContent = 'W' + actor.worker_id;
var statusEl = document.getElementById('addrStatus');
if (actor.poisoned) {
statusEl.textContent = 'POISONED';
statusEl.className = 'info-value poisoned';
} else {
statusEl.textContent = 'Healthy';
statusEl.className = 'info-value healthy';
}
document.getElementById('addrLastMsg').textContent = shortTypeName(actor.last_msg_type);
document.getElementById('addrMsgs').textContent = actor.messages_processed.toLocaleString();
document.getElementById('addrMailbox').textContent = actor.mailbox_depth;
// Compute rate
var rate = actor.messages_processed - history.prev_msgs;
if (rate < 0) rate = 0;
history.prev_msgs = actor.messages_processed;
history.rates.push(rate);
history.mailbox.push(actor.mailbox_depth);
if (history.rates.length > 300) { history.rates.shift(); history.mailbox.shift(); }
// Rate per second (SSE interval is ~200ms, so multiply by 5)
document.getElementById('addrRate').textContent = (rate * 5).toLocaleString();
// Worker load
if (stats.workers) {
var w = stats.workers.find(function(w) { return w.id === actor.worker_id; });
if (w) {
document.getElementById('addrWorkerLoad').textContent =
w.num_actors + ' actors, mbox ' + w.mailbox_depth;
}
}
updateSparklineSvg(document.getElementById('rateSpark'), history.rates, '#4caf50');
updateSparklineSvg(document.getElementById('mboxSpark'), history.mailbox, '#2196f3');
}
var es = new EventSource('/events');
es.addEventListener('stats', function(e) {
try { updateDetail(JSON.parse(e.data)); } catch(err) { console.error(err); }
});
es.addEventListener('done', function() {
dot.className = 'status-dot disconnected';
es.close();
});
es.onerror = function() { dot.className = 'status-dot disconnected'; };
es.onopen = function() { dot.className = 'status-dot'; };
})();
</script>
</body>
</html>
"##;