diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a0fdf..e52e7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## 1.0.3 — 2026-03-22 +## 1.0.8 — 2026-04-12 + +- Add per-GPU power monitoring with power bar and current/limit display +- NVIDIA GPUs: query power via NVML `nvmlDeviceGetPowerUsage()` and limit via `nvmlDeviceGetPowerManagementLimit()` +- AMD GPUs: read power from hwmon `power1_input` and limit from `power1_cap` sysfs +- Power bar scales to each GPU's specific power limit instead of fixed 600W +- Power display shows `current/limit W` with color-coded bar (green→yellow→red) +- Tested: `cargo build --release`, version check, ldd confirms dynamic linking + +## 1.0.7 — 2026-03-22 - Static linking via musl — binary now runs on any Linux distro regardless of GLIBC version - No more "GLIBC_2.xx not found" errors on older systems diff --git a/ktop-rs/src/app.rs b/ktop-rs/src/app.rs index ccf733a..ec68a37 100644 --- a/ktop-rs/src/app.rs +++ b/ktop-rs/src/app.rs @@ -45,6 +45,8 @@ pub struct AppState { pub gpu_infos: Vec, pub gpu_util_hist: HashMap>, pub gpu_mem_hist: HashMap>, + pub gpu_power_watts: HashMap, + pub gpu_power_limits: HashMap, // Network pub net_up: f64, @@ -154,6 +156,8 @@ pub fn run( gpu_infos: Vec::new(), gpu_util_hist, gpu_mem_hist, + gpu_power_watts: HashMap::new(), + gpu_power_limits: HashMap::new(), net_up: 0.0, net_down: 0.0, net_up_hist: VecDeque::with_capacity(HISTORY_LEN), @@ -262,14 +266,31 @@ pub fn run( } state.gpu_temps = gpu_temps; + // GPU power (per-GPU) + let mut gpu_power_watts = HashMap::new(); + let mut gpu_power_limits = HashMap::new(); + if let Some(ref nv) = nvidia { + for (i, (power, limit)) in nv.power_with_limits().into_iter().enumerate() { + if let Some(p) = power { + gpu_power_watts.insert(i, p); + } + gpu_power_limits.insert(i, limit); + } + } + for (j, card) in amd_cards.iter().enumerate() { + let idx = nvidia_count + j; + if let Some((power, limit)) = card.power_with_limit() { + gpu_power_watts.insert(idx, power); + gpu_power_limits.insert(idx, limit); + } + } + state.gpu_power_watts = gpu_power_watts; + state.gpu_power_limits = gpu_power_limits; + let cpu_power = power_estimator.sample_cpu_watts(); - let nvidia_power = nvidia - .as_ref() - .map(|nv| nv.power_watts().into_iter().flatten().sum::()) - .unwrap_or(0.0); - let amd_power: f64 = amd_cards.iter().filter_map(|card| card.power_watts()).sum(); - let total_power = cpu_power.unwrap_or(0.0) + nvidia_power + amd_power; - state.est_power_watts = if cpu_power.is_some() || nvidia_power > 0.0 || amd_power > 0.0 { + let nvidia_power = state.gpu_power_watts.values().filter(|&&v| v > 0.0).sum::(); + let total_power = cpu_power.unwrap_or(0.0) + nvidia_power; + state.est_power_watts = if cpu_power.is_some() || nvidia_power > 0.0 { Some(total_power) } else { None diff --git a/ktop-rs/src/gpu.rs b/ktop-rs/src/gpu.rs index 4ef19ba..bf8224e 100644 --- a/ktop-rs/src/gpu.rs +++ b/ktop-rs/src/gpu.rs @@ -89,6 +89,28 @@ impl NvidiaState { powers } + pub fn power_with_limits(&self) -> Vec<(Option, f64)> { + let mut results = Vec::new(); + for i in 0..self.count { + let (power, limit) = self + .nvml + .device_by_index(i as u32) + .ok() + .map(|dev| { + let power = dev.power_usage().ok().map(|mw| mw as f64 / 1000.0); + let limit_mw = dev + .power_management_limit() + .ok() + .unwrap_or(600_000); + let limit = limit_mw as f64 / 1000.0; + (power, limit) + }) + .unwrap_or((None, 600.0)); + results.push((power, limit)); + } + results + } + pub fn temps(&self) -> Vec> { let mut temps = Vec::new(); for i in 0..self.count { @@ -297,6 +319,34 @@ impl AmdCard { let value = raw.trim().parse::().ok()?; Some(value / self.power_scale) } + + pub fn power_with_limit(&self) -> Option<(f64, f64)> { + let power_path = self.power_path.as_ref()?; + let raw = fs::read_to_string(power_path).ok()?; + let value = raw.trim().parse::().ok()?; + let power = value / self.power_scale; + + let power_cap_path = self + .power_path + .as_ref() + .and_then(|p| p.parent()) + .map(|p| p.join("power1_cap")); + let limit = if let Some(cap_path) = power_cap_path { + if cap_path.is_file() { + fs::read_to_string(&cap_path) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v / self.power_scale) + .unwrap_or(600.0) + } else { + 600.0 + } + } else { + 600.0 + }; + + Some((power, limit)) + } } fn glob_paths(pattern: &str) -> Vec { diff --git a/ktop-rs/src/ui.rs b/ktop-rs/src/ui.rs index fcb6453..0c8a787 100644 --- a/ktop-rs/src/ui.rs +++ b/ktop-rs/src/ui.rs @@ -227,17 +227,37 @@ fn render_gpu(f: &mut Frame, area: Rect, state: &AppState) { for (i, g) in gpus.iter().enumerate() { let inner_w = gpu_chunks[i].width.saturating_sub(4) as usize; - let bar_w = inner_w.saturating_sub(14).max(5); - let spark_w = inner_w.saturating_sub(6).max(10); + let bar_w = inner_w.saturating_sub(18).max(5); + let _spark_w = inner_w.saturating_sub(10).max(10); let uc = color_for_pct(g.util, theme); let mc = color_for_pct(g.mem_pct, theme); + let (power_watts, power_limit) = ( + state.gpu_power_watts.get(&g.id).copied().unwrap_or(0.0), + state.gpu_power_limits.get(&g.id).copied().unwrap_or(600.0), + ); + let power_pct = if power_limit > 0.0 { + (power_watts / power_limit * 100.0).min(100.0) + } else { + 0.0 + }; + let pc = if power_pct < 60.0 { + theme.bar_low + } else if power_pct < 85.0 { + theme.bar_mid + } else { + theme.bar_high + }; let util_bar = bar_spans(g.util, bar_w, theme); let mem_bar = bar_spans(g.mem_pct, bar_w, theme); + let power_bar = bar_spans(power_pct, bar_w, theme); - let spark_u = state.gpu_util_hist.get(&g.id).map(|h| sparkline(h, spark_w)).unwrap_or_default(); - let spark_m = state.gpu_mem_hist.get(&g.id).map(|h| sparkline(h, spark_w)).unwrap_or_default(); + let spark_p = if power_watts > 0.0 { + format!("{:.1} W", power_watts) + } else { + "N/A".to_string() + }; let mut lines = Vec::new(); @@ -246,9 +266,6 @@ fn render_gpu(f: &mut Frame, area: Rect, state: &AppState) { util_line.extend(util_bar); util_line.push(Span::styled(format!(" {:5.1}%", g.util), Style::default().fg(uc))); lines.push(Line::from(util_line)); - - // Util sparkline - lines.push(Line::from(Span::styled(format!(" {}", spark_u), Style::default().fg(uc)))); lines.push(Line::from("")); // Mem line @@ -256,12 +273,18 @@ fn render_gpu(f: &mut Frame, area: Rect, state: &AppState) { mem_line.extend(mem_bar); mem_line.push(Span::styled(format!(" {:5.1}%", g.mem_pct), Style::default().fg(mc))); lines.push(Line::from(mem_line)); - - // Mem info lines.push(Line::from(format!(" {:.1}/{:.1} GB", g.mem_used_gb, g.mem_total_gb))); + lines.push(Line::from("")); - // Mem sparkline - lines.push(Line::from(Span::styled(format!(" {}", spark_m), Style::default().fg(mc)))); + // Power line + let mut power_line = vec![Span::styled("Power", Style::default().add_modifier(Modifier::BOLD))]; + power_line.extend(power_bar); + power_line.push(Span::styled( + format!(" {:6.1}/{:6.1} W", power_watts, power_limit), + Style::default().fg(pc), + )); + lines.push(Line::from(power_line)); + lines.push(Line::from(Span::styled(format!(" {}", spark_p), Style::default().fg(pc)))); let name_short = g.name .replace("NVIDIA ", "")