768 lines
23 KiB
Rust
768 lines
23 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use ratatui::{
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::Line,
|
|
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap},
|
|
Frame,
|
|
};
|
|
|
|
use crate::state::{sort_indicator, AppState, FocusPane, ProcessSample, ProcessSort};
|
|
|
|
pub fn draw(frame: &mut Frame, state: &AppState) {
|
|
let root = frame.size();
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1),
|
|
Constraint::Min(8),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(root);
|
|
|
|
draw_header(frame, chunks[0], state);
|
|
draw_body(frame, chunks[1], state);
|
|
draw_footer(frame, chunks[2], state);
|
|
}
|
|
|
|
fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let uptime = format_uptime(state.snapshot.uptime_seconds);
|
|
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,
|
|
state.snapshot.load_avg_5m,
|
|
state.snapshot.load_avg_15m,
|
|
if state.paused { " [PAUSED]" } else { "" }
|
|
);
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(line).style(Style::default().fg(Color::Gray)),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let has_right_sidebar = area.width >= 120;
|
|
|
|
let body_chunks = if has_right_sidebar {
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Min(68), Constraint::Length(34)])
|
|
.split(area)
|
|
} else {
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Min(1)])
|
|
.split(area)
|
|
};
|
|
|
|
let main = body_chunks[0];
|
|
let right = if has_right_sidebar {
|
|
Some(body_chunks[1])
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let cpu_height = (main.height / 4).clamp(6, 12);
|
|
let middle_height = (main.height / 4).clamp(7, 10);
|
|
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(cpu_height),
|
|
Constraint::Length(middle_height),
|
|
Constraint::Min(7),
|
|
])
|
|
.split(main);
|
|
|
|
draw_cpu_section(frame, main_chunks[0], state);
|
|
draw_memory_disk_section(frame, main_chunks[1], state);
|
|
|
|
let table_area = if !has_right_sidebar && state.show_process_details {
|
|
let split = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(6), Constraint::Length(8)])
|
|
.split(main_chunks[2]);
|
|
draw_process_details(frame, split[1], state);
|
|
split[0]
|
|
} else {
|
|
main_chunks[2]
|
|
};
|
|
|
|
draw_process_table(frame, table_area, state);
|
|
|
|
if let Some(sidebar) = right {
|
|
if state.show_process_details {
|
|
let split = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(8),
|
|
Constraint::Length(5),
|
|
Constraint::Length(6),
|
|
])
|
|
.split(sidebar);
|
|
draw_process_details(frame, split[0], state);
|
|
draw_gpu(frame, split[1], state);
|
|
draw_network(frame, split[2], state);
|
|
} else {
|
|
let split = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(5), Constraint::Min(4)])
|
|
.split(sidebar);
|
|
draw_gpu(frame, split[0], state);
|
|
draw_network(frame, split[1], state);
|
|
}
|
|
} else {
|
|
let net_line = compact_status_line(state);
|
|
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) {
|
|
let block = Block::default().title("CPU").borders(Borders::ALL);
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.height < 2 || inner.width < 10 {
|
|
return;
|
|
}
|
|
|
|
let split = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
|
.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),
|
|
split[0],
|
|
);
|
|
|
|
let cores = &state.snapshot.cpu_per_core_percent;
|
|
if cores.is_empty() || split[1].height == 0 {
|
|
return;
|
|
}
|
|
|
|
let col_width = 20_u16;
|
|
let columns = (split[1].width / col_width).max(1) as usize;
|
|
let rows_per_col = split[1].height as usize;
|
|
let max_visible = (columns * rows_per_col).min(cores.len());
|
|
|
|
let mut lines = Vec::with_capacity(rows_per_col);
|
|
for row in 0..rows_per_col {
|
|
let mut line = String::new();
|
|
for col in 0..columns {
|
|
let idx = col * rows_per_col + row;
|
|
if idx >= max_visible {
|
|
continue;
|
|
}
|
|
let segment = format_core_mini(idx, cores[idx], col_width as usize);
|
|
line.push_str(&segment);
|
|
}
|
|
lines.push(Line::from(line));
|
|
}
|
|
|
|
if max_visible < cores.len() && !lines.is_empty() {
|
|
let extra = cores.len() - max_visible;
|
|
let last_line = lines.pop().unwrap_or_default();
|
|
let mut last = last_line
|
|
.spans
|
|
.into_iter()
|
|
.map(|span| span.content.to_string())
|
|
.collect::<String>();
|
|
last.push_str(&format!(" +{extra} more"));
|
|
lines.push(Line::from(last));
|
|
}
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(lines)
|
|
.alignment(Alignment::Left)
|
|
.style(Style::default().fg(Color::Gray)),
|
|
split[1],
|
|
);
|
|
}
|
|
|
|
fn draw_memory_disk_section(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
|
|
.split(area);
|
|
|
|
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(1), Constraint::Length(1)])
|
|
.split(inner);
|
|
|
|
let mem_pct = percent(
|
|
state.snapshot.mem_used_bytes,
|
|
state.snapshot.mem_total_bytes,
|
|
);
|
|
let swap_pct = percent(
|
|
state.snapshot.swap_used_bytes,
|
|
state.snapshot.swap_total_bytes,
|
|
);
|
|
|
|
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_compact(state.snapshot.mem_used_bytes),
|
|
fmt_bytes_compact(state.snapshot.mem_total_bytes)
|
|
)),
|
|
rows[0],
|
|
);
|
|
|
|
frame.render_widget(
|
|
Gauge::default()
|
|
.gauge_style(Style::default().fg(Color::Yellow))
|
|
.percent(swap_pct as u16)
|
|
.label(format!(
|
|
"swap {:>5.1}% {}/{}",
|
|
swap_pct,
|
|
fmt_bytes_compact(state.snapshot.swap_used_bytes),
|
|
fmt_bytes_compact(state.snapshot.swap_total_bytes)
|
|
)),
|
|
rows[1],
|
|
);
|
|
}
|
|
|
|
fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let block = Block::default()
|
|
.title(if state.show_pseudo_fs {
|
|
"Disk [all]"
|
|
} else {
|
|
"Disk [filtered]"
|
|
})
|
|
.borders(Borders::ALL);
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.height == 0 {
|
|
return;
|
|
}
|
|
|
|
let mut seen_mounts = HashSet::new();
|
|
let mut disks = state
|
|
.snapshot
|
|
.disk_samples
|
|
.iter()
|
|
.filter(|d| {
|
|
if state.show_pseudo_fs {
|
|
return true;
|
|
}
|
|
if is_noisy_pseudo_fs(&d.fs_type) {
|
|
return false;
|
|
}
|
|
if d.mount.starts_with("/tmp") {
|
|
return false;
|
|
}
|
|
seen_mounts.insert(d.mount.as_str())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
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 = inner.height as usize;
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(vec![Constraint::Length(1); rows.max(1)])
|
|
.split(inner);
|
|
|
|
if disks.is_empty() {
|
|
frame.render_widget(Paragraph::new("no mounts"), inner);
|
|
return;
|
|
}
|
|
|
|
for (i, disk) in disks.into_iter().take(rows).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!(
|
|
"{} {:>4.0}% {}/{}",
|
|
truncate(&disk.mount, 12),
|
|
pct,
|
|
fmt_bytes_compact(disk.used_bytes),
|
|
fmt_bytes_compact(disk.total_bytes)
|
|
)),
|
|
layout[i],
|
|
);
|
|
}
|
|
}
|
|
|
|
fn draw_gpu(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let block = Block::default().title("GPU").borders(Borders::ALL);
|
|
let lines = if state.snapshot.gpus.is_empty() {
|
|
vec![Line::from("No GPU detected")]
|
|
} else {
|
|
state
|
|
.snapshot
|
|
.gpus
|
|
.iter()
|
|
.take(2)
|
|
.map(|gpu| {
|
|
let util = gpu
|
|
.utilization_percent
|
|
.map(|u| format!("{u:.0}%"))
|
|
.unwrap_or_else(|| "-".to_string());
|
|
let mem = match (gpu.mem_used_bytes, gpu.mem_total_bytes) {
|
|
(Some(used), Some(total)) => {
|
|
format!("{}/{}", fmt_bytes_compact(used), fmt_bytes_compact(total))
|
|
}
|
|
_ => "-".to_string(),
|
|
};
|
|
Line::from(format!(
|
|
"{} {} u:{} m:{} t:{}",
|
|
truncate(&gpu.vendor, 4),
|
|
truncate(&gpu.name, 10),
|
|
util,
|
|
mem,
|
|
gpu.temperature_c
|
|
.map(|t| format!("{t:.0}C"))
|
|
.unwrap_or_else(|| "-".to_string())
|
|
))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(lines)
|
|
.block(block)
|
|
.alignment(Alignment::Left)
|
|
.wrap(Wrap { trim: true }),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let block = Block::default().title("Net").borders(Borders::ALL);
|
|
|
|
let iface_label = match state.snapshot.network_interfaces.split_first() {
|
|
Some((first, rest)) if !rest.is_empty() => format!("if {} (+{})", first, rest.len()),
|
|
Some((first, _)) => format!("if {first}"),
|
|
None => "if -".to_string(),
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(format!(
|
|
"RX {}/s TX {}/s",
|
|
fmt_bytes_compact(state.network_rate.rx_bps as u64),
|
|
fmt_bytes_compact(state.network_rate.tx_bps as u64)
|
|
)),
|
|
Line::from(format!(
|
|
"tot {}/{}",
|
|
fmt_bytes_compact(state.snapshot.network_totals.rx_bytes),
|
|
fmt_bytes_compact(state.snapshot.network_totals.tx_bytes)
|
|
)),
|
|
Line::from(iface_label),
|
|
];
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(lines)
|
|
.block(block)
|
|
.alignment(Alignment::Left)
|
|
.wrap(Wrap { trim: true }),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let focused = state.focus == FocusPane::ProcessTable;
|
|
let sort_char = sort_indicator(state.process_sort_order);
|
|
let title = format!(
|
|
"Processes [{}{}]",
|
|
sort_key_label(state.process_sort),
|
|
sort_char
|
|
);
|
|
let border_style = if focused {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
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 >= 90;
|
|
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_compact(p.mem_bytes))),
|
|
Cell::from(truncate(&p.state, 6)),
|
|
Cell::from(truncate(p.user.as_deref().unwrap_or("-"), 8)),
|
|
Cell::from(truncate(&p.command, 30)),
|
|
])
|
|
.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_compact(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![
|
|
sort_header("PID", ProcessSort::Pid, state),
|
|
sort_header("CPU%", ProcessSort::Cpu, state),
|
|
sort_header("MEM%", ProcessSort::Mem, state),
|
|
"RSS".to_string(),
|
|
"STATE".to_string(),
|
|
"USER".to_string(),
|
|
"COMMAND".to_string(),
|
|
])
|
|
.style(
|
|
Style::default()
|
|
.fg(Color::Gray)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
vec![
|
|
Constraint::Length(6),
|
|
Constraint::Length(6),
|
|
Constraint::Length(6),
|
|
Constraint::Length(9),
|
|
Constraint::Length(6),
|
|
Constraint::Length(8),
|
|
Constraint::Min(10),
|
|
],
|
|
)
|
|
} else {
|
|
(
|
|
Row::new(vec![
|
|
sort_header("PID", ProcessSort::Pid, state),
|
|
sort_header("CPU%", ProcessSort::Cpu, state),
|
|
sort_header("MEM%", ProcessSort::Mem, state),
|
|
"RSS".to_string(),
|
|
"STATE".to_string(),
|
|
"COMMAND".to_string(),
|
|
])
|
|
.style(
|
|
Style::default()
|
|
.fg(Color::Gray)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
vec![
|
|
Constraint::Length(6),
|
|
Constraint::Length(6),
|
|
Constraint::Length(6),
|
|
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_process_details(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let block = Block::default().title("Details").borders(Borders::ALL);
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.width < 10 || inner.height < 2 {
|
|
return;
|
|
}
|
|
|
|
let Some(process) = selected_process(state) else {
|
|
frame.render_widget(Paragraph::new("no process selected"), inner);
|
|
return;
|
|
};
|
|
|
|
let lines = vec![
|
|
Line::from(format!(
|
|
"pid {} ppid {}",
|
|
process.pid,
|
|
process
|
|
.ppid
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "-".to_string())
|
|
)),
|
|
Line::from(format!(
|
|
"user {} uid {}",
|
|
process.user.as_deref().unwrap_or("-"),
|
|
process.uid.as_deref().unwrap_or("-")
|
|
)),
|
|
Line::from(format!(
|
|
"thr {} nice {} cpu {}",
|
|
process
|
|
.threads
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "-".to_string()),
|
|
process
|
|
.nice
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "-".to_string()),
|
|
process
|
|
.cpu_time_seconds
|
|
.map(format_uptime)
|
|
.unwrap_or_else(|| "-".to_string())
|
|
)),
|
|
Line::from(format!(
|
|
"rss {} vms {}",
|
|
fmt_bytes_compact(process.mem_bytes),
|
|
fmt_bytes_compact(process.vms_bytes)
|
|
)),
|
|
Line::from("cmd:"),
|
|
Line::from(process.command.clone()),
|
|
];
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(lines)
|
|
.alignment(Alignment::Left)
|
|
.wrap(Wrap { trim: false }),
|
|
inner,
|
|
);
|
|
}
|
|
|
|
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
|
|
let focus = if state.focus == FocusPane::ProcessTable {
|
|
"focus:proc"
|
|
} else {
|
|
"focus:summary"
|
|
};
|
|
let line = format!(
|
|
"q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | r reverse | d details | f fs-filter | {focus}"
|
|
);
|
|
frame.render_widget(
|
|
Paragraph::new(line)
|
|
.alignment(Alignment::Left)
|
|
.style(Style::default().fg(Color::DarkGray)),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn selected_process(state: &AppState) -> Option<&ProcessSample> {
|
|
state.snapshot.processes.get(
|
|
state
|
|
.selected_process
|
|
.min(state.snapshot.processes.len().saturating_sub(1)),
|
|
)
|
|
}
|
|
|
|
fn sort_key_label(sort: ProcessSort) -> &'static str {
|
|
match sort {
|
|
ProcessSort::Cpu => "CPU",
|
|
ProcessSort::Mem => "MEM",
|
|
ProcessSort::Pid => "PID",
|
|
}
|
|
}
|
|
|
|
fn sort_header(name: &str, col: ProcessSort, state: &AppState) -> String {
|
|
if state.process_sort == col {
|
|
format!("{name}{}", sort_indicator(state.process_sort_order))
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
fn compact_status_line(state: &AppState) -> String {
|
|
let gpu = state.snapshot.gpus.first().map_or_else(
|
|
|| "GPU -".to_string(),
|
|
|g| {
|
|
let util = g
|
|
.utilization_percent
|
|
.map(|u| format!("{u:.0}%"))
|
|
.unwrap_or_else(|| "-".to_string());
|
|
format!("GPU {util}")
|
|
},
|
|
);
|
|
|
|
format!(
|
|
"RX {}/s | TX {}/s | tot {}/{} | {}",
|
|
fmt_bytes_compact(state.network_rate.rx_bps as u64),
|
|
fmt_bytes_compact(state.network_rate.tx_bps as u64),
|
|
fmt_bytes_compact(state.snapshot.network_totals.rx_bytes),
|
|
fmt_bytes_compact(state.snapshot.network_totals.tx_bytes),
|
|
gpu
|
|
)
|
|
}
|
|
|
|
fn is_noisy_pseudo_fs(fs_type: &str) -> bool {
|
|
matches!(fs_type, "tmpfs" | "devtmpfs" | "overlay")
|
|
}
|
|
|
|
fn percent(used: u64, total: u64) -> f64 {
|
|
if total == 0 {
|
|
return 0.0;
|
|
}
|
|
((used as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
|
|
}
|
|
|
|
fn fmt_bytes_compact(bytes: u64) -> String {
|
|
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
if bytes == 0 {
|
|
return "0B".to_string();
|
|
}
|
|
|
|
let mut value = bytes as f64;
|
|
let mut idx = 0;
|
|
while value >= 1024.0 && idx < UNITS.len() - 1 {
|
|
value /= 1024.0;
|
|
idx += 1;
|
|
}
|
|
|
|
if idx == 0 {
|
|
format!("{}{}", bytes, UNITS[idx])
|
|
} else {
|
|
format!("{value:.1}{}", UNITS[idx])
|
|
}
|
|
}
|
|
|
|
fn format_uptime(seconds: u64) -> String {
|
|
let days = seconds / 86_400;
|
|
let hours = (seconds % 86_400) / 3_600;
|
|
let mins = (seconds % 3_600) / 60;
|
|
let secs = seconds % 60;
|
|
|
|
if days > 0 {
|
|
format!("{days}d {hours:02}:{mins:02}:{secs:02}")
|
|
} else {
|
|
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
|
|
}
|
|
|
|
fn format_core_mini(index: usize, pct: f32, width: usize) -> String {
|
|
let bars = mini_bar(pct, 6);
|
|
let segment = format!("cpu{:02} {} {:>4.1}%", index, bars, pct);
|
|
format!("{segment:<width$}", width = width)
|
|
}
|
|
|
|
fn mini_bar(pct: f32, width: usize) -> String {
|
|
let filled = ((pct.clamp(0.0, 100.0) / 100.0) * width as f32).round() as usize;
|
|
let mut out = String::with_capacity(width);
|
|
for i in 0..width {
|
|
if i < filled {
|
|
out.push('█');
|
|
} else {
|
|
out.push('░');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn mini_bar_shapes() {
|
|
assert_eq!(mini_bar(0.0, 4), "░░░░");
|
|
assert_eq!(mini_bar(100.0, 4), "████");
|
|
}
|
|
|
|
#[test]
|
|
fn compact_bytes_has_no_space() {
|
|
assert_eq!(fmt_bytes_compact(0), "0B");
|
|
assert!(fmt_bytes_compact(1024).ends_with("KiB"));
|
|
}
|
|
|
|
#[test]
|
|
fn noisy_fs_filter_matches_expected() {
|
|
assert!(is_noisy_pseudo_fs("tmpfs"));
|
|
assert!(is_noisy_pseudo_fs("overlay"));
|
|
assert!(!is_noisy_pseudo_fs("ext4"));
|
|
}
|
|
}
|