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

View File

@@ -1,14 +1,15 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::Result; use anyhow::Result;
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System}; use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System, Users};
use crate::state::{DiskSample, Snapshot, Totals}; use crate::state::{DiskSample, ProcessSample, Snapshot, Totals};
pub struct Collector { pub struct Collector {
system: System, system: System,
networks: Networks, networks: Networks,
disks: Disks, disks: Disks,
users: Users,
host: String, host: String,
last_sample_at: Instant, last_sample_at: Instant,
} }
@@ -28,13 +29,14 @@ impl Collector {
networks.refresh(); networks.refresh();
let disks = Disks::new_with_refreshed_list(); let disks = Disks::new_with_refreshed_list();
let users = Users::new_with_refreshed_list();
let host = System::host_name().unwrap_or_else(|| "unknown-host".to_string()); let host = System::host_name().unwrap_or_else(|| "unknown-host".to_string());
Self { Self {
system, system,
networks, networks,
disks, disks,
users,
host, host,
last_sample_at: Instant::now(), last_sample_at: Instant::now(),
} }
@@ -46,6 +48,7 @@ impl Collector {
self.system.refresh_processes(); self.system.refresh_processes();
self.networks.refresh(); self.networks.refresh();
self.disks.refresh(); self.disks.refresh();
self.users.refresh_list();
let elapsed = self.last_sample_at.elapsed(); let elapsed = self.last_sample_at.elapsed();
self.last_sample_at = Instant::now(); self.last_sample_at = Instant::now();
@@ -69,14 +72,47 @@ impl Collector {
.map(|disk| { .map(|disk| {
let total = disk.total_space(); let total = disk.total_space();
let used = total.saturating_sub(disk.available_space()); let used = total.saturating_sub(disk.available_space());
let mount = disk.mount_point().to_string_lossy().into_owned();
DiskSample { DiskSample {
name: disk.name().to_string_lossy().into_owned(), mount,
total_bytes: total, total_bytes: total,
used_bytes: used, used_bytes: used,
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let processes = self
.system
.processes()
.iter()
.map(|(pid, process)| {
let command = if process.cmd().is_empty() {
process.name().to_string()
} else {
process
.cmd()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(" ")
};
let user = process
.user_id()
.and_then(|uid| self.users.get_user_by_id(uid))
.map(|u| u.name().to_string());
ProcessSample {
pid: pid.as_u32(),
cpu_percent: process.cpu_usage(),
mem_bytes: process.memory(),
state: format!("{:?}", process.status()),
user,
command,
}
})
.collect::<Vec<_>>();
let (rx_bytes, tx_bytes) = self.networks.iter().fold((0_u64, 0_u64), |acc, (_, data)| { let (rx_bytes, tx_bytes) = self.networks.iter().fold((0_u64, 0_u64), |acc, (_, data)| {
( (
acc.0.saturating_add(data.total_received()), acc.0.saturating_add(data.total_received()),
@@ -84,10 +120,13 @@ impl Collector {
) )
}); });
let load = System::load_average();
let snapshot = Snapshot { let snapshot = Snapshot {
host: self.host.clone(), host: self.host.clone(),
uptime_seconds: System::uptime(), uptime_seconds: System::uptime(),
load_avg_1m: System::load_average().one, load_avg_1m: load.one,
load_avg_5m: load.five,
load_avg_15m: load.fifteen,
cpu_total_percent, cpu_total_percent,
cpu_per_core_percent, cpu_per_core_percent,
mem_used_bytes, mem_used_bytes,
@@ -96,6 +135,7 @@ impl Collector {
swap_total_bytes, swap_total_bytes,
disk_samples, disk_samples,
process_count: self.system.processes().len(), process_count: self.system.processes().len(),
processes,
network_totals: Totals { rx_bytes, tx_bytes }, network_totals: Totals { rx_bytes, tx_bytes },
}; };

92
src/input.rs Normal file
View File

@@ -0,0 +1,92 @@
use std::time::Duration;
use crossterm::event::KeyCode;
use crate::state::{AppState, FocusPane, ProcessSort};
pub enum InputEvent {
None,
Redraw,
Quit,
}
pub fn handle_key(app: &mut AppState, key: KeyCode) -> InputEvent {
match key {
KeyCode::Char('q') | KeyCode::Esc => return InputEvent::Quit,
KeyCode::Tab => {
app.focus = match app.focus {
FocusPane::Summary => FocusPane::ProcessTable,
FocusPane::ProcessTable => FocusPane::Summary,
};
return InputEvent::Redraw;
}
KeyCode::Char(' ') => {
app.paused = !app.paused;
return InputEvent::Redraw;
}
KeyCode::Char('p') if app.focus != FocusPane::ProcessTable => {
app.paused = !app.paused;
return InputEvent::Redraw;
}
KeyCode::Char('d') => {
app.detailed_view = !app.detailed_view;
return InputEvent::Redraw;
}
KeyCode::Char('+') | KeyCode::Char('=') => {
app.refresh_interval = (app.refresh_interval + Duration::from_millis(200))
.min(Duration::from_millis(5_000));
return InputEvent::Redraw;
}
KeyCode::Char('-') => {
app.refresh_interval = app
.refresh_interval
.saturating_sub(Duration::from_millis(200))
.max(Duration::from_millis(200));
return InputEvent::Redraw;
}
_ => {}
}
if app.focus != FocusPane::ProcessTable {
return InputEvent::None;
}
match key {
KeyCode::Up | KeyCode::Char('k') => {
app.selected_process = app.selected_process.saturating_sub(1);
app.clamp_selection();
InputEvent::Redraw
}
KeyCode::Down | KeyCode::Char('j') => {
app.selected_process = app.selected_process.saturating_add(1);
app.clamp_selection();
InputEvent::Redraw
}
KeyCode::PageUp => {
app.selected_process = app.selected_process.saturating_sub(10);
app.clamp_selection();
InputEvent::Redraw
}
KeyCode::PageDown => {
app.selected_process = app.selected_process.saturating_add(10);
app.clamp_selection();
InputEvent::Redraw
}
KeyCode::Char('c') => {
app.process_sort = ProcessSort::Cpu;
app.apply_sort();
InputEvent::Redraw
}
KeyCode::Char('m') => {
app.process_sort = ProcessSort::Mem;
app.apply_sort();
InputEvent::Redraw
}
KeyCode::Char('p') => {
app.process_sort = ProcessSort::Pid;
app.apply_sort();
InputEvent::Redraw
}
_ => InputEvent::None,
}
}

View File

@@ -1,5 +1,6 @@
mod collectors; mod collectors;
mod config; mod config;
mod input;
mod state; mod state;
mod ui; mod ui;
@@ -10,12 +11,13 @@ use anyhow::Result;
use clap::Parser; use clap::Parser;
use collectors::Collector; use collectors::Collector;
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode, KeyEventKind}, event::{self, Event, KeyEventKind},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use input::{handle_key, InputEvent};
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use state::{compute_throughput, AppState, Throughput}; use state::{compute_throughput, AppState, FocusPane, ProcessSort, Throughput};
fn main() -> Result<()> { fn main() -> Result<()> {
let cfg = config::Config::parse(); let cfg = config::Config::parse();
@@ -31,10 +33,13 @@ fn run(cfg: config::Config) -> Result<()> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let mut collector = Collector::new(); let mut collector = Collector::new();
let (first_snapshot, _) = collector.sample()?; let (mut first_snapshot, _) = collector.sample()?;
let mut app = AppState { let mut app = AppState {
snapshot: first_snapshot, snapshot: {
state::sort_processes(&mut first_snapshot.processes, ProcessSort::Cpu);
first_snapshot
},
network_rate: Throughput { network_rate: Throughput {
rx_bps: 0.0, rx_bps: 0.0,
tx_bps: 0.0, tx_bps: 0.0,
@@ -42,44 +47,49 @@ fn run(cfg: config::Config) -> Result<()> {
paused: false, paused: false,
detailed_view: false, detailed_view: false,
refresh_interval: cfg.refresh_interval(), refresh_interval: cfg.refresh_interval(),
focus: FocusPane::ProcessTable,
process_sort: ProcessSort::Cpu,
selected_process: 0,
history: std::collections::VecDeque::new(),
history_cap: 120,
}; };
app.push_history();
let mut last_refresh = Instant::now(); let mut last_refresh = Instant::now();
let mut dirty = true;
let result = loop { let result = loop {
terminal.draw(|f| ui::draw(f, &app))?; if dirty {
terminal.draw(|f| ui::draw(f, &app))?;
dirty = false;
}
if event::poll(Duration::from_millis(100))? { let poll_timeout = Duration::from_millis(50);
if event::poll(poll_timeout)? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press { if key.kind != KeyEventKind::Press {
continue; continue;
} }
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()), match handle_key(&mut app, key.code) {
KeyCode::Char('p') | KeyCode::Char(' ') => app.paused = !app.paused, InputEvent::Quit => break Ok(()),
KeyCode::Char('d') => app.detailed_view = !app.detailed_view, InputEvent::Redraw => dirty = true,
KeyCode::Char('+') | KeyCode::Char('=') => { InputEvent::None => {}
app.refresh_interval = (app.refresh_interval + Duration::from_millis(200))
.min(Duration::from_millis(5_000));
}
KeyCode::Char('-') => {
app.refresh_interval = app
.refresh_interval
.saturating_sub(Duration::from_millis(200))
.max(Duration::from_millis(200));
}
_ => {}
} }
} }
} }
if !app.paused && last_refresh.elapsed() >= app.refresh_interval { if !app.paused && last_refresh.elapsed() >= app.refresh_interval {
let prev_totals = app.snapshot.network_totals; let prev_totals = app.snapshot.network_totals;
let (new_snapshot, elapsed) = collector.sample()?; let (mut new_snapshot, elapsed) = collector.sample()?;
state::sort_processes(&mut new_snapshot.processes, app.process_sort);
app.network_rate = app.network_rate =
compute_throughput(prev_totals, new_snapshot.network_totals, elapsed); compute_throughput(prev_totals, new_snapshot.network_totals, elapsed);
app.snapshot = new_snapshot; app.snapshot = new_snapshot;
app.clamp_selection();
app.push_history();
last_refresh = Instant::now(); last_refresh = Instant::now();
dirty = true;
} }
}; };

View File

@@ -1,4 +1,4 @@
use std::time::Duration; use std::{cmp::Ordering, collections::VecDeque, time::Duration};
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct Totals { pub struct Totals {
@@ -14,16 +14,28 @@ pub struct Throughput {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DiskSample { pub struct DiskSample {
pub name: String, pub mount: String,
pub total_bytes: u64, pub total_bytes: u64,
pub used_bytes: u64, pub used_bytes: u64,
} }
#[derive(Debug, Clone)]
pub struct ProcessSample {
pub pid: u32,
pub cpu_percent: f32,
pub mem_bytes: u64,
pub state: String,
pub user: Option<String>,
pub command: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Snapshot { pub struct Snapshot {
pub host: String, pub host: String,
pub uptime_seconds: u64, pub uptime_seconds: u64,
pub load_avg_1m: f64, pub load_avg_1m: f64,
pub load_avg_5m: f64,
pub load_avg_15m: f64,
pub cpu_total_percent: f32, pub cpu_total_percent: f32,
pub cpu_per_core_percent: Vec<f32>, pub cpu_per_core_percent: Vec<f32>,
pub mem_used_bytes: u64, pub mem_used_bytes: u64,
@@ -32,6 +44,28 @@ pub struct Snapshot {
pub swap_total_bytes: u64, pub swap_total_bytes: u64,
pub disk_samples: Vec<DiskSample>, pub disk_samples: Vec<DiskSample>,
pub process_count: usize, pub process_count: usize,
pub processes: Vec<ProcessSample>,
pub network_totals: Totals,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessSort {
Cpu,
Mem,
Pid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusPane {
Summary,
ProcessTable,
}
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct SamplePoint {
pub cpu_total_percent: f32,
pub mem_used_bytes: u64,
pub network_totals: Totals, pub network_totals: Totals,
} }
@@ -42,6 +76,53 @@ pub struct AppState {
pub paused: bool, pub paused: bool,
pub detailed_view: bool, pub detailed_view: bool,
pub refresh_interval: Duration, pub refresh_interval: Duration,
pub focus: FocusPane,
pub process_sort: ProcessSort,
pub selected_process: usize,
pub history: VecDeque<SamplePoint>,
pub history_cap: usize,
}
impl AppState {
pub fn apply_sort(&mut self) {
sort_processes(&mut self.snapshot.processes, self.process_sort);
self.clamp_selection();
}
pub fn clamp_selection(&mut self) {
if self.snapshot.processes.is_empty() {
self.selected_process = 0;
return;
}
let max_idx = self.snapshot.processes.len().saturating_sub(1);
self.selected_process = self.selected_process.min(max_idx);
}
pub fn push_history(&mut self) {
self.history.push_back(SamplePoint {
cpu_total_percent: self.snapshot.cpu_total_percent,
mem_used_bytes: self.snapshot.mem_used_bytes,
network_totals: self.snapshot.network_totals,
});
while self.history.len() > self.history_cap {
self.history.pop_front();
}
}
}
pub fn sort_processes(processes: &mut [ProcessSample], key: ProcessSort) {
processes.sort_by(|a, b| match key {
ProcessSort::Cpu => desc_f32(a.cpu_percent, b.cpu_percent).then_with(|| a.pid.cmp(&b.pid)),
ProcessSort::Mem => b
.mem_bytes
.cmp(&a.mem_bytes)
.then_with(|| a.pid.cmp(&b.pid)),
ProcessSort::Pid => a.pid.cmp(&b.pid),
});
}
fn desc_f32(a: f32, b: f32) -> Ordering {
b.partial_cmp(&a).unwrap_or(Ordering::Equal)
} }
pub fn compute_throughput(prev: Totals, curr: Totals, elapsed: Duration) -> Throughput { pub fn compute_throughput(prev: Totals, curr: Totals, elapsed: Duration) -> Throughput {
@@ -92,4 +173,31 @@ mod tests {
assert_eq!(rate.rx_bps, 0.0); assert_eq!(rate.rx_bps, 0.0);
assert_eq!(rate.tx_bps, 0.0); assert_eq!(rate.tx_bps, 0.0);
} }
#[test]
fn sort_by_cpu_then_pid() {
let mut processes = vec![
ProcessSample {
pid: 2,
cpu_percent: 10.0,
mem_bytes: 1,
state: "Run".into(),
user: None,
command: "a".into(),
},
ProcessSample {
pid: 1,
cpu_percent: 10.0,
mem_bytes: 2,
state: "Run".into(),
user: None,
command: "b".into(),
},
];
sort_processes(&mut processes, ProcessSort::Cpu);
assert_eq!(processes[0].pid, 1);
assert_eq!(processes[1].pid, 2);
}
} }

519
src/ui.rs
View File

@@ -2,20 +2,19 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph, Wrap}, widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap},
Frame, Frame,
}; };
use crate::state::AppState; use crate::state::{AppState, FocusPane, ProcessSort};
pub fn draw(frame: &mut Frame, state: &AppState) { pub fn draw(frame: &mut Frame, state: &AppState) {
let root = frame.size(); let root = frame.size();
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), Constraint::Length(1),
Constraint::Min(7), Constraint::Min(8),
Constraint::Length(1), Constraint::Length(1),
]) ])
.split(root); .split(root);
@@ -27,79 +26,162 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) { fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) {
let uptime = format_uptime(state.snapshot.uptime_seconds); let uptime = format_uptime(state.snapshot.uptime_seconds);
let refresh_ms = state.refresh_interval.as_millis(); let line = format!(
let title = format!( "{} up {} refresh:{}ms procs:{} load:{:.2}/{:.2}/{:.2}{}",
" {} | uptime {} | proc {} | load(1m) {:.2} | {}ms {}",
state.snapshot.host, state.snapshot.host,
uptime, uptime,
state.refresh_interval.as_millis(),
state.snapshot.process_count, state.snapshot.process_count,
state.snapshot.load_avg_1m, state.snapshot.load_avg_1m,
refresh_ms, state.snapshot.load_avg_5m,
if state.paused { "[PAUSED]" } else { "" } state.snapshot.load_avg_15m,
if state.paused { " [PAUSED]" } else { "" }
); );
frame.render_widget( frame.render_widget(
Paragraph::new(title) Paragraph::new(line).style(Style::default().fg(Color::Gray)),
.block(Block::default().borders(Borders::ALL).title("sloptop"))
.alignment(Alignment::Left),
area, area,
); );
} }
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) { fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
if area.width < 90 { let has_sidebar = area.width >= 100;
let chunks = Layout::default() let body_chunks = if has_sidebar {
.direction(Direction::Vertical) Layout::default()
.constraints([ .direction(Direction::Horizontal)
Constraint::Length(3), .constraints([Constraint::Min(60), Constraint::Length(28)])
Constraint::Length(3), .split(area)
Constraint::Length(6), } else {
Constraint::Min(4), Layout::default()
]) .direction(Direction::Horizontal)
.split(area); .constraints([Constraint::Min(1)])
.split(area)
};
draw_cpu(frame, chunks[0], state); let main = body_chunks[0];
draw_memory(frame, chunks[1], state); let sidebar = if has_sidebar {
draw_network(frame, chunks[2], state); Some(body_chunks[1])
draw_disks(frame, chunks[3], state); } 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; 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() let chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
.split(area); .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) .direction(Direction::Vertical)
.constraints([ .constraints([Constraint::Length(1), Constraint::Length(1)])
Constraint::Length(3), .split(inner);
Constraint::Length(3),
Constraint::Min(5),
])
.split(chunks[0]);
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( let mem_pct = percent(
state.snapshot.mem_used_bytes, state.snapshot.mem_used_bytes,
state.snapshot.mem_total_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, state.snapshot.swap_total_bytes,
); );
let text = vec![Line::from(vec![ frame.render_widget(
Span::raw(format!("mem {:>5.1}% ", mem_pct)), Gauge::default()
Span::styled( .gauge_style(Style::default().fg(Color::Green))
format!( .percent(mem_pct as u16)
"{} / {}", .label(format!(
"mem {:>5.1}% {} / {}",
mem_pct,
fmt_bytes(state.snapshot.mem_used_bytes), fmt_bytes(state.snapshot.mem_used_bytes),
fmt_bytes(state.snapshot.mem_total_bytes) fmt_bytes(state.snapshot.mem_total_bytes)
), )),
Style::default().fg(Color::Green), rows[0],
),
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,
); );
}
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( frame.render_widget(
Paragraph::new(lines) Gauge::default()
.block( .gauge_style(Style::default().fg(Color::Yellow))
Block::default() .percent(swap_pct as u16)
.title("Network / Details") .label(format!(
.borders(Borders::ALL), "swap {:>5.1}% {} / {}",
) swap_pct,
.wrap(Wrap { trim: true }), fmt_bytes(state.snapshot.swap_used_bytes),
area, fmt_bytes(state.snapshot.swap_total_bytes)
)),
rows[1],
); );
} }
fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) { fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
let mut lines = Vec::new(); let block = Block::default().title("Disk").borders(Borders::ALL);
if state.snapshot.disk_samples.is_empty() { let inner = block.inner(area);
lines.push(Line::from("No disks reported")); frame.render_widget(block, area);
} else {
for disk in &state.snapshot.disk_samples { if inner.height == 0 {
let pct = percent(disk.used_bytes, disk.total_bytes); return;
lines.push(Line::from(format!( }
"{} {:>5.1}% {} / {}",
disk.name, let max_disks = inner.height as usize;
pct, let mut disks = state.snapshot.disk_samples.clone();
fmt_bytes(disk.used_bytes), disks.sort_by(|a, b| {
fmt_bytes(disk.total_bytes) 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( frame.render_widget(
Paragraph::new(lines) Paragraph::new(lines)
.block(Block::default().title("Disk").borders(Borders::ALL)) .block(block)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true }), .wrap(Wrap { trim: true }),
area, area,
); );
} }
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) { fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
let mode = if state.detailed_view { let focused = state.focus == FocusPane::ProcessTable;
"detail:on" 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 { } 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 { fn percent(used: u64, total: u64) -> f64 {
if total == 0 { if total == 0 {
return 0.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 { fn fmt_bytes(bytes: u64) -> String {
@@ -261,3 +475,12 @@ fn format_uptime(seconds: u64) -> String {
format!("{hours:02}:{mins:02}:{secs:02}") 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
}