Compare commits

...

10 commits

Author SHA1 Message Date
Claude
0f5c739337 fix: minor visual details 2026-02-13 21:39:44 +07:00
Claude
982d82776d feat(dashboard): per-message-type breakdown with bounded tracking (Stage 8)
Track per-actor message type histogram in ActorSlot (bounded to 32
distinct types). Extend ActorSnapshot and ActorInfo to carry sorted
type counts through the stats pipeline.

Web actor detail page shows horizontal bar chart with per-type colors,
counts, and percentages. TUI actor detail view renders text-based bar
table with 6-color rotation. Data flows through existing SSE stats
events with no additional endpoints needed.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
Claude
c7a0bd2523 feat(dashboard): per-actor logging with tracing integration (Stage 7)
Add actor-level tracing span in worker tick_all to propagate actor_addr
to user log events. DashboardLayer now extracts actor_addr from both
span extensions and event fields. EventStore gains read_for_actor() for
filtered log queries.

Web actor detail page gets a live-updating log panel with per-level
filter buttons (ERR/WARN/INFO/DBG/TRC) and auto-scroll. TUI actor
detail view adds scrollable log panel with j/k navigation and 1-5 keys
for level toggling. REST endpoint at /api/logs?actor=<hex>&limit=N&level=L.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
Claude
991b212191 feat: message flow topology visualization (Stage 6)
Add worker-level message flow topology with force-directed graph
visualization on a dedicated /topology web page.

- topology.rs: worker_topology() derives graph from cross_sends/local_sends
  stats (TopologyNode, TopologyEdge, TopologySnapshot)
- /topology page with interactive force-directed graph layout:
  - Nodes sized by actor count, colored by worker
  - Edges show local sends (green self-loops) and cross-worker sends (blue)
  - Edge thickness proportional to message volume
  - Physics simulation with repulsion, attraction, and gravity
- SSE "topology" event emitted every ~1s (every 5th stats tick)
- /api/topology REST endpoint for on-demand snapshot

Infrastructure ready for future per-actor topology with core instrumentation.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
Claude
70555c2daf feat: warning/anomaly detection system (Stage 5)
Add automated detection of 6 runtime anomaly types with severity levels:

- PoisonedActor (critical): actor panicked
- StalledActor (high): no message processing while mailbox > 0
- GrowingMailbox (medium): consecutive increases in mailbox depth
- MailboxOverflow (medium): messages dropped (backpressure triggered)
- WorkerImbalance (low): one worker has >2x average actor count
- EmptyWorker (low): worker has 0 actors while others have many

Configurable thresholds with sensible defaults. 7 scenario tests covering
all warning types, threshold behavior, and streak reset logic.

Web: warning banner at top of overview page with severity-colored alerts
TUI: warning count in summary bar (yellow when active)
SSE: new "warnings" event sent with each stats tick

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
Claude
6cfafa04b7 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
2026-02-13 21:20:23 +07:00
Claude
41930ef8f4 feat: search and filter for actor tables (Stage 3)
TUI:
- Press / to enter search mode (vim-style), type to filter actors in
  real-time by address, message type, or worker ID
- Enter locks the filter, Esc clears it
- Navigation bounds respect filtered results

Web (actors page):
- Worker dropdown filter (dynamically populated from live data)
- Status filter (All / Healthy / Poisoned)
- Mailbox depth threshold filter (min depth)
- Text search now also matches message type names

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
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
Claude
144b4ca5ef feat: time-series history infrastructure with sparklines (Stage 1)
Add in-process ring buffer history for per-worker and per-actor stats,
with sparkline rendering on both web (SVG) and TUI (ratatui) surfaces.

- history.rs: DashboardHistory with bounded VecDeque ring buffers, LRU
  actor eviction, thread-safe RwLock, 4 unit tests
- SSE producer records stats every tick, sends initial history snapshot
- /api/history REST endpoint for on-demand history JSON
- Web: inline SVG sparklines in worker detail headers (rates + mailbox)
- TUI: per-worker Sparkline widgets in overview layout

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
Claude
a1f5f581fe research: dashboard improvement plan from 10 comparable systems
Cycle 0 research complete. Analyzed Erlang Observer, observer_cli,
wobserver, Phoenix LiveDashboard, Akka Insights, Ray Dashboard,
Orleans Dashboard, tokio-console, RabbitMQ Management, and
Consul/Nomad UIs. Identified 8 implementation stages prioritized
P0-P2: time-series history, actor detail drill-down, search/filter,
worker viz, warning detection, topology, logging, and msg-type
breakdown.

Full research notes in CLAUDE/notes/ (gitignored, session-local).

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-13 21:20:23 +07:00
18 changed files with 2596 additions and 117 deletions

View file

@ -0,0 +1,403 @@
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; }
.type-breakdown {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; margin-bottom: 12px;
}
.type-breakdown h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.type-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 11px; }
.type-name { color: #e0e0e0; min-width: 180px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.type-bar-bg { flex: 1; background: #1e2030; height: 14px; border-radius: 2px; overflow: hidden; }
.type-bar-fill { height: 100%; border-radius: 2px; }
.type-count { color: #888; min-width: 60px; text-align: right; }
.type-pct { color: #555; min-width: 40px; text-align: right; }
.logs-panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; margin-bottom: 12px;
}
.logs-panel h3 {
font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px;
margin-bottom: 8px; display: flex; align-items: center; gap: 12px;
}
.level-filter { display: flex; gap: 4px; }
.level-btn {
background: #1e2030; border: 1px solid #2a2d3e; border-radius: 3px;
color: #888; font-size: 10px; padding: 1px 6px; cursor: pointer;
font-family: inherit;
}
.level-btn.active { border-color: #555; color: #fff; }
.level-btn.error { color: #f44336; }
.level-btn.warn { color: #ff9800; }
.level-btn.info { color: #2196f3; }
.level-btn.debug { color: #888; }
.level-btn.trace { color: #555; }
.log-list {
max-height: 400px; overflow-y: auto; font-size: 11px; line-height: 1.6;
}
.log-entry { display: flex; gap: 8px; padding: 1px 0; border-bottom: 1px solid #1a1c2e; }
.log-time { color: #555; white-space: nowrap; min-width: 80px; }
.log-level { font-weight: 700; min-width: 50px; }
.log-level.ERROR { color: #f44336; }
.log-level.WARN { color: #ff9800; }
.log-level.INFO { color: #2196f3; }
.log-level.DEBUG { color: #888; }
.log-level.TRACE { color: #555; }
.log-msg { color: #e0e0e0; word-break: break-all; }
::-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 class="type-breakdown" id="typeBreakdown" style="display:none;">
<h3>Message Types</h3>
<div id="typeRows"></div>
</div>
<div class="logs-panel">
<h3>
<span>Logs</span>
<span id="logCount" style="color:#555;">(0)</span>
<div class="level-filter">
<button class="level-btn error active" data-level="ERROR" onclick="toggleLevel(this)">ERR</button>
<button class="level-btn warn active" data-level="WARN" onclick="toggleLevel(this)">WARN</button>
<button class="level-btn info active" data-level="INFO" onclick="toggleLevel(this)">INFO</button>
<button class="level-btn debug active" data-level="DEBUG" onclick="toggleLevel(this)">DBG</button>
<button class="level-btn trace active" data-level="TRACE" onclick="toggleLevel(this)">TRC</button>
</div>
</h3>
<div class="log-list" id="logList"></div>
</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');
// Update message type breakdown
updateTypeBreakdown(actor.message_type_counts);
}
var typeColors = ['#4caf50','#2196f3','#ff9800','#9c27b0','#00bcd4','#f44336','#ffeb3b','#e91e63'];
function updateTypeBreakdown(types) {
var panel = document.getElementById('typeBreakdown');
var container = document.getElementById('typeRows');
if (!types || types.length === 0) { panel.style.display = 'none'; return; }
panel.style.display = '';
var total = 0;
for (var i = 0; i < types.length; i++) total += types[i][1];
if (total === 0) { panel.style.display = 'none'; return; }
var html = '';
for (var i = 0; i < types.length; i++) {
var name = types[i][0];
var count = types[i][1];
var pct = (count / total * 100).toFixed(1);
var barPct = (count / types[0][1] * 100).toFixed(1);
var color = typeColors[i % typeColors.length];
var shortName = name.split('::').pop();
html += '<div class="type-row">' +
'<span class="type-name" title="' + escapeHtml(name) + '">' + escapeHtml(shortName) + '</span>' +
'<div class="type-bar-bg"><div class="type-bar-fill" style="width:' + barPct + '%;background:' + color + ';"></div></div>' +
'<span class="type-count">' + count.toLocaleString() + '</span>' +
'<span class="type-pct">' + pct + '%</span>' +
'</div>';
}
container.innerHTML = html;
}
// ─── Logging ─────────────────────────────────────────────────
var activeLevels = { ERROR: true, WARN: true, INFO: true, DEBUG: true, TRACE: true };
var allLogs = [];
var logList = document.getElementById('logList');
var logCount = document.getElementById('logCount');
var autoScroll = true;
window.toggleLevel = function(btn) {
var lvl = btn.getAttribute('data-level');
activeLevels[lvl] = !activeLevels[lvl];
btn.classList.toggle('active');
renderLogs();
};
function formatLogTime(ms) {
var d = new Date(ms);
return ('0' + d.getHours()).slice(-2) + ':' +
('0' + d.getMinutes()).slice(-2) + ':' +
('0' + d.getSeconds()).slice(-2) + '.' +
('00' + d.getMilliseconds()).slice(-3);
}
function renderLogs() {
var visible = allLogs.filter(function(e) { return activeLevels[e.level]; });
logCount.textContent = '(' + visible.length + ')';
var html = '';
for (var i = 0; i < visible.length; i++) {
var e = visible[i];
html += '<div class="log-entry">' +
'<span class="log-time">' + formatLogTime(e.timestamp_ms) + '</span>' +
'<span class="log-level ' + e.level + '">' + e.level + '</span>' +
'<span class="log-msg">' + escapeHtml(e.message) + '</span>' +
'</div>';
}
logList.innerHTML = html;
if (autoScroll) {
logList.scrollTop = logList.scrollHeight;
}
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function addLogEntries(events) {
for (var i = 0; i < events.length; i++) {
var e = events[i];
if (!e.actor_addr) continue;
// Match if event's actor_addr starts with or contains target
var a = e.actor_addr.toLowerCase();
if (a.indexOf(targetAddr.toLowerCase()) === 0 || a === targetAddr.toLowerCase()) {
allLogs.push(e);
}
}
// Keep bounded
while (allLogs.length > 500) allLogs.shift();
renderLogs();
}
// Fetch initial logs
fetch('/api/logs?actor=' + targetAddr + '&limit=200')
.then(function(r) { return r.json(); })
.then(function(data) { if (Array.isArray(data)) addLogEntries(data); })
.catch(function() {});
logList.addEventListener('scroll', function() {
autoScroll = (logList.scrollTop + logList.clientHeight >= logList.scrollHeight - 20);
});
// ─── SSE ────────────────────────────────────────────────────
var es = new EventSource('/events');
es.addEventListener('stats', function(e) {
try { updateDetail(JSON.parse(e.data)); } catch(err) { console.error(err); }
});
es.addEventListener('activity', function(e) {
try { addLogEntries(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>
"##;

View file

@ -60,7 +60,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
.panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; overflow: hidden;
padding: 14px; overflow: visible;
}
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
@ -75,7 +75,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
.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; }
canvas { width: 100%; height: 200px; }
canvas { width: 100%; height: 220px; }
.search-wrap { margin-bottom: 10px; display: flex; align-items: center; gap: 12px; }
.search-input {
@ -109,7 +109,12 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
tr.clickable { cursor: pointer; }
tr.clickable:hover { background: #1a1d2c; }
.detail-panel { display: none; }
.detail-panel {
display: none; position: fixed; bottom: 12px; right: 12px;
width: 420px; max-height: 320px; overflow-y: auto;
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; z-index: 100; box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.detail-panel.visible { display: block; }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.detail-close {
@ -118,17 +123,17 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
}
.detail-close:hover { color: #e0e0e0; border-color: #555; }
.detail-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px;
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 10px;
}
.detail-item { background: #1c1f2e; border-radius: 4px; padding: 8px 10px; }
.detail-item { background: #1c1f2e; border-radius: 4px; padding: 6px 8px; }
.detail-item .d-label { font-size: 10px; color: #888; text-transform: uppercase; }
.detail-item .d-value { font-size: 14px; font-weight: 700; color: #fff; margin-top: 2px; word-break: break-all; }
.detail-item .d-value { font-size: 13px; font-weight: 700; color: #fff; margin-top: 2px; word-break: break-all; }
.poisoned-badge {
background: #f44336; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 6px; border-radius: 3px; letter-spacing: 0.5px;
}
.healthy-badge { color: #4caf50; font-size: 12px; }
canvas.sparkline { width: 100%; height: 60px; }
canvas.sparkline { width: 100%; height: 50px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
@ -187,7 +192,16 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
<div class="panel full-width">
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
<div class="search-wrap">
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address or worker..." />
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address, type, or worker..." />
<select id="workerFilter" class="search-input" style="width:120px;">
<option value="">All Workers</option>
</select>
<select id="statusFilter" class="search-input" style="width:120px;">
<option value="">All Status</option>
<option value="healthy">Healthy</option>
<option value="poisoned">Poisoned</option>
</select>
<input type="number" id="minDepth" class="search-input" style="width:100px;" placeholder="Min depth" min="0" />
<span id="searchInfo" class="search-info"></span>
</div>
<div class="actor-list-wrap">
@ -207,23 +221,25 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
</div>
</div>
<!-- Actor detail panel -->
<div id="detailPanel" class="panel full-width detail-panel">
<div class="detail-header">
<h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
<button class="detail-close" id="detailClose">Close</button>
</div>
<div class="detail-grid">
<div class="detail-item"><div class="d-label">Full Address</div><div class="d-value" id="detailFullAddr" style="font-size:11px;"></div></div>
<div class="detail-item"><div class="d-label">Worker</div><div class="d-value" id="detailWorker"></div></div>
<div class="detail-item"><div class="d-label">Mailbox Depth</div><div class="d-value" id="detailMailbox"></div></div>
<div class="detail-item"><div class="d-label">Messages Processed</div><div class="d-value" id="detailMsgCount"></div></div>
<div class="detail-item"><div class="d-label">Last Message Type</div><div class="d-value" id="detailLastMsg"></div></div>
<div class="detail-item"><div class="d-label">Status</div><div class="d-value" id="detailStatus"></div></div>
</div>
<h2>Mailbox Depth History</h2>
<canvas id="sparkline" class="sparkline"></canvas>
</div>
<!-- Actor detail overlay (fixed position, doesn't affect grid layout) -->
<div id="detailPanel" class="detail-panel">
<div class="detail-header">
<h2 style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:1px;">Actor <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
<button class="detail-close" id="detailClose">&times;</button>
</div>
<div class="detail-grid">
<div class="detail-item"><div class="d-label">Address</div><div class="d-value" id="detailFullAddr" style="font-size:10px;"></div></div>
<div class="detail-item"><div class="d-label">Worker</div><div class="d-value" id="detailWorker"></div></div>
<div class="detail-item"><div class="d-label">Mailbox</div><div class="d-value" id="detailMailbox"></div></div>
<div class="detail-item"><div class="d-label">Messages</div><div class="d-value" id="detailMsgCount"></div></div>
<div class="detail-item"><div class="d-label">Last Type</div><div class="d-value" id="detailLastMsg"></div></div>
<div class="detail-item"><div class="d-label">Status</div><div class="d-value" id="detailStatus"></div></div>
</div>
<div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Mailbox History</div>
<canvas id="sparkline" class="sparkline"></canvas>
</div>
<script>
@ -374,11 +390,12 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
var maxCount = Math.max(1, Math.max.apply(null, counts));
var barW = Math.max(12, Math.floor((W - 40) / buckets.length) - 8);
var topPad = 20;
var chartH = H - 35;
for (var i = 0; i < buckets.length; i++) {
var x = 20 + i * (barW + 8);
var h = (counts[i] / maxCount) * (chartH - 10);
var h = (counts[i] / maxCount) * (chartH - topPad);
depthCtx.fillStyle = bucketColors[i];
depthCtx.globalAlpha = 0.85;
depthCtx.fillRect(x, chartH - h, barW, h);
@ -424,11 +441,12 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
var maxCount = Math.max(1, Math.max.apply(null, entries.map(function(e) { return e.count; })));
var barW = Math.max(12, Math.floor((W - 40) / entries.length) - 8);
var topPad = 20;
var chartH = H - 35;
for (var i = 0; i < entries.length; i++) {
var x = 20 + i * (barW + 8);
var h = (entries[i].count / maxCount) * (chartH - 10);
var h = (entries[i].count / maxCount) * (chartH - topPad);
workerCtx.fillStyle = colors[entries[i].id % colors.length];
workerCtx.globalAlpha = 0.85;
workerCtx.fillRect(x, chartH - h, barW, h);
@ -449,13 +467,29 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
function renderActorTable() {
var filter = document.getElementById('actorSearch').value.toLowerCase();
var filtered = currentActors;
if (filter) {
filtered = currentActors.filter(function(a) {
var workerFilter = document.getElementById('workerFilter').value;
var statusFilter = document.getElementById('statusFilter').value;
var minDepthVal = document.getElementById('minDepth').value;
var minDepth = minDepthVal ? parseInt(minDepthVal, 10) : 0;
var filtered = currentActors.filter(function(a) {
// Text search
if (filter) {
var hex = addrToHex(a.address).toLowerCase();
return hex.indexOf(filter) >= 0 || ('w' + a.worker_id).indexOf(filter) >= 0;
});
}
var msgType = (a.last_msg_type || '').toLowerCase();
if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0) {
return false;
}
}
// Worker filter
if (workerFilter && a.worker_id !== parseInt(workerFilter, 10)) return false;
// Status filter
if (statusFilter === 'healthy' && a.poisoned) return false;
if (statusFilter === 'poisoned' && !a.poisoned) return false;
// Min depth
if (minDepth > 0 && a.mailbox_depth < minDepth) return false;
return true;
});
// Sort
filtered.sort(function(a, b) {
@ -531,14 +565,14 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
// ── Focus / detail panel ───────────────────────────────
function focusActor(fullHex) {
focusedAddrHex = fullHex;
document.getElementById('detailPanel').className = 'panel full-width detail-panel visible';
document.getElementById('detailPanel').className = 'detail-panel visible';
updateDetailPanel();
renderActorTable();
}
function clearFocus() {
focusedAddrHex = null;
document.getElementById('detailPanel').className = 'panel full-width detail-panel';
document.getElementById('detailPanel').className = 'detail-panel';
renderActorTable();
}
@ -558,8 +592,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
return;
}
document.getElementById('detailAddr').textContent = addrToHex(actor.address);
document.getElementById('detailFullAddr').textContent = focusedAddrHex;
document.getElementById('detailAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
document.getElementById('detailFullAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
document.getElementById('detailWorker').textContent = 'W' + actor.worker_id;
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
@ -647,11 +681,17 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
});
}
// ── Search handler ─────────────────────────────────────
// ── Search/filter handlers ─────────────────────────────
document.getElementById('actorSearch').addEventListener('keyup', function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(renderActorTable, 150);
});
document.getElementById('workerFilter').addEventListener('change', renderActorTable);
document.getElementById('statusFilter').addEventListener('change', renderActorTable);
document.getElementById('minDepth').addEventListener('input', function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(renderActorTable, 150);
});
// ── Status helpers ─────────────────────────────────────
function setStatus(s) {
@ -697,6 +737,21 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
if (!liveAddrs[key]) delete depthHistory[key];
}
// Update worker filter dropdown
var wSelect = document.getElementById('workerFilter');
var curVal = wSelect.value;
var workerIds = {};
for (var i = 0; i < currentActors.length; i++) workerIds[currentActors[i].worker_id] = true;
var wids = Object.keys(workerIds).sort(function(a,b) { return +a - +b; });
wSelect.innerHTML = '<option value="">All Workers</option>';
wids.forEach(function(wid) {
var opt = document.createElement('option');
opt.value = wid;
opt.textContent = 'W' + wid;
wSelect.appendChild(opt);
});
wSelect.value = curVal;
renderActorTable();
if (focusedAddrHex) updateDetailPanel();
} catch(err) { console.error('stats parse error', err); }

View file

@ -60,6 +60,7 @@ impl StatsHook for StatsCollector {
last_msg_type: s.last_msg_type.map(|t| t.to_string()),
messages_processed: s.messages_processed,
poisoned: s.poisoned,
message_type_counts: s.message_type_counts.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
}));
}
}

View file

@ -77,6 +77,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
.chart-panel { grid-row: span 2; }
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 table { width: 100%; border-collapse: collapse; }
.actor-table-wrap th, .actor-table-wrap td {
@ -113,6 +124,8 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
.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-header .sparkline-wrap { display: inline-flex; gap: 8px; margin-left: 12px; }
.worker-group-header .sparkline-wrap svg { vertical-align: middle; }
.worker-group-body { display: none; }
.worker-group.open .worker-group-body { display: block; }
.worker-group-body table { width: 100%; border-collapse: collapse; }
@ -152,11 +165,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
<div id="progressFill" class="progress-fill"></div>
</div>
<div id="warningBanner" style="display:none;padding:8px 20px;background:#1c1f2e;border-bottom:1px solid #2a2d3e;font-size:12px;"></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>
<h2>Worker Utilization</h2>
<div id="workerCards" class="worker-cards"></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 class="panel">
@ -203,6 +222,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var isReplay = (DASHBOARD_MODE === 'replay');
var lastUptimeMs = null;
var lastStatsTime = null;
var workerHistory = {}; // { id: { message_rates: [], mailbox_depths: [] } }
var dot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel');
@ -240,48 +260,83 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
}
setInterval(updateUptime, 1000);
var canvas = document.getElementById('workerChart');
var ctx = canvas.getContext('2d');
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
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);
var phaseColors = ['#4caf50', '#2196f3', '#00bcd4', '#f44336'];
// Group tick phases: processing=2, delivery=1+4, spawns=0+3, overhead=5
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; })));
var barW = Math.max(8, Math.floor((W - 40) / workers.length) - 6);
var chartH = H - 30;
function renderWorkerCards(data) {
var container = document.getElementById('workerCards');
if (!data.workers) return;
container.innerHTML = '';
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);
data.workers.forEach(function(w, idx) {
var timings = data.tick_timings ? data.tick_timings[idx] : null;
var phases = computePhases(timings);
var load = phases.load || 0;
var fracs = phases.fracs || [0.25, 0.25, 0.25, 0.25];
var mh = Math.min(w.mailbox_depth * 2, chartH * 0.4);
ctx.globalAlpha = 0.4;
ctx.fillRect(x, chartH * 0.55, barW, mh);
var card = document.createElement('div');
card.className = 'worker-card';
ctx.globalAlpha = 1;
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('W' + w.id, x + barW / 2, H - 2);
// ID
var idSpan = document.createElement('span');
idSpan.className = 'wc-id';
idSpan.style.color = colors[w.id % colors.length];
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) {
@ -298,7 +353,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
document.getElementById('statWorkers').textContent = data.num_workers || 0;
document.getElementById('statMailbox').textContent = totalMailbox;
drawWorkerChart(data.workers);
renderWorkerCards(data);
var tbody = document.getElementById('actorTableBody');
tbody.innerHTML = '';
@ -316,7 +371,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
hex += '\u2026';
}
var tr = document.createElement('tr');
tr.innerHTML = '<td style="color:#aaa;font-size:11px;">' + hex + '</td><td>W' + wid + '</td>';
tr.innerHTML = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td><td>W' + wid + '</td>';
tbody.appendChild(tr);
});
if (data.actors.length > 200) {
@ -347,6 +402,34 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
return parts[parts.length - 1];
}
function renderSparklineSvg(data, w, h, color) {
if (!data || data.length < 2) return '';
var max = Math.max.apply(null, data);
if (max === 0) max = 1;
var step = w / (data.length - 1);
var points = data.map(function(v, i) {
return (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2) - 1).toFixed(1);
}).join(' ');
return '<svg width="' + w + '" height="' + h + '" style="vertical-align:middle">' +
'<polyline fill="none" stroke="' + color + '" stroke-width="1.5" points="' + points + '"/></svg>';
}
function pushHistorySample(stats) {
if (!stats.workers) return;
stats.workers.forEach(function(w) {
if (!workerHistory[w.id]) {
workerHistory[w.id] = { message_rates: [], mailbox_depths: [], prev_msgs: w.messages_processed };
}
var wh = workerHistory[w.id];
var rate = w.messages_processed - wh.prev_msgs;
if (rate < 0) rate = 0;
wh.prev_msgs = w.messages_processed;
wh.message_rates.push(rate);
wh.mailbox_depths.push(w.mailbox_depth);
if (wh.message_rates.length > 300) { wh.message_rates.shift(); wh.mailbox_depths.shift(); }
});
}
function updateWorkerDetails(data) {
var container = document.getElementById('workerDetailContainer');
if (!data.workers) return;
@ -377,8 +460,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var hdr = document.createElement('div');
hdr.className = 'worker-group-header';
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
var wh = workerHistory[wid];
var sparkHtml = '';
if (wh) {
sparkHtml = '<span class="sparkline-wrap">' +
renderSparklineSvg(wh.message_rates, 80, 16, '#4caf50') +
renderSparklineSvg(wh.mailbox_depths, 80, 16, '#2196f3') +
'</span>';
}
hdr.innerHTML =
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
sparkHtml +
'<span class="summary">' + g.actors.length + ' actors, ' +
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' +
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
@ -400,7 +492,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
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>' +
rows += '<tr><td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>' +
'<td>' + a.mailbox_depth + '</td>' +
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
});
@ -467,7 +559,44 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var es = new EventSource('/events');
es.addEventListener('stats', function(e) {
try { updateStats(JSON.parse(e.data)); } catch(err) { console.error('stats parse error', err); }
try {
var data = JSON.parse(e.data);
pushHistorySample(data);
updateStats(data);
} catch(err) { console.error('stats parse error', err); }
});
es.addEventListener('history', function(e) {
try {
var data = JSON.parse(e.data);
if (data.workers) {
data.workers.forEach(function(w) {
workerHistory[w.id] = {
message_rates: w.message_rates || [],
mailbox_depths: w.mailbox_depths || [],
prev_msgs: 0
};
});
}
} catch(err) { console.error('history parse error', err); }
});
es.addEventListener('warnings', function(e) {
try {
var warnings = JSON.parse(e.data);
var banner = document.getElementById('warningBanner');
if (warnings.length === 0) {
banner.style.display = 'none';
return;
}
banner.style.display = 'block';
var sevColors = {critical:'#f44336',high:'#ff5722',medium:'#ff9800',low:'#888'};
var html = warnings.map(function(w) {
var c = sevColors[w.severity] || '#888';
return '<span style="color:' + c + ';">\u26A0 ' + w.description + '</span>';
}).join(' &nbsp; ');
banner.innerHTML = '<span style="color:#ff9800;font-weight:700;">WARNINGS (' + warnings.length + ')</span> &nbsp; ' + html;
} catch(err) { console.error('warnings parse error', err); }
});
es.addEventListener('activity', function(e) {

View file

@ -40,9 +40,9 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
.graph-panel {
grid-row: 1 / 3; border-right: 1px solid #2a2d3e; position: relative;
min-height: 0;
min-height: 0; overflow: hidden;
}
.graph-panel canvas { width: 100%; height: 100%; display: block; }
.graph-panel canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: block; }
.side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
@ -176,8 +176,17 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
</div>
</div>
<div class="bottom-section">
<h2>Recent Probes</h2>
<div class="scroll-wrap" id="probesWrap"></div>
<h2>Recent Probes <span id="probeCount" style="color:#555;font-weight:400;"></span></h2>
<div class="scroll-wrap">
<table style="width:100%;border-collapse:collapse;">
<thead><tr>
<th style="color:#888;font-weight:500;font-size:11px;">State</th>
<th style="color:#888;font-weight:500;font-size:11px;">Node</th>
<th style="color:#888;font-weight:500;font-size:11px;">Address</th>
</tr></thead>
<tbody id="probesBody"></tbody>
</table>
</div>
</div>
<div class="bottom-section">
<h2>Routing Buckets</h2>
@ -653,14 +662,26 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
cacheBody.appendChild(tr);
}
// Recent probes
var probesWrap = document.getElementById('probesWrap');
probesWrap.innerHTML = '';
// Recent probes — cross-reference with members for state + address
var memberMap = {};
for (var i = 0; i < d.members.length; i++) {
memberMap[d.members[i].node_id] = d.members[i];
}
var probesBody = document.getElementById('probesBody');
probesBody.innerHTML = '';
document.getElementById('probeCount').textContent = '(' + d.recent_probe_targets.length + ')';
for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) {
var div = document.createElement('div');
div.style.cssText = 'padding:2px 0;color:#aaa;font-size:11px;border-bottom:1px solid #1c1f2e;';
div.textContent = d.recent_probe_targets[i].substring(0, 16) + '\u2026';
probesWrap.appendChild(div);
var pid = d.recent_probe_targets[i];
var mem = memberMap[pid];
var state = mem ? mem.state : 'unknown';
var addr = mem ? mem.addr : '\u2014';
var cls = 'state-' + state;
var tr = document.createElement('tr');
tr.innerHTML =
'<td class="' + cls + '" style="font-size:10px;">' + state + '</td>' +
'<td style="color:#aaa;font-size:10px;">' + pid.substring(0, 12) + '\u2026</td>' +
'<td style="font-size:10px;">' + addr + '</td>';
probesBody.appendChild(tr);
}
// Routing bucket histogram

View file

@ -0,0 +1,344 @@
//! In-process time-series history for dashboard sparklines and trend detection.
//!
//! Stores bounded ring buffers of per-worker and per-actor stats, sampled at
//! a configurable interval. All data is kept in memory with automatic eviction
//! of the oldest samples when capacity is reached.
use std::collections::{HashMap, VecDeque};
use std::sync::RwLock;
use swactor::actor::ActorAddress;
use swactor::stats::{ActorInfo, RuntimeStats};
/// Configuration for history collection.
#[derive(Debug, Clone)]
pub struct HistoryConfig {
/// Maximum samples per worker (default: 300 = 5 min at 1/sec).
pub max_worker_samples: usize,
/// Maximum samples per actor (default: 300).
pub max_actor_samples: usize,
/// Maximum number of actors tracked (LRU eviction). Default: 1000.
pub max_tracked_actors: usize,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
max_worker_samples: 300,
max_actor_samples: 300,
max_tracked_actors: 1000,
}
}
}
/// Time-series data for a single worker.
#[derive(Debug, Clone)]
pub struct WorkerHistory {
pub message_rates: VecDeque<f64>,
pub mailbox_depths: VecDeque<u64>,
pub actor_counts: VecDeque<u32>,
prev_messages: u64,
}
impl WorkerHistory {
fn new() -> Self {
Self {
message_rates: VecDeque::new(),
mailbox_depths: VecDeque::new(),
actor_counts: VecDeque::new(),
prev_messages: 0,
}
}
fn push(&mut self, messages_processed: u64, mailbox_depth: usize, num_actors: usize, cap: usize) {
let rate = messages_processed.saturating_sub(self.prev_messages) as f64;
self.prev_messages = messages_processed;
push_bounded(&mut self.message_rates, rate, cap);
push_bounded(&mut self.mailbox_depths, mailbox_depth as u64, cap);
push_bounded(&mut self.actor_counts, num_actors as u32, cap);
}
}
/// Time-series data for a single actor.
#[derive(Debug, Clone)]
pub struct ActorHistory {
pub mailbox_depths: VecDeque<u64>,
pub message_rates: VecDeque<f64>,
prev_messages: u64,
last_seen_sample: u64,
}
impl ActorHistory {
fn new(sample_counter: u64) -> Self {
Self {
mailbox_depths: VecDeque::new(),
message_rates: VecDeque::new(),
prev_messages: 0,
last_seen_sample: sample_counter,
}
}
fn push(&mut self, info: &ActorInfo, cap: usize, sample_counter: u64) {
let rate = info.messages_processed.saturating_sub(self.prev_messages) as f64;
self.prev_messages = info.messages_processed;
self.last_seen_sample = sample_counter;
push_bounded(&mut self.mailbox_depths, info.mailbox_depth as u64, cap);
push_bounded(&mut self.message_rates, rate, cap);
}
}
fn push_bounded<T>(buf: &mut VecDeque<T>, val: T, cap: usize) {
if buf.len() >= cap {
buf.pop_front();
}
buf.push_back(val);
}
/// Thread-safe history store. Written by the sampler, read by SSE/TUI.
pub struct DashboardHistory {
inner: RwLock<HistoryInner>,
config: HistoryConfig,
}
struct HistoryInner {
workers: Vec<WorkerHistory>,
actors: HashMap<ActorAddress, ActorHistory>,
sample_counter: u64,
}
impl DashboardHistory {
pub fn new(config: HistoryConfig) -> Self {
Self {
inner: RwLock::new(HistoryInner {
workers: Vec::new(),
actors: HashMap::new(),
sample_counter: 0,
}),
config,
}
}
/// Record a stats snapshot. Called by the sampler thread.
pub fn record(&self, stats: &RuntimeStats) {
let mut inner = self.inner.write().unwrap();
inner.sample_counter += 1;
let counter = inner.sample_counter;
// Resize workers vec if needed
while inner.workers.len() < stats.workers.len() {
inner.workers.push(WorkerHistory::new());
}
// Record per-worker data
for w in &stats.workers {
if let Some(wh) = inner.workers.get_mut(w.id) {
wh.push(
w.messages_processed,
w.mailbox_depth,
w.num_actors,
self.config.max_worker_samples,
);
}
}
// Record per-actor data
for a in &stats.actor_details {
let ah = inner.actors.entry(a.address).or_insert_with(|| ActorHistory::new(counter));
ah.push(a, self.config.max_actor_samples, counter);
}
// LRU eviction: remove actors not seen recently if over capacity
if inner.actors.len() > self.config.max_tracked_actors {
let mut entries: Vec<(ActorAddress, u64)> = inner
.actors
.iter()
.map(|(addr, ah)| (*addr, ah.last_seen_sample))
.collect();
entries.sort_by_key(|&(_, seen)| seen);
let to_remove = inner.actors.len() - self.config.max_tracked_actors;
for (addr, _) in entries.into_iter().take(to_remove) {
inner.actors.remove(&addr);
}
}
}
/// Get a snapshot of worker history for rendering sparklines.
/// Returns Vec indexed by worker_id, each containing recent message rates.
pub fn worker_sparklines(&self) -> Vec<Vec<u64>> {
let inner = self.inner.read().unwrap();
inner
.workers
.iter()
.map(|wh| wh.message_rates.iter().map(|r| *r as u64).collect())
.collect()
}
/// Get worker mailbox depth history.
pub fn worker_mailbox_sparklines(&self) -> Vec<Vec<u64>> {
let inner = self.inner.read().unwrap();
inner
.workers
.iter()
.map(|wh| wh.mailbox_depths.iter().copied().collect())
.collect()
}
/// Get sparkline data for a specific actor.
pub fn actor_sparkline(&self, addr: &ActorAddress) -> Option<(Vec<u64>, Vec<u64>)> {
let inner = self.inner.read().unwrap();
inner.actors.get(addr).map(|ah| {
let mailbox: Vec<u64> = ah.mailbox_depths.iter().copied().collect();
let rates: Vec<u64> = ah.message_rates.iter().map(|r| *r as u64).collect();
(mailbox, rates)
})
}
/// Get total sample count (useful for knowing if history is available).
pub fn sample_count(&self) -> u64 {
self.inner.read().unwrap().sample_counter
}
/// Serialize worker history as JSON for the SSE initial payload.
pub fn worker_history_json(&self) -> String {
let sparklines = self.worker_sparklines();
let mailbox = self.worker_mailbox_sparklines();
serde_json::json!({
"workers": sparklines.iter().enumerate().map(|(i, rates)| {
serde_json::json!({
"id": i,
"message_rates": rates,
"mailbox_depths": mailbox.get(i).unwrap_or(&Vec::new()),
})
}).collect::<Vec<_>>(),
})
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use swactor::stats::{ActorInfo, WorkerInfo};
fn make_stats(workers: Vec<(u64, usize, usize)>, actors: Vec<ActorInfo>) -> RuntimeStats {
RuntimeStats {
num_workers: workers.len(),
uptime_ms: 0,
actors: actors.iter().map(|a| (a.address, a.worker_id)).collect(),
workers: workers
.into_iter()
.enumerate()
.map(|(id, (msgs, depth, n_actors))| WorkerInfo {
id,
num_actors: n_actors,
mailbox_depth: depth,
messages_processed: msgs,
local_sends: 0,
cross_sends: 0,
inbox_sends: 0,
type_mismatches: 0,
panics: 0,
messages_dropped: 0,
restarts: 0,
stops: 0,
})
.collect(),
actor_details: actors,
tick_timings: Vec::new(),
}
}
fn make_actor(id: u8, worker: usize, depth: usize, msgs: u64) -> ActorInfo {
ActorInfo {
address: ActorAddress([id; 32]),
worker_id: worker,
mailbox_depth: depth,
last_msg_type: None,
messages_processed: msgs,
poisoned: false,
message_type_counts: Vec::new(),
}
}
#[test]
fn worker_rates_accumulate_over_samples() {
let history = DashboardHistory::new(HistoryConfig::default());
// First sample: establishes baseline (rate will be the raw value since prev=0)
let stats1 = make_stats(vec![(100, 5, 2)], vec![]);
history.record(&stats1);
// Second sample: delta = 150 - 100 = 50
let stats2 = make_stats(vec![(150, 3, 2)], vec![]);
history.record(&stats2);
let sparklines = history.worker_sparklines();
assert_eq!(sparklines.len(), 1);
assert_eq!(sparklines[0].len(), 2);
assert_eq!(sparklines[0][0], 100); // first sample: 100 - 0
assert_eq!(sparklines[0][1], 50); // second sample: 150 - 100
}
#[test]
fn bounded_eviction_drops_oldest() {
let config = HistoryConfig {
max_worker_samples: 3,
..Default::default()
};
let history = DashboardHistory::new(config);
for i in 0..5u64 {
let stats = make_stats(vec![(i * 10, 0, 0)], vec![]);
history.record(&stats);
}
let sparklines = history.worker_sparklines();
assert_eq!(sparklines[0].len(), 3); // capped at 3
}
#[test]
fn actor_lru_eviction_keeps_most_recent() {
let config = HistoryConfig {
max_tracked_actors: 2,
..Default::default()
};
let history = DashboardHistory::new(config);
// Sample 1: actors A and B
let stats1 = make_stats(
vec![(0, 0, 2)],
vec![make_actor(1, 0, 0, 0), make_actor(2, 0, 0, 0)],
);
history.record(&stats1);
// Sample 2: actors B and C (A not seen)
let stats2 = make_stats(
vec![(0, 0, 2)],
vec![make_actor(2, 0, 0, 0), make_actor(3, 0, 0, 0)],
);
history.record(&stats2);
// A should be evicted (LRU), B and C kept
assert!(history.actor_sparkline(&ActorAddress([1; 32])).is_none());
assert!(history.actor_sparkline(&ActorAddress([2; 32])).is_some());
assert!(history.actor_sparkline(&ActorAddress([3; 32])).is_some());
}
#[test]
fn actor_rates_track_deltas() {
let history = DashboardHistory::new(HistoryConfig::default());
let stats1 = make_stats(vec![(0, 0, 1)], vec![make_actor(1, 0, 5, 100)]);
history.record(&stats1);
let stats2 = make_stats(vec![(0, 0, 1)], vec![make_actor(1, 0, 3, 175)]);
history.record(&stats2);
let (mailbox, rates) = history.actor_sparkline(&ActorAddress([1; 32])).unwrap();
assert_eq!(mailbox, vec![5, 3]);
assert_eq!(rates[0], 100); // first: 100 - 0
assert_eq!(rates[1], 75); // second: 175 - 100
}
}

View file

@ -20,6 +20,7 @@ pub struct DashboardEvent {
pub level: String,
pub message: String,
pub worker_id: Option<usize>,
pub actor_addr: Option<String>,
pub fields: serde_json::Map<String, serde_json::Value>,
}
@ -83,6 +84,28 @@ impl EventStore {
(batch, new_cursor)
}
/// Read recent events for a specific actor address (hex prefix match).
/// Returns up to `limit` most recent matching events.
pub fn read_for_actor(&self, actor_hex: &str, limit: usize) -> Vec<DashboardEvent> {
let events = self.events.lock().unwrap();
let lower = actor_hex.to_lowercase();
events
.iter()
.rev()
.filter(|e| {
e.actor_addr
.as_ref()
.map(|a| a.to_lowercase().starts_with(&lower) || a.to_lowercase().contains(&lower))
.unwrap_or(false)
})
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
/// Drains the full recording log. Only available when recording is enabled.
/// This is destructive — events are consumed. Intended for `save_trace()`.
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
@ -183,24 +206,39 @@ where
let mut visitor = FieldVisitor::new();
event.record(&mut visitor);
// Walk span context to find worker_id
// Walk span context to find worker_id and actor_addr
let mut worker_id = None;
let mut actor_addr = None;
if let Some(scope) = ctx.event_scope(event) {
for span in scope {
let exts = span.extensions();
if let Some(wid) = exts.get::<WorkerIdField>() {
worker_id = Some(wid.0);
if worker_id.is_none() {
if let Some(wid) = exts.get::<WorkerIdField>() {
worker_id = Some(wid.0);
}
}
if actor_addr.is_none() {
if let Some(aa) = exts.get::<ActorAddrField>() {
actor_addr = Some(aa.0.clone());
}
}
if worker_id.is_some() && actor_addr.is_some() {
break;
}
}
}
// Also check if worker_id was a field on the event itself
// Also check if worker_id or actor_addr was a field on the event itself
if worker_id.is_none() {
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
worker_id = n.as_u64().map(|v| v as usize);
}
}
if actor_addr.is_none() {
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
actor_addr = Some(s.clone());
}
}
let dashboard_event = DashboardEvent {
seq: 0, // filled by push()
@ -208,6 +246,7 @@ where
level: event.metadata().level().to_string(),
message: visitor.message,
worker_id,
actor_addr,
fields: visitor.fields,
};
@ -215,19 +254,25 @@ where
}
fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
// Extract worker_id from span fields and store in extensions
// Extract worker_id and actor_addr from span fields and store in extensions
let mut visitor = FieldVisitor::new();
attrs.record(&mut visitor);
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
if let Some(wid) = n.as_u64() {
if let Some(span) = ctx.span(id) {
if let Some(span) = ctx.span(id) {
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
if let Some(wid) = n.as_u64() {
span.extensions_mut().insert(WorkerIdField(wid as usize));
}
}
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
span.extensions_mut().insert(ActorAddrField(s.clone()));
}
}
}
}
/// Stored in span extensions to propagate worker_id to child events.
struct WorkerIdField(usize);
/// Stored in span extensions to propagate actor_addr to child events.
struct ActorAddrField(String);

View file

@ -1,10 +1,15 @@
pub mod collector;
pub mod history;
pub mod investigate;
pub mod layer;
pub mod trace;
pub mod warnings;
mod actor_detail_html;
mod actors_html;
mod dashboard_html;
mod server;
pub mod topology;
mod topology_html;
#[cfg(feature = "tui")]
pub mod tui;
@ -27,6 +32,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use crate::collector::StatsCollector;
use crate::history::{DashboardHistory, HistoryConfig};
use crate::layer::{now_ms, DashboardLayer, EventStore};
use crate::trace::{RuntimeTrace, TimestampedStats};
@ -80,6 +86,7 @@ pub struct DashboardHandle {
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
history: Arc<DashboardHistory>,
recording: bool,
#[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
@ -115,6 +122,11 @@ impl DashboardHandle {
*self.distribution.lock().unwrap() = Some(provider);
}
/// Access the time-series history store (for TUI sparklines, etc.).
pub fn history(&self) -> &Arc<DashboardHistory> {
&self.history
}
/// Signal the dashboard to shut down (SSE clients receive "done").
pub fn shutdown(&self) {
self.shutdown.store(true, Ordering::Release);
@ -159,6 +171,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
let shutdown = Arc::new(AtomicBool::new(false));
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
#[cfg(feature = "distribution")]
let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> =
@ -169,6 +182,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
Arc::clone(&runtime),
Arc::clone(&collector),
Arc::clone(&shutdown),
Arc::clone(&history),
config.port,
#[cfg(feature = "distribution")]
Arc::clone(&distribution),
@ -210,6 +224,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
collector,
shutdown,
stats_timeline,
history,
recording: config.record,
#[cfg(feature = "distribution")]
distribution,

View file

@ -8,11 +8,16 @@ use std::collections::HashMap;
use swactor::runtime::Runtime;
use crate::actor_detail_html::ACTOR_DETAIL_HTML;
use crate::actors_html::ACTORS_HTML;
use crate::collector::StatsCollector;
use crate::dashboard_html::DASHBOARD_HTML;
use crate::history::DashboardHistory;
use crate::layer::EventStore;
use crate::topology;
use crate::topology_html::TOPOLOGY_HTML;
use crate::trace::RuntimeTrace;
use crate::warnings::{WarningConfig, WarningDetector};
#[cfg(feature = "distribution")]
use crate::distribution_collector::DistributionStatsProvider;
@ -119,6 +124,7 @@ pub(crate) fn spawn_http_server(
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
history: Arc<DashboardHistory>,
port: u16,
#[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
@ -134,6 +140,7 @@ pub(crate) fn spawn_http_server(
let runtime = Arc::clone(&runtime);
let collector = Arc::clone(&collector);
let shutdown = Arc::clone(&shutdown);
let history = Arc::clone(&history);
let cmd_router = Arc::clone(&cmd_router);
#[cfg(feature = "distribution")]
let distribution = Arc::clone(&distribution);
@ -149,6 +156,7 @@ pub(crate) fn spawn_http_server(
match path {
"/" => respond_html(request, DASHBOARD_HTML, "live"),
"/actors" => respond_html(request, ACTORS_HTML, "live"),
"/topology" => respond_html(request, TOPOLOGY_HTML, "live"),
#[cfg(feature = "distribution")]
"/distribution" => respond_html(request, DISTRIBUTION_HTML, "live"),
"/events" => {
@ -158,6 +166,7 @@ pub(crate) fn spawn_http_server(
Arc::clone(&runtime),
Arc::clone(&collector),
Arc::clone(&shutdown),
Arc::clone(&history),
#[cfg(feature = "distribution")]
Arc::clone(&distribution),
);
@ -169,6 +178,16 @@ pub(crate) fn spawn_http_server(
Arc::clone(&collector),
);
}
"/api/history" => {
handle_history_api(request, Arc::clone(&history));
}
"/api/topology" => {
handle_topology_api(
request,
Arc::clone(&runtime),
Arc::clone(&collector),
);
}
"/api/investigate" => {
handle_investigate_api(
request,
@ -185,6 +204,13 @@ pub(crate) fn spawn_http_server(
Arc::clone(&distribution),
);
}
"/api/logs" => {
handle_logs_api(request, &url, Arc::clone(&store));
}
_ if path.starts_with("/actor/") => {
let hex = &path[7..]; // strip "/actor/"
respond_actor_detail(request, hex);
}
_ => respond_404(request),
}
}
@ -192,12 +218,25 @@ pub(crate) fn spawn_http_server(
}
}
fn respond_actor_detail(request: tiny_http::Request, hex_addr: &str) {
let html = ACTOR_DETAIL_HTML
.replace("__DASHBOARD_MODE__", "live")
.replace("__ACTOR_ADDR__", hex_addr);
let response = tiny_http::Response::from_string(html).with_header(
"Content-Type: text/html; charset=utf-8"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
fn handle_live_sse(
request: tiny_http::Request,
store: Arc<EventStore>,
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
history: Arc<DashboardHistory>,
#[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
) {
@ -207,6 +246,14 @@ fn handle_live_sse(
// Spawn producer thread
thread::spawn(move || {
let mut cursor: u64 = 0;
let mut warning_detector = WarningDetector::new(WarningConfig::default());
let mut tick_count: u64 = 0;
// Send initial history snapshot so sparklines render immediately
if history.sample_count() > 0 {
let json = history.worker_history_json();
let _ = tx.send(format_sse("history", &json));
}
loop {
// Send stats if runtime is available
@ -217,10 +264,33 @@ fn handle_live_sse(
if let Some(col) = collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
history.record(&stats);
// Run warning detection
let warnings = warning_detector.check(&stats);
if !warnings.is_empty() {
if let Ok(wjson) = serde_json::to_string(&warnings) {
if tx.send(format_sse("warnings", &wjson)).is_err() {
return;
}
}
}
let json = serde_json::to_string(&stats).unwrap();
if tx.send(format_sse("stats", &json)).is_err() {
return;
}
// Send topology every 5th tick (~1/sec)
tick_count += 1;
if tick_count % 5 == 0 {
let topo = topology::worker_topology(&stats);
if let Ok(tjson) = serde_json::to_string(&topo) {
if tx.send(format_sse("topology", &tjson)).is_err() {
return;
}
}
}
}
}
@ -353,6 +423,67 @@ fn handle_distribution_api(
let _ = request.respond(response);
}
fn handle_topology_api(
request: tiny_http::Request,
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
) {
let maybe_rt = runtime.lock().unwrap().clone();
let json = match maybe_rt {
Some(rt) => {
let mut stats = rt.stats();
if let Some(col) = collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
let topo = topology::worker_topology(&stats);
serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into())
}
None => "{}".to_string(),
};
let response = tiny_http::Response::from_string(json).with_header(
"Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
fn handle_logs_api(request: tiny_http::Request, url: &str, store: Arc<EventStore>) {
let params = parse_query_string(url);
let actor = params.get("actor").cloned().unwrap_or_default();
let limit: usize = params
.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(200);
let level = params.get("level").cloned();
let mut events = store.read_for_actor(&actor, limit);
// Filter by level if specified
if let Some(ref lvl) = level {
let lvl_upper = lvl.to_uppercase();
events.retain(|e| e.level == lvl_upper);
}
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into());
let response = tiny_http::Response::from_string(json).with_header(
"Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
fn handle_history_api(request: tiny_http::Request, history: Arc<DashboardHistory>) {
let json = history.worker_history_json();
let response = tiny_http::Response::from_string(json).with_header(
"Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
fn parse_query_string(url: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(qs) = url.split('?').nth(1) {

View file

@ -0,0 +1,82 @@
//! Actor-to-actor (and worker-to-worker) message flow topology.
//!
//! Currently derives topology from per-worker cross_sends/local_sends stats.
//! Future: sample-based per-actor source→destination tracking with core instrumentation.
use swactor::stats::RuntimeStats;
/// An edge in the topology graph.
#[derive(Debug, Clone, serde::Serialize)]
pub struct TopologyEdge {
pub source: String,
pub target: String,
pub weight: u64,
pub label: String,
}
/// A node in the topology graph.
#[derive(Debug, Clone, serde::Serialize)]
pub struct TopologyNode {
pub id: String,
pub label: String,
pub actor_count: usize,
pub group: usize,
}
/// A snapshot of the current topology.
#[derive(Debug, Clone, serde::Serialize)]
pub struct TopologySnapshot {
pub nodes: Vec<TopologyNode>,
pub edges: Vec<TopologyEdge>,
}
/// Build a worker-level topology from RuntimeStats.
///
/// Workers are nodes, edges represent message flow:
/// - Self-loops for local_sends
/// - Cross-edges distributed proportionally (until per-destination tracking exists)
pub fn worker_topology(stats: &RuntimeStats) -> TopologySnapshot {
let mut nodes = Vec::new();
let mut edges = Vec::new();
for w in &stats.workers {
nodes.push(TopologyNode {
id: format!("w{}", w.id),
label: format!("W{}", w.id),
actor_count: w.num_actors,
group: w.id,
});
// Local sends = self-loop
if w.local_sends > 0 {
edges.push(TopologyEdge {
source: format!("w{}", w.id),
target: format!("w{}", w.id),
weight: w.local_sends,
label: format!("{} local", w.local_sends),
});
}
// Cross sends — without per-destination data, distribute evenly to other workers
if w.cross_sends > 0 && stats.workers.len() > 1 {
let others: Vec<&swactor::stats::WorkerInfo> =
stats.workers.iter().filter(|o| o.id != w.id).collect();
let per_worker = w.cross_sends / others.len() as u64;
let remainder = w.cross_sends % others.len() as u64;
for (i, other) in others.iter().enumerate() {
let count = per_worker + if (i as u64) < remainder { 1 } else { 0 };
if count > 0 {
edges.push(TopologyEdge {
source: format!("w{}", w.id),
target: format!("w{}", other.id),
weight: count,
label: format!("{} cross", count),
});
}
}
}
}
TopologySnapshot { nodes, edges }
}

View file

@ -0,0 +1,280 @@
pub const TOPOLOGY_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>Topology — 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;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.content { padding: 0; display: flex; flex-direction: column; height: calc(100vh - 49px); }
canvas#topoCanvas { flex: 1; width: 100%; cursor: grab; }
canvas#topoCanvas:active { cursor: grabbing; }
.legend {
padding: 8px 20px; background: #161822; border-top: 1px solid #2a2d3e;
font-size: 11px; color: #888;
}
</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>
<a href="/topology" class="nav-link active">Topology</a>
<a href="/distribution" class="nav-link">Distribution</a>
</nav>
</div>
</div>
<div class="content">
<canvas id="topoCanvas"></canvas>
<div class="legend">
Node size = actor count. Edge thickness = message volume. Green = local sends. Blue = cross-worker sends.
</div>
</div>
<script>
(function() {
var canvas = document.getElementById('topoCanvas');
var ctx = canvas.getContext('2d');
var dot = document.getElementById('statusDot');
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
var nodes = [];
var edges = [];
var positions = {};
function resize() {
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
function initPositions() {
var W = canvas.getBoundingClientRect().width;
var H = canvas.getBoundingClientRect().height;
var cx = W / 2, cy = H / 2;
var r = Math.min(W, H) * 0.3;
nodes.forEach(function(n, i) {
if (!positions[n.id]) {
var angle = (2 * Math.PI * i) / Math.max(1, nodes.length);
positions[n.id] = {
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle),
vx: 0, vy: 0
};
}
});
}
function simulate() {
var W = canvas.getBoundingClientRect().width;
var H = canvas.getBoundingClientRect().height;
var cx = W / 2, cy = H / 2;
// Repulsion between nodes
for (var i = 0; i < nodes.length; i++) {
var pi = positions[nodes[i].id];
if (!pi) continue;
for (var j = i + 1; j < nodes.length; j++) {
var pj = positions[nodes[j].id];
if (!pj) continue;
var dx = pi.x - pj.x;
var dy = pi.y - pj.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = 8000 / (dist * dist);
pi.vx += dx / dist * force;
pi.vy += dy / dist * force;
pj.vx -= dx / dist * force;
pj.vy -= dy / dist * force;
}
}
// Attraction along edges
edges.forEach(function(e) {
if (e.source === e.target) return;
var ps = positions[e.source];
var pt = positions[e.target];
if (!ps || !pt) return;
var dx = pt.x - ps.x;
var dy = pt.y - ps.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = (dist - 150) * 0.01;
ps.vx += dx / dist * force;
ps.vy += dy / dist * force;
pt.vx -= dx / dist * force;
pt.vy -= dy / dist * force;
});
// Gravity toward center
for (var i = 0; i < nodes.length; i++) {
var p = positions[nodes[i].id];
if (!p) continue;
p.vx += (cx - p.x) * 0.002;
p.vy += (cy - p.y) * 0.002;
}
// Apply velocity with damping
for (var i = 0; i < nodes.length; i++) {
var p = positions[nodes[i].id];
if (!p) continue;
p.vx *= 0.85;
p.vy *= 0.85;
p.x += p.vx;
p.y += p.vy;
p.x = Math.max(30, Math.min(W - 30, p.x));
p.y = Math.max(30, Math.min(H - 30, p.y));
}
}
function draw() {
var W = canvas.getBoundingClientRect().width;
var H = canvas.getBoundingClientRect().height;
ctx.clearRect(0, 0, W, H);
if (nodes.length === 0) {
ctx.fillStyle = '#555';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText('Waiting for topology data...', W / 2, H / 2);
return;
}
// Draw edges
var maxWeight = Math.max(1, Math.max.apply(null, edges.map(function(e) { return e.weight; })));
edges.forEach(function(e) {
var ps = positions[e.source];
var pt = positions[e.target];
if (!ps || !pt) return;
var isSelf = e.source === e.target;
var thickness = Math.max(1, (e.weight / maxWeight) * 6);
var color = isSelf ? 'rgba(76, 175, 80, 0.5)' : 'rgba(33, 150, 243, 0.5)';
if (isSelf) {
// Self-loop: small arc above the node
ctx.beginPath();
ctx.arc(ps.x, ps.y - 25, 15, 0.3, Math.PI - 0.3);
ctx.strokeStyle = color;
ctx.lineWidth = thickness;
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(ps.x, ps.y);
ctx.lineTo(pt.x, pt.y);
ctx.strokeStyle = color;
ctx.lineWidth = thickness;
ctx.stroke();
// Arrow
var angle = Math.atan2(pt.y - ps.y, pt.x - ps.x);
var headLen = 8;
var mx = (ps.x + pt.x) / 2;
var my = (ps.y + pt.y) / 2;
ctx.beginPath();
ctx.moveTo(mx, my);
ctx.lineTo(mx - headLen * Math.cos(angle - 0.3), my - headLen * Math.sin(angle - 0.3));
ctx.moveTo(mx, my);
ctx.lineTo(mx - headLen * Math.cos(angle + 0.3), my - headLen * Math.sin(angle + 0.3));
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
// Edge label
ctx.fillStyle = '#666';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText(e.label, mx, my - 6);
}
});
// Draw nodes
nodes.forEach(function(n) {
var p = positions[n.id];
if (!p) return;
var r = Math.max(12, 8 + n.actor_count * 2);
var color = colors[n.group % colors.length];
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(n.label, p.x, p.y);
ctx.fillStyle = '#888';
ctx.font = '9px monospace';
ctx.fillText(n.actor_count + ' actors', p.x, p.y + r + 12);
});
}
function updateTopology(data) {
nodes = data.nodes || [];
edges = data.edges || [];
initPositions();
}
function tick() {
simulate();
draw();
requestAnimationFrame(tick);
}
tick();
var es = new EventSource('/events');
es.addEventListener('topology', function(e) {
try { updateTopology(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>
"##;

View file

@ -1,9 +1,14 @@
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use swactor::actor::ActorAddress;
use swactor::stats::{RuntimeStats, TickTiming};
use crate::layer::{DashboardEvent, EventStore};
use crate::warnings::{Warning, WarningConfig, WarningDetector};
/// Per-worker data prepared for rendering.
pub struct WorkerView {
pub id: usize,
@ -15,6 +20,10 @@ pub struct WorkerView {
/// Fraction of bar for each phase group: [processing, delivery, spawns, overhead]
pub phase_fractions: [f64; 4],
pub panics: u64,
/// Recent message rates for sparkline rendering.
pub sparkline_rates: Vec<u64>,
/// Recent mailbox depths for sparkline rendering.
pub sparkline_mailbox: Vec<u64>,
}
/// One row in the actor table.
@ -25,6 +34,12 @@ pub struct ActorRow {
pub last_msg_type: Option<String>,
pub messages_processed: u64,
pub poisoned: bool,
/// Per-actor mailbox sparkline (from local ring buffer).
pub sparkline_mailbox: Vec<u64>,
/// Per-actor message rate sparkline.
pub sparkline_rates: Vec<u64>,
/// Per-message-type counts, sorted descending.
pub message_type_counts: Vec<(String, u64)>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
@ -62,6 +77,7 @@ impl SortColumn {
pub enum ViewMode {
Overview,
WorkerDetail,
ActorDetail,
#[cfg(feature = "distribution")]
Distribution,
}
@ -79,7 +95,13 @@ pub struct App {
pub total_panics: u64,
pub num_workers: usize,
pub view_mode: ViewMode,
pub prev_view_mode: ViewMode,
pub focused_worker: usize,
pub focused_actor: Option<ActorAddress>,
pub search_active: bool,
pub search_query: String,
pub search_locked: bool,
pub warnings: Vec<Warning>,
#[cfg(feature = "distribution")]
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
@ -90,6 +112,21 @@ pub struct App {
prev_time: Instant,
/// Rolling msg rates (smoothed)
msg_rates: Vec<f64>,
/// Per-worker sparkline history (message rate deltas).
sparkline_rates: Vec<VecDeque<u64>>,
/// Per-worker sparkline history (mailbox depths).
sparkline_mailbox: Vec<VecDeque<u64>>,
/// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths).
actor_sparklines: HashMap<ActorAddress, (u64, VecDeque<u64>, VecDeque<u64>)>,
warning_detector: WarningDetector,
/// Event store for fetching per-actor logs.
event_store: Option<Arc<EventStore>>,
/// Cached log entries for the focused actor.
pub actor_logs: Vec<DashboardEvent>,
/// Scroll offset for actor log view.
pub log_scroll: usize,
/// Active log level filter (all enabled by default).
pub log_levels: [bool; 5], // ERROR, WARN, INFO, DEBUG, TRACE
}
impl App {
@ -107,7 +144,13 @@ impl App {
total_panics: 0,
num_workers: 0,
view_mode: ViewMode::Overview,
prev_view_mode: ViewMode::Overview,
focused_worker: 0,
focused_actor: None,
search_active: false,
search_query: String::new(),
search_locked: false,
warnings: Vec::new(),
#[cfg(feature = "distribution")]
distribution: None,
#[cfg(feature = "distribution")]
@ -115,9 +158,22 @@ impl App {
prev_messages: Vec::new(),
prev_time: Instant::now(),
msg_rates: Vec::new(),
sparkline_rates: Vec::new(),
sparkline_mailbox: Vec::new(),
actor_sparklines: HashMap::new(),
warning_detector: WarningDetector::new(WarningConfig::default()),
event_store: None,
actor_logs: Vec::new(),
log_scroll: 0,
log_levels: [true; 5],
}
}
/// Set the event store for per-actor log retrieval.
pub fn set_event_store(&mut self, store: Arc<EventStore>) {
self.event_store = Some(store);
}
#[cfg(feature = "distribution")]
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
@ -125,6 +181,62 @@ impl App {
self.distribution = Some(snapshot);
}
/// Get visible actor rows (filtered by search query if active).
pub fn visible_actor_rows(&self) -> Vec<&ActorRow> {
if self.search_query.is_empty() {
self.actor_rows.iter().collect()
} else {
let q = self.search_query.to_lowercase();
self.actor_rows.iter().filter(|r| {
let addr = format!("{}", r.address).to_lowercase();
let msg = r.last_msg_type.as_deref().unwrap_or("").to_lowercase();
let worker = format!("w{}", r.worker_id);
addr.contains(&q) || msg.contains(&q) || worker.contains(&q)
}).collect()
}
}
/// Get the focused actor's data (for actor detail view).
pub fn focused_actor_row(&self) -> Option<&ActorRow> {
self.focused_actor.as_ref().and_then(|addr| {
self.actor_rows.iter().find(|r| r.address == *addr)
})
}
/// Refresh the actor log cache from the event store.
pub fn refresh_actor_logs(&mut self) {
if let (Some(addr), Some(store)) = (&self.focused_actor, &self.event_store) {
let hex = format!("{}", addr);
self.actor_logs = store.read_for_actor(&hex, 200);
} else {
self.actor_logs.clear();
}
}
/// Get visible log entries (filtered by level).
pub fn visible_logs(&self) -> Vec<&DashboardEvent> {
self.actor_logs
.iter()
.filter(|e| {
match e.level.as_str() {
"ERROR" => self.log_levels[0],
"WARN" => self.log_levels[1],
"INFO" => self.log_levels[2],
"DEBUG" => self.log_levels[3],
"TRACE" => self.log_levels[4],
_ => true,
}
})
.collect()
}
/// Toggle a log level filter (0=ERROR, 1=WARN, 2=INFO, 3=DEBUG, 4=TRACE).
pub fn toggle_log_level(&mut self, idx: usize) {
if idx < 5 {
self.log_levels[idx] = !self.log_levels[idx];
}
}
/// Actor rows filtered to the focused worker (for worker detail view).
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
self.actor_rows
@ -144,6 +256,8 @@ impl App {
if self.prev_messages.len() != stats.workers.len() {
self.prev_messages = stats.workers.iter().map(|w| w.messages_processed).collect();
self.msg_rates = vec![0.0; stats.workers.len()];
self.sparkline_rates.resize_with(stats.workers.len(), VecDeque::new);
self.sparkline_mailbox.resize_with(stats.workers.len(), VecDeque::new);
}
// Compute per-worker views
@ -163,8 +277,19 @@ impl App {
self.msg_rates[i] * 0.6 + rate * 0.4
};
self.msg_rates[i] = smoothed;
// Track sparkline history
let delta = w.messages_processed.saturating_sub(self.prev_messages[i]);
self.prev_messages[i] = w.messages_processed;
let spark_rates = &mut self.sparkline_rates[i];
if spark_rates.len() >= 60 { spark_rates.pop_front(); }
spark_rates.push_back(delta);
let spark_mbox = &mut self.sparkline_mailbox[i];
if spark_mbox.len() >= 60 { spark_mbox.pop_front(); }
spark_mbox.push_back(w.mailbox_depth as u64);
// Load % and phase fractions from tick timings
let timings = stats.tick_timings.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
let (load_pct, phase_fractions) = compute_load_and_phases(timings);
@ -178,6 +303,8 @@ impl App {
load_pct,
phase_fractions,
panics: w.panics,
sparkline_rates: spark_rates.iter().copied().collect(),
sparkline_mailbox: spark_mbox.iter().copied().collect(),
});
}
@ -187,9 +314,20 @@ impl App {
self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum();
self.total_panics = stats.workers.iter().map(|w| w.panics).sum();
// Build actor table
// Build actor table with sparkline history
self.actor_rows.clear();
for a in &stats.actor_details {
let (prev, rates_buf, mbox_buf) = self.actor_sparklines
.entry(a.address)
.or_insert_with(|| (0, VecDeque::new(), VecDeque::new()));
let rate_delta = a.messages_processed.saturating_sub(*prev);
*prev = a.messages_processed;
if rates_buf.len() >= 60 { rates_buf.pop_front(); }
rates_buf.push_back(rate_delta);
if mbox_buf.len() >= 60 { mbox_buf.pop_front(); }
mbox_buf.push_back(a.mailbox_depth as u64);
self.actor_rows.push(ActorRow {
address: a.address,
worker_id: a.worker_id,
@ -197,10 +335,21 @@ impl App {
last_msg_type: a.last_msg_type.clone(),
messages_processed: a.messages_processed,
poisoned: a.poisoned,
sparkline_rates: rates_buf.iter().copied().collect(),
sparkline_mailbox: mbox_buf.iter().copied().collect(),
message_type_counts: a.message_type_counts.clone(),
});
}
self.sort_actors();
// Run warning detection
self.warnings = self.warning_detector.check(&stats);
// Refresh actor logs if in detail view
if self.view_mode == ViewMode::ActorDetail {
self.refresh_actor_logs();
}
// Clamp selection
if !self.actor_rows.is_empty() {
self.selected = self.selected.min(self.actor_rows.len() - 1);
@ -246,12 +395,45 @@ impl App {
}
pub fn handle_key(&mut self, key: KeyEvent) {
// Search mode input handling
if self.search_active {
match key.code {
KeyCode::Esc => {
self.search_active = false;
if !self.search_locked {
self.search_query.clear();
}
return;
}
KeyCode::Enter => {
self.search_active = false;
self.search_locked = !self.search_query.is_empty();
return;
}
KeyCode::Backspace => {
self.search_query.pop();
return;
}
KeyCode::Char(c) => {
self.search_query.push(c);
return;
}
_ => return,
}
}
// Global keys
match key.code {
KeyCode::Char('q') => { self.should_quit = true; return; }
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true; return;
}
KeyCode::Char('/') => {
self.search_active = true;
self.search_locked = false;
self.search_query.clear();
return;
}
KeyCode::Char('s') => {
self.sort_column = self.sort_column.next();
self.sort_actors();
@ -265,6 +447,7 @@ impl App {
KeyCode::Tab => {
self.view_mode = match self.view_mode {
ViewMode::Overview => ViewMode::WorkerDetail,
ViewMode::ActorDetail => ViewMode::Overview,
#[cfg(feature = "distribution")]
ViewMode::WorkerDetail => ViewMode::Distribution,
#[cfg(not(feature = "distribution"))]
@ -280,13 +463,15 @@ impl App {
match self.view_mode {
ViewMode::Overview => self.handle_key_overview(key),
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
ViewMode::ActorDetail => self.handle_key_actor_detail(key),
#[cfg(feature = "distribution")]
ViewMode::Distribution => self.handle_key_distribution(key),
}
}
fn handle_key_overview(&mut self, key: KeyEvent) {
let max = if self.actor_rows.is_empty() { 0 } else { self.actor_rows.len() - 1 };
let visible = self.visible_actor_rows();
let max = if visible.is_empty() { 0 } else { visible.len() - 1 };
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
@ -303,11 +488,14 @@ impl App {
KeyCode::Home => { self.selected = 0; }
KeyCode::End => { self.selected = max; }
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
// Enter worker detail for the selected actor's worker
if let Some(row) = self.actor_rows.get(self.selected) {
self.focused_worker = row.worker_id;
// Enter actor detail for the selected visible actor
if let Some(&row) = visible.get(self.selected) {
self.focused_actor = Some(row.address);
self.prev_view_mode = ViewMode::Overview;
self.view_mode = ViewMode::ActorDetail;
self.log_scroll = 0;
self.refresh_actor_logs();
}
self.view_mode = ViewMode::WorkerDetail;
}
_ => {}
}
@ -337,6 +525,38 @@ impl App {
}
}
fn handle_key_actor_detail(&mut self, key: KeyEvent) {
let max_scroll = self.visible_logs().len().saturating_sub(1);
match key.code {
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
self.view_mode = self.prev_view_mode;
self.focused_actor = None;
self.actor_logs.clear();
self.log_scroll = 0;
}
KeyCode::Down | KeyCode::Char('j') => {
self.log_scroll = (self.log_scroll + 1).min(max_scroll);
}
KeyCode::Up | KeyCode::Char('k') => {
self.log_scroll = self.log_scroll.saturating_sub(1);
}
KeyCode::PageDown => {
self.log_scroll = (self.log_scroll + 20).min(max_scroll);
}
KeyCode::PageUp => {
self.log_scroll = self.log_scroll.saturating_sub(20);
}
KeyCode::Home => { self.log_scroll = 0; }
KeyCode::End => { self.log_scroll = max_scroll; }
KeyCode::Char('1') => self.toggle_log_level(0),
KeyCode::Char('2') => self.toggle_log_level(1),
KeyCode::Char('3') => self.toggle_log_level(2),
KeyCode::Char('4') => self.toggle_log_level(3),
KeyCode::Char('5') => self.toggle_log_level(4),
_ => {}
}
}
#[cfg(feature = "distribution")]
fn handle_key_distribution(&mut self, key: KeyEvent) {
let max = self

View file

@ -18,6 +18,7 @@ use swactor::runtime::Runtime;
use crate::collector::StatsCollector;
#[cfg(feature = "distribution")]
use crate::distribution_collector::DistributionStatsProvider;
use crate::layer::EventStore;
use self::app::App;
use self::event::{AppEvent, EventLoop};
use self::types::RuntimeEndpoint;
@ -47,6 +48,7 @@ pub fn start_tui(
#[cfg(feature = "distribution")]
distribution: Option<Arc<dyn DistributionStatsProvider>>,
config: TuiConfig,
event_store: Option<Arc<EventStore>>,
) -> io::Result<()> {
// Set up terminal
crossterm::terminal::enable_raw_mode()?;
@ -72,6 +74,7 @@ pub fn start_tui(
#[cfg(feature = "distribution")]
distribution,
config,
event_store,
);
// Restore terminal
@ -92,8 +95,12 @@ fn run_loop(
#[cfg(feature = "distribution")]
distribution: Option<Arc<dyn DistributionStatsProvider>>,
config: TuiConfig,
event_store: Option<Arc<EventStore>>,
) -> io::Result<()> {
let mut app = App::new();
if let Some(store) = event_store {
app.set_event_store(store);
}
let mut table_state = TableState::default();
let events = EventLoop::new(config.poll_interval_ms);

View file

@ -2,7 +2,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Sparkline, Table, TableState};
use super::app::{App, SortColumn, ViewMode};
@ -11,6 +11,7 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
match app.view_mode {
ViewMode::Overview => draw_overview(f, app, table_state),
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
ViewMode::ActorDetail => draw_actor_detail(f, app),
#[cfg(feature = "distribution")]
ViewMode::Distribution => draw_distribution(f, app, table_state),
}
@ -20,17 +21,23 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) {
let num_workers = app.workers.len().max(1);
let has_sparkline_data = app.workers.iter().any(|w| w.sparkline_rates.len() > 1);
let sparkline_height = if has_sparkline_data { 4u16 } else { 0u16 };
let chunks = Layout::vertical([
Constraint::Length(num_workers as u16),
Constraint::Length(sparkline_height),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(f.area());
draw_worker_bars(f, app, chunks[0]);
draw_summary(f, app, chunks[1]);
draw_actor_table(f, app, table_state, chunks[2]);
if has_sparkline_data {
draw_worker_sparklines(f, app, chunks[1]);
}
draw_summary(f, app, chunks[2]);
draw_actor_table(f, app, table_state, chunks[3]);
}
/// Render htop-style worker bars.
@ -45,6 +52,36 @@ fn draw_worker_bars(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(paragraph, area);
}
/// Render per-worker sparklines showing message rate trends.
fn draw_worker_sparklines(f: &mut Frame, app: &App, area: Rect) {
if app.workers.is_empty() {
return;
}
// Split area horizontally: one sparkline per worker
let constraints: Vec<Constraint> = app
.workers
.iter()
.map(|_| Constraint::Ratio(1, app.workers.len() as u32))
.collect();
let cols = Layout::horizontal(constraints).split(area);
for (i, w) in app.workers.iter().enumerate() {
if let Some(&col_area) = cols.get(i) {
let block = Block::default()
.borders(Borders::NONE)
.title(Span::styled(
format!(" W{} ", w.id),
Style::default().fg(Color::DarkGray),
));
let sparkline = Sparkline::default()
.block(block)
.data(&w.sparkline_rates)
.style(Style::default().fg(Color::Green));
f.render_widget(sparkline, col_area);
}
}
}
fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> {
let id_str = format!("{:>3}", w.id);
let suffix = format!(
@ -129,7 +166,7 @@ fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
Style::default().fg(Color::DarkGray)
};
let line = Line::from(vec![
let mut spans = vec![
Span::styled(" Workers: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.num_workers),
@ -152,13 +189,47 @@ fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
),
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", app.total_panics), panics_style),
]);
];
if !app.warnings.is_empty() {
spans.push(Span::styled(" Warnings: ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("{}", app.warnings.len()),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
));
}
let line = Line::from(spans);
f.render_widget(Paragraph::new(line), area);
}
/// Render the actor table with scrolling and selection.
/// Render the actor table with scrolling, selection, and search.
fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
// Split area: optional search bar + table
let has_search = app.search_active || !app.search_query.is_empty();
let search_height = if has_search { 1u16 } else { 0 };
let chunks = Layout::vertical([
Constraint::Length(search_height),
Constraint::Fill(1),
])
.split(area);
// Draw search bar
if has_search {
let search_style = if app.search_active {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let cursor = if app.search_active { "\u{2588}" } else { "" };
let prefix = if app.search_locked { " [locked] /" } else { " /" };
let line = Line::from(vec![
Span::styled(prefix, Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}{}", app.search_query, cursor), search_style),
]);
f.render_widget(Paragraph::new(line), chunks[0]);
}
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
let columns = [
@ -181,8 +252,8 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
});
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = app
.actor_rows
let visible = app.visible_actor_rows();
let rows: Vec<Row> = visible
.iter()
.map(|a| actor_row_cells(a))
.collect();
@ -190,7 +261,22 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
table_state.select(Some(app.selected));
let help_text =
" q: quit \u{2191}\u{2193}: scroll s: sort column r: reverse Tab: worker view Enter: drill in";
" q: quit /: search \u{2191}\u{2193}: scroll s: sort r: reverse Tab: worker view Enter: detail";
let title = if !app.search_query.is_empty() {
format!(
" Actors ({} of {} matching \"{}\") ",
visible.len(),
app.actor_rows.len(),
app.search_query,
)
} else {
format!(
" Actors (sorted by {}{}) ",
app.sort_column.label(),
sort_arrow
)
};
let table = Table::new(
rows,
@ -206,11 +292,7 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(
" Actors (sorted by {}{}) ",
app.sort_column.label(),
sort_arrow
))
.title(title)
.title_bottom(Line::from(help_text).centered()),
)
.row_highlight_style(
@ -443,6 +525,231 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta
f.render_stateful_widget(table, area, table_state);
}
// ─── Actor Detail View ──────────────────────────────────────────────────────
fn draw_actor_detail(f: &mut Frame, app: &App) {
let actor = match app.focused_actor_row() {
Some(a) => a,
None => {
let msg = Paragraph::new(" No actor selected. Press Esc to go back.")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(msg, f.area());
return;
}
};
let has_types = !actor.message_type_counts.is_empty();
let type_height = if has_types {
(actor.message_type_counts.len() as u16 + 2).min(10)
} else {
0
};
let chunks = Layout::vertical([
Constraint::Length(5), // Info card
Constraint::Length(5), // Rate sparkline
Constraint::Length(5), // Mailbox sparkline
Constraint::Length(type_height), // Type breakdown
Constraint::Length(1), // Help bar
Constraint::Fill(1), // Logs panel
])
.split(f.area());
// Info card
let addr_str = format!("{}", actor.address);
let status = if actor.poisoned { "POISONED" } else { "Healthy" };
let status_color = if actor.poisoned { Color::Red } else { Color::Green };
let msg_type = actor.last_msg_type.as_deref()
.map(|s| short_type_name(Some(s)))
.unwrap_or_else(|| "\u{2014}".to_string());
let info_lines = vec![
Line::from(vec![
Span::styled(" Address: ", Style::default().fg(Color::DarkGray)),
Span::styled(addr_str, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)),
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(actor.messages_processed),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor.mailbox_depth),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)),
Span::styled(msg_type, Style::default().fg(Color::Green)),
]),
];
let info_block = Block::default()
.borders(Borders::ALL)
.title(" Actor Detail ");
let info = Paragraph::new(info_lines).block(info_block);
f.render_widget(info, chunks[0]);
// Rate sparkline
let rate_block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(" Msg Rate ", Style::default().fg(Color::Green)));
let rate_sparkline = Sparkline::default()
.block(rate_block)
.data(&actor.sparkline_rates)
.style(Style::default().fg(Color::Green));
f.render_widget(rate_sparkline, chunks[1]);
// Mailbox sparkline
let mbox_block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(" Mailbox Depth ", Style::default().fg(Color::Blue)));
let mbox_sparkline = Sparkline::default()
.block(mbox_block)
.data(&actor.sparkline_mailbox)
.style(Style::default().fg(Color::Blue));
f.render_widget(mbox_sparkline, chunks[2]);
// Message type breakdown
if has_types {
draw_type_breakdown(f, &actor.message_type_counts, chunks[3]);
}
// Help bar
let level_names = ["ERR", "WARN", "INFO", "DBG", "TRC"];
let level_colors = [Color::Red, Color::Yellow, Color::Blue, Color::DarkGray, Color::DarkGray];
let mut help_spans: Vec<Span> = vec![
Span::styled(
" Esc: back \u{2191}\u{2193}: scroll logs ",
Style::default().fg(Color::DarkGray),
),
];
for (i, &name) in level_names.iter().enumerate() {
let active = app.log_levels[i];
let color = if active { level_colors[i] } else { Color::DarkGray };
let style = if active {
Style::default().fg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color)
};
help_spans.push(Span::styled(format!("{}:{} ", i + 1, name), style));
}
f.render_widget(Paragraph::new(Line::from(help_spans)), chunks[4]);
// Logs panel
draw_actor_logs(f, app, chunks[5]);
}
fn draw_type_breakdown(f: &mut Frame, types: &[(String, u64)], area: Rect) {
let total: u64 = types.iter().map(|(_, c)| *c).sum();
let max_count = types.first().map(|(_, c)| *c).unwrap_or(1).max(1);
let inner_height = area.height.saturating_sub(2) as usize;
let bar_colors = [Color::Green, Color::Blue, Color::Yellow, Color::Magenta, Color::Cyan, Color::Red];
let lines: Vec<Line> = types
.iter()
.take(inner_height)
.enumerate()
.map(|(i, (name, count))| {
let short = name.rsplit("::").next().unwrap_or(name);
let pct = if total > 0 { *count as f64 / total as f64 * 100.0 } else { 0.0 };
let bar_width = 20usize;
let filled = ((*count as f64 / max_count as f64) * bar_width as f64).round() as usize;
let color = bar_colors[i % bar_colors.len()];
Line::from(vec![
Span::styled(
format!(" {:>16} ", short),
Style::default().fg(Color::White),
),
Span::styled(
"\u{2588}".repeat(filled),
Style::default().fg(color),
),
Span::styled(
" ".repeat(bar_width.saturating_sub(filled)),
Style::default(),
),
Span::styled(
format!(" {:>8} ", count),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:>5.1}%", pct),
Style::default().fg(Color::DarkGray),
),
])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Message Types ({}) ", types.len()));
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) {
let visible = app.visible_logs();
let log_count = visible.len();
let inner_height = area.height.saturating_sub(2) as usize; // borders
// Compute scroll window
let scroll = app.log_scroll.min(log_count.saturating_sub(inner_height));
let lines: Vec<Line> = visible
.iter()
.skip(scroll)
.take(inner_height)
.map(|e| {
let level_color = match e.level.as_str() {
"ERROR" => Color::Red,
"WARN" => Color::Yellow,
"INFO" => Color::Blue,
"DEBUG" => Color::DarkGray,
"TRACE" => Color::DarkGray,
_ => Color::White,
};
let ts = {
let secs = e.timestamp_ms / 1000;
let ms = e.timestamp_ms % 1000;
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, ms)
};
Line::from(vec![
Span::styled(
format!(" {} ", ts),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{:<5} ", e.level),
Style::default().fg(level_color).add_modifier(Modifier::BOLD),
),
Span::styled(
e.message.clone(),
Style::default().fg(Color::White),
),
])
})
.collect();
let title = format!(" Logs ({}) ", log_count);
let block = Block::default()
.borders(Borders::ALL)
.title(title);
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
// ─── Distribution View ──────────────────────────────────────────────────────
#[cfg(feature = "distribution")]

View file

@ -0,0 +1,392 @@
//! Automated anomaly detection for the runtime dashboard.
//!
//! Runs on each stats sample, comparing consecutive snapshots to detect
//! growing mailboxes, stalled actors, worker imbalance, and other conditions.
use std::collections::HashMap;
use swactor::actor::ActorAddress;
use swactor::stats::RuntimeStats;
/// Types of warnings the detector can produce.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WarningType {
GrowingMailbox,
StalledActor,
PoisonedActor,
WorkerImbalance,
EmptyWorker,
MailboxOverflow,
}
/// Severity levels for warnings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
/// An active warning.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Warning {
pub warning_type: WarningType,
pub severity: Severity,
pub entity: String,
pub description: String,
}
/// Configuration for warning thresholds.
#[derive(Debug, Clone)]
pub struct WarningConfig {
/// Consecutive samples with increasing mailbox depth before warning.
pub growing_mailbox_threshold: usize,
/// Consecutive ticks with no message processing while mailbox > 0.
pub stalled_actor_threshold: usize,
/// A worker is "imbalanced" if it has > this ratio times the average load.
pub worker_imbalance_ratio: f64,
}
impl Default for WarningConfig {
fn default() -> Self {
Self {
growing_mailbox_threshold: 5,
stalled_actor_threshold: 10,
worker_imbalance_ratio: 2.0,
}
}
}
/// Per-actor tracking state.
struct ActorState {
prev_mailbox: usize,
prev_messages: u64,
growing_streak: usize,
stalled_streak: usize,
}
/// Warning detection engine. Call `check()` on each stats sample.
pub struct WarningDetector {
config: WarningConfig,
actors: HashMap<ActorAddress, ActorState>,
}
impl WarningDetector {
pub fn new(config: WarningConfig) -> Self {
Self {
config,
actors: HashMap::new(),
}
}
/// Analyze a stats snapshot and return all active warnings.
pub fn check(&mut self, stats: &RuntimeStats) -> Vec<Warning> {
let mut warnings = Vec::new();
// Track which actors are still alive
let mut live_addrs: std::collections::HashSet<ActorAddress> =
std::collections::HashSet::new();
for actor in &stats.actor_details {
live_addrs.insert(actor.address);
// Poisoned actor — immediate critical warning
if actor.poisoned {
warnings.push(Warning {
warning_type: WarningType::PoisonedActor,
severity: Severity::Critical,
entity: format!("{}", actor.address),
description: "Actor is poisoned (panicked)".to_string(),
});
}
let state = self.actors.entry(actor.address).or_insert(ActorState {
prev_mailbox: actor.mailbox_depth,
prev_messages: actor.messages_processed,
growing_streak: 0,
stalled_streak: 0,
});
// Growing mailbox detection
if actor.mailbox_depth > state.prev_mailbox && actor.mailbox_depth > 0 {
state.growing_streak += 1;
} else {
state.growing_streak = 0;
}
if state.growing_streak >= self.config.growing_mailbox_threshold {
warnings.push(Warning {
warning_type: WarningType::GrowingMailbox,
severity: Severity::Medium,
entity: format!("{}", actor.address),
description: format!(
"Mailbox growing for {} consecutive samples (depth: {})",
state.growing_streak, actor.mailbox_depth,
),
});
}
// Stalled actor detection
if actor.messages_processed == state.prev_messages && actor.mailbox_depth > 0 {
state.stalled_streak += 1;
} else {
state.stalled_streak = 0;
}
if state.stalled_streak >= self.config.stalled_actor_threshold {
warnings.push(Warning {
warning_type: WarningType::StalledActor,
severity: Severity::High,
entity: format!("{}", actor.address),
description: format!(
"No messages processed for {} ticks with {} pending",
state.stalled_streak, actor.mailbox_depth,
),
});
}
state.prev_mailbox = actor.mailbox_depth;
state.prev_messages = actor.messages_processed;
}
// Clean up dead actors
self.actors.retain(|addr, _| live_addrs.contains(addr));
// Mailbox overflow detection
for w in &stats.workers {
if w.messages_dropped > 0 {
warnings.push(Warning {
warning_type: WarningType::MailboxOverflow,
severity: Severity::Medium,
entity: format!("Worker {}", w.id),
description: format!("{} messages dropped", w.messages_dropped),
});
}
}
// Worker imbalance and empty worker detection
if stats.workers.len() > 1 {
let total_actors: usize = stats.workers.iter().map(|w| w.num_actors).sum();
let avg = total_actors as f64 / stats.workers.len() as f64;
for w in &stats.workers {
if avg > 0.0 && w.num_actors as f64 > avg * self.config.worker_imbalance_ratio {
warnings.push(Warning {
warning_type: WarningType::WorkerImbalance,
severity: Severity::Low,
entity: format!("Worker {}", w.id),
description: format!(
"{} actors vs {:.0} average ({:.1}x)",
w.num_actors, avg, w.num_actors as f64 / avg,
),
});
}
if w.num_actors == 0 && total_actors > 0 {
warnings.push(Warning {
warning_type: WarningType::EmptyWorker,
severity: Severity::Low,
entity: format!("Worker {}", w.id),
description: "Worker has no actors while others do".to_string(),
});
}
}
}
// Sort by severity (critical first)
warnings.sort_by(|a, b| b.severity.cmp(&a.severity));
warnings
}
}
#[cfg(test)]
mod tests {
use super::*;
use swactor::stats::{ActorInfo, WorkerInfo};
fn make_worker(id: usize, actors: usize, dropped: u64) -> WorkerInfo {
WorkerInfo {
id,
num_actors: actors,
mailbox_depth: 0,
messages_processed: 0,
local_sends: 0,
cross_sends: 0,
inbox_sends: 0,
type_mismatches: 0,
panics: 0,
messages_dropped: dropped,
restarts: 0,
stops: 0,
}
}
fn make_actor(id: u8, depth: usize, msgs: u64, poisoned: bool) -> ActorInfo {
ActorInfo {
address: ActorAddress([id; 32]),
worker_id: 0,
mailbox_depth: depth,
last_msg_type: None,
messages_processed: msgs,
poisoned,
message_type_counts: Vec::new(),
}
}
fn make_stats(workers: Vec<WorkerInfo>, actors: Vec<ActorInfo>) -> RuntimeStats {
RuntimeStats {
num_workers: workers.len(),
uptime_ms: 0,
actors: actors.iter().map(|a| (a.address, a.worker_id)).collect(),
workers,
actor_details: actors,
tick_timings: Vec::new(),
}
}
#[test]
fn poisoned_actor_triggers_critical_warning() {
let mut detector = WarningDetector::new(WarningConfig::default());
let stats = make_stats(
vec![make_worker(0, 1, 0)],
vec![make_actor(1, 0, 10, true)],
);
let warnings = detector.check(&stats);
assert!(warnings.iter().any(|w| w.warning_type == WarningType::PoisonedActor));
assert!(warnings.iter().any(|w| w.severity == Severity::Critical));
}
#[test]
fn growing_mailbox_triggers_after_threshold() {
let config = WarningConfig {
growing_mailbox_threshold: 3,
..Default::default()
};
let mut detector = WarningDetector::new(config);
// 4 samples with increasing mailbox: should trigger at sample 4
for depth in 1..=4 {
let stats = make_stats(
vec![make_worker(0, 1, 0)],
vec![make_actor(1, depth, 0, false)],
);
let warnings = detector.check(&stats);
if depth < 4 {
assert!(!warnings.iter().any(|w| w.warning_type == WarningType::GrowingMailbox),
"should not trigger at depth {}", depth);
} else {
assert!(warnings.iter().any(|w| w.warning_type == WarningType::GrowingMailbox),
"should trigger at depth {}", depth);
}
}
}
#[test]
fn growing_mailbox_resets_on_decrease() {
let config = WarningConfig {
growing_mailbox_threshold: 3,
..Default::default()
};
let mut detector = WarningDetector::new(config);
// Grow for 2 samples, then decrease, then grow again
for depth in [1, 2, 1, 2, 3, 4] {
let stats = make_stats(
vec![make_worker(0, 1, 0)],
vec![make_actor(1, depth, 0, false)],
);
detector.check(&stats);
}
// After 1,2 → streak=2; then 1 → streak=0; then 2,3,4 → streak=3 → triggers
let stats = make_stats(
vec![make_worker(0, 1, 0)],
vec![make_actor(1, 5, 0, false)],
);
let warnings = detector.check(&stats);
assert!(warnings.iter().any(|w| w.warning_type == WarningType::GrowingMailbox));
}
#[test]
fn stalled_actor_triggers_when_not_processing() {
let config = WarningConfig {
stalled_actor_threshold: 3,
..Default::default()
};
let mut detector = WarningDetector::new(config);
// Same messages_processed, nonzero mailbox for 4 ticks
for _ in 0..4 {
let stats = make_stats(
vec![make_worker(0, 1, 0)],
vec![make_actor(1, 5, 100, false)],
);
let warnings = detector.check(&stats);
// Last one should trigger
if warnings.iter().any(|w| w.warning_type == WarningType::StalledActor) {
return; // test passed
}
}
panic!("expected StalledActor warning");
}
#[test]
fn worker_imbalance_detected() {
let mut detector = WarningDetector::new(WarningConfig::default());
// Worker 0: 10 actors, Worker 1: 1 actor. Avg=5.5, ratio=10/5.5=1.8
// With ratio threshold 2.0, this should NOT trigger
let stats = make_stats(
vec![make_worker(0, 10, 0), make_worker(1, 1, 0)],
vec![],
);
let warnings = detector.check(&stats);
assert!(!warnings.iter().any(|w| w.warning_type == WarningType::WorkerImbalance));
// Worker 0: 20 actors, Worker 1: 1 actor. Avg=10.5, ratio=20/10.5=1.9 — still no
// Worker 0: 30 actors, Worker 1: 1 actor. Avg=15.5, ratio=30/15.5=1.9 — still no
// Worker 0: 100 actors, Worker 1: 1 actor. Avg=50.5, ratio=100/50.5=1.98 — almost
// Worker 0: 100 actors, Worker 1: 0 actor. Avg=50, ratio=100/50=2.0 — at threshold
let stats2 = make_stats(
vec![make_worker(0, 100, 0), make_worker(1, 1, 0)],
vec![],
);
let warnings2 = detector.check(&stats2);
// 100 / 50.5 = 1.98 — not > 2.0
assert!(!warnings2.iter().any(|w| w.warning_type == WarningType::WorkerImbalance));
// Now 200 vs 1: 200/100.5 = ~1.99 — still not. Let's do 300 vs 1: 300/150.5 = ~2.0
// Actually need > 2x. Let's do 50 vs 1: avg=25.5, ratio=50/25.5=1.96. Nope.
// 10 vs 1 vs 1: avg=4, ratio=10/4=2.5 — triggers!
let stats3 = make_stats(
vec![make_worker(0, 10, 0), make_worker(1, 1, 0), make_worker(2, 1, 0)],
vec![],
);
let warnings3 = detector.check(&stats3);
assert!(warnings3.iter().any(|w| w.warning_type == WarningType::WorkerImbalance));
}
#[test]
fn empty_worker_detected() {
let mut detector = WarningDetector::new(WarningConfig::default());
let stats = make_stats(
vec![make_worker(0, 5, 0), make_worker(1, 0, 0)],
vec![],
);
let warnings = detector.check(&stats);
assert!(warnings.iter().any(|w| w.warning_type == WarningType::EmptyWorker));
}
#[test]
fn mailbox_overflow_detected() {
let mut detector = WarningDetector::new(WarningConfig::default());
let stats = make_stats(
vec![make_worker(0, 1, 42)],
vec![],
);
let warnings = detector.check(&stats);
assert!(warnings.iter().any(|w| w.warning_type == WarningType::MailboxOverflow));
}
}

View file

@ -0,0 +1,28 @@
# Dashboard Improvements — Research Phase
## Summary
Researched 10 comparable monitoring/dashboard systems to inform swactor's dashboard improvement plan.
## Systems Analyzed
- **Actor runtimes**: Erlang Observer (GUI/CLI/Web), Akka Insights, Ray Dashboard, Orleans Dashboard
- **Async/runtime tools**: tokio-console, Lunatic
- **Message/infrastructure**: RabbitMQ Management, Consul UI, Nomad UI
- **Web frameworks**: Phoenix LiveDashboard
## Key Findings
1. **Time-series history** is table-stakes — every system provides it
2. **Actor detail drill-down** is universal (Observer has 6-tab process info, Orleans has grain state inspection)
3. **Search/filter** exists in every system
4. **Warning/anomaly detection** (tokio-console's lint system) is a high-value differentiator
5. **Topology visualization** (Consul golden metrics, Observer supervision tree) is rare but powerful
## Implementation Plan
8 feature stages defined (see `CLAUDE/notes/feature-stages/`):
1. Time-Series History Infrastructure
2. Actor Detail Drill-Down
3. Search and Filter
4. Per-Worker Utilization Visualization
5. Warning/Anomaly Detection
6. Actor-to-Actor Message Flow Topology
7. Per-Actor Logging
8. Per-Message-Type Breakdown

View file

@ -120,6 +120,8 @@ pub struct ActorSnapshot {
pub last_msg_type: Option<&'static str>,
pub messages_processed: u64,
pub poisoned: bool,
/// Per-message-type counts, sorted descending by count.
pub message_type_counts: Vec<(&'static str, u64)>,
}
/// Observer hook called by workers after productive ticks.
@ -150,6 +152,9 @@ pub struct ActorInfo {
/// Whether the actor has panicked and is no longer processing messages.
#[cfg_attr(feature = "serde", serde(default))]
pub poisoned: bool,
/// Per-message-type counts, sorted descending by count. Top 32 types.
#[cfg_attr(feature = "serde", serde(default))]
pub message_type_counts: Vec<(String, u64)>,
}
/// Snapshot of overall runtime state.

View file

@ -610,6 +610,8 @@ struct ActorSlot {
started: bool,
last_msg_type: Option<&'static str>,
messages_processed: u64,
/// Per-message-type counters (bounded to 32 entries).
msg_type_counts: HashMap<&'static str, u64>,
/// Per-actor mailbox capacity. 0 = unbounded.
mailbox_capacity: usize,
overflow_policy: MailboxOverflow,
@ -645,6 +647,7 @@ impl ActorPool {
started: false,
last_msg_type: None,
messages_processed: 0,
msg_type_counts: HashMap::new(),
mailbox_capacity: self.default_mailbox_capacity,
overflow_policy: self.default_overflow_policy,
});
@ -698,6 +701,9 @@ impl ActorPool {
continue;
}
#[cfg(feature = "tracing")]
let _actor_span = tracing::trace_span!("actor.tick", actor_addr = %addr).entered();
let ctx = Ctx::new(inner, addr);
// Call on_start once, before first message
@ -762,6 +768,10 @@ impl ActorPool {
Ok(Some(type_name)) => {
slot.last_msg_type = Some(type_name);
slot.messages_processed += 1;
// Track per-type counts (bounded to 32 distinct types)
if slot.msg_type_counts.len() < 32 || slot.msg_type_counts.contains_key(type_name) {
*slot.msg_type_counts.entry(type_name).or_insert(0) += 1;
}
}
}
count += 1;
@ -834,12 +844,16 @@ impl ActorPool {
pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) {
out.clear();
out.extend(self.actors.iter().map(|(&addr, slot)| {
let mut type_counts: Vec<(&'static str, u64)> =
slot.msg_type_counts.iter().map(|(&k, &v)| (k, v)).collect();
type_counts.sort_by(|a, b| b.1.cmp(&a.1));
ActorSnapshot {
address: addr,
mailbox_depth: slot.mailbox.len(),
last_msg_type: slot.last_msg_type,
messages_processed: slot.messages_processed,
poisoned: slot.poisoned,
message_type_counts: type_counts,
}
}));
}