From c897d94180831fd31aff7849d7e8542d4a218658 Mon Sep 17 00:00:00 2001 From: DCreason Date: Thu, 19 Feb 2026 01:52:59 +0000 Subject: [PATCH] sprint 3 --- README.md | 24 ++- src/collectors.rs | 16 ++ src/input.rs | 19 +- src/main.rs | 16 +- src/state.rs | 143 +++++++++++---- src/ui.rs | 435 ++++++++++++++++++++++++++++++++++------------ 6 files changed, 490 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index a7e8e50..c923b01 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ ## Features -- CPU usage (total + per-core in detailed view) -- Memory and swap usage -- Disk usage per mounted disk -- Network throughput (RX/TX bytes per second) and totals -- Process count, uptime, host, and 1-minute load average -- Adjustable refresh interval and pause/resume -- Responsive layout for narrow terminals +- htop-style process table by default with selection and scrolling +- Sortable process columns (PID/CPU/MEM) with direction indicators +- Toggleable process details pane (right on wide terminals, bottom on narrow) +- CPU total bar + per-core mini-bars +- Memory/swap bars and disk usage bars (top-N by usage) +- Disk pseudo-filesystem filtering toggle (`tmpfs`/`devtmpfs`/`overlay`) +- Compact network rates + totals + interface label +- Adjustable refresh interval, pause/resume, responsive layout ## Build @@ -42,10 +43,15 @@ Accepted interval range: `200..=5000` ms. ## Keybindings - `q` / `Esc`: quit -- `p` / `Space`: pause/resume sampling +- `p` / `Space`: pause/resume sampling (when summary is focused) - `+` / `=`: increase refresh interval by 200 ms - `-`: decrease refresh interval by 200 ms -- `d`: toggle detailed view (shows per-core CPU list) +- `Tab`: switch focus between summary and process table +- `↑`/`↓`, `PgUp`/`PgDn`, `j`/`k`: move process selection +- `c` / `m` / `p`: sort by CPU / MEM / PID (`p` sorts PID when process table focused) +- `r`: reverse sort direction +- `d`: toggle process details pane +- `f`: toggle pseudo-filesystem disk filtering ## Notes diff --git a/src/collectors.rs b/src/collectors.rs index 5d45245..b5cf260 100644 --- a/src/collectors.rs +++ b/src/collectors.rs @@ -73,8 +73,10 @@ impl Collector { let total = disk.total_space(); let used = total.saturating_sub(disk.available_space()); let mount = disk.mount_point().to_string_lossy().into_owned(); + let fs_type = disk.file_system().to_string_lossy().into_owned(); DiskSample { mount, + fs_type, total_bytes: total, used_bytes: used, } @@ -97,6 +99,7 @@ impl Collector { .join(" ") }; + let uid = process.user_id().map(|u| format!("{u:?}")); let user = process .user_id() .and_then(|uid| self.users.get_user_by_id(uid)) @@ -104,11 +107,17 @@ impl Collector { ProcessSample { pid: pid.as_u32(), + ppid: process.parent().map(|p| p.as_u32()), cpu_percent: process.cpu_usage(), mem_bytes: process.memory(), + vms_bytes: process.virtual_memory(), state: format!("{:?}", process.status()), user, + uid, command, + threads: process.tasks().map(|tasks| tasks.len()), + nice: None, + cpu_time_seconds: Some(process.run_time()), } }) .collect::>(); @@ -120,6 +129,12 @@ impl Collector { ) }); + let network_interfaces = self + .networks + .keys() + .map(|name| name.to_string()) + .collect::>(); + let load = System::load_average(); let snapshot = Snapshot { host: self.host.clone(), @@ -137,6 +152,7 @@ impl Collector { process_count: self.system.processes().len(), processes, network_totals: Totals { rx_bytes, tx_bytes }, + network_interfaces, }; Ok((snapshot, elapsed)) diff --git a/src/input.rs b/src/input.rs index 5a349c4..603bd3e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -29,7 +29,11 @@ pub fn handle_key(app: &mut AppState, key: KeyCode) -> InputEvent { return InputEvent::Redraw; } KeyCode::Char('d') => { - app.detailed_view = !app.detailed_view; + app.show_process_details = !app.show_process_details; + return InputEvent::Redraw; + } + KeyCode::Char('f') => { + app.show_pseudo_fs = !app.show_pseudo_fs; return InputEvent::Redraw; } KeyCode::Char('+') | KeyCode::Char('=') => { @@ -73,18 +77,19 @@ pub fn handle_key(app: &mut AppState, key: KeyCode) -> InputEvent { InputEvent::Redraw } KeyCode::Char('c') => { - app.process_sort = ProcessSort::Cpu; - app.apply_sort(); + app.set_sort(ProcessSort::Cpu); InputEvent::Redraw } KeyCode::Char('m') => { - app.process_sort = ProcessSort::Mem; - app.apply_sort(); + app.set_sort(ProcessSort::Mem); InputEvent::Redraw } KeyCode::Char('p') => { - app.process_sort = ProcessSort::Pid; - app.apply_sort(); + app.set_sort(ProcessSort::Pid); + InputEvent::Redraw + } + KeyCode::Char('r') => { + app.reverse_sort(); InputEvent::Redraw } _ => InputEvent::None, diff --git a/src/main.rs b/src/main.rs index a6c9a60..ca1bbf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,11 @@ fn run(cfg: config::Config) -> Result<()> { let mut app = AppState { snapshot: { - state::sort_processes(&mut first_snapshot.processes, ProcessSort::Cpu); + state::sort_processes( + &mut first_snapshot.processes, + ProcessSort::Cpu, + state::default_order_for(ProcessSort::Cpu), + ); first_snapshot }, network_rate: Throughput { @@ -45,10 +49,12 @@ fn run(cfg: config::Config) -> Result<()> { tx_bps: 0.0, }, paused: false, - detailed_view: false, + show_process_details: false, + show_pseudo_fs: false, refresh_interval: cfg.refresh_interval(), focus: FocusPane::ProcessTable, process_sort: ProcessSort::Cpu, + process_sort_order: state::default_order_for(ProcessSort::Cpu), selected_process: 0, history: std::collections::VecDeque::new(), history_cap: 120, @@ -82,7 +88,11 @@ fn run(cfg: config::Config) -> Result<()> { if !app.paused && last_refresh.elapsed() >= app.refresh_interval { let prev_totals = app.snapshot.network_totals; let (mut new_snapshot, elapsed) = collector.sample()?; - state::sort_processes(&mut new_snapshot.processes, app.process_sort); + state::sort_processes( + &mut new_snapshot.processes, + app.process_sort, + app.process_sort_order, + ); app.network_rate = compute_throughput(prev_totals, new_snapshot.network_totals, elapsed); app.snapshot = new_snapshot; diff --git a/src/state.rs b/src/state.rs index cd64f5d..8e6d37d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -15,6 +15,7 @@ pub struct Throughput { #[derive(Debug, Clone)] pub struct DiskSample { pub mount: String, + pub fs_type: String, pub total_bytes: u64, pub used_bytes: u64, } @@ -22,11 +23,17 @@ pub struct DiskSample { #[derive(Debug, Clone)] pub struct ProcessSample { pub pid: u32, + pub ppid: Option, pub cpu_percent: f32, pub mem_bytes: u64, + pub vms_bytes: u64, pub state: String, pub user: Option, + pub uid: Option, pub command: String, + pub threads: Option, + pub nice: Option, + pub cpu_time_seconds: Option, } #[derive(Debug, Clone)] @@ -46,6 +53,7 @@ pub struct Snapshot { pub process_count: usize, pub processes: Vec, pub network_totals: Totals, + pub network_interfaces: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -55,6 +63,12 @@ pub enum ProcessSort { Pid, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortOrder { + Asc, + Desc, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusPane { Summary, @@ -74,10 +88,12 @@ pub struct AppState { pub snapshot: Snapshot, pub network_rate: Throughput, pub paused: bool, - pub detailed_view: bool, + pub show_process_details: bool, + pub show_pseudo_fs: bool, pub refresh_interval: Duration, pub focus: FocusPane, pub process_sort: ProcessSort, + pub process_sort_order: SortOrder, pub selected_process: usize, pub history: VecDeque, pub history_cap: usize, @@ -85,10 +101,29 @@ pub struct AppState { impl AppState { pub fn apply_sort(&mut self) { - sort_processes(&mut self.snapshot.processes, self.process_sort); + sort_processes( + &mut self.snapshot.processes, + self.process_sort, + self.process_sort_order, + ); self.clamp_selection(); } + pub fn set_sort(&mut self, next_sort: ProcessSort) { + if self.process_sort == next_sort { + self.process_sort_order = reverse_order(self.process_sort_order); + } else { + self.process_sort = next_sort; + self.process_sort_order = default_order_for(next_sort); + } + self.apply_sort(); + } + + pub fn reverse_sort(&mut self) { + self.process_sort_order = reverse_order(self.process_sort_order); + self.apply_sort(); + } + pub fn clamp_selection(&mut self) { if self.snapshot.processes.is_empty() { self.selected_process = 0; @@ -110,19 +145,46 @@ impl AppState { } } -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), +pub fn default_order_for(sort: ProcessSort) -> SortOrder { + match sort { + ProcessSort::Cpu | ProcessSort::Mem => SortOrder::Desc, + ProcessSort::Pid => SortOrder::Asc, + } +} + +pub fn reverse_order(order: SortOrder) -> SortOrder { + match order { + SortOrder::Asc => SortOrder::Desc, + SortOrder::Desc => SortOrder::Asc, + } +} + +pub fn sort_indicator(order: SortOrder) -> char { + match order { + SortOrder::Asc => '▲', + SortOrder::Desc => '▼', + } +} + +pub fn sort_processes(processes: &mut [ProcessSample], key: ProcessSort, order: SortOrder) { + processes.sort_by(|a, b| { + let primary = match key { + ProcessSort::Cpu => cmp_f32_asc(a.cpu_percent, b.cpu_percent), + ProcessSort::Mem => a.mem_bytes.cmp(&b.mem_bytes), + ProcessSort::Pid => a.pid.cmp(&b.pid), + }; + + let primary = match order { + SortOrder::Asc => primary, + SortOrder::Desc => primary.reverse(), + }; + + primary.then_with(|| a.pid.cmp(&b.pid)) }); } -fn desc_f32(a: f32, b: f32) -> Ordering { - b.partial_cmp(&a).unwrap_or(Ordering::Equal) +fn cmp_f32_asc(a: f32, b: f32) -> Ordering { + a.partial_cmp(&b).unwrap_or(Ordering::Equal) } pub fn compute_throughput(prev: Totals, curr: Totals, elapsed: Duration) -> Throughput { @@ -140,6 +202,23 @@ pub fn compute_throughput(prev: Totals, curr: Totals, elapsed: Duration) -> Thro mod tests { use super::*; + fn p(pid: u32, cpu: f32, mem_bytes: u64) -> ProcessSample { + ProcessSample { + pid, + ppid: None, + cpu_percent: cpu, + mem_bytes, + vms_bytes: 0, + state: "Run".into(), + user: None, + uid: None, + command: String::new(), + threads: None, + nice: None, + cpu_time_seconds: None, + } + } + #[test] fn throughput_is_delta_over_time() { let prev = Totals { @@ -175,29 +254,25 @@ mod tests { } #[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); + fn sort_by_cpu_then_pid_desc() { + let mut processes = vec![p(2, 10.0, 1), p(1, 10.0, 2), p(3, 90.0, 1)]; + sort_processes(&mut processes, ProcessSort::Cpu, SortOrder::Desc); + assert_eq!(processes[0].pid, 3); + assert_eq!(processes[1].pid, 1); + assert_eq!(processes[2].pid, 2); + } + #[test] + fn sort_by_pid_asc() { + let mut processes = vec![p(4, 0.0, 0), p(1, 0.0, 0), p(2, 0.0, 0)]; + sort_processes(&mut processes, ProcessSort::Pid, SortOrder::Asc); assert_eq!(processes[0].pid, 1); - assert_eq!(processes[1].pid, 2); + assert_eq!(processes[2].pid, 4); + } + + #[test] + fn reverse_sort_order_toggles() { + assert_eq!(reverse_order(SortOrder::Asc), SortOrder::Desc); + assert_eq!(reverse_order(SortOrder::Desc), SortOrder::Asc); } } diff --git a/src/ui.rs b/src/ui.rs index 93ad07c..5e5d7f4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,12 +1,14 @@ +use std::collections::HashSet; + use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, - text::{Line, Span}, + text::Line, widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap}, Frame, }; -use crate::state::{AppState, FocusPane, ProcessSort}; +use crate::state::{sort_indicator, AppState, FocusPane, ProcessSample, ProcessSort}; pub fn draw(frame: &mut Frame, state: &AppState) { let root = frame.size(); @@ -45,11 +47,12 @@ fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) { } fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) { - let has_sidebar = area.width >= 100; - let body_chunks = if has_sidebar { + let has_right_sidebar = area.width >= 120; + + let body_chunks = if has_right_sidebar { Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(60), Constraint::Length(28)]) + .constraints([Constraint::Min(68), Constraint::Length(34)]) .split(area) } else { Layout::default() @@ -59,41 +62,53 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) { }; let main = body_chunks[0]; - let sidebar = if has_sidebar { + let right = if has_right_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 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(9), + Constraint::Length(middle_height), Constraint::Min(7), ]) .split(main); - draw_cpu_section(frame, main_chunks[0], state, cores_visible); + draw_cpu_section(frame, main_chunks[0], state); 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); + 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 { - 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) - ); + 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(6)]) + .split(sidebar); + draw_process_details(frame, split[0], state); + draw_network(frame, split[1], state); + } else { + draw_network(frame, sidebar, state); + } + } else { + let net_line = compact_network_line(state); let net_area = Rect { x: main.x, y: main.y, @@ -107,22 +122,18 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) { } } -fn draw_cpu_section(frame: &mut Frame, area: Rect, state: &AppState, cores_visible: usize) { +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 == 0 { + if inner.height < 2 || inner.width < 10 { return; } - let mut rows = vec![Constraint::Length(1)]; - for _ in 0..cores_visible { - rows.push(Constraint::Length(1)); - } - let rows = Layout::default() + let split = Layout::default() .direction(Direction::Vertical) - .constraints(rows) + .constraints([Constraint::Length(1), Constraint::Min(1)]) .split(inner); let total_label = format!( @@ -138,24 +149,51 @@ fn draw_cpu_section(frame: &mut Frame, area: Rect, state: &AppState, cores_visib ) .percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16) .label(total_label), - rows[0], + split[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], - ); + 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::(); + 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) { @@ -196,10 +234,10 @@ fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) { .gauge_style(Style::default().fg(Color::Green)) .percent(mem_pct as u16) .label(format!( - "mem {:>5.1}% {} / {}", + "mem {:>5.1}% {}/{}", mem_pct, - fmt_bytes(state.snapshot.mem_used_bytes), - fmt_bytes(state.snapshot.mem_total_bytes) + fmt_bytes_compact(state.snapshot.mem_used_bytes), + fmt_bytes_compact(state.snapshot.mem_total_bytes) )), rows[0], ); @@ -209,17 +247,23 @@ fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) { .gauge_style(Style::default().fg(Color::Yellow)) .percent(swap_pct as u16) .label(format!( - "swap {:>5.1}% {} / {}", + "swap {:>5.1}% {}/{}", swap_pct, - fmt_bytes(state.snapshot.swap_used_bytes), - fmt_bytes(state.snapshot.swap_total_bytes) + 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("Disk").borders(Borders::ALL); + 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); @@ -227,65 +271,82 @@ fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) { return; } - let max_disks = inner.height as usize; - let mut disks = state.snapshot.disk_samples.clone(); + 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::>(); + 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() + let rows = inner.height as usize; + let layout = Layout::default() .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(1); max_disks.max(1)]) + .constraints(vec![Constraint::Length(1); rows.max(1)]) .split(inner); - for (i, disk) in disks.iter().take(max_disks).enumerate() { + 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!( - "{} {:>5.1}% {} / {}", - truncate(&disk.mount, 10), + "{} {:>4.0}% {}/{}", + truncate(&disk.mount, 12), pct, - fmt_bytes(disk.used_bytes), - fmt_bytes(disk.total_bytes) + fmt_bytes_compact(disk.used_bytes), + fmt_bytes_compact(disk.total_bytes) )), - rows[i], + layout[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) - ))); - } + 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) @@ -298,13 +359,11 @@ fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) { 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 [{}]", - match state.process_sort { - ProcessSort::Cpu => "CPU", - ProcessSort::Mem => "MEM", - ProcessSort::Pid => "PID", - } + "Processes [{}{}]", + sort_key_label(state.process_sort), + sort_char ); let border_style = if focused { Style::default().fg(Color::Cyan) @@ -324,7 +383,7 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { return; } - let show_user = inner.width >= 84; + let show_user = inner.width >= 90; let visible_rows = inner.height.saturating_sub(1) as usize; let selected = state .selected_process @@ -355,10 +414,10 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { 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(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, 28)), + Cell::from(truncate(&p.command, 30)), ]) .style(style) } else { @@ -366,7 +425,7 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { 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(format!("{:>9}", fmt_bytes_compact(p.mem_bytes))), Cell::from(truncate(&p.state, 6)), Cell::from(truncate(&p.command, 22)), ]) @@ -378,7 +437,13 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { let (header, widths) = if show_user { ( Row::new(vec![ - "PID", "CPU%", "MEM%", "RSS", "STATE", "USER", "COMMAND", + 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() @@ -387,8 +452,8 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { ), vec![ Constraint::Length(6), - Constraint::Length(5), - Constraint::Length(5), + Constraint::Length(6), + Constraint::Length(6), Constraint::Length(9), Constraint::Length(6), Constraint::Length(8), @@ -397,15 +462,23 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { ) } else { ( - Row::new(vec!["PID", "CPU%", "MEM%", "RSS", "STATE", "COMMAND"]).style( + 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(5), - Constraint::Length(5), + Constraint::Length(6), + Constraint::Length(6), Constraint::Length(9), Constraint::Length(6), Constraint::Min(10), @@ -420,14 +493,75 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) { 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 help = "q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | arrows/PgUp/PgDn"; - let line = format!("{help} | {focus}"); + 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) @@ -436,6 +570,44 @@ fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) { ); } +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_network_line(state: &AppState) -> String { + 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) + ) +} + +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; @@ -443,10 +615,10 @@ fn percent(used: u64, total: u64) -> f64 { ((used as f64 / total as f64) * 100.0).clamp(0.0, 100.0) } -fn fmt_bytes(bytes: u64) -> String { +fn fmt_bytes_compact(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; if bytes == 0 { - return "0 B".to_string(); + return "0B".to_string(); } let mut value = bytes as f64; @@ -457,9 +629,9 @@ fn fmt_bytes(bytes: u64) -> String { } if idx == 0 { - format!("{} {}", bytes, UNITS[idx]) + format!("{}{}", bytes, UNITS[idx]) } else { - format!("{value:.1} {}", UNITS[idx]) + format!("{value:.1}{}", UNITS[idx]) } } @@ -484,3 +656,46 @@ fn truncate(s: &str, max: usize) -> 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: 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")); + } +}