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:
Claude 2026-02-13 08:53:00 +00:00
parent 144b4ca5ef
commit 5e3a779cba
7 changed files with 396 additions and 9 deletions

View 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>
"##;

View file

@ -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();

View file

@ -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>';
}); });

View file

@ -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;

View file

@ -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>,

View file

@ -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

View file

@ -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")]