This commit is contained in:
2026-02-19 01:39:45 +00:00
parent 4a9dc1d565
commit 0784b7fc8f
5 changed files with 650 additions and 177 deletions

519
src/ui.rs
View File

@@ -2,20 +2,19 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap},
Frame,
};
use crate::state::AppState;
use crate::state::{AppState, FocusPane, ProcessSort};
pub fn draw(frame: &mut Frame, state: &AppState) {
let root = frame.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(7),
Constraint::Length(1),
Constraint::Min(8),
Constraint::Length(1),
])
.split(root);
@@ -27,79 +26,162 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) {
let uptime = format_uptime(state.snapshot.uptime_seconds);
let refresh_ms = state.refresh_interval.as_millis();
let title = format!(
" {} | uptime {} | proc {} | load(1m) {:.2} | {}ms {}",
let line = format!(
"{} up {} refresh:{}ms procs:{} load:{:.2}/{:.2}/{:.2}{}",
state.snapshot.host,
uptime,
state.refresh_interval.as_millis(),
state.snapshot.process_count,
state.snapshot.load_avg_1m,
refresh_ms,
if state.paused { "[PAUSED]" } else { "" }
state.snapshot.load_avg_5m,
state.snapshot.load_avg_15m,
if state.paused { " [PAUSED]" } else { "" }
);
frame.render_widget(
Paragraph::new(title)
.block(Block::default().borders(Borders::ALL).title("sloptop"))
.alignment(Alignment::Left),
Paragraph::new(line).style(Style::default().fg(Color::Gray)),
area,
);
}
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
if area.width < 90 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(6),
Constraint::Min(4),
])
.split(area);
let has_sidebar = area.width >= 100;
let body_chunks = if has_sidebar {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(60), Constraint::Length(28)])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1)])
.split(area)
};
draw_cpu(frame, chunks[0], state);
draw_memory(frame, chunks[1], state);
draw_network(frame, chunks[2], state);
draw_disks(frame, chunks[3], state);
let main = body_chunks[0];
let sidebar = if has_sidebar {
Some(body_chunks[1])
} else {
None
};
let cores = state.snapshot.cpu_per_core_percent.len();
let cores_visible = if main.width < 90 {
cores.min(4)
} else {
cores.min(8)
};
let cpu_height = (cores_visible as u16) + 3;
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(cpu_height),
Constraint::Length(9),
Constraint::Min(7),
])
.split(main);
draw_cpu_section(frame, main_chunks[0], state, cores_visible);
draw_memory_disk_section(frame, main_chunks[1], state);
draw_process_table(frame, main_chunks[2], state);
if let Some(net) = sidebar {
draw_network(frame, net, state);
} else {
let net_line = format!(
"RX {}/s | TX {}/s",
fmt_bytes(state.network_rate.rx_bps as u64),
fmt_bytes(state.network_rate.tx_bps as u64)
);
let net_area = Rect {
x: main.x,
y: main.y,
width: main.width.min(net_line.len() as u16 + 2),
height: 1,
};
frame.render_widget(
Paragraph::new(net_line).style(Style::default().fg(Color::DarkGray)),
net_area,
);
}
}
fn draw_cpu_section(frame: &mut Frame, area: Rect, state: &AppState, cores_visible: usize) {
let block = Block::default().title("CPU").borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let mut rows = vec![Constraint::Length(1)];
for _ in 0..cores_visible {
rows.push(Constraint::Length(1));
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(rows)
.split(inner);
let total_label = format!(
"total {:>5.1}%",
state.snapshot.cpu_total_percent.clamp(0.0, 100.0)
);
frame.render_widget(
Gauge::default()
.gauge_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
.label(total_label),
rows[0],
);
for (i, usage) in state
.snapshot
.cpu_per_core_percent
.iter()
.take(cores_visible)
.enumerate()
{
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Blue))
.percent(usage.clamp(0.0, 100.0) as u16)
.label(format!("cpu{:02} {:>5.1}%", i, usage)),
rows[i + 1],
);
}
}
fn draw_memory_disk_section(frame: &mut Frame, area: Rect, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
.split(area);
let left_chunks = Layout::default()
draw_mem_swap(frame, chunks[0], state);
draw_disks(frame, chunks[1], state);
}
fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::default().title("Memory").borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 2 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(5),
])
.split(chunks[0]);
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(inner);
draw_cpu(frame, left_chunks[0], state);
draw_memory(frame, left_chunks[1], state);
draw_disks(frame, left_chunks[2], state);
draw_network(frame, chunks[1], state);
}
fn draw_cpu(frame: &mut Frame, area: Rect, state: &AppState) {
let gauge = Gauge::default()
.block(Block::default().title("CPU").borders(Borders::ALL))
.gauge_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
.label(format!("{:>5.1}%", state.snapshot.cpu_total_percent));
frame.render_widget(gauge, area);
}
fn draw_memory(frame: &mut Frame, area: Rect, state: &AppState) {
let mem_pct = percent(
state.snapshot.mem_used_bytes,
state.snapshot.mem_total_bytes,
@@ -109,124 +191,256 @@ fn draw_memory(frame: &mut Frame, area: Rect, state: &AppState) {
state.snapshot.swap_total_bytes,
);
let text = vec![Line::from(vec![
Span::raw(format!("mem {:>5.1}% ", mem_pct)),
Span::styled(
format!(
"{} / {}",
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.percent(mem_pct as u16)
.label(format!(
"mem {:>5.1}% {} / {}",
mem_pct,
fmt_bytes(state.snapshot.mem_used_bytes),
fmt_bytes(state.snapshot.mem_total_bytes)
),
Style::default().fg(Color::Green),
),
Span::raw(format!(
" swap {:>5.1}% {} / {}",
swap_pct,
fmt_bytes(state.snapshot.swap_used_bytes),
fmt_bytes(state.snapshot.swap_total_bytes)
)),
])];
frame.render_widget(
Paragraph::new(text)
.block(Block::default().title("Memory").borders(Borders::ALL))
.wrap(Wrap { trim: true }),
area,
)),
rows[0],
);
}
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
let mut lines = vec![
Line::from(format!(
"RX: {}/s",
fmt_bytes(state.network_rate.rx_bps as u64)
)),
Line::from(format!(
"TX: {}/s",
fmt_bytes(state.network_rate.tx_bps as u64)
)),
Line::from(format!(
"Total RX: {}",
fmt_bytes(state.snapshot.network_totals.rx_bytes)
)),
Line::from(format!(
"Total TX: {}",
fmt_bytes(state.snapshot.network_totals.tx_bytes)
)),
];
if state.detailed_view {
lines.push(Line::from(""));
lines.push(Line::from("Per-core CPU:"));
for (idx, usage) in state
.snapshot
.cpu_per_core_percent
.iter()
.enumerate()
.take(10)
{
lines.push(Line::from(format!("core {:02}: {:>5.1}%", idx, usage)));
}
if state.snapshot.cpu_per_core_percent.len() > 10 {
lines.push(Line::from(format!(
"... and {} more cores",
state.snapshot.cpu_per_core_percent.len() - 10
)));
}
}
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.title("Network / Details")
.borders(Borders::ALL),
)
.wrap(Wrap { trim: true }),
area,
Gauge::default()
.gauge_style(Style::default().fg(Color::Yellow))
.percent(swap_pct as u16)
.label(format!(
"swap {:>5.1}% {} / {}",
swap_pct,
fmt_bytes(state.snapshot.swap_used_bytes),
fmt_bytes(state.snapshot.swap_total_bytes)
)),
rows[1],
);
}
fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
let mut lines = Vec::new();
if state.snapshot.disk_samples.is_empty() {
lines.push(Line::from("No disks reported"));
} else {
for disk in &state.snapshot.disk_samples {
let pct = percent(disk.used_bytes, disk.total_bytes);
lines.push(Line::from(format!(
"{} {:>5.1}% {} / {}",
disk.name,
pct,
fmt_bytes(disk.used_bytes),
fmt_bytes(disk.total_bytes)
)));
}
let block = Block::default().title("Disk").borders(Borders::ALL);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let max_disks = inner.height as usize;
let mut disks = state.snapshot.disk_samples.clone();
disks.sort_by(|a, b| {
let ap = percent(a.used_bytes, a.total_bytes);
let bp = percent(b.used_bytes, b.total_bytes);
bp.partial_cmp(&ap).unwrap_or(std::cmp::Ordering::Equal)
});
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); max_disks.max(1)])
.split(inner);
for (i, disk) in disks.iter().take(max_disks).enumerate() {
let pct = percent(disk.used_bytes, disk.total_bytes);
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta))
.percent(pct as u16)
.label(format!(
"{} {:>5.1}% {} / {}",
truncate(&disk.mount, 10),
pct,
fmt_bytes(disk.used_bytes),
fmt_bytes(disk.total_bytes)
)),
rows[i],
);
}
}
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::default().title("Net").borders(Borders::ALL);
let mut lines = vec![Line::from(vec![
Span::styled("RX ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}/s", fmt_bytes(state.network_rate.rx_bps as u64)),
Style::default().fg(Color::Cyan),
),
])];
lines.push(Line::from(vec![
Span::styled("TX ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}/s", fmt_bytes(state.network_rate.tx_bps as u64)),
Style::default().fg(Color::Cyan),
),
]));
if state.detailed_view {
lines.push(Line::from(""));
lines.push(Line::from(format!(
"tot rx {}",
fmt_bytes(state.snapshot.network_totals.rx_bytes)
)));
lines.push(Line::from(format!(
"tot tx {}",
fmt_bytes(state.snapshot.network_totals.tx_bytes)
)));
}
frame.render_widget(
Paragraph::new(lines)
.block(Block::default().title("Disk").borders(Borders::ALL))
.block(block)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true }),
area,
);
}
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
let mode = if state.detailed_view {
"detail:on"
fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
let focused = state.focus == FocusPane::ProcessTable;
let title = format!(
"Processes [{}]",
match state.process_sort {
ProcessSort::Cpu => "CPU",
ProcessSort::Mem => "MEM",
ProcessSort::Pid => "PID",
}
);
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
"detail:off"
Style::default().fg(Color::DarkGray)
};
let help = format!("q quit | p pause | +/- interval | d toggle detail | {mode}");
frame.render_widget(Paragraph::new(help), area);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 2 {
return;
}
let show_user = inner.width >= 84;
let visible_rows = inner.height.saturating_sub(1) as usize;
let selected = state
.selected_process
.min(state.snapshot.processes.len().saturating_sub(1));
let mut start = selected.saturating_sub(visible_rows / 2);
if start + visible_rows > state.snapshot.processes.len() {
start = state.snapshot.processes.len().saturating_sub(visible_rows);
}
let rows = state
.snapshot
.processes
.iter()
.enumerate()
.skip(start)
.take(visible_rows)
.map(|(idx, p)| {
let mem_pct = percent(p.mem_bytes, state.snapshot.mem_total_bytes);
let style = if idx == selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
if show_user {
Row::new(vec![
Cell::from(format!("{:>6}", p.pid)),
Cell::from(format!("{:>5.1}", p.cpu_percent)),
Cell::from(format!("{:>5.1}", mem_pct)),
Cell::from(format!("{:>9}", fmt_bytes(p.mem_bytes))),
Cell::from(truncate(&p.state, 6)),
Cell::from(truncate(p.user.as_deref().unwrap_or("-"), 8)),
Cell::from(truncate(&p.command, 28)),
])
.style(style)
} else {
Row::new(vec![
Cell::from(format!("{:>6}", p.pid)),
Cell::from(format!("{:>5.1}", p.cpu_percent)),
Cell::from(format!("{:>5.1}", mem_pct)),
Cell::from(format!("{:>9}", fmt_bytes(p.mem_bytes))),
Cell::from(truncate(&p.state, 6)),
Cell::from(truncate(&p.command, 22)),
])
.style(style)
}
})
.collect::<Vec<_>>();
let (header, widths) = if show_user {
(
Row::new(vec![
"PID", "CPU%", "MEM%", "RSS", "STATE", "USER", "COMMAND",
])
.style(
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
),
vec![
Constraint::Length(6),
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(9),
Constraint::Length(6),
Constraint::Length(8),
Constraint::Min(10),
],
)
} else {
(
Row::new(vec!["PID", "CPU%", "MEM%", "RSS", "STATE", "COMMAND"]).style(
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
),
vec![
Constraint::Length(6),
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(9),
Constraint::Length(6),
Constraint::Min(10),
],
)
};
let table = Table::new(rows, widths)
.header(header)
.column_spacing(1)
.block(Block::default());
frame.render_widget(table, inner);
}
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
let focus = if state.focus == FocusPane::ProcessTable {
"focus:proc"
} else {
"focus:summary"
};
let help = "q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | arrows/PgUp/PgDn";
let line = format!("{help} | {focus}");
frame.render_widget(
Paragraph::new(line)
.alignment(Alignment::Left)
.style(Style::default().fg(Color::DarkGray)),
area,
);
}
fn percent(used: u64, total: u64) -> f64 {
if total == 0 {
return 0.0;
}
(used as f64 / total as f64) * 100.0
((used as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
}
fn fmt_bytes(bytes: u64) -> String {
@@ -261,3 +475,12 @@ fn format_uptime(seconds: u64) -> String {
format!("{hours:02}:{mins:02}:{secs:02}")
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out = s.chars().take(max.saturating_sub(1)).collect::<String>();
out.push('~');
out
}