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
This commit is contained in:
parent
144b4ca5ef
commit
5e3a779cba
7 changed files with 396 additions and 9 deletions
230
crates/runtime-dashboard/src/actor_detail_html.rs
Normal file
230
crates/runtime-dashboard/src/actor_detail_html.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Actor Detail — Swactor Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||||
|
.status-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||||
|
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-dot.disconnected { background: #f44336; }
|
||||||
|
|
||||||
|
.header-left { display: flex; align-items: center; }
|
||||||
|
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||||
|
.nav-link {
|
||||||
|
color: #888; text-decoration: none; font-size: 12px;
|
||||||
|
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-link:hover { color: #e0e0e0; }
|
||||||
|
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||||
|
|
||||||
|
.content { padding: 16px 20px; max-width: 900px; }
|
||||||
|
|
||||||
|
.breadcrumb { color: #555; font-size: 12px; margin-bottom: 12px; }
|
||||||
|
.breadcrumb a { color: #888; text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { color: #e0e0e0; }
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 16px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.info-row { display: flex; gap: 24px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.info-label { color: #888; font-size: 11px; text-transform: uppercase; }
|
||||||
|
.info-value { color: #fff; font-weight: 700; font-size: 15px; }
|
||||||
|
.info-value.healthy { color: #4caf50; }
|
||||||
|
.info-value.poisoned { color: #f44336; }
|
||||||
|
|
||||||
|
.stats-cards {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 4px;
|
||||||
|
padding: 12px; text-align: center;
|
||||||
|
}
|
||||||
|
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||||
|
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||||
|
|
||||||
|
.sparkline-panel {
|
||||||
|
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||||
|
padding: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.sparkline-panel h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||||
|
.sparkline-panel svg { width: 100%; height: 50px; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: #0f1117; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>
|
||||||
|
Swactor Runtime Dashboard
|
||||||
|
<span id="statusDot" class="status-dot"></span>
|
||||||
|
</h1>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="/" class="nav-link">Overview</a>
|
||||||
|
<a href="/actors" class="nav-link">Actors</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/">Overview</a> / <a href="/actors">Actors</a> / <span id="addrBreadcrumb">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-row">
|
||||||
|
<div><div class="info-label">Address</div><div class="info-value" id="addrFull">—</div></div>
|
||||||
|
<div><div class="info-label">Worker</div><div class="info-value" id="addrWorker">—</div></div>
|
||||||
|
<div><div class="info-label">Status</div><div class="info-value" id="addrStatus">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<div><div class="info-label">Last Message Type</div><div class="info-value" id="addrLastMsg" style="color:#4caf50;font-size:13px;">—</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-cards">
|
||||||
|
<div class="stat-card"><div class="value" id="addrMsgs">0</div><div class="label">Messages</div></div>
|
||||||
|
<div class="stat-card"><div class="value" id="addrMailbox">0</div><div class="label">Mailbox</div></div>
|
||||||
|
<div class="stat-card"><div class="value" id="addrRate">0</div><div class="label">Msg/s</div></div>
|
||||||
|
<div class="stat-card"><div class="value" id="addrWorkerLoad">—</div><div class="label">Worker Load</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sparkline-panel">
|
||||||
|
<h3>Message Rate</h3>
|
||||||
|
<svg id="rateSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sparkline-panel">
|
||||||
|
<h3>Mailbox Depth</h3>
|
||||||
|
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var targetAddr = '__ACTOR_ADDR__';
|
||||||
|
var dot = document.getElementById('statusDot');
|
||||||
|
|
||||||
|
var history = { rates: [], mailbox: [], prev_msgs: 0 };
|
||||||
|
|
||||||
|
function formatAddr(addr) {
|
||||||
|
if (!addr) return '';
|
||||||
|
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
|
||||||
|
var hex = '';
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
hex += ('0' + bytes[i].toString(16)).slice(-2);
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddr(hex) {
|
||||||
|
return hex.length > 16 ? hex.substring(0, 16) + '\u2026' : hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTypeName(full) {
|
||||||
|
if (!full) return '\u2014';
|
||||||
|
var parts = full.split('::');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSparklineSvg(svgEl, data, color) {
|
||||||
|
if (!data || data.length < 2) { svgEl.innerHTML = ''; return; }
|
||||||
|
var max = Math.max.apply(null, data);
|
||||||
|
if (max === 0) max = 1;
|
||||||
|
var w = 400, h = 50;
|
||||||
|
var step = w / (data.length - 1);
|
||||||
|
var points = data.map(function(v, i) {
|
||||||
|
return (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 4) - 2).toFixed(1);
|
||||||
|
}).join(' ');
|
||||||
|
svgEl.innerHTML = '<polyline fill="none" stroke="' + color + '" stroke-width="2" points="' + points + '"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActor(stats) {
|
||||||
|
if (!stats.actor_details) return null;
|
||||||
|
for (var i = 0; i < stats.actor_details.length; i++) {
|
||||||
|
var a = stats.actor_details[i];
|
||||||
|
var hex = formatAddr(a.address);
|
||||||
|
if (hex === targetAddr || hex.indexOf(targetAddr) === 0) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetail(stats) {
|
||||||
|
var actor = findActor(stats);
|
||||||
|
if (!actor) return;
|
||||||
|
|
||||||
|
var hex = formatAddr(actor.address);
|
||||||
|
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
|
||||||
|
document.getElementById('addrFull').textContent = hex;
|
||||||
|
document.getElementById('addrWorker').textContent = 'W' + actor.worker_id;
|
||||||
|
|
||||||
|
var statusEl = document.getElementById('addrStatus');
|
||||||
|
if (actor.poisoned) {
|
||||||
|
statusEl.textContent = 'POISONED';
|
||||||
|
statusEl.className = 'info-value poisoned';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Healthy';
|
||||||
|
statusEl.className = 'info-value healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addrLastMsg').textContent = shortTypeName(actor.last_msg_type);
|
||||||
|
document.getElementById('addrMsgs').textContent = actor.messages_processed.toLocaleString();
|
||||||
|
document.getElementById('addrMailbox').textContent = actor.mailbox_depth;
|
||||||
|
|
||||||
|
// Compute rate
|
||||||
|
var rate = actor.messages_processed - history.prev_msgs;
|
||||||
|
if (rate < 0) rate = 0;
|
||||||
|
history.prev_msgs = actor.messages_processed;
|
||||||
|
history.rates.push(rate);
|
||||||
|
history.mailbox.push(actor.mailbox_depth);
|
||||||
|
if (history.rates.length > 300) { history.rates.shift(); history.mailbox.shift(); }
|
||||||
|
|
||||||
|
// Rate per second (SSE interval is ~200ms, so multiply by 5)
|
||||||
|
document.getElementById('addrRate').textContent = (rate * 5).toLocaleString();
|
||||||
|
|
||||||
|
// Worker load
|
||||||
|
if (stats.workers) {
|
||||||
|
var w = stats.workers.find(function(w) { return w.id === actor.worker_id; });
|
||||||
|
if (w) {
|
||||||
|
document.getElementById('addrWorkerLoad').textContent =
|
||||||
|
w.num_actors + ' actors, mbox ' + w.mailbox_depth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSparklineSvg(document.getElementById('rateSpark'), history.rates, '#4caf50');
|
||||||
|
updateSparklineSvg(document.getElementById('mboxSpark'), history.mailbox, '#2196f3');
|
||||||
|
}
|
||||||
|
|
||||||
|
var es = new EventSource('/events');
|
||||||
|
|
||||||
|
es.addEventListener('stats', function(e) {
|
||||||
|
try { updateDetail(JSON.parse(e.data)); } catch(err) { console.error(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', function() {
|
||||||
|
dot.className = 'status-dot disconnected';
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = function() { dot.className = 'status-dot disconnected'; };
|
||||||
|
es.onopen = function() { dot.className = 'status-dot'; };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"##;
|
||||||
|
|
@ -558,8 +558,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('detailAddr').textContent = addrToHex(actor.address);
|
document.getElementById('detailAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
|
||||||
document.getElementById('detailFullAddr').textContent = focusedAddrHex;
|
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('detailWorker').textContent = 'W' + actor.worker_id;
|
||||||
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
|
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
|
||||||
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
|
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||||
hex += '\u2026';
|
hex += '\u2026';
|
||||||
}
|
}
|
||||||
var tr = document.createElement('tr');
|
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);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
if (data.actors.length > 200) {
|
if (data.actors.length > 200) {
|
||||||
|
|
@ -440,7 +440,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||||
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
|
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
|
||||||
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
|
||||||
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
|
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>' + a.mailbox_depth + '</td>' +
|
||||||
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
|
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ pub mod history;
|
||||||
pub mod investigate;
|
pub mod investigate;
|
||||||
pub mod layer;
|
pub mod layer;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
|
mod actor_detail_html;
|
||||||
mod actors_html;
|
mod actors_html;
|
||||||
mod dashboard_html;
|
mod dashboard_html;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use std::collections::HashMap;
|
||||||
|
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::actor_detail_html::ACTOR_DETAIL_HTML;
|
||||||
use crate::actors_html::ACTORS_HTML;
|
use crate::actors_html::ACTORS_HTML;
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
use crate::dashboard_html::DASHBOARD_HTML;
|
use crate::dashboard_html::DASHBOARD_HTML;
|
||||||
|
|
@ -192,6 +193,10 @@ pub(crate) fn spawn_http_server(
|
||||||
Arc::clone(&distribution),
|
Arc::clone(&distribution),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_ if path.starts_with("/actor/") => {
|
||||||
|
let hex = &path[7..]; // strip "/actor/"
|
||||||
|
respond_actor_detail(request, hex);
|
||||||
|
}
|
||||||
_ => respond_404(request),
|
_ => respond_404(request),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +204,18 @@ 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(
|
fn handle_live_sse(
|
||||||
request: tiny_http::Request,
|
request: tiny_http::Request,
|
||||||
store: Arc<EventStore>,
|
store: Arc<EventStore>,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::VecDeque;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
@ -30,6 +30,10 @@ pub struct ActorRow {
|
||||||
pub last_msg_type: Option<String>,
|
pub last_msg_type: Option<String>,
|
||||||
pub messages_processed: u64,
|
pub messages_processed: u64,
|
||||||
pub poisoned: bool,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -67,6 +71,7 @@ impl SortColumn {
|
||||||
pub enum ViewMode {
|
pub enum ViewMode {
|
||||||
Overview,
|
Overview,
|
||||||
WorkerDetail,
|
WorkerDetail,
|
||||||
|
ActorDetail,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
Distribution,
|
Distribution,
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +89,9 @@ pub struct App {
|
||||||
pub total_panics: u64,
|
pub total_panics: u64,
|
||||||
pub num_workers: usize,
|
pub num_workers: usize,
|
||||||
pub view_mode: ViewMode,
|
pub view_mode: ViewMode,
|
||||||
|
pub prev_view_mode: ViewMode,
|
||||||
pub focused_worker: usize,
|
pub focused_worker: usize,
|
||||||
|
pub focused_actor: Option<ActorAddress>,
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
||||||
|
|
@ -99,6 +106,8 @@ pub struct App {
|
||||||
sparkline_rates: Vec<VecDeque<u64>>,
|
sparkline_rates: Vec<VecDeque<u64>>,
|
||||||
/// Per-worker sparkline history (mailbox depths).
|
/// Per-worker sparkline history (mailbox depths).
|
||||||
sparkline_mailbox: Vec<VecDeque<u64>>,
|
sparkline_mailbox: Vec<VecDeque<u64>>,
|
||||||
|
/// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths).
|
||||||
|
actor_sparklines: HashMap<ActorAddress, (u64, VecDeque<u64>, VecDeque<u64>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -116,7 +125,9 @@ impl App {
|
||||||
total_panics: 0,
|
total_panics: 0,
|
||||||
num_workers: 0,
|
num_workers: 0,
|
||||||
view_mode: ViewMode::Overview,
|
view_mode: ViewMode::Overview,
|
||||||
|
prev_view_mode: ViewMode::Overview,
|
||||||
focused_worker: 0,
|
focused_worker: 0,
|
||||||
|
focused_actor: None,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: None,
|
distribution: None,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
|
|
@ -126,6 +137,7 @@ impl App {
|
||||||
msg_rates: Vec::new(),
|
msg_rates: Vec::new(),
|
||||||
sparkline_rates: Vec::new(),
|
sparkline_rates: Vec::new(),
|
||||||
sparkline_mailbox: Vec::new(),
|
sparkline_mailbox: Vec::new(),
|
||||||
|
actor_sparklines: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +148,13 @@ impl App {
|
||||||
self.distribution = Some(snapshot);
|
self.distribution = Some(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Actor rows filtered to the focused worker (for worker detail view).
|
/// Actor rows filtered to the focused worker (for worker detail view).
|
||||||
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
|
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
|
||||||
self.actor_rows
|
self.actor_rows
|
||||||
|
|
@ -213,9 +232,20 @@ impl App {
|
||||||
self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum();
|
self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum();
|
||||||
self.total_panics = stats.workers.iter().map(|w| w.panics).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();
|
self.actor_rows.clear();
|
||||||
for a in &stats.actor_details {
|
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 {
|
self.actor_rows.push(ActorRow {
|
||||||
address: a.address,
|
address: a.address,
|
||||||
worker_id: a.worker_id,
|
worker_id: a.worker_id,
|
||||||
|
|
@ -223,6 +253,8 @@ impl App {
|
||||||
last_msg_type: a.last_msg_type.clone(),
|
last_msg_type: a.last_msg_type.clone(),
|
||||||
messages_processed: a.messages_processed,
|
messages_processed: a.messages_processed,
|
||||||
poisoned: a.poisoned,
|
poisoned: a.poisoned,
|
||||||
|
sparkline_rates: rates_buf.iter().copied().collect(),
|
||||||
|
sparkline_mailbox: mbox_buf.iter().copied().collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.sort_actors();
|
self.sort_actors();
|
||||||
|
|
@ -291,6 +323,7 @@ impl App {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
self.view_mode = match self.view_mode {
|
self.view_mode = match self.view_mode {
|
||||||
ViewMode::Overview => ViewMode::WorkerDetail,
|
ViewMode::Overview => ViewMode::WorkerDetail,
|
||||||
|
ViewMode::ActorDetail => ViewMode::Overview,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
ViewMode::WorkerDetail => ViewMode::Distribution,
|
ViewMode::WorkerDetail => ViewMode::Distribution,
|
||||||
#[cfg(not(feature = "distribution"))]
|
#[cfg(not(feature = "distribution"))]
|
||||||
|
|
@ -306,6 +339,7 @@ impl App {
|
||||||
match self.view_mode {
|
match self.view_mode {
|
||||||
ViewMode::Overview => self.handle_key_overview(key),
|
ViewMode::Overview => self.handle_key_overview(key),
|
||||||
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
||||||
|
ViewMode::ActorDetail => self.handle_key_actor_detail(key),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
ViewMode::Distribution => self.handle_key_distribution(key),
|
ViewMode::Distribution => self.handle_key_distribution(key),
|
||||||
}
|
}
|
||||||
|
|
@ -329,11 +363,12 @@ impl App {
|
||||||
KeyCode::Home => { self.selected = 0; }
|
KeyCode::Home => { self.selected = 0; }
|
||||||
KeyCode::End => { self.selected = max; }
|
KeyCode::End => { self.selected = max; }
|
||||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
||||||
// Enter worker detail for the selected actor's worker
|
// Enter actor detail for the selected actor
|
||||||
if let Some(row) = self.actor_rows.get(self.selected) {
|
if let Some(row) = self.actor_rows.get(self.selected) {
|
||||||
self.focused_worker = row.worker_id;
|
self.focused_actor = Some(row.address);
|
||||||
|
self.prev_view_mode = ViewMode::Overview;
|
||||||
|
self.view_mode = ViewMode::ActorDetail;
|
||||||
}
|
}
|
||||||
self.view_mode = ViewMode::WorkerDetail;
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -363,6 +398,16 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_key_actor_detail(&mut self, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
self.view_mode = self.prev_view_mode;
|
||||||
|
self.focused_actor = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
fn handle_key_distribution(&mut self, key: KeyEvent) {
|
fn handle_key_distribution(&mut self, key: KeyEvent) {
|
||||||
let max = self
|
let max = self
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
||||||
match app.view_mode {
|
match app.view_mode {
|
||||||
ViewMode::Overview => draw_overview(f, app, table_state),
|
ViewMode::Overview => draw_overview(f, app, table_state),
|
||||||
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
||||||
|
ViewMode::ActorDetail => draw_actor_detail(f, app),
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
ViewMode::Distribution => draw_distribution(f, app, table_state),
|
ViewMode::Distribution => draw_distribution(f, app, table_state),
|
||||||
}
|
}
|
||||||
|
|
@ -479,6 +480,99 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta
|
||||||
f.render_stateful_widget(table, area, table_state);
|
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 chunks = Layout::vertical([
|
||||||
|
Constraint::Length(5), // Info card
|
||||||
|
Constraint::Length(5), // Rate sparkline
|
||||||
|
Constraint::Length(5), // Mailbox sparkline
|
||||||
|
Constraint::Length(1), // Help bar
|
||||||
|
Constraint::Fill(1), // Spacer
|
||||||
|
])
|
||||||
|
.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]);
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let help = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" Esc/\u{2190}: back Tab: overview q: quit",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(Paragraph::new(help), chunks[3]);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Distribution View ──────────────────────────────────────────────────────
|
// ─── Distribution View ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue