sprint 4 adds gpu support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
11
src/state.rs
11
src/state.rs
@@ -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)]
|
||||
|
||||
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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user