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
This commit is contained in:
Claude 2026-02-13 09:16:02 +00:00
parent c7a0bd2523
commit 982d82776d
8 changed files with 141 additions and 7 deletions

View file

@ -62,6 +62,18 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
.sparkline-panel h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; } .sparkline-panel h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.sparkline-panel svg { width: 100%; height: 50px; } .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 { .logs-panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px; background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; margin-bottom: 12px; padding: 14px; margin-bottom: 12px;
@ -148,6 +160,11 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg> <svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
</div> </div>
<div class="type-breakdown" id="typeBreakdown" style="display:none;">
<h3>Message Types</h3>
<div id="typeRows"></div>
</div>
<div class="logs-panel"> <div class="logs-panel">
<h3> <h3>
<span>Logs</span> <span>Logs</span>
@ -257,6 +274,38 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
updateSparklineSvg(document.getElementById('rateSpark'), history.rates, '#4caf50'); updateSparklineSvg(document.getElementById('rateSpark'), history.rates, '#4caf50');
updateSparklineSvg(document.getElementById('mboxSpark'), history.mailbox, '#2196f3'); 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 ───────────────────────────────────────────────── // ─── Logging ─────────────────────────────────────────────────

View file

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

View file

@ -258,6 +258,7 @@ mod tests {
last_msg_type: None, last_msg_type: None,
messages_processed: msgs, messages_processed: msgs,
poisoned: false, poisoned: false,
message_type_counts: Vec::new(),
} }
} }

View file

@ -38,6 +38,8 @@ pub struct ActorRow {
pub sparkline_mailbox: Vec<u64>, pub sparkline_mailbox: Vec<u64>,
/// Per-actor message rate sparkline. /// Per-actor message rate sparkline.
pub sparkline_rates: Vec<u64>, pub sparkline_rates: Vec<u64>,
/// Per-message-type counts, sorted descending.
pub message_type_counts: Vec<(String, u64)>,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@ -335,6 +337,7 @@ impl App {
poisoned: a.poisoned, poisoned: a.poisoned,
sparkline_rates: rates_buf.iter().copied().collect(), sparkline_rates: rates_buf.iter().copied().collect(),
sparkline_mailbox: mbox_buf.iter().copied().collect(), sparkline_mailbox: mbox_buf.iter().copied().collect(),
message_type_counts: a.message_type_counts.clone(),
}); });
} }
self.sort_actors(); self.sort_actors();

View file

@ -538,10 +538,18 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
} }
}; };
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([ let chunks = Layout::vertical([
Constraint::Length(5), // Info card Constraint::Length(5), // Info card
Constraint::Length(5), // Rate sparkline Constraint::Length(5), // Rate sparkline
Constraint::Length(5), // Mailbox sparkline Constraint::Length(5), // Mailbox sparkline
Constraint::Length(type_height), // Type breakdown
Constraint::Length(1), // Help bar Constraint::Length(1), // Help bar
Constraint::Fill(1), // Logs panel Constraint::Fill(1), // Logs panel
]) ])
@ -608,6 +616,11 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
.style(Style::default().fg(Color::Blue)); .style(Style::default().fg(Color::Blue));
f.render_widget(mbox_sparkline, chunks[2]); 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 // Help bar
let level_names = ["ERR", "WARN", "INFO", "DBG", "TRC"]; let level_names = ["ERR", "WARN", "INFO", "DBG", "TRC"];
let level_colors = [Color::Red, Color::Yellow, Color::Blue, Color::DarkGray, Color::DarkGray]; let level_colors = [Color::Red, Color::Yellow, Color::Blue, Color::DarkGray, Color::DarkGray];
@ -627,10 +640,60 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
}; };
help_spans.push(Span::styled(format!("{}:{} ", i + 1, name), style)); help_spans.push(Span::styled(format!("{}:{} ", i + 1, name), style));
} }
f.render_widget(Paragraph::new(Line::from(help_spans)), chunks[3]); f.render_widget(Paragraph::new(Line::from(help_spans)), chunks[4]);
// Logs panel // Logs panel
draw_actor_logs(f, app, chunks[4]); 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) { fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) {

View file

@ -232,6 +232,7 @@ mod tests {
last_msg_type: None, last_msg_type: None,
messages_processed: msgs, messages_processed: msgs,
poisoned, poisoned,
message_type_counts: Vec::new(),
} }
} }

View file

@ -120,6 +120,8 @@ pub struct ActorSnapshot {
pub last_msg_type: Option<&'static str>, pub last_msg_type: Option<&'static str>,
pub messages_processed: u64, pub messages_processed: u64,
pub poisoned: bool, 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. /// 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. /// Whether the actor has panicked and is no longer processing messages.
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub poisoned: bool, 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. /// Snapshot of overall runtime state.

View file

@ -610,6 +610,8 @@ struct ActorSlot {
started: bool, started: bool,
last_msg_type: Option<&'static str>, last_msg_type: Option<&'static str>,
messages_processed: u64, messages_processed: u64,
/// Per-message-type counters (bounded to 32 entries).
msg_type_counts: HashMap<&'static str, u64>,
/// Per-actor mailbox capacity. 0 = unbounded. /// Per-actor mailbox capacity. 0 = unbounded.
mailbox_capacity: usize, mailbox_capacity: usize,
overflow_policy: MailboxOverflow, overflow_policy: MailboxOverflow,
@ -645,6 +647,7 @@ impl ActorPool {
started: false, started: false,
last_msg_type: None, last_msg_type: None,
messages_processed: 0, messages_processed: 0,
msg_type_counts: HashMap::new(),
mailbox_capacity: self.default_mailbox_capacity, mailbox_capacity: self.default_mailbox_capacity,
overflow_policy: self.default_overflow_policy, overflow_policy: self.default_overflow_policy,
}); });
@ -765,6 +768,10 @@ impl ActorPool {
Ok(Some(type_name)) => { Ok(Some(type_name)) => {
slot.last_msg_type = Some(type_name); slot.last_msg_type = Some(type_name);
slot.messages_processed += 1; 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; count += 1;
@ -837,12 +844,16 @@ impl ActorPool {
pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) { pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) {
out.clear(); out.clear();
out.extend(self.actors.iter().map(|(&addr, slot)| { 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 { ActorSnapshot {
address: addr, address: addr,
mailbox_depth: slot.mailbox.len(), mailbox_depth: slot.mailbox.len(),
last_msg_type: slot.last_msg_type, last_msg_type: slot.last_msg_type,
messages_processed: slot.messages_processed, messages_processed: slot.messages_processed,
poisoned: slot.poisoned, poisoned: slot.poisoned,
message_type_counts: type_counts,
} }
})); }));
} }