sprint 3
This commit is contained in:
24
README.md
24
README.md
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- CPU usage (total + per-core in detailed view)
|
- htop-style process table by default with selection and scrolling
|
||||||
- Memory and swap usage
|
- Sortable process columns (PID/CPU/MEM) with direction indicators
|
||||||
- Disk usage per mounted disk
|
- Toggleable process details pane (right on wide terminals, bottom on narrow)
|
||||||
- Network throughput (RX/TX bytes per second) and totals
|
- CPU total bar + per-core mini-bars
|
||||||
- Process count, uptime, host, and 1-minute load average
|
- Memory/swap bars and disk usage bars (top-N by usage)
|
||||||
- Adjustable refresh interval and pause/resume
|
- Disk pseudo-filesystem filtering toggle (`tmpfs`/`devtmpfs`/`overlay`)
|
||||||
- Responsive layout for narrow terminals
|
- Compact network rates + totals + interface label
|
||||||
|
- Adjustable refresh interval, pause/resume, responsive layout
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -42,10 +43,15 @@ Accepted interval range: `200..=5000` ms.
|
|||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
- `q` / `Esc`: quit
|
- `q` / `Esc`: quit
|
||||||
- `p` / `Space`: pause/resume sampling
|
- `p` / `Space`: pause/resume sampling (when summary is focused)
|
||||||
- `+` / `=`: increase refresh interval by 200 ms
|
- `+` / `=`: increase refresh interval by 200 ms
|
||||||
- `-`: decrease 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ impl Collector {
|
|||||||
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();
|
let mount = disk.mount_point().to_string_lossy().into_owned();
|
||||||
|
let fs_type = disk.file_system().to_string_lossy().into_owned();
|
||||||
DiskSample {
|
DiskSample {
|
||||||
mount,
|
mount,
|
||||||
|
fs_type,
|
||||||
total_bytes: total,
|
total_bytes: total,
|
||||||
used_bytes: used,
|
used_bytes: used,
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,7 @@ impl Collector {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let uid = process.user_id().map(|u| format!("{u:?}"));
|
||||||
let user = process
|
let user = process
|
||||||
.user_id()
|
.user_id()
|
||||||
.and_then(|uid| self.users.get_user_by_id(uid))
|
.and_then(|uid| self.users.get_user_by_id(uid))
|
||||||
@@ -104,11 +107,17 @@ impl Collector {
|
|||||||
|
|
||||||
ProcessSample {
|
ProcessSample {
|
||||||
pid: pid.as_u32(),
|
pid: pid.as_u32(),
|
||||||
|
ppid: process.parent().map(|p| p.as_u32()),
|
||||||
cpu_percent: process.cpu_usage(),
|
cpu_percent: process.cpu_usage(),
|
||||||
mem_bytes: process.memory(),
|
mem_bytes: process.memory(),
|
||||||
|
vms_bytes: process.virtual_memory(),
|
||||||
state: format!("{:?}", process.status()),
|
state: format!("{:?}", process.status()),
|
||||||
user,
|
user,
|
||||||
|
uid,
|
||||||
command,
|
command,
|
||||||
|
threads: process.tasks().map(|tasks| tasks.len()),
|
||||||
|
nice: None,
|
||||||
|
cpu_time_seconds: Some(process.run_time()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -120,6 +129,12 @@ impl Collector {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let network_interfaces = self
|
||||||
|
.networks
|
||||||
|
.keys()
|
||||||
|
.map(|name| name.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let load = System::load_average();
|
let load = System::load_average();
|
||||||
let snapshot = Snapshot {
|
let snapshot = Snapshot {
|
||||||
host: self.host.clone(),
|
host: self.host.clone(),
|
||||||
@@ -137,6 +152,7 @@ impl Collector {
|
|||||||
process_count: self.system.processes().len(),
|
process_count: self.system.processes().len(),
|
||||||
processes,
|
processes,
|
||||||
network_totals: Totals { rx_bytes, tx_bytes },
|
network_totals: Totals { rx_bytes, tx_bytes },
|
||||||
|
network_interfaces,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((snapshot, elapsed))
|
Ok((snapshot, elapsed))
|
||||||
|
|||||||
19
src/input.rs
19
src/input.rs
@@ -29,7 +29,11 @@ pub fn handle_key(app: &mut AppState, key: KeyCode) -> InputEvent {
|
|||||||
return InputEvent::Redraw;
|
return InputEvent::Redraw;
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') => {
|
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;
|
return InputEvent::Redraw;
|
||||||
}
|
}
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||||
@@ -73,18 +77,19 @@ pub fn handle_key(app: &mut AppState, key: KeyCode) -> InputEvent {
|
|||||||
InputEvent::Redraw
|
InputEvent::Redraw
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') => {
|
KeyCode::Char('c') => {
|
||||||
app.process_sort = ProcessSort::Cpu;
|
app.set_sort(ProcessSort::Cpu);
|
||||||
app.apply_sort();
|
|
||||||
InputEvent::Redraw
|
InputEvent::Redraw
|
||||||
}
|
}
|
||||||
KeyCode::Char('m') => {
|
KeyCode::Char('m') => {
|
||||||
app.process_sort = ProcessSort::Mem;
|
app.set_sort(ProcessSort::Mem);
|
||||||
app.apply_sort();
|
|
||||||
InputEvent::Redraw
|
InputEvent::Redraw
|
||||||
}
|
}
|
||||||
KeyCode::Char('p') => {
|
KeyCode::Char('p') => {
|
||||||
app.process_sort = ProcessSort::Pid;
|
app.set_sort(ProcessSort::Pid);
|
||||||
app.apply_sort();
|
InputEvent::Redraw
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
app.reverse_sort();
|
||||||
InputEvent::Redraw
|
InputEvent::Redraw
|
||||||
}
|
}
|
||||||
_ => InputEvent::None,
|
_ => InputEvent::None,
|
||||||
|
|||||||
16
src/main.rs
16
src/main.rs
@@ -37,7 +37,11 @@ fn run(cfg: config::Config) -> Result<()> {
|
|||||||
|
|
||||||
let mut app = AppState {
|
let mut app = AppState {
|
||||||
snapshot: {
|
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
|
first_snapshot
|
||||||
},
|
},
|
||||||
network_rate: Throughput {
|
network_rate: Throughput {
|
||||||
@@ -45,10 +49,12 @@ fn run(cfg: config::Config) -> Result<()> {
|
|||||||
tx_bps: 0.0,
|
tx_bps: 0.0,
|
||||||
},
|
},
|
||||||
paused: false,
|
paused: false,
|
||||||
detailed_view: false,
|
show_process_details: false,
|
||||||
|
show_pseudo_fs: false,
|
||||||
refresh_interval: cfg.refresh_interval(),
|
refresh_interval: cfg.refresh_interval(),
|
||||||
focus: FocusPane::ProcessTable,
|
focus: FocusPane::ProcessTable,
|
||||||
process_sort: ProcessSort::Cpu,
|
process_sort: ProcessSort::Cpu,
|
||||||
|
process_sort_order: state::default_order_for(ProcessSort::Cpu),
|
||||||
selected_process: 0,
|
selected_process: 0,
|
||||||
history: std::collections::VecDeque::new(),
|
history: std::collections::VecDeque::new(),
|
||||||
history_cap: 120,
|
history_cap: 120,
|
||||||
@@ -82,7 +88,11 @@ fn run(cfg: config::Config) -> Result<()> {
|
|||||||
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 (mut new_snapshot, elapsed) = collector.sample()?;
|
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 =
|
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;
|
||||||
|
|||||||
143
src/state.rs
143
src/state.rs
@@ -15,6 +15,7 @@ pub struct Throughput {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DiskSample {
|
pub struct DiskSample {
|
||||||
pub mount: String,
|
pub mount: String,
|
||||||
|
pub fs_type: String,
|
||||||
pub total_bytes: u64,
|
pub total_bytes: u64,
|
||||||
pub used_bytes: u64,
|
pub used_bytes: u64,
|
||||||
}
|
}
|
||||||
@@ -22,11 +23,17 @@ pub struct DiskSample {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProcessSample {
|
pub struct ProcessSample {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
|
pub ppid: Option<u32>,
|
||||||
pub cpu_percent: f32,
|
pub cpu_percent: f32,
|
||||||
pub mem_bytes: u64,
|
pub mem_bytes: u64,
|
||||||
|
pub vms_bytes: u64,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub user: Option<String>,
|
pub user: Option<String>,
|
||||||
|
pub uid: Option<String>,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
pub threads: Option<usize>,
|
||||||
|
pub nice: Option<i32>,
|
||||||
|
pub cpu_time_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -46,6 +53,7 @@ pub struct Snapshot {
|
|||||||
pub process_count: usize,
|
pub process_count: usize,
|
||||||
pub processes: Vec<ProcessSample>,
|
pub processes: Vec<ProcessSample>,
|
||||||
pub network_totals: Totals,
|
pub network_totals: Totals,
|
||||||
|
pub network_interfaces: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -55,6 +63,12 @@ pub enum ProcessSort {
|
|||||||
Pid,
|
Pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortOrder {
|
||||||
|
Asc,
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FocusPane {
|
pub enum FocusPane {
|
||||||
Summary,
|
Summary,
|
||||||
@@ -74,10 +88,12 @@ pub struct AppState {
|
|||||||
pub snapshot: Snapshot,
|
pub snapshot: Snapshot,
|
||||||
pub network_rate: Throughput,
|
pub network_rate: Throughput,
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
pub detailed_view: bool,
|
pub show_process_details: bool,
|
||||||
|
pub show_pseudo_fs: bool,
|
||||||
pub refresh_interval: Duration,
|
pub refresh_interval: Duration,
|
||||||
pub focus: FocusPane,
|
pub focus: FocusPane,
|
||||||
pub process_sort: ProcessSort,
|
pub process_sort: ProcessSort,
|
||||||
|
pub process_sort_order: SortOrder,
|
||||||
pub selected_process: usize,
|
pub selected_process: usize,
|
||||||
pub history: VecDeque<SamplePoint>,
|
pub history: VecDeque<SamplePoint>,
|
||||||
pub history_cap: usize,
|
pub history_cap: usize,
|
||||||
@@ -85,10 +101,29 @@ pub struct AppState {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn apply_sort(&mut self) {
|
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();
|
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) {
|
pub fn clamp_selection(&mut self) {
|
||||||
if self.snapshot.processes.is_empty() {
|
if self.snapshot.processes.is_empty() {
|
||||||
self.selected_process = 0;
|
self.selected_process = 0;
|
||||||
@@ -110,19 +145,46 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sort_processes(processes: &mut [ProcessSample], key: ProcessSort) {
|
pub fn default_order_for(sort: ProcessSort) -> SortOrder {
|
||||||
processes.sort_by(|a, b| match key {
|
match sort {
|
||||||
ProcessSort::Cpu => desc_f32(a.cpu_percent, b.cpu_percent).then_with(|| a.pid.cmp(&b.pid)),
|
ProcessSort::Cpu | ProcessSort::Mem => SortOrder::Desc,
|
||||||
ProcessSort::Mem => b
|
ProcessSort::Pid => SortOrder::Asc,
|
||||||
.mem_bytes
|
}
|
||||||
.cmp(&a.mem_bytes)
|
}
|
||||||
.then_with(|| a.pid.cmp(&b.pid)),
|
|
||||||
ProcessSort::Pid => a.pid.cmp(&b.pid),
|
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 {
|
fn cmp_f32_asc(a: f32, b: f32) -> Ordering {
|
||||||
b.partial_cmp(&a).unwrap_or(Ordering::Equal)
|
a.partial_cmp(&b).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 {
|
||||||
@@ -140,6 +202,23 @@ pub fn compute_throughput(prev: Totals, curr: Totals, elapsed: Duration) -> Thro
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn throughput_is_delta_over_time() {
|
fn throughput_is_delta_over_time() {
|
||||||
let prev = Totals {
|
let prev = Totals {
|
||||||
@@ -175,29 +254,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sort_by_cpu_then_pid() {
|
fn sort_by_cpu_then_pid_desc() {
|
||||||
let mut processes = vec![
|
let mut processes = vec![p(2, 10.0, 1), p(1, 10.0, 2), p(3, 90.0, 1)];
|
||||||
ProcessSample {
|
sort_processes(&mut processes, ProcessSort::Cpu, SortOrder::Desc);
|
||||||
pid: 2,
|
assert_eq!(processes[0].pid, 3);
|
||||||
cpu_percent: 10.0,
|
assert_eq!(processes[1].pid, 1);
|
||||||
mem_bytes: 1,
|
assert_eq!(processes[2].pid, 2);
|
||||||
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);
|
|
||||||
|
|
||||||
|
#[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[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
435
src/ui.rs
435
src/ui.rs
@@ -1,12 +1,14 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use ratatui::{
|
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,
|
||||||
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap},
|
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::state::{AppState, FocusPane, ProcessSort};
|
use crate::state::{sort_indicator, AppState, FocusPane, ProcessSample, 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();
|
||||||
@@ -45,11 +47,12 @@ fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let has_sidebar = area.width >= 100;
|
let has_right_sidebar = area.width >= 120;
|
||||||
let body_chunks = if has_sidebar {
|
|
||||||
|
let body_chunks = if has_right_sidebar {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Min(60), Constraint::Length(28)])
|
.constraints([Constraint::Min(68), Constraint::Length(34)])
|
||||||
.split(area)
|
.split(area)
|
||||||
} else {
|
} else {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
@@ -59,41 +62,53 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let main = body_chunks[0];
|
let main = body_chunks[0];
|
||||||
let sidebar = if has_sidebar {
|
let right = if has_right_sidebar {
|
||||||
Some(body_chunks[1])
|
Some(body_chunks[1])
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let cores = state.snapshot.cpu_per_core_percent.len();
|
let cpu_height = (main.height / 4).clamp(6, 12);
|
||||||
let cores_visible = if main.width < 90 {
|
let middle_height = (main.height / 4).clamp(7, 10);
|
||||||
cores.min(4)
|
|
||||||
} else {
|
|
||||||
cores.min(8)
|
|
||||||
};
|
|
||||||
let cpu_height = (cores_visible as u16) + 3;
|
|
||||||
|
|
||||||
let main_chunks = Layout::default()
|
let main_chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(cpu_height),
|
Constraint::Length(cpu_height),
|
||||||
Constraint::Length(9),
|
Constraint::Length(middle_height),
|
||||||
Constraint::Min(7),
|
Constraint::Min(7),
|
||||||
])
|
])
|
||||||
.split(main);
|
.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_memory_disk_section(frame, main_chunks[1], state);
|
||||||
draw_process_table(frame, main_chunks[2], state);
|
|
||||||
|
|
||||||
if let Some(net) = sidebar {
|
let table_area = if !has_right_sidebar && state.show_process_details {
|
||||||
draw_network(frame, net, state);
|
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 {
|
} else {
|
||||||
let net_line = format!(
|
main_chunks[2]
|
||||||
"RX {}/s | TX {}/s",
|
};
|
||||||
fmt_bytes(state.network_rate.rx_bps as u64),
|
|
||||||
fmt_bytes(state.network_rate.tx_bps as u64)
|
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 {
|
let net_area = Rect {
|
||||||
x: main.x,
|
x: main.x,
|
||||||
y: main.y,
|
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 block = Block::default().title("CPU").borders(Borders::ALL);
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
if inner.height == 0 {
|
if inner.height < 2 || inner.width < 10 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rows = vec![Constraint::Length(1)];
|
let split = Layout::default()
|
||||||
for _ in 0..cores_visible {
|
|
||||||
rows.push(Constraint::Length(1));
|
|
||||||
}
|
|
||||||
let rows = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(rows)
|
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
let total_label = format!(
|
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)
|
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
|
||||||
.label(total_label),
|
.label(total_label),
|
||||||
rows[0],
|
split[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
for (i, usage) in state
|
let cores = &state.snapshot.cpu_per_core_percent;
|
||||||
.snapshot
|
if cores.is_empty() || split[1].height == 0 {
|
||||||
.cpu_per_core_percent
|
return;
|
||||||
.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 col_width = 20_u16;
|
||||||
|
let columns = (split[1].width / col_width).max(1) as usize;
|
||||||
|
let rows_per_col = split[1].height as usize;
|
||||||
|
let max_visible = (columns * rows_per_col).min(cores.len());
|
||||||
|
|
||||||
|
let mut lines = Vec::with_capacity(rows_per_col);
|
||||||
|
for row in 0..rows_per_col {
|
||||||
|
let mut line = String::new();
|
||||||
|
for col in 0..columns {
|
||||||
|
let idx = col * rows_per_col + row;
|
||||||
|
if idx >= max_visible {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let segment = format_core_mini(idx, cores[idx], col_width as usize);
|
||||||
|
line.push_str(&segment);
|
||||||
|
}
|
||||||
|
lines.push(Line::from(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
if max_visible < cores.len() && !lines.is_empty() {
|
||||||
|
let extra = cores.len() - max_visible;
|
||||||
|
let last_line = lines.pop().unwrap_or_default();
|
||||||
|
let mut last = last_line
|
||||||
|
.spans
|
||||||
|
.into_iter()
|
||||||
|
.map(|span| span.content.to_string())
|
||||||
|
.collect::<String>();
|
||||||
|
last.push_str(&format!(" +{extra} more"));
|
||||||
|
lines.push(Line::from(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.alignment(Alignment::Left)
|
||||||
|
.style(Style::default().fg(Color::Gray)),
|
||||||
|
split[1],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_memory_disk_section(frame: &mut Frame, area: Rect, state: &AppState) {
|
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))
|
.gauge_style(Style::default().fg(Color::Green))
|
||||||
.percent(mem_pct as u16)
|
.percent(mem_pct as u16)
|
||||||
.label(format!(
|
.label(format!(
|
||||||
"mem {:>5.1}% {} / {}",
|
"mem {:>5.1}% {}/{}",
|
||||||
mem_pct,
|
mem_pct,
|
||||||
fmt_bytes(state.snapshot.mem_used_bytes),
|
fmt_bytes_compact(state.snapshot.mem_used_bytes),
|
||||||
fmt_bytes(state.snapshot.mem_total_bytes)
|
fmt_bytes_compact(state.snapshot.mem_total_bytes)
|
||||||
)),
|
)),
|
||||||
rows[0],
|
rows[0],
|
||||||
);
|
);
|
||||||
@@ -209,17 +247,23 @@ fn draw_mem_swap(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
.gauge_style(Style::default().fg(Color::Yellow))
|
.gauge_style(Style::default().fg(Color::Yellow))
|
||||||
.percent(swap_pct as u16)
|
.percent(swap_pct as u16)
|
||||||
.label(format!(
|
.label(format!(
|
||||||
"swap {:>5.1}% {} / {}",
|
"swap {:>5.1}% {}/{}",
|
||||||
swap_pct,
|
swap_pct,
|
||||||
fmt_bytes(state.snapshot.swap_used_bytes),
|
fmt_bytes_compact(state.snapshot.swap_used_bytes),
|
||||||
fmt_bytes(state.snapshot.swap_total_bytes)
|
fmt_bytes_compact(state.snapshot.swap_total_bytes)
|
||||||
)),
|
)),
|
||||||
rows[1],
|
rows[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
|
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);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
@@ -227,65 +271,82 @@ fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_disks = inner.height as usize;
|
let mut seen_mounts = HashSet::new();
|
||||||
let mut disks = state.snapshot.disk_samples.clone();
|
let mut disks = state
|
||||||
|
.snapshot
|
||||||
|
.disk_samples
|
||||||
|
.iter()
|
||||||
|
.filter(|d| {
|
||||||
|
if state.show_pseudo_fs {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if is_noisy_pseudo_fs(&d.fs_type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if d.mount.starts_with("/tmp") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen_mounts.insert(d.mount.as_str())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
disks.sort_by(|a, b| {
|
disks.sort_by(|a, b| {
|
||||||
let ap = percent(a.used_bytes, a.total_bytes);
|
let ap = percent(a.used_bytes, a.total_bytes);
|
||||||
let bp = percent(b.used_bytes, b.total_bytes);
|
let bp = percent(b.used_bytes, b.total_bytes);
|
||||||
bp.partial_cmp(&ap).unwrap_or(std::cmp::Ordering::Equal)
|
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)
|
.direction(Direction::Vertical)
|
||||||
.constraints(vec![Constraint::Length(1); max_disks.max(1)])
|
.constraints(vec![Constraint::Length(1); rows.max(1)])
|
||||||
.split(inner);
|
.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);
|
let pct = percent(disk.used_bytes, disk.total_bytes);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Gauge::default()
|
Gauge::default()
|
||||||
.gauge_style(Style::default().fg(Color::Magenta))
|
.gauge_style(Style::default().fg(Color::Magenta))
|
||||||
.percent(pct as u16)
|
.percent(pct as u16)
|
||||||
.label(format!(
|
.label(format!(
|
||||||
"{} {:>5.1}% {} / {}",
|
"{} {:>4.0}% {}/{}",
|
||||||
truncate(&disk.mount, 10),
|
truncate(&disk.mount, 12),
|
||||||
pct,
|
pct,
|
||||||
fmt_bytes(disk.used_bytes),
|
fmt_bytes_compact(disk.used_bytes),
|
||||||
fmt_bytes(disk.total_bytes)
|
fmt_bytes_compact(disk.total_bytes)
|
||||||
)),
|
)),
|
||||||
rows[i],
|
layout[i],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let block = Block::default().title("Net").borders(Borders::ALL);
|
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 {
|
let iface_label = match state.snapshot.network_interfaces.split_first() {
|
||||||
lines.push(Line::from(""));
|
Some((first, rest)) if !rest.is_empty() => format!("if {} (+{})", first, rest.len()),
|
||||||
lines.push(Line::from(format!(
|
Some((first, _)) => format!("if {first}"),
|
||||||
"tot rx {}",
|
None => "if -".to_string(),
|
||||||
fmt_bytes(state.snapshot.network_totals.rx_bytes)
|
};
|
||||||
)));
|
|
||||||
lines.push(Line::from(format!(
|
let lines = vec![
|
||||||
"tot tx {}",
|
Line::from(format!(
|
||||||
fmt_bytes(state.snapshot.network_totals.tx_bytes)
|
"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(
|
frame.render_widget(
|
||||||
Paragraph::new(lines)
|
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) {
|
fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let focused = state.focus == FocusPane::ProcessTable;
|
let focused = state.focus == FocusPane::ProcessTable;
|
||||||
|
let sort_char = sort_indicator(state.process_sort_order);
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"Processes [{}]",
|
"Processes [{}{}]",
|
||||||
match state.process_sort {
|
sort_key_label(state.process_sort),
|
||||||
ProcessSort::Cpu => "CPU",
|
sort_char
|
||||||
ProcessSort::Mem => "MEM",
|
|
||||||
ProcessSort::Pid => "PID",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
let border_style = if focused {
|
let border_style = if focused {
|
||||||
Style::default().fg(Color::Cyan)
|
Style::default().fg(Color::Cyan)
|
||||||
@@ -324,7 +383,7 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let show_user = inner.width >= 84;
|
let show_user = inner.width >= 90;
|
||||||
let visible_rows = inner.height.saturating_sub(1) as usize;
|
let visible_rows = inner.height.saturating_sub(1) as usize;
|
||||||
let selected = state
|
let selected = state
|
||||||
.selected_process
|
.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!("{:>6}", p.pid)),
|
||||||
Cell::from(format!("{:>5.1}", p.cpu_percent)),
|
Cell::from(format!("{:>5.1}", p.cpu_percent)),
|
||||||
Cell::from(format!("{:>5.1}", mem_pct)),
|
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.state, 6)),
|
||||||
Cell::from(truncate(p.user.as_deref().unwrap_or("-"), 8)),
|
Cell::from(truncate(p.user.as_deref().unwrap_or("-"), 8)),
|
||||||
Cell::from(truncate(&p.command, 28)),
|
Cell::from(truncate(&p.command, 30)),
|
||||||
])
|
])
|
||||||
.style(style)
|
.style(style)
|
||||||
} else {
|
} 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!("{:>6}", p.pid)),
|
||||||
Cell::from(format!("{:>5.1}", p.cpu_percent)),
|
Cell::from(format!("{:>5.1}", p.cpu_percent)),
|
||||||
Cell::from(format!("{:>5.1}", mem_pct)),
|
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.state, 6)),
|
||||||
Cell::from(truncate(&p.command, 22)),
|
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 {
|
let (header, widths) = if show_user {
|
||||||
(
|
(
|
||||||
Row::new(vec![
|
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(
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -387,8 +452,8 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
),
|
),
|
||||||
vec![
|
vec![
|
||||||
Constraint::Length(6),
|
Constraint::Length(6),
|
||||||
Constraint::Length(5),
|
Constraint::Length(6),
|
||||||
Constraint::Length(5),
|
Constraint::Length(6),
|
||||||
Constraint::Length(9),
|
Constraint::Length(9),
|
||||||
Constraint::Length(6),
|
Constraint::Length(6),
|
||||||
Constraint::Length(8),
|
Constraint::Length(8),
|
||||||
@@ -397,15 +462,23 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
)
|
)
|
||||||
} else {
|
} 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()
|
Style::default()
|
||||||
.fg(Color::Gray)
|
.fg(Color::Gray)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
vec![
|
vec![
|
||||||
Constraint::Length(6),
|
Constraint::Length(6),
|
||||||
Constraint::Length(5),
|
Constraint::Length(6),
|
||||||
Constraint::Length(5),
|
Constraint::Length(6),
|
||||||
Constraint::Length(9),
|
Constraint::Length(9),
|
||||||
Constraint::Length(6),
|
Constraint::Length(6),
|
||||||
Constraint::Min(10),
|
Constraint::Min(10),
|
||||||
@@ -420,14 +493,75 @@ fn draw_process_table(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
frame.render_widget(table, inner);
|
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) {
|
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let focus = if state.focus == FocusPane::ProcessTable {
|
let focus = if state.focus == FocusPane::ProcessTable {
|
||||||
"focus:proc"
|
"focus:proc"
|
||||||
} else {
|
} else {
|
||||||
"focus:summary"
|
"focus:summary"
|
||||||
};
|
};
|
||||||
let help = "q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | arrows/PgUp/PgDn";
|
let line = format!(
|
||||||
let line = format!("{help} | {focus}");
|
"q quit | p/space pause | +/- interval | Tab focus | c/m/p sort | r reverse | d details | f fs-filter | {focus}"
|
||||||
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(line)
|
Paragraph::new(line)
|
||||||
.alignment(Alignment::Left)
|
.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 {
|
fn percent(used: u64, total: u64) -> f64 {
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return 0.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)
|
((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"];
|
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
if bytes == 0 {
|
if bytes == 0 {
|
||||||
return "0 B".to_string();
|
return "0B".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut value = bytes as f64;
|
let mut value = bytes as f64;
|
||||||
@@ -457,9 +629,9 @@ fn fmt_bytes(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
format!("{} {}", bytes, UNITS[idx])
|
format!("{}{}", bytes, UNITS[idx])
|
||||||
} else {
|
} 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.push('~');
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_core_mini(index: usize, pct: f32, width: usize) -> String {
|
||||||
|
let bars = mini_bar(pct, 6);
|
||||||
|
let segment = format!("cpu{:02} {} {:>4.1}%", index, bars, pct);
|
||||||
|
format!("{segment:<width$}", width = width)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mini_bar(pct: f32, width: usize) -> String {
|
||||||
|
let filled = ((pct.clamp(0.0, 100.0) / 100.0) * width as f32).round() as usize;
|
||||||
|
let mut out = String::with_capacity(width);
|
||||||
|
for i in 0..width {
|
||||||
|
if i < filled {
|
||||||
|
out.push('█');
|
||||||
|
} else {
|
||||||
|
out.push('░');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mini_bar_shapes() {
|
||||||
|
assert_eq!(mini_bar(0.0, 4), "░░░░");
|
||||||
|
assert_eq!(mini_bar(100.0, 4), "████");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_bytes_has_no_space() {
|
||||||
|
assert_eq!(fmt_bytes_compact(0), "0B");
|
||||||
|
assert!(fmt_bytes_compact(1024).ends_with("KiB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noisy_fs_filter_matches_expected() {
|
||||||
|
assert!(is_noisy_pseudo_fs("tmpfs"));
|
||||||
|
assert!(is_noisy_pseudo_fs("overlay"));
|
||||||
|
assert!(!is_noisy_pseudo_fs("ext4"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user