Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 28 additions & 7 deletions ktop-rs/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub struct AppState {
pub gpu_infos: Vec<GpuInfo>,
pub gpu_util_hist: HashMap<usize, VecDeque<f64>>,
pub gpu_mem_hist: HashMap<usize, VecDeque<f64>>,
pub gpu_power_watts: HashMap<usize, f64>,
pub gpu_power_limits: HashMap<usize, f64>,

// Network
pub net_up: f64,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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::<f64>())
.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::<f64>();
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
Expand Down
50 changes: 50 additions & 0 deletions ktop-rs/src/gpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,28 @@ impl NvidiaState {
powers
}

pub fn power_with_limits(&self) -> Vec<(Option<f64>, 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<Option<GpuTemp>> {
let mut temps = Vec::new();
for i in 0..self.count {
Expand Down Expand Up @@ -297,6 +319,34 @@ impl AmdCard {
let value = raw.trim().parse::<f64>().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::<f64>().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::<f64>().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<PathBuf> {
Expand Down
45 changes: 34 additions & 11 deletions ktop-rs/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -246,22 +266,25 @@ 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
let mut mem_line = vec![Span::styled("Mem ", Style::default().add_modifier(Modifier::BOLD))];
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 ", "")
Expand Down