swactor/crates/runtime-dashboard/src/tui/ui.rs

718 lines
24 KiB
Rust
Raw Normal View History

use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use super::app::{App, SortColumn, ViewMode};
/// Render the full dashboard into the given frame.
pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
match app.view_mode {
ViewMode::Overview => draw_overview(f, app, table_state),
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
#[cfg(feature = "distribution")]
ViewMode::Distribution => draw_distribution(f, app, table_state),
}
}
// ─── Overview ────────────────────────────────────────────────────────────────
fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) {
let num_workers = app.workers.len().max(1);
let chunks = Layout::vertical([
Constraint::Length(num_workers as u16),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(f.area());
draw_worker_bars(f, app, chunks[0]);
draw_summary(f, app, chunks[1]);
draw_actor_table(f, app, table_state, chunks[2]);
}
/// Render htop-style worker bars.
fn draw_worker_bars(f: &mut Frame, app: &App, area: Rect) {
let lines: Vec<Line> = app
.workers
.iter()
.map(|w| build_worker_line(w, area.width as usize))
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> {
let id_str = format!("{:>3}", w.id);
let suffix = format!(
" {} actors {} m/s",
w.num_actors,
format_rate(w.msg_rate),
);
let pct_str = format!("{:>5.1}%", w.load_pct);
let overhead = id_str.len() + 1 + 1 + pct_str.len() + suffix.len();
let bar_width = if total_width > overhead {
total_width - overhead
} else {
10
};
let filled = ((w.load_pct / 100.0) * bar_width as f64).round() as usize;
let filled = filled.min(bar_width);
let phase_colors = [Color::Green, Color::Blue, Color::Cyan, Color::Red];
let mut phase_cells = [0usize; 4];
if filled > 0 {
let mut assigned = 0usize;
for i in 0..4 {
phase_cells[i] = (w.phase_fractions[i] * filled as f64).round() as usize;
assigned += phase_cells[i];
}
if assigned != filled {
let diff = filled as isize - assigned as isize;
let max_idx = phase_cells
.iter()
.enumerate()
.max_by_key(|(_, v)| *v)
.map(|(i, _)| i)
.unwrap_or(0);
phase_cells[max_idx] = (phase_cells[max_idx] as isize + diff).max(0) as usize;
}
}
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::raw(id_str));
spans.push(Span::raw("["));
for (i, &color) in phase_colors.iter().enumerate() {
let count = phase_cells[i];
if count > 0 {
spans.push(Span::styled(
"|".repeat(count),
Style::default().fg(color),
));
}
}
let filled_total: usize = phase_cells.iter().sum();
let empty = bar_width.saturating_sub(filled_total);
if empty >= pct_str.len() {
let padding = empty - pct_str.len();
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(
pct_str,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
pct_str,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::raw("]"));
spans.push(Span::styled(suffix, Style::default().fg(Color::DarkGray)));
Line::from(spans)
}
/// Render the summary stats line.
fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
let panics_style = if app.total_panics > 0 {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let line = Line::from(vec![
Span::styled(" Workers: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.num_workers),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(" Actors: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.total_actors),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
Span::styled(" Msgs: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(app.total_messages),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.total_mailbox),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", app.total_panics), panics_style),
]);
f.render_widget(Paragraph::new(line), area);
}
/// Render the actor table with scrolling and selection.
fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
let columns = [
SortColumn::Address,
SortColumn::Worker,
SortColumn::Mailbox,
SortColumn::MsgCount,
SortColumn::LastMsg,
];
let header_cells = columns.iter().map(|&col| {
let mut label = col.label().to_string();
if col == app.sort_column {
label.push_str(sort_arrow);
}
Cell::from(label).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = app
.actor_rows
.iter()
.map(|a| actor_row_cells(a))
.collect();
table_state.select(Some(app.selected));
let help_text =
" q: quit \u{2191}\u{2193}: scroll s: sort column r: reverse Tab: worker view Enter: drill in";
let table = Table::new(
rows,
[
Constraint::Min(20),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(24),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(
" Actors (sorted by {}{}) ",
app.sort_column.label(),
sort_arrow
))
.title_bottom(Line::from(help_text).centered()),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(table, area, table_state);
}
fn actor_row_cells(a: &super::app::ActorRow) -> Row<'static> {
let addr = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
} else {
addr
};
let addr_style = if a.poisoned {
Style::default().fg(Color::Red)
} else {
Style::default()
};
let msg_short = short_type_name(a.last_msg_type.as_deref());
let msg_style = if a.poisoned {
Style::default().fg(Color::Red)
} else if a.last_msg_type.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Row::new(vec![
Cell::from(addr_short).style(addr_style),
Cell::from(format!("{}", a.worker_id)),
Cell::from(format!("{}", a.mailbox_depth)),
Cell::from(format!("{}", a.messages_processed)),
Cell::from(msg_short).style(msg_style),
])
}
// ─── Worker Detail View ──────────────────────────────────────────────────────
fn draw_worker_detail(f: &mut Frame, app: &App, table_state: &mut TableState) {
let num_workers = app.workers.len().max(1);
let chunks = Layout::vertical([
Constraint::Length(num_workers as u16),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(f.area());
draw_worker_selector(f, app, chunks[0]);
draw_worker_summary(f, app, chunks[1]);
draw_focused_actor_table(f, app, table_state, chunks[2]);
}
/// Worker list with highlight on the focused worker.
fn draw_worker_selector(f: &mut Frame, app: &App, area: Rect) {
let lines: Vec<Line> = app
.workers
.iter()
.map(|w| {
let mut line = build_worker_line(w, area.width as usize);
if w.id == app.focused_worker {
// Highlight the focused worker row
line = line.style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
}
line
})
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
/// Summary line for the focused worker.
fn draw_worker_summary(f: &mut Frame, app: &App, area: Rect) {
let focused = app.workers.iter().find(|w| w.id == app.focused_worker);
let line = match focused {
Some(w) => {
let panics_style = if w.panics > 0 {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let actor_count = app
.actor_rows
.iter()
.filter(|r| r.worker_id == app.focused_worker)
.count();
Line::from(vec![
Span::styled(" W", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", w.id),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Actors: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor_count),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Msgs: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(w.messages_processed),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({} m/s)", format_rate(w.msg_rate)),
Style::default().fg(Color::DarkGray),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", w.mailbox_depth),
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", w.panics), panics_style),
])
}
None => Line::from(Span::styled(
" No workers",
Style::default().fg(Color::DarkGray),
)),
};
f.render_widget(Paragraph::new(line), area);
}
/// Actor table filtered to the focused worker.
fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
let columns = [SortColumn::Address, SortColumn::Mailbox, SortColumn::MsgCount, SortColumn::LastMsg];
let header_cells = columns.iter().map(|&col| {
let mut label = col.label().to_string();
if col == app.sort_column {
label.push_str(sort_arrow);
}
Cell::from(label).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1);
let focused_rows: Vec<&super::app::ActorRow> = app.focused_actor_rows();
let rows: Vec<Row> = focused_rows
.iter()
.map(|a| {
let addr = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
} else {
addr
};
let addr_style = if a.poisoned {
Style::default().fg(Color::Red)
} else {
Style::default()
};
let msg_short = short_type_name(a.last_msg_type.as_deref());
let msg_style = if a.poisoned {
Style::default().fg(Color::Red)
} else if a.last_msg_type.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Row::new(vec![
Cell::from(addr_short).style(addr_style),
Cell::from(format!("{}", a.mailbox_depth)),
Cell::from(format!("{}", a.messages_processed)),
Cell::from(msg_short).style(msg_style),
])
})
.collect();
// Don't select any row in the focused table — it's read-only
table_state.select(None);
let help_text = " Esc/\u{2190}: overview \u{2191}\u{2193}: select worker s: sort r: reverse Tab: overview q: quit";
let table = Table::new(
rows,
[
Constraint::Min(20),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(28),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" W{} Actors ", app.focused_worker))
.title_bottom(Line::from(help_text).centered()),
);
f.render_stateful_widget(table, area, table_state);
}
// ─── Distribution View ──────────────────────────────────────────────────────
#[cfg(feature = "distribution")]
fn draw_distribution(f: &mut Frame, app: &App, table_state: &mut TableState) {
let chunks = Layout::vertical([
Constraint::Length(3), // Summary bar
Constraint::Fill(1), // Members table
Constraint::Length(10), // Bottom panels: cache + routing
Constraint::Length(1), // Help bar
])
.split(f.area());
draw_dist_summary(f, app, chunks[0]);
draw_dist_members(f, app, table_state, chunks[1]);
let bottom = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(chunks[2]);
draw_dist_cache(f, app, bottom[0]);
draw_dist_routing(f, app, bottom[1]);
draw_dist_help(f, chunks[3]);
}
#[cfg(feature = "distribution")]
fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) {
let (node_id, listen_addr, alive, suspect, dead, cache, dir, rt_size, rt_buckets, repair) =
match &app.distribution {
Some(d) => (
&d.node_id[..d.node_id.len().min(16)],
d.listen_addr.as_str(),
d.alive_count,
d.suspect_count,
d.dead_count,
d.cache_size,
d.directory_entry_count,
d.routing_table_size,
d.routing_buckets.len(),
d.repair_queue_size,
),
None => ("—", "—", 0, 0, 0, 0, 0, 0, 0, 0),
};
let lines = vec![
Line::from(vec![
Span::styled(" Node: ", Style::default().fg(Color::DarkGray)),
Span::styled(
node_id.to_string(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(" Addr: ", Style::default().fg(Color::DarkGray)),
Span::styled(listen_addr.to_string(), Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" Members: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{alive}"), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(" alive, ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{suspect}"),
if suspect > 0 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) },
),
Span::styled(" suspect, ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{dead}"),
if dead > 0 { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) },
),
Span::styled(" dead", Style::default().fg(Color::DarkGray)),
Span::styled(format!(" Cache: {cache}"), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" Directory: {dir}"), Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(format!(" Routing: {rt_size} nodes, {rt_buckets} buckets"), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" Repair queue: {repair}"), Style::default().fg(Color::DarkGray)),
]),
];
let block = Block::default().borders(Borders::ALL).title(" Distribution ");
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
#[cfg(feature = "distribution")]
fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
let header_cells = ["STATE", "NODE ID", "ADDRESS", "INCARNATION"].iter().map(|&h| {
Cell::from(h).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = match &app.distribution {
Some(d) => d
.members
.iter()
.map(|m| {
let state_style = match m.state.as_str() {
"alive" => Style::default().fg(Color::Green),
"suspect" => Style::default().fg(Color::Yellow),
"dead" => Style::default().fg(Color::Red),
_ => Style::default(),
};
let id_short = if m.node_id.len() > 16 {
format!("{}...", &m.node_id[..14])
} else {
m.node_id.clone()
};
Row::new(vec![
Cell::from(m.state.clone()).style(state_style),
Cell::from(id_short),
Cell::from(m.addr.clone()),
Cell::from(format!("{}", m.incarnation)),
])
})
.collect(),
None => vec![],
};
table_state.select(Some(app.dist_member_selected));
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Min(18),
Constraint::Length(22),
Constraint::Length(12),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Members "),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(table, area, table_state);
}
#[cfg(feature = "distribution")]
fn draw_dist_cache(f: &mut Frame, app: &App, area: Rect) {
let rows: Vec<Row> = match &app.distribution {
Some(d) => d
.cache_entries
.iter()
.take(area.height.saturating_sub(2) as usize)
.map(|e| {
let actor_short = if e.actor_addr.len() > 16 {
format!("{}...", &e.actor_addr[..14])
} else {
e.actor_addr.clone()
};
let node_short = if e.node_id.len() > 12 {
format!("{}...", &e.node_id[..10])
} else {
e.node_id.clone()
};
Row::new(vec![
Cell::from(actor_short),
Cell::from(node_short),
])
})
.collect(),
None => vec![],
};
let header = Row::new(vec![
Cell::from("ACTOR").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Cell::from("NODE").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
])
.height(1);
let table = Table::new(
rows,
[Constraint::Percentage(55), Constraint::Percentage(45)],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Cache "));
f.render_widget(table, area);
}
#[cfg(feature = "distribution")]
fn draw_dist_routing(f: &mut Frame, app: &App, area: Rect) {
let buckets: Vec<(usize, usize)> = match &app.distribution {
Some(d) => d.routing_buckets.clone(),
None => vec![],
};
let max_count = buckets.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
let bar_max_width = area.width.saturating_sub(16) as usize; // space for "[NNN] " + " N"
let lines: Vec<Line> = buckets
.iter()
.take(area.height.saturating_sub(2) as usize)
.map(|(idx, count)| {
let bar_len = (*count as f64 / max_count as f64 * bar_max_width as f64).round() as usize;
let bar_len = bar_len.max(1);
Line::from(vec![
Span::styled(
format!(" [{:>3}] ", idx),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"\u{2588}".repeat(bar_len),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!(" {}", count),
Style::default().fg(Color::White),
),
])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title(" Routing Buckets ");
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
#[cfg(feature = "distribution")]
fn draw_dist_help(f: &mut Frame, area: Rect) {
let help = Line::from(vec![
Span::styled(
" Tab: views \u{2191}\u{2193}: scroll Esc: overview q: quit",
Style::default().fg(Color::DarkGray),
),
]);
f.render_widget(Paragraph::new(help), area);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn short_type_name(full: Option<&str>) -> String {
match full {
Some(s) => s.rsplit("::").next().unwrap_or(s).to_string(),
None => "\u{2014}".to_string(), // em dash
}
}
fn format_num(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
result.chars().rev().collect()
}
fn format_rate(rate: f64) -> String {
if rate >= 1_000_000.0 {
format!("{:.1}M", rate / 1_000_000.0)
} else if rate >= 1_000.0 {
format!("{:.1}k", rate / 1_000.0)
} else {
format!("{:.0}", rate)
}
}