From 0784b7fc8f7b2358035cce7562f61b09a4564166 Mon Sep 17 00:00:00 2001 From: DCreason Date: Thu, 19 Feb 2026 01:39:45 +0000 Subject: [PATCH] sprint 2 --- src/collectors.rs | 50 ++++- src/input.rs | 92 ++++++++ src/main.rs | 54 +++-- src/state.rs | 112 +++++++++- src/ui.rs | 519 +++++++++++++++++++++++++++++++++------------- 5 files changed, 650 insertions(+), 177 deletions(-) create mode 100644 src/input.rs diff --git a/src/collectors.rs b/src/collectors.rs index a3c4fa4..5d45245 100644 --- a/src/collectors.rs +++ b/src/collectors.rs @@ -1,14 +1,15 @@ use std::time::{Duration, Instant}; 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 { system: System, networks: Networks, disks: Disks, + users: Users, host: String, last_sample_at: Instant, } @@ -28,13 +29,14 @@ impl Collector { networks.refresh(); 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()); Self { system, networks, disks, + users, host, last_sample_at: Instant::now(), } @@ -46,6 +48,7 @@ impl Collector { self.system.refresh_processes(); self.networks.refresh(); self.disks.refresh(); + self.users.refresh_list(); let elapsed = self.last_sample_at.elapsed(); self.last_sample_at = Instant::now(); @@ -69,14 +72,47 @@ impl Collector { .map(|disk| { let total = disk.total_space(); let used = total.saturating_sub(disk.available_space()); + let mount = disk.mount_point().to_string_lossy().into_owned(); DiskSample { - name: disk.name().to_string_lossy().into_owned(), + mount, total_bytes: total, used_bytes: used, } }) .collect::>(); + 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::>() + .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::>(); + let (rx_bytes, tx_bytes) = self.networks.iter().fold((0_u64, 0_u64), |acc, (_, data)| { ( acc.0.saturating_add(data.total_received()), @@ -84,10 +120,13 @@ impl Collector { ) }); + let load = System::load_average(); let snapshot = Snapshot { host: self.host.clone(), 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_per_core_percent, mem_used_bytes, @@ -96,6 +135,7 @@ impl Collector { swap_total_bytes, disk_samples, process_count: self.system.processes().len(), + processes, network_totals: Totals { rx_bytes, tx_bytes }, }; diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..5a349c4 --- /dev/null +++ b/src/input.rs @@ -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, + } +} diff --git a/src/main.rs b/src/main.rs index 327ce51..a6c9a60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod collectors; mod config; +mod input; mod state; mod ui; @@ -10,12 +11,13 @@ use anyhow::Result; use clap::Parser; use collectors::Collector; use crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, + event::{self, Event, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use input::{handle_key, InputEvent}; use ratatui::{backend::CrosstermBackend, Terminal}; -use state::{compute_throughput, AppState, Throughput}; +use state::{compute_throughput, AppState, FocusPane, ProcessSort, Throughput}; fn main() -> Result<()> { let cfg = config::Config::parse(); @@ -31,10 +33,13 @@ fn run(cfg: config::Config) -> Result<()> { let mut terminal = Terminal::new(backend)?; let mut collector = Collector::new(); - let (first_snapshot, _) = collector.sample()?; + let (mut first_snapshot, _) = collector.sample()?; let mut app = AppState { - snapshot: first_snapshot, + snapshot: { + state::sort_processes(&mut first_snapshot.processes, ProcessSort::Cpu); + first_snapshot + }, network_rate: Throughput { rx_bps: 0.0, tx_bps: 0.0, @@ -42,44 +47,49 @@ fn run(cfg: config::Config) -> Result<()> { paused: false, detailed_view: false, 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 dirty = true; 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 key.kind != KeyEventKind::Press { continue; } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break Ok(()), - KeyCode::Char('p') | KeyCode::Char(' ') => app.paused = !app.paused, - KeyCode::Char('d') => app.detailed_view = !app.detailed_view, - KeyCode::Char('+') | KeyCode::Char('=') => { - 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)); - } - _ => {} + + match handle_key(&mut app, key.code) { + InputEvent::Quit => break Ok(()), + InputEvent::Redraw => dirty = true, + InputEvent::None => {} } } } if !app.paused && last_refresh.elapsed() >= app.refresh_interval { 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 = compute_throughput(prev_totals, new_snapshot.network_totals, elapsed); app.snapshot = new_snapshot; + app.clamp_selection(); + app.push_history(); last_refresh = Instant::now(); + dirty = true; } }; diff --git a/src/state.rs b/src/state.rs index b6bf7ce..cd64f5d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{cmp::Ordering, collections::VecDeque, time::Duration}; #[derive(Debug, Clone, Copy)] pub struct Totals { @@ -14,16 +14,28 @@ pub struct Throughput { #[derive(Debug, Clone)] pub struct DiskSample { - pub name: String, + pub mount: String, pub total_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, + pub command: String, +} + #[derive(Debug, Clone)] pub struct Snapshot { pub host: String, pub uptime_seconds: u64, pub load_avg_1m: f64, + pub load_avg_5m: f64, + pub load_avg_15m: f64, pub cpu_total_percent: f32, pub cpu_per_core_percent: Vec, pub mem_used_bytes: u64, @@ -32,6 +44,28 @@ pub struct Snapshot { pub swap_total_bytes: u64, pub disk_samples: Vec, pub process_count: usize, + pub processes: Vec, + 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, } @@ -42,6 +76,53 @@ pub struct AppState { pub paused: bool, pub detailed_view: bool, pub refresh_interval: Duration, + pub focus: FocusPane, + pub process_sort: ProcessSort, + pub selected_process: usize, + pub history: VecDeque, + 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 { @@ -92,4 +173,31 @@ mod tests { assert_eq!(rate.rx_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); + } } diff --git a/src/ui.rs b/src/ui.rs index 3e0e504..93ad07c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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::>(); + + 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::(); + out.push('~'); + out +}