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:
parent
c7a0bd2523
commit
982d82776d
8 changed files with 141 additions and 7 deletions
|
|
@ -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 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##"<!DOCTYPE html>
|
|||
<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>
|
||||
|
|
@ -257,6 +274,38 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
|||
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ mod tests {
|
|||
last_msg_type: None,
|
||||
messages_processed: msgs,
|
||||
poisoned: false,
|
||||
message_type_counts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ pub struct ActorRow {
|
|||
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)]
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
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
|
||||
])
|
||||
|
|
@ -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<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) {
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ mod tests {
|
|||
last_msg_type: None,
|
||||
messages_processed: msgs,
|
||||
poisoned,
|
||||
message_type_counts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue