use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState},
Frame,
};
use super::app::{App, CounterRate, PortThroughput};
use super::theme::ThemeColors;
const HELP_KEYS: &[(&str, &str)] = &[
("↑ / k", "Move up"),
("↓ / j", "Move down"),
("Enter", "Toggle detail panel"),
("Esc", "Close detail / quit"),
("t", "Cycle theme"),
("h", "Toggle this help"),
("q", "Quit"),
("", ""),
("", "── Detail mode ──"),
("↑ / k", "Scroll up"),
("↓ / j", "Scroll down"),
("", "Scroll past end → next device"),
("", "Scroll past top → prev device"),
];
const RDMA_LINK_GBPS: f64 = 100.0;
const BAR_WIDTH: usize = 12;
pub fn draw(frame: &mut Frame, app: &mut App) {
let tc = app.theme.colors();
if tc.bg != ratatui::style::Color::Reset {
frame.render_widget(
Block::default().style(Style::default().bg(tc.bg)),
frame.area(),
);
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(5),
Constraint::Length(1),
])
.split(frame.area());
draw_header(frame, app, chunks[0], &tc);
draw_body(frame, app, chunks[1], &tc);
draw_status_bar(frame, app, chunks[2], &tc);
if app.show_help {
draw_help_popup(frame, &tc);
}
}
fn draw_body(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) {
if app.show_detail {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
draw_table(frame, app, split[0], tc);
draw_detail(frame, app, split[1], tc);
} else {
draw_table(frame, app, area, tc);
}
}
fn header_line1(app: &App, tc: &ThemeColors) -> Line<'static> {
Line::from(vec![
styled(" rdmatop ", tc.accent, true),
styled(
&format!(
"- {} │ {} │ load average: {}",
app.sysinfo.hostname, app.sysinfo.uptime, app.sysinfo.load_avg
),
tc.muted,
false,
),
])
}
fn header_line2(app: &App, tc: &ThemeColors) -> Line<'static> {
let n = app.throughputs.len();
let total_tx: f64 = app.throughputs.iter().map(|t| t.tx_gbps).sum();
let total_rx: f64 = app.throughputs.iter().map(|t| t.rx_gbps).sum();
let total_drops: f64 = app.throughputs.iter().map(|t| t.rx_drops_per_sec).sum();
let drop_color = if total_drops > 0.0 {
tc.error
} else {
tc.muted
};
Line::from(vec![
styled(
&format!(" RDMA: {} device{}", n, if n == 1 { "" } else { "s" }),
tc.fg,
false,
),
styled(" │ TX: ", tc.muted, false),
styled(&format!("{:.2} Gbps", total_tx), tc.good, false),
styled(" │ RX: ", tc.muted, false),
styled(&format!("{:.2} Gbps", total_rx), tc.good, false),
styled(" │ Drops: ", tc.muted, false),
styled(&format!("{:.0}/s", total_drops), drop_color, false),
styled(
&format!(" │ {:.1}s │ theme: {}", app.elapsed, app.theme.label()),
tc.muted,
false,
),
])
}
fn cpu_bar(pct: f32, width: usize, tc: &ThemeColors) -> Vec<Span<'static>> {
let filled = ((pct / 100.0) * width as f32).round() as usize;
let empty = width.saturating_sub(filled);
let color = if pct > 80.0 {
tc.error
} else if pct > 50.0 {
tc.warning
} else {
tc.good
};
vec![
styled("[", tc.muted, false),
styled(&"|".repeat(filled), color, false),
styled(&" ".repeat(empty), tc.muted, false),
styled(&format!("{:>5.1}%]", pct), color, false),
]
}
fn mem_bar(used: u64, total: u64, pct: f32, width: usize, tc: &ThemeColors) -> Vec<Span<'static>> {
let filled = ((pct / 100.0) * width as f32).round() as usize;
let empty = width.saturating_sub(filled);
let color = if pct > 80.0 {
tc.error
} else if pct > 50.0 {
tc.warning
} else {
tc.good
};
let label = if total >= 1024 {
format!("{:.1}/{:.1}G]", used as f64 / 1024.0, total as f64 / 1024.0)
} else {
format!("{}/{}M]", used, total)
};
vec![
styled("[", tc.muted, false),
styled(&"|".repeat(filled), color, false),
styled(&" ".repeat(empty), tc.muted, false),
styled(&label, color, false),
]
}
fn header_line3(app: &App, tc: &ThemeColors) -> Line<'static> {
let s = &app.sysinfo;
let mut spans = vec![styled(" CPU ", tc.muted, false)];
spans.extend(cpu_bar(s.cpu_pct, 20, tc));
spans.push(styled(" Mem ", tc.muted, false));
spans.extend(mem_bar(s.mem_used_mb, s.mem_total_mb, s.mem_pct, 20, tc));
spans.push(styled(" Net ", tc.muted, false));
spans.push(styled(
&format!(
"↓{}/s ↑{}/s",
fmt_bytes_short(s.net.rx_bytes_per_sec),
fmt_bytes_short(s.net.tx_bytes_per_sec),
),
tc.fg,
false,
));
Line::from(spans)
}
fn draw_header(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(tc.border));
let lines = vec![
header_line1(app, tc),
header_line2(app, tc),
header_line3(app, tc),
];
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn gbps_bar(gbps: f64) -> String {
let ratio = (gbps / RDMA_LINK_GBPS).clamp(0.0, 1.0);
let filled = (ratio * BAR_WIDTH as f64).round() as usize;
format!("{}{}", "█".repeat(filled), "░".repeat(BAR_WIDTH - filled))
}
fn throughput_to_row(t: &PortThroughput, tc: &ThemeColors) -> Row<'static> {
let tx_c = gbps_color(t.tx_gbps, tc);
let rx_c = gbps_color(t.rx_gbps, tc);
let drop_c = if t.rx_drops_per_sec > 0.0 {
tc.error
} else {
tc.muted
};
Row::new(vec![
Cell::from(t.dev_name.clone()).style(Style::default().fg(tc.fg)),
Cell::from(t.port.to_string()).style(Style::default().fg(tc.muted)),
Cell::from(gbps_bar(t.tx_gbps)).style(Style::default().fg(tx_c)),
Cell::from(format!("{:.2}", t.tx_gbps)).style(Style::default().fg(tx_c)),
Cell::from(gbps_bar(t.rx_gbps)).style(Style::default().fg(rx_c)),
Cell::from(format!("{:.2}", t.rx_gbps)).style(Style::default().fg(rx_c)),
Cell::from(format_pps(t.tx_pkts_per_sec)).style(Style::default().fg(tc.fg)),
Cell::from(format_pps(t.rx_pkts_per_sec)).style(Style::default().fg(tc.fg)),
Cell::from(format!("{:.0}", t.rx_drops_per_sec)).style(Style::default().fg(drop_c)),
])
}
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) {
let header = Row::new([
"Device", "Port", "TX ▏", "TX Gbps", "RX ▏", "RX Gbps", "TX pps", "RX pps", "Drops/s",
])
.style(
Style::default()
.fg(tc.header_fg)
.add_modifier(Modifier::BOLD),
)
.height(1);
let rows: Vec<Row> = app
.throughputs
.iter()
.map(|t| throughput_to_row(t, tc))
.collect();
let widths = [
Constraint::Length(16),
Constraint::Length(6),
Constraint::Length(BAR_WIDTH as u16),
Constraint::Length(9),
Constraint::Length(BAR_WIDTH as u16),
Constraint::Length(9),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(9),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(tc.border))
.title(" RDMA Throughput ")
.title_style(Style::default().fg(tc.accent)),
)
.row_highlight_style(
Style::default()
.bg(tc.highlight_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = TableState::default();
if !app.throughputs.is_empty() {
state.select(Some(app.selected_row));
}
frame.render_stateful_widget(table, area, &mut state);
}
const DETAIL_COUNTERS: &[&str] = &[
"send_bytes",
"send_wrs",
"recv_bytes",
"recv_wrs",
"rdma_write_bytes",
"rdma_write_wrs",
"rdma_write_wr_err",
"rdma_write_recv_bytes",
"rdma_read_bytes",
"rdma_read_wrs",
"rdma_read_wr_err",
"rdma_read_resp_bytes",
"retrans_bytes",
"retrans_pkts",
"retrans_timeout_events",
"unresponsive_remote_events",
"impaired_remote_conn_events",
];
fn sparkline_str(data: &[f64], width: usize) -> String {
const BARS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let max = data.iter().cloned().fold(0.0f64, f64::max).max(0.01);
let start = data.len().saturating_sub(width);
let mut s = String::with_capacity(width);
for &v in &data[start..] {
let idx = ((v / max) * 8.0).round() as usize;
s.push(BARS[idx.min(8)]);
}
while s.chars().count() < width {
s.insert(0, ' ');
}
s
}
fn build_detail_lines(
t: &PortThroughput,
procs: &[&crate::stat::ProcessRdmaInfo],
history: Option<&super::app::DeviceHistory>,
tc: &ThemeColors,
) -> Vec<Line<'static>> {
let mut lines = build_device_header(t, history, tc);
append_active_counters(&mut lines, t, tc);
append_process_table(&mut lines, procs, tc);
lines
}
fn build_device_header(
t: &PortThroughput,
history: Option<&super::app::DeviceHistory>,
tc: &ThemeColors,
) -> Vec<Line<'static>> {
let spark_w = 30;
let (tx_spark, rx_spark) = match history {
Some(h) => (sparkline_str(&h.tx, spark_w), sparkline_str(&h.rx, spark_w)),
None => (" ".repeat(spark_w), " ".repeat(spark_w)),
};
vec![
Line::from(vec![
styled(" Device: ", tc.muted, false),
styled(&format!("{}/{}", t.dev_name, t.port), tc.accent, true),
]),
Line::from(vec![
styled(" TX: ", tc.muted, false),
styled(&format!("{:.2} Gbps ", t.tx_gbps), tc.good, false),
styled(&tx_spark, tc.good, false),
]),
Line::from(vec![
styled(" RX: ", tc.muted, false),
styled(&format!("{:.2} Gbps ", t.rx_gbps), tc.accent, false),
styled(&rx_spark, tc.accent, false),
]),
Line::from(""),
]
}
fn append_active_counters(lines: &mut Vec<Line<'static>>, t: &PortThroughput, tc: &ThemeColors) {
let counters: Vec<_> = t
.counter_rates
.iter()
.filter(|r| DETAIL_COUNTERS.contains(&r.name.as_str()))
.collect();
if !counters.is_empty() {
for r in &counters {
lines.push(counter_rate_line(r, tc));
}
lines.push(Line::from(""));
}
}
const PROC_HEADER: &str =
" PID USER NI S VIRT RES SHR MEM% QPs THR COMMAND";
fn append_process_table(
lines: &mut Vec<Line<'static>>,
procs: &[&crate::stat::ProcessRdmaInfo],
tc: &ThemeColors,
) {
lines.push(Line::from(vec![styled(PROC_HEADER, tc.header_fg, true)]));
if procs.is_empty() {
lines.push(Line::from(styled(" (no RDMA processes)", tc.muted, false)));
} else {
for p in procs {
lines.push(process_line(p, tc));
}
}
}
fn process_line(p: &crate::stat::ProcessRdmaInfo, tc: &ThemeColors) -> Line<'static> {
let state_color = match p.state {
'R' => tc.good,
'S' | 'I' => tc.muted,
'D' => tc.warning,
'Z' | 'T' => tc.error,
_ => tc.fg,
};
Line::from(vec![
styled(&format!(" {:<7}", p.pid), tc.accent, false),
styled(&format!(" {:<8}", truncate(&p.user, 8)), tc.fg, false),
styled(&format!(" {:<2}", p.nice), tc.muted, false),
styled(&format!(" {:>1}", p.state), state_color, false),
styled(&format!(" {:>8}", fmt_mem_kb(p.virt_kb)), tc.fg, false),
styled(&format!(" {:>8}", fmt_mem_kb(p.res_kb)), tc.good, false),
styled(&format!(" {:>6}", fmt_mem_kb(p.shr_kb)), tc.fg, false),
styled(&format!(" {:>4.1}", p.mem_pct), tc.fg, false),
styled(&format!(" {:>5}", p.qp_count), tc.accent, false),
styled(&format!(" {:>4}", p.threads), tc.muted, false),
styled(&format!(" {}", truncate(&p.cmdline, 40)), tc.fg, false),
])
}
fn draw_detail(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) {
let Some(t) = app.selected_throughput().cloned() else {
frame.render_widget(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(tc.border))
.title(" Detail "),
area,
);
return;
};
let history = app.history.get(&t.dev_name);
let procs = app.selected_device_processes();
let lines = build_detail_lines(&t, &procs, history, tc);
let visible = area.height.saturating_sub(2);
app.detail_max_scroll = (lines.len() as u16).saturating_sub(visible);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(tc.border))
.title(format!(" {} ", t.dev_name))
.title_style(Style::default().fg(tc.accent).add_modifier(Modifier::BOLD));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.scroll((app.detail_scroll, 0)),
area,
);
}
fn counter_rate_line(r: &CounterRate, tc: &ThemeColors) -> Line<'static> {
let rate_str = if r.is_bytes {
format_bytes(r.rate)
} else {
format_rate(r.rate)
};
let color = counter_color(r, tc);
Line::from(vec![
Span::styled(format!(" {:<35}", r.name), Style::default().fg(tc.fg)),
Span::styled(format!("{:>12}", rate_str), Style::default().fg(color)),
Span::styled(format!(" Δ {}", r.delta), Style::default().fg(tc.muted)),
])
}
fn counter_color(r: &CounterRate, tc: &ThemeColors) -> ratatui::style::Color {
let is_error = r.name.contains("err") || r.name.contains("drop");
let is_warn = r.name.contains("retrans")
|| r.name.contains("unresponsive")
|| r.name.contains("impaired");
match (r.delta > 0, is_error, is_warn) {
(true, true, _) => tc.error,
(true, _, true) => tc.warning,
(true, _, _) => tc.good,
_ => tc.muted,
}
}
fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) {
let hint = if app.show_detail {
"Enter/Esc:close"
} else {
"Enter:detail"
};
let line = Line::from(vec![
Span::styled(
" NORMAL ",
Style::default()
.fg(tc.status_fg)
.bg(tc.status_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ↑↓/jk:nav {} t:theme h:help q:quit", hint),
Style::default().fg(tc.muted),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn draw_help_popup(frame: &mut Frame, tc: &ThemeColors) {
let area = frame.area();
let w = 50.min(area.width.saturating_sub(4));
let h = 18.min(area.height.saturating_sub(4));
let popup = centered_rect(area, w, h);
frame.render_widget(Clear, popup);
let lines: Vec<Line> = HELP_KEYS
.iter()
.map(|(key, desc)| help_line(key, desc, tc))
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(tc.accent))
.title(" Help (h/Esc to close) ")
.title_style(Style::default().fg(tc.accent).add_modifier(Modifier::BOLD));
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
fn help_line(key: &str, desc: &str, tc: &ThemeColors) -> Line<'static> {
if key.is_empty() {
Line::from(styled(&format!(" {}", desc), tc.group_title, false))
} else {
Line::from(vec![
styled(&format!(" {:<14}", key), tc.accent, false),
styled(desc, tc.fg, false),
])
}
}
fn centered_rect(area: Rect, w: u16, h: u16) -> Rect {
Rect::new(
area.x + (area.width.saturating_sub(w)) / 2,
area.y + (area.height.saturating_sub(h)) / 2,
w,
h,
)
}
fn styled(text: &str, color: ratatui::style::Color, bold: bool) -> Span<'static> {
let s = Style::default().fg(color);
Span::styled(
text.to_string(),
if bold {
s.add_modifier(Modifier::BOLD)
} else {
s
},
)
}
fn fmt_bytes_short(bps: f64) -> String {
if bps >= 1_000_000_000.0 {
format!("{:.1}G", bps / 1_000_000_000.0)
} else if bps >= 1_000_000.0 {
format!("{:.1}M", bps / 1_000_000.0)
} else if bps >= 1_000.0 {
format!("{:.1}K", bps / 1_000.0)
} else {
format!("{:.0}B", bps)
}
}
fn format_bytes(bps: f64) -> String {
if bps >= 1_073_741_824.0 {
format!("{:.2} GB/s", bps / 1_073_741_824.0)
} else if bps >= 1_048_576.0 {
format!("{:.2} MB/s", bps / 1_048_576.0)
} else if bps >= 1024.0 {
format!("{:.2} KB/s", bps / 1024.0)
} else {
format!("{:.0} B/s", bps)
}
}
fn format_pps(pps: f64) -> String {
if pps >= 1_000_000.0 {
format!("{:.2}M", pps / 1_000_000.0)
} else if pps >= 1_000.0 {
format!("{:.1}K", pps / 1_000.0)
} else {
format!("{:.0}", pps)
}
}
fn format_rate(rate: f64) -> String {
if rate >= 1_000_000.0 {
format!("{:.2}M/s", rate / 1_000_000.0)
} else if rate >= 1_000.0 {
format!("{:.1}K/s", rate / 1_000.0)
} else {
format!("{:.1}/s", rate)
}
}
fn gbps_color(gbps: f64, tc: &ThemeColors) -> ratatui::style::Color {
if gbps >= 10.0 {
tc.good
} else if gbps >= 1.0 {
tc.warning
} else {
tc.muted
}
}
fn fmt_mem_kb(kb: u64) -> String {
if kb >= 1_048_576 {
format!("{:.1}G", kb as f64 / 1_048_576.0)
} else if kb >= 1024 {
format!("{:.0}M", kb as f64 / 1024.0)
} else {
format!("{}K", kb)
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}…", &s[..max - 1])
}
}