sprint 2
This commit is contained in:
@@ -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
92
src/input.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/main.rs
52
src/main.rs
@@ -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 {
|
||||||
|
if dirty {
|
||||||
terminal.draw(|f| ui::draw(f, &app))?;
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
112
src/state.rs
112
src/state.rs
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
487
src/ui.rs
487
src/ui.rs
@@ -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,
|
||||||
|
state.snapshot.load_avg_15m,
|
||||||
if state.paused { " [PAUSED]" } else { "" }
|
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 {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3),
|
Constraint::Length(cpu_height),
|
||||||
Constraint::Length(3),
|
Constraint::Length(9),
|
||||||
Constraint::Length(6),
|
Constraint::Min(7),
|
||||||
Constraint::Min(4),
|
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(main);
|
||||||
|
|
||||||
draw_cpu(frame, chunks[0], state);
|
draw_cpu_section(frame, main_chunks[0], state, cores_visible);
|
||||||
draw_memory(frame, chunks[1], state);
|
draw_memory_disk_section(frame, main_chunks[1], state);
|
||||||
draw_network(frame, chunks[2], state);
|
draw_process_table(frame, main_chunks[2], state);
|
||||||
draw_disks(frame, chunks[3], 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 chunks = Layout::default()
|
let mut rows = vec![Constraint::Length(1)];
|
||||||
.direction(Direction::Horizontal)
|
for _ in 0..cores_visible {
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
rows.push(Constraint::Length(1));
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let left_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3),
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
let rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(rows)
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
fn draw_cpu(frame: &mut Frame, area: Rect, state: &AppState) {
|
let total_label = format!(
|
||||||
let gauge = Gauge::default()
|
"total {:>5.1}%",
|
||||||
.block(Block::default().title("CPU").borders(Borders::ALL))
|
state.snapshot.cpu_total_percent.clamp(0.0, 100.0)
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
.gauge_style(
|
.gauge_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Cyan)
|
.fg(Color::Cyan)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
|
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
|
||||||
.label(format!("{:>5.1}%", state.snapshot.cpu_total_percent));
|
.label(total_label),
|
||||||
frame.render_widget(gauge, area);
|
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(frame: &mut Frame, area: Rect, state: &AppState) {
|
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(
|
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!(
|
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Yellow))
|
||||||
|
.percent(swap_pct as u16)
|
||||||
|
.label(format!(
|
||||||
"swap {:>5.1}% {} / {}",
|
"swap {:>5.1}% {} / {}",
|
||||||
swap_pct,
|
swap_pct,
|
||||||
fmt_bytes(state.snapshot.swap_used_bytes),
|
fmt_bytes(state.snapshot.swap_used_bytes),
|
||||||
fmt_bytes(state.snapshot.swap_total_bytes)
|
fmt_bytes(state.snapshot.swap_total_bytes)
|
||||||
)),
|
)),
|
||||||
])];
|
rows[1],
|
||||||
|
|
||||||
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(
|
|
||||||
Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title("Network / Details")
|
|
||||||
.borders(Borders::ALL),
|
|
||||||
)
|
|
||||||
.wrap(Wrap { trim: true }),
|
|
||||||
area,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
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);
|
let pct = percent(disk.used_bytes, disk.total_bytes);
|
||||||
lines.push(Line::from(format!(
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Magenta))
|
||||||
|
.percent(pct as u16)
|
||||||
|
.label(format!(
|
||||||
"{} {:>5.1}% {} / {}",
|
"{} {:>5.1}% {} / {}",
|
||||||
disk.name,
|
truncate(&disk.mount, 10),
|
||||||
pct,
|
pct,
|
||||||
fmt_bytes(disk.used_bytes),
|
fmt_bytes(disk.used_bytes),
|
||||||
fmt_bytes(disk.total_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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user