sprint 4 adds gpu support

This commit is contained in:
2026-02-19 15:43:43 +00:00
parent c897d94180
commit 6b6e0ed9bd
4 changed files with 241 additions and 9 deletions

View File

@@ -8,6 +8,7 @@
- Sortable process columns (PID/CPU/MEM) with direction indicators
- Toggleable process details pane (right on wide terminals, bottom on narrow)
- CPU total bar + per-core mini-bars
- GPU widget (NVIDIA via `nvidia-smi`, AMD via Linux sysfs when available)
- Memory/swap bars and disk usage bars (top-N by usage)
- Disk pseudo-filesystem filtering toggle (`tmpfs`/`devtmpfs`/`overlay`)
- Compact network rates + totals + interface label
@@ -58,6 +59,7 @@ Accepted interval range: `200..=5000` ms.
- Target platform is Linux.
- Network throughput is computed as a delta between consecutive samples.
- 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

View File

@@ -1,9 +1,14 @@
use std::time::{Duration, Instant};
use std::{
fs,
path::{Path, PathBuf},
process::Command,
time::{Duration, Instant},
};
use anyhow::Result;
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 {
system: System,
@@ -136,6 +141,7 @@ impl Collector {
.collect::<Vec<_>>();
let load = System::load_average();
let gpus = collect_gpu_samples();
let snapshot = Snapshot {
host: self.host.clone(),
uptime_seconds: System::uptime(),
@@ -153,8 +159,155 @@ impl Collector {
processes,
network_totals: Totals { rx_bytes, tx_bytes },
network_interfaces,
gpus,
};
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));
}
}

View File

@@ -36,6 +36,16 @@ pub struct ProcessSample {
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)]
pub struct Snapshot {
pub host: String,
@@ -54,6 +64,7 @@ pub struct Snapshot {
pub processes: Vec<ProcessSample>,
pub network_totals: Totals,
pub network_interfaces: Vec<String>,
pub gpus: Vec<GpuSample>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -100,15 +100,25 @@ fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
if state.show_process_details {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(6)])
.constraints([
Constraint::Min(8),
Constraint::Length(5),
Constraint::Length(6),
])
.split(sidebar);
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 {
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 {
let net_line = compact_network_line(state);
let net_line = compact_status_line(state);
let net_area = Rect {
x: main.x,
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) {
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!(
"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.tx_bps as u64),
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
)
}