From 982d82776dd83c73bf3796a62df00ecc6bce42f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 09:16:02 +0000 Subject: [PATCH] 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 --- .../src/actor_detail_html.rs | 49 ++++++++++++ crates/runtime-dashboard/src/collector.rs | 1 + crates/runtime-dashboard/src/history.rs | 1 + crates/runtime-dashboard/src/tui/app.rs | 3 + crates/runtime-dashboard/src/tui/ui.rs | 77 +++++++++++++++++-- crates/runtime-dashboard/src/warnings.rs | 1 + src/stats.rs | 5 ++ src/worker.rs | 11 +++ 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/crates/runtime-dashboard/src/actor_detail_html.rs b/crates/runtime-dashboard/src/actor_detail_html.rs index 9f6fdb5..788a54e 100644 --- a/crates/runtime-dashboard/src/actor_detail_html.rs +++ b/crates/runtime-dashboard/src/actor_detail_html.rs @@ -62,6 +62,18 @@ pub const ACTOR_DETAIL_HTML: &str = r##" .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; @@ -148,6 +160,11 @@ pub const ACTOR_DETAIL_HTML: &str = r##" + +

Logs @@ -257,6 +274,38 @@ pub const ACTOR_DETAIL_HTML: &str = r##" 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 += '
' + + '' + escapeHtml(shortName) + '' + + '
' + + '' + count.toLocaleString() + '' + + '' + pct + '%' + + '
'; + } + container.innerHTML = html; } // ─── Logging ───────────────────────────────────────────────── diff --git a/crates/runtime-dashboard/src/collector.rs b/crates/runtime-dashboard/src/collector.rs index ab1dcd7..145032b 100644 --- a/crates/runtime-dashboard/src/collector.rs +++ b/crates/runtime-dashboard/src/collector.rs @@ -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(), })); } } diff --git a/crates/runtime-dashboard/src/history.rs b/crates/runtime-dashboard/src/history.rs index dd5727d..51be11d 100644 --- a/crates/runtime-dashboard/src/history.rs +++ b/crates/runtime-dashboard/src/history.rs @@ -258,6 +258,7 @@ mod tests { last_msg_type: None, messages_processed: msgs, poisoned: false, + message_type_counts: Vec::new(), } } diff --git a/crates/runtime-dashboard/src/tui/app.rs b/crates/runtime-dashboard/src/tui/app.rs index 31ee12e..7406d89 100644 --- a/crates/runtime-dashboard/src/tui/app.rs +++ b/crates/runtime-dashboard/src/tui/app.rs @@ -38,6 +38,8 @@ pub struct ActorRow { pub sparkline_mailbox: Vec, /// Per-actor message rate sparkline. pub sparkline_rates: Vec, + /// Per-message-type counts, sorted descending. + pub message_type_counts: Vec<(String, u64)>, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -335,6 +337,7 @@ impl App { 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(); diff --git a/crates/runtime-dashboard/src/tui/ui.rs b/crates/runtime-dashboard/src/tui/ui.rs index c97d037..db26d13 100644 --- a/crates/runtime-dashboard/src/tui/ui.rs +++ b/crates/runtime-dashboard/src/tui/ui.rs @@ -538,12 +538,20 @@ 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([ - Constraint::Length(5), // Info card - Constraint::Length(5), // Rate sparkline - Constraint::Length(5), // Mailbox sparkline - Constraint::Length(1), // Help bar - Constraint::Fill(1), // Logs panel + 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()); @@ -608,6 +616,11 @@ fn draw_actor_detail(f: &mut Frame, app: &App) { .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]; @@ -627,10 +640,60 @@ fn draw_actor_detail(f: &mut Frame, app: &App) { }; 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 - 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 = 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) { diff --git a/crates/runtime-dashboard/src/warnings.rs b/crates/runtime-dashboard/src/warnings.rs index 35400ee..d1756d1 100644 --- a/crates/runtime-dashboard/src/warnings.rs +++ b/crates/runtime-dashboard/src/warnings.rs @@ -232,6 +232,7 @@ mod tests { last_msg_type: None, messages_processed: msgs, poisoned, + message_type_counts: Vec::new(), } } diff --git a/src/stats.rs b/src/stats.rs index 5cabf5b..d899b06 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -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. diff --git a/src/worker.rs b/src/worker.rs index 1cd7fef..acaa33b 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -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, }); @@ -765,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; @@ -837,12 +844,16 @@ impl ActorPool { pub fn mailbox_depths_into(&self, out: &mut Vec) { 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, } })); }