1use std::time::{Duration, Instant};
2
3use anyhow::Result;
4use crossbeam_channel::Receiver;
5use eframe::egui;
6use eframe::egui::{Color32, RichText, Stroke};
7use egui_plot::{Legend, Line, Plot, PlotPoints};
8
9use crate::history::History;
10use crate::model::{GpuInfo, GpuProcess, GpuProcessKind, GpuSample, MetricKind};
11use crate::sampler::{SamplerEvent, SamplerHandle};
12
13#[derive(Debug, Clone, Copy)]
14pub struct GuiConfig {
15 pub sample_interval: Duration,
16 pub frame_interval: Duration,
17 pub history_retention: Duration,
18 pub initial_window: Duration,
19}
20
21pub struct GuiApp {
22 devices: Vec<GpuInfo>,
23 backend_label: String,
24 rx: Receiver<SamplerEvent>,
25 sampler: Option<SamplerHandle>,
26 histories: Vec<History>,
27 config: GuiConfig,
28 started: Instant,
29 last_error: Option<String>,
30 samples_seen: u64,
31 selected_gpu: usize,
32 window_index: usize,
33 auto_follow: bool,
34}
35
36impl GuiApp {
37 pub fn new(
38 devices: Vec<GpuInfo>,
39 backend_label: String,
40 rx: Receiver<SamplerEvent>,
41 sampler: SamplerHandle,
42 config: GuiConfig,
43 ) -> Self {
44 let histories = devices
45 .iter()
46 .map(|_| History::new(config.history_retention))
47 .collect();
48
49 Self {
50 devices,
51 backend_label,
52 rx,
53 sampler: Some(sampler),
54 histories,
55 config,
56 started: Instant::now(),
57 last_error: None,
58 samples_seen: 0,
59 selected_gpu: 0,
60 window_index: closest_window(config.initial_window),
61 auto_follow: true,
62 }
63 }
64
65 fn ingest(&mut self) {
66 while let Ok(event) = self.rx.try_recv() {
67 match event {
68 SamplerEvent::Samples(samples) => {
69 self.samples_seen += samples.len() as u64;
70 for sample in samples {
71 if let Some(history) = self.histories.get_mut(sample.gpu_id) {
72 history.push(sample);
73 }
74 }
75 }
76 SamplerEvent::Error(error) => {
77 self.last_error = Some(error);
78 }
79 }
80 }
81 if self.selected_gpu >= self.devices.len() {
82 self.selected_gpu = self.devices.len().saturating_sub(1);
83 }
84 }
85
86 fn selected_history(&self) -> Option<&History> {
87 self.histories.get(self.selected_gpu)
88 }
89
90 fn visible_window(&self) -> Duration {
91 GUI_WINDOWS[self.window_index]
92 }
93
94 fn total_stored_samples(&self) -> usize {
95 self.histories.iter().map(History::len).sum()
96 }
97
98 fn draw_top_bar(&mut self, ui: &mut egui::Ui) {
99 ui.horizontal_wrapped(|ui| {
100 ui.heading("gpu-histop");
101 ui.separator();
102 ui.label(format!("{} GPU(s)", self.devices.len()));
103 ui.label(format!("backend {}", self.backend_label));
104 ui.label(format!("sample {:.1} Hz", hz(self.config.sample_interval)));
105 ui.label(format!("stored {}", self.total_stored_samples()));
106 ui.label(format!("up {}", format_duration(self.started.elapsed())));
107 ui.separator();
108 ui.checkbox(&mut self.auto_follow, "follow");
109
110 egui::ComboBox::from_id_salt("history_window")
111 .selected_text(format_duration(self.visible_window()))
112 .show_ui(ui, |ui| {
113 for (index, window) in GUI_WINDOWS.iter().enumerate() {
114 ui.selectable_value(
115 &mut self.window_index,
116 index,
117 format_duration(*window),
118 );
119 }
120 });
121 });
122
123 if let Some(error) = &self.last_error {
124 ui.colored_label(Color32::from_rgb(230, 80, 70), error);
125 }
126 }
127
128 fn draw_sidebar(&mut self, ui: &mut egui::Ui) {
129 ui.heading("GPUs");
130 ui.add_space(6.0);
131
132 for (index, device) in self.devices.iter().enumerate() {
133 let latest = self.histories.get(index).and_then(History::latest);
134 let title = format!("GPU {} {}", device.id, device.name);
135 let summary = latest
136 .map(gpu_sidebar_summary)
137 .unwrap_or_else(|| "waiting for samples".to_owned());
138
139 let response = ui.selectable_label(self.selected_gpu == index, title);
140 ui.label(RichText::new(summary).small().color(Color32::GRAY));
141 ui.add_space(8.0);
142 if response.clicked() {
143 self.selected_gpu = index;
144 }
145 }
146 }
147
148 fn draw_main(&mut self, ui: &mut egui::Ui) {
149 let Some(device) = self.devices.get(self.selected_gpu) else {
150 ui.centered_and_justified(|ui| {
151 ui.label("No GPUs found");
152 });
153 return;
154 };
155 let Some(history) = self.selected_history() else {
156 ui.label("No history available");
157 return;
158 };
159
160 ui.horizontal_wrapped(|ui| {
161 ui.heading(format!("GPU {} {}", device.id, device.name));
162 if let Some(uuid) = &device.uuid {
163 ui.label(RichText::new(short_uuid(uuid)).color(Color32::GRAY));
164 }
165 });
166
167 if let Some(sample) = history.latest() {
168 draw_metric_strip(ui, sample);
169 }
170
171 ui.separator();
172 egui::ScrollArea::vertical()
173 .auto_shrink([false, false])
174 .show(ui, |ui| {
175 self.draw_metric_plots(ui, history);
176 ui.separator();
177 self.draw_process_table(ui, history.latest());
178 });
179 }
180
181 fn draw_metric_plots(&self, ui: &mut egui::Ui, history: &History) {
182 let columns = if ui.available_width() >= 980.0 { 2 } else { 1 };
183 ui.columns(columns, |cols| {
184 for (index, metric) in MetricKind::ALL.iter().copied().enumerate() {
185 let column = index % columns;
186 draw_metric_plot(
187 &mut cols[column],
188 history,
189 metric,
190 self.selected_gpu,
191 self.visible_window(),
192 );
193 }
194 });
195 }
196
197 fn draw_process_table(&self, ui: &mut egui::Ui, sample: Option<&GpuSample>) {
198 ui.heading("Processes");
199 ui.label(
200 RichText::new("NVML compute, graphics, and MPS contexts; sorted by GPU memory.")
201 .small()
202 .color(Color32::GRAY),
203 );
204
205 let Some(sample) = sample else {
206 ui.label("Waiting for samples.");
207 return;
208 };
209
210 if sample.processes.is_empty() {
211 ui.label("No processes reported for this GPU.");
212 return;
213 }
214
215 egui::ScrollArea::vertical()
216 .max_height(260.0)
217 .auto_shrink([false, false])
218 .show(ui, |ui| {
219 egui::Grid::new("process_table")
220 .striped(true)
221 .num_columns(6)
222 .spacing([16.0, 6.0])
223 .show(ui, |ui| {
224 table_header(ui, "TYPE");
225 table_header(ui, "PID");
226 table_header(ui, "USER");
227 table_header(ui, "GPU MEM");
228 table_header(ui, "MIG");
229 table_header(ui, "COMMAND");
230 ui.end_row();
231
232 for process in &sample.processes {
233 ui.colored_label(process_color(process), process.kind_label());
234 ui.monospace(process.pid.to_string());
235 ui.label(process.user.as_deref().unwrap_or("?"));
236 ui.label(
237 process
238 .used_gpu_memory_bytes
239 .map(format_bytes_compact)
240 .unwrap_or_else(|| "n/a".to_owned()),
241 );
242 ui.label(process_mig_label(process));
243 ui.monospace(process.command.as_deref().unwrap_or("?"));
244 ui.end_row();
245 }
246 });
247 });
248 }
249}
250
251impl Drop for GuiApp {
252 fn drop(&mut self) {
253 if let Some(sampler) = self.sampler.take() {
254 sampler.stop();
255 }
256 }
257}
258
259impl eframe::App for GuiApp {
260 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
261 self.ingest();
262
263 egui::Panel::top("top_bar")
264 .resizable(false)
265 .show_inside(ui, |ui| {
266 ui.add_space(6.0);
267 self.draw_top_bar(ui);
268 ui.add_space(6.0);
269 });
270
271 egui::Panel::left("gpu_sidebar")
272 .resizable(true)
273 .default_size(260.0)
274 .size_range(220.0..=380.0)
275 .show_inside(ui, |ui| {
276 self.draw_sidebar(ui);
277 });
278
279 egui::CentralPanel::default().show_inside(ui, |ui| {
280 self.draw_main(ui);
281 });
282
283 if self.auto_follow {
284 ui.ctx().request_repaint_after(self.config.frame_interval);
285 }
286 }
287}
288
289pub fn run(app: GuiApp) -> Result<()> {
290 let native_options = eframe::NativeOptions {
291 viewport: egui::ViewportBuilder::default()
292 .with_title("gpu-histop")
293 .with_inner_size([1420.0, 920.0])
294 .with_min_inner_size([980.0, 660.0]),
295 ..Default::default()
296 };
297
298 eframe::run_native(
299 "gpu-histop",
300 native_options,
301 Box::new(move |_cc| Ok(Box::new(app))),
302 )?;
303 Ok(())
304}
305
306fn draw_metric_strip(ui: &mut egui::Ui, sample: &GpuSample) {
307 ui.horizontal_wrapped(|ui| {
308 metric_badge(
309 ui,
310 "GPU",
311 sample.gpu_util_percent,
312 "%",
313 metric_color(MetricKind::GpuUtil),
314 );
315 metric_badge(
316 ui,
317 "MEM",
318 sample.mem_util_percent,
319 "%",
320 metric_color(MetricKind::MemUtil),
321 );
322 ui.label(format!(
323 "VRAM {}",
324 vram_summary(sample.vram_used_bytes, sample.vram_total_bytes)
325 ));
326 metric_badge(
327 ui,
328 "PWR",
329 sample.power_watts,
330 "W",
331 metric_color(MetricKind::Power),
332 );
333 if let Some(limit) = sample.power_limit_watts {
334 ui.label(RichText::new(format!("/{limit:.0}W")).color(Color32::GRAY));
335 }
336 metric_badge(
337 ui,
338 "TEMP",
339 sample.temperature_celsius,
340 "C",
341 metric_color(MetricKind::Temperature),
342 );
343 metric_badge(
344 ui,
345 "FAN",
346 sample.fan_percent,
347 "%",
348 metric_color(MetricKind::Fan),
349 );
350 ui.label(format!(
351 "CLK {}/{} MHz",
352 whole_or_na(sample.graphics_clock_mhz),
353 whole_or_na(sample.memory_clock_mhz)
354 ));
355 ui.label(format!("PROC {}", sample.processes.len()));
356 });
357}
358
359fn metric_badge(ui: &mut egui::Ui, label: &str, value: Option<f64>, unit: &str, color: Color32) {
360 let value = value
361 .map(|value| format!("{value:.0}{unit}"))
362 .unwrap_or_else(|| "n/a".to_owned());
363 ui.label(
364 RichText::new(format!("{label} {value}"))
365 .color(color)
366 .strong(),
367 );
368}
369
370fn draw_metric_plot(
371 ui: &mut egui::Ui,
372 history: &History,
373 metric: MetricKind,
374 gpu_id: usize,
375 window: Duration,
376) {
377 let now = Instant::now();
378 let latest = history.latest();
379 let points = metric_points(history, metric, now, window);
380 let latest_value = latest.and_then(|sample| metric.value(sample));
381 let title = latest_value
382 .map(|value| format!("{} {:.1}{}", metric.title(), value, metric.unit()))
383 .unwrap_or_else(|| format!("{} n/a", metric.title()));
384 let color = metric_color(metric);
385 let (min_y, max_y) = metric_scale(history, metric, latest, now, window);
386
387 ui.group(|ui| {
388 ui.horizontal(|ui| {
389 ui.label(RichText::new(title).strong().color(color));
390 ui.label(
391 RichText::new(format_duration(window))
392 .small()
393 .color(Color32::GRAY),
394 );
395 });
396
397 Plot::new(format!("gpu_{gpu_id}_{metric:?}"))
398 .height(170.0)
399 .legend(Legend::default())
400 .show_grid(true)
401 .allow_drag(true)
402 .allow_zoom(true)
403 .include_x(-(window.as_secs_f64()))
404 .include_x(0.0)
405 .include_y(min_y)
406 .include_y(max_y)
407 .label_formatter(move |name, point| {
408 if name.is_empty() {
409 format!("{:.2}s, {:.2}{}", point.x, point.y, metric.unit())
410 } else {
411 format!("{name}\n{:.2}s, {:.2}{}", point.x, point.y, metric.unit())
412 }
413 })
414 .show(ui, |plot_ui| {
415 if !points.is_empty() {
416 plot_ui.line(
417 Line::new(metric.title(), points)
418 .color(color)
419 .stroke(Stroke::new(1.6, color)),
420 );
421 }
422 });
423 });
424}
425
426fn metric_points(
427 history: &History,
428 metric: MetricKind,
429 now: Instant,
430 window: Duration,
431) -> PlotPoints<'static> {
432 history
433 .iter_window(now, window)
434 .filter_map(|sample| {
435 let value = metric.value(sample)?;
436 let age = if sample.at <= now {
437 now.duration_since(sample.at).as_secs_f64()
438 } else {
439 0.0
440 };
441 Some([-age, value])
442 })
443 .collect::<Vec<_>>()
444 .into()
445}
446
447fn metric_scale(
448 history: &History,
449 metric: MetricKind,
450 latest: Option<&GpuSample>,
451 now: Instant,
452 window: Duration,
453) -> (f64, f64) {
454 if let Some(range) = metric.fixed_range(latest) {
455 return range;
456 }
457
458 let mut max_seen: f64 = 0.0;
459 for sample in history.iter_window(now, window) {
460 if let Some(value) = metric.value(sample) {
461 max_seen = max_seen.max(value);
462 }
463 }
464 (0.0, nice_ceiling((max_seen * 1.15).max(1.0)))
465}
466
467fn table_header(ui: &mut egui::Ui, text: &str) {
468 ui.label(RichText::new(text).small().strong().color(Color32::GRAY));
469}
470
471fn gpu_sidebar_summary(sample: &GpuSample) -> String {
472 format!(
473 "GPU {} | VRAM {} | {} proc | PWR {} | TEMP {}",
474 percent_or_na(sample.gpu_util_percent),
475 sample
476 .vram_used_percent()
477 .map(|v| format!("{v:.0}%"))
478 .unwrap_or_else(|| "n/a".to_owned()),
479 sample.processes.len(),
480 sample
481 .power_watts
482 .map(|v| format!("{v:.0}W"))
483 .unwrap_or_else(|| "n/a".to_owned()),
484 sample
485 .temperature_celsius
486 .map(|v| format!("{v:.0}C"))
487 .unwrap_or_else(|| "n/a".to_owned())
488 )
489}
490
491fn metric_color(metric: MetricKind) -> Color32 {
492 match metric {
493 MetricKind::GpuUtil => Color32::from_rgb(65, 200, 230),
494 MetricKind::MemUtil => Color32::from_rgb(80, 210, 140),
495 MetricKind::VramUsed => Color32::from_rgb(230, 190, 75),
496 MetricKind::Power => Color32::from_rgb(210, 120, 230),
497 MetricKind::Temperature => Color32::from_rgb(235, 100, 85),
498 MetricKind::Fan => Color32::from_rgb(115, 150, 245),
499 }
500}
501
502fn process_color(process: &GpuProcess) -> Color32 {
503 if process.kinds.contains(&GpuProcessKind::Graphics) {
504 Color32::from_rgb(230, 190, 75)
505 } else if process.kinds.contains(&GpuProcessKind::Mps) {
506 Color32::from_rgb(210, 120, 230)
507 } else {
508 Color32::from_rgb(80, 210, 140)
509 }
510}
511
512fn process_mig_label(process: &GpuProcess) -> String {
513 match (process.gpu_instance_id, process.compute_instance_id) {
514 (Some(gpu), Some(compute)) => format!("gi={gpu}/ci={compute}"),
515 (Some(gpu), None) => format!("gi={gpu}"),
516 _ => String::new(),
517 }
518}
519
520fn vram_summary(used: Option<u64>, total: Option<u64>) -> String {
521 match (used, total) {
522 (Some(used), Some(total)) if total > 0 => format!(
523 "{:.1}/{:.1} GiB {:.0}%",
524 bytes_to_gib(used),
525 bytes_to_gib(total),
526 used as f64 * 100.0 / total as f64
527 ),
528 (Some(used), _) => format!("{:.1} GiB used", bytes_to_gib(used)),
529 _ => "n/a".to_owned(),
530 }
531}
532
533fn bytes_to_gib(bytes: u64) -> f64 {
534 bytes as f64 / 1024.0 / 1024.0 / 1024.0
535}
536
537fn format_bytes_compact(bytes: u64) -> String {
538 let gib = bytes_to_gib(bytes);
539 if gib >= 10.0 {
540 format!("{gib:.0} GiB")
541 } else if gib >= 1.0 {
542 format!("{gib:.1} GiB")
543 } else {
544 format!("{:.0} MiB", bytes as f64 / 1024.0 / 1024.0)
545 }
546}
547
548fn percent_or_na(value: Option<f64>) -> String {
549 value
550 .map(|value| format!("{value:.0}%"))
551 .unwrap_or_else(|| "n/a".to_owned())
552}
553
554fn whole_or_na(value: Option<f64>) -> String {
555 value
556 .map(|v| format!("{v:.0}"))
557 .unwrap_or_else(|| "n/a".to_owned())
558}
559
560fn short_uuid(uuid: &str) -> &str {
561 uuid.rsplit('-').next().unwrap_or(uuid)
562}
563
564fn hz(duration: Duration) -> f64 {
565 1.0 / duration.as_secs_f64().max(0.001)
566}
567
568fn format_duration(duration: Duration) -> String {
569 let seconds = duration.as_secs();
570 if seconds < 60 {
571 format!("{seconds}s")
572 } else if seconds < 3600 {
573 format!("{}m{:02}s", seconds / 60, seconds % 60)
574 } else {
575 format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60)
576 }
577}
578
579fn nice_ceiling(value: f64) -> f64 {
580 if value <= 10.0 {
581 return 10.0;
582 }
583
584 let magnitude = 10_f64.powf(value.log10().floor());
585 let normalized = value / magnitude;
586 let rounded = if normalized <= 2.0 {
587 2.0
588 } else if normalized <= 5.0 {
589 5.0
590 } else {
591 10.0
592 };
593 rounded * magnitude
594}
595
596const GUI_WINDOWS: [Duration; 8] = [
597 Duration::from_secs(10),
598 Duration::from_secs(30),
599 Duration::from_secs(60),
600 Duration::from_secs(5 * 60),
601 Duration::from_secs(10 * 60),
602 Duration::from_secs(30 * 60),
603 Duration::from_secs(60 * 60),
604 Duration::from_secs(3 * 60 * 60),
605];
606
607fn closest_window(target: Duration) -> usize {
608 GUI_WINDOWS
609 .iter()
610 .enumerate()
611 .min_by_key(|(_, window)| window.abs_diff(target))
612 .map(|(index, _)| index)
613 .unwrap_or(2)
614}