diff --git a/README.md b/README.md index c923b01..8dc215c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/collectors.rs b/src/collectors.rs index b5cf260..1d1381b 100644 --- a/src/collectors.rs +++ b/src/collectors.rs @@ -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::>(); 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 { + let mut gpus = collect_nvidia_gpus(); + gpus.extend(collect_amd_gpus()); + gpus +} + +fn collect_nvidia_gpus() -> Vec { + 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 { + let parts = line.split(',').map(|s| s.trim()).collect::>(); + 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 { + 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 { + 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 { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()) +} + +fn parse_f32(s: &str) -> Option { + if s.eq_ignore_ascii_case("N/A") || s.is_empty() { + return None; + } + s.parse::().ok() +} + +fn parse_u64(s: &str) -> Option { + if s.eq_ignore_ascii_case("N/A") || s.is_empty() { + return None; + } + s.parse::().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)); + } +} diff --git a/src/state.rs b/src/state.rs index 8e6d37d..0225105 100644 --- a/src/state.rs +++ b/src/state.rs @@ -36,6 +36,16 @@ pub struct ProcessSample { pub cpu_time_seconds: Option, } +#[derive(Debug, Clone)] +pub struct GpuSample { + pub vendor: String, + pub name: String, + pub utilization_percent: Option, + pub mem_used_bytes: Option, + pub mem_total_bytes: Option, + pub temperature_c: Option, +} + #[derive(Debug, Clone)] pub struct Snapshot { pub host: String, @@ -54,6 +64,7 @@ pub struct Snapshot { pub processes: Vec, pub network_totals: Totals, pub network_interfaces: Vec, + pub gpus: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/ui.rs b/src/ui.rs index 5e5d7f4..bdef191 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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::>() + }; + + 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 ) }