sprint 4 adds gpu support
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- Sortable process columns (PID/CPU/MEM) with direction indicators
|
- Sortable process columns (PID/CPU/MEM) with direction indicators
|
||||||
- Toggleable process details pane (right on wide terminals, bottom on narrow)
|
- Toggleable process details pane (right on wide terminals, bottom on narrow)
|
||||||
- CPU total bar + per-core mini-bars
|
- CPU total bar + per-core mini-bars
|
||||||
|
- GPU widget (NVIDIA via `nvidia-smi`, AMD via Linux sysfs when available)
|
||||||
- Memory/swap bars and disk usage bars (top-N by usage)
|
- Memory/swap bars and disk usage bars (top-N by usage)
|
||||||
- Disk pseudo-filesystem filtering toggle (`tmpfs`/`devtmpfs`/`overlay`)
|
- Disk pseudo-filesystem filtering toggle (`tmpfs`/`devtmpfs`/`overlay`)
|
||||||
- Compact network rates + totals + interface label
|
- Compact network rates + totals + interface label
|
||||||
@@ -58,6 +59,7 @@ Accepted interval range: `200..=5000` ms.
|
|||||||
- Target platform is Linux.
|
- Target platform is Linux.
|
||||||
- Network throughput is computed as a delta between consecutive samples.
|
- Network throughput is computed as a delta between consecutive samples.
|
||||||
- Disk and network metrics are system-wide aggregates.
|
- Disk and network metrics are system-wide aggregates.
|
||||||
|
- GPU metrics are best-effort: NVIDIA requires `nvidia-smi`; AMD uses available `/sys/class/drm` fields.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System, Users};
|
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System, Users};
|
||||||
|
|
||||||
use crate::state::{DiskSample, ProcessSample, Snapshot, Totals};
|
use crate::state::{DiskSample, GpuSample, ProcessSample, Snapshot, Totals};
|
||||||
|
|
||||||
pub struct Collector {
|
pub struct Collector {
|
||||||
system: System,
|
system: System,
|
||||||
@@ -136,6 +141,7 @@ impl Collector {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let load = System::load_average();
|
let load = System::load_average();
|
||||||
|
let gpus = collect_gpu_samples();
|
||||||
let snapshot = Snapshot {
|
let snapshot = Snapshot {
|
||||||
host: self.host.clone(),
|
host: self.host.clone(),
|
||||||
uptime_seconds: System::uptime(),
|
uptime_seconds: System::uptime(),
|
||||||
@@ -153,8 +159,155 @@ impl Collector {
|
|||||||
processes,
|
processes,
|
||||||
network_totals: Totals { rx_bytes, tx_bytes },
|
network_totals: Totals { rx_bytes, tx_bytes },
|
||||||
network_interfaces,
|
network_interfaces,
|
||||||
|
gpus,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((snapshot, elapsed))
|
Ok((snapshot, elapsed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_gpu_samples() -> Vec<GpuSample> {
|
||||||
|
let mut gpus = collect_nvidia_gpus();
|
||||||
|
gpus.extend(collect_amd_gpus());
|
||||||
|
gpus
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_nvidia_gpus() -> Vec<GpuSample> {
|
||||||
|
let output = Command::new("nvidia-smi")
|
||||||
|
.args([
|
||||||
|
"--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu",
|
||||||
|
"--format=csv,noheader,nounits",
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let Ok(output) = output else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !output.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(gpu) = parse_nvidia_smi_line(line) {
|
||||||
|
out.push(gpu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_nvidia_smi_line(line: &str) -> Option<GpuSample> {
|
||||||
|
let parts = line.split(',').map(|s| s.trim()).collect::<Vec<_>>();
|
||||||
|
if parts.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(GpuSample {
|
||||||
|
vendor: "NVIDIA".to_string(),
|
||||||
|
name: parts[0].to_string(),
|
||||||
|
utilization_percent: parse_f32(parts[1]),
|
||||||
|
mem_used_bytes: parse_u64(parts[2]).map(|v| v * 1024 * 1024),
|
||||||
|
mem_total_bytes: parse_u64(parts[3]).map(|v| v * 1024 * 1024),
|
||||||
|
temperature_c: parse_f32(parts[4]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_amd_gpus() -> Vec<GpuSample> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let drm_path = Path::new("/sys/class/drm");
|
||||||
|
let Ok(entries) = fs::read_dir(drm_path) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if !name.starts_with("card") || name.contains('-') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_path = entry.path().join("device");
|
||||||
|
if !device_path.exists() || !is_amd_device(&device_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpu_name = read_trimmed(device_path.join("product_name"))
|
||||||
|
.or_else(|| read_trimmed(device_path.join("vendor")))
|
||||||
|
.unwrap_or_else(|| "AMD GPU".to_string());
|
||||||
|
|
||||||
|
let utilization_percent =
|
||||||
|
read_trimmed(device_path.join("gpu_busy_percent")).and_then(|s| parse_f32(&s));
|
||||||
|
let mem_used_bytes =
|
||||||
|
read_trimmed(device_path.join("mem_info_vram_used")).and_then(|s| parse_u64(&s));
|
||||||
|
let mem_total_bytes =
|
||||||
|
read_trimmed(device_path.join("mem_info_vram_total")).and_then(|s| parse_u64(&s));
|
||||||
|
let temperature_c = read_amd_temp_c(&device_path);
|
||||||
|
|
||||||
|
out.push(GpuSample {
|
||||||
|
vendor: "AMD".to_string(),
|
||||||
|
name: gpu_name,
|
||||||
|
utilization_percent,
|
||||||
|
mem_used_bytes,
|
||||||
|
mem_total_bytes,
|
||||||
|
temperature_c,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_amd_device(device_path: &Path) -> bool {
|
||||||
|
read_trimmed(device_path.join("vendor"))
|
||||||
|
.map(|vendor| vendor.eq_ignore_ascii_case("0x1002"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_amd_temp_c(device_path: &Path) -> Option<f32> {
|
||||||
|
let hwmon_dir = device_path.join("hwmon");
|
||||||
|
let Ok(entries) = fs::read_dir(hwmon_dir) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let p = entry.path().join("temp1_input");
|
||||||
|
if let Some(raw) = read_trimmed(p).and_then(|v| parse_f32(&v)) {
|
||||||
|
return Some(raw / 1000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_trimmed(path: PathBuf) -> Option<String> {
|
||||||
|
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_f32(s: &str) -> Option<f32> {
|
||||||
|
if s.eq_ignore_ascii_case("N/A") || s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.parse::<f32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u64(s: &str) -> Option<u64> {
|
||||||
|
if s.eq_ignore_ascii_case("N/A") || s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.parse::<u64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nvidia_line_works() {
|
||||||
|
let line = "NVIDIA GeForce RTX 3080, 47, 1234, 10240, 67";
|
||||||
|
let gpu = parse_nvidia_smi_line(line).expect("expected a parsed gpu");
|
||||||
|
assert_eq!(gpu.vendor, "NVIDIA");
|
||||||
|
assert_eq!(gpu.name, "NVIDIA GeForce RTX 3080");
|
||||||
|
assert_eq!(gpu.utilization_percent, Some(47.0));
|
||||||
|
assert_eq!(gpu.mem_total_bytes, Some(10_240 * 1024 * 1024));
|
||||||
|
assert_eq!(gpu.temperature_c, Some(67.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
11
src/state.rs
11
src/state.rs
@@ -36,6 +36,16 @@ pub struct ProcessSample {
|
|||||||
pub cpu_time_seconds: Option<u64>,
|
pub cpu_time_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GpuSample {
|
||||||
|
pub vendor: String,
|
||||||
|
pub name: String,
|
||||||
|
pub utilization_percent: Option<f32>,
|
||||||
|
pub mem_used_bytes: Option<u64>,
|
||||||
|
pub mem_total_bytes: Option<u64>,
|
||||||
|
pub temperature_c: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Snapshot {
|
pub struct Snapshot {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -54,6 +64,7 @@ pub struct Snapshot {
|
|||||||
pub processes: Vec<ProcessSample>,
|
pub processes: Vec<ProcessSample>,
|
||||||
pub network_totals: Totals,
|
pub network_totals: Totals,
|
||||||
pub network_interfaces: Vec<String>,
|
pub network_interfaces: Vec<String>,
|
||||||
|
pub gpus: Vec<GpuSample>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
80
src/ui.rs
80
src/ui.rs
@@ -100,15 +100,25 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
if state.show_process_details {
|
if state.show_process_details {
|
||||||
let split = Layout::default()
|
let split = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Min(8), Constraint::Length(6)])
|
.constraints([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(6),
|
||||||
|
])
|
||||||
.split(sidebar);
|
.split(sidebar);
|
||||||
draw_process_details(frame, split[0], state);
|
draw_process_details(frame, split[0], state);
|
||||||
draw_network(frame, split[1], state);
|
draw_gpu(frame, split[1], state);
|
||||||
|
draw_network(frame, split[2], state);
|
||||||
} else {
|
} else {
|
||||||
draw_network(frame, sidebar, state);
|
let split = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(5), Constraint::Min(4)])
|
||||||
|
.split(sidebar);
|
||||||
|
draw_gpu(frame, split[0], state);
|
||||||
|
draw_network(frame, split[1], state);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let net_line = compact_network_line(state);
|
let net_line = compact_status_line(state);
|
||||||
let net_area = Rect {
|
let net_area = Rect {
|
||||||
x: main.x,
|
x: main.x,
|
||||||
y: main.y,
|
y: main.y,
|
||||||
@@ -325,6 +335,50 @@ fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_gpu(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let block = Block::default().title("GPU").borders(Borders::ALL);
|
||||||
|
let lines = if state.snapshot.gpus.is_empty() {
|
||||||
|
vec![Line::from("No GPU detected")]
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.snapshot
|
||||||
|
.gpus
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|gpu| {
|
||||||
|
let util = gpu
|
||||||
|
.utilization_percent
|
||||||
|
.map(|u| format!("{u:.0}%"))
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
let mem = match (gpu.mem_used_bytes, gpu.mem_total_bytes) {
|
||||||
|
(Some(used), Some(total)) => {
|
||||||
|
format!("{}/{}", fmt_bytes_compact(used), fmt_bytes_compact(total))
|
||||||
|
}
|
||||||
|
_ => "-".to_string(),
|
||||||
|
};
|
||||||
|
Line::from(format!(
|
||||||
|
"{} {} u:{} m:{} t:{}",
|
||||||
|
truncate(&gpu.vendor, 4),
|
||||||
|
truncate(&gpu.name, 10),
|
||||||
|
util,
|
||||||
|
mem,
|
||||||
|
gpu.temperature_c
|
||||||
|
.map(|t| format!("{t:.0}C"))
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left)
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -594,13 +648,25 @@ fn sort_header(name: &str, col: ProcessSort, state: &AppState) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_network_line(state: &AppState) -> String {
|
fn compact_status_line(state: &AppState) -> String {
|
||||||
|
let gpu = state.snapshot.gpus.first().map_or_else(
|
||||||
|
|| "GPU -".to_string(),
|
||||||
|
|g| {
|
||||||
|
let util = g
|
||||||
|
.utilization_percent
|
||||||
|
.map(|u| format!("{u:.0}%"))
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
format!("GPU {util}")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"RX {}/s | TX {}/s | tot {}/{}",
|
"RX {}/s | TX {}/s | tot {}/{} | {}",
|
||||||
fmt_bytes_compact(state.network_rate.rx_bps as u64),
|
fmt_bytes_compact(state.network_rate.rx_bps as u64),
|
||||||
fmt_bytes_compact(state.network_rate.tx_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.rx_bytes),
|
||||||
fmt_bytes_compact(state.snapshot.network_totals.tx_bytes)
|
fmt_bytes_compact(state.snapshot.network_totals.tx_bytes),
|
||||||
|
gpu
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user