1use std::time::{Duration, Instant};
2
3use anyhow::Result;
4use crossbeam_channel::Receiver;
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use ratatui::Frame;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, Clear, Paragraph};
11
12use crate::chart::BrailleChart;
13use crate::history::History;
14use crate::model::{GpuInfo, GpuProcess, GpuProcessKind, GpuSample, MetricKind};
15use crate::sampler::SamplerEvent;
16
17#[derive(Debug, Clone, Copy)]
18pub struct TuiConfig {
19 pub sample_interval: Duration,
20 pub frame_interval: Duration,
21 pub history_retention: Duration,
22 pub initial_window: Duration,
23}
24
25pub struct App {
26 devices: Vec<GpuInfo>,
27 backend_label: String,
28 rx: Receiver<SamplerEvent>,
29 histories: Vec<History>,
30 config: TuiConfig,
31 started: Instant,
32 last_error: Option<String>,
33 samples_seen: u64,
34 window_index: usize,
35 selected_gpu: usize,
36 process_popup_open: bool,
37 process_scroll: usize,
38}
39
40impl App {
41 pub fn new(
42 devices: Vec<GpuInfo>,
43 backend_label: String,
44 rx: Receiver<SamplerEvent>,
45 config: TuiConfig,
46 ) -> Self {
47 let histories = devices
48 .iter()
49 .map(|_| History::new(config.history_retention))
50 .collect();
51 let window_index = closest_window(config.initial_window);
52
53 Self {
54 devices,
55 backend_label,
56 rx,
57 histories,
58 config,
59 started: Instant::now(),
60 last_error: None,
61 samples_seen: 0,
62 window_index,
63 selected_gpu: 0,
64 process_popup_open: false,
65 process_scroll: 0,
66 }
67 }
68
69 fn ingest(&mut self) {
70 while let Ok(event) = self.rx.try_recv() {
71 match event {
72 SamplerEvent::Samples(samples) => {
73 self.samples_seen += samples.len() as u64;
74 for sample in samples {
75 if let Some(history) = self.histories.get_mut(sample.gpu_id) {
76 history.push(sample);
77 }
78 }
79 }
80 SamplerEvent::Error(error) => {
81 self.last_error = Some(error);
82 }
83 }
84 }
85 }
86
87 fn window(&self) -> Duration {
88 WINDOWS[self.window_index]
89 }
90
91 fn zoom_in(&mut self) {
92 self.window_index = self.window_index.saturating_sub(1);
93 }
94
95 fn zoom_out(&mut self) {
96 self.window_index = (self.window_index + 1).min(WINDOWS.len() - 1);
97 }
98
99 fn select_next_gpu(&mut self) {
100 if !self.devices.is_empty() {
101 self.selected_gpu = (self.selected_gpu + 1) % self.devices.len();
102 self.process_scroll = 0;
103 }
104 }
105
106 fn select_previous_gpu(&mut self) {
107 if !self.devices.is_empty() {
108 self.selected_gpu = if self.selected_gpu == 0 {
109 self.devices.len() - 1
110 } else {
111 self.selected_gpu - 1
112 };
113 self.process_scroll = 0;
114 }
115 }
116
117 fn open_process_popup(&mut self) {
118 if !self.devices.is_empty() {
119 self.process_popup_open = true;
120 self.process_scroll = 0;
121 }
122 }
123
124 fn close_process_popup(&mut self) {
125 self.process_popup_open = false;
126 }
127
128 fn scroll_processes_by(&mut self, delta: isize) {
129 if delta < 0 {
130 self.process_scroll = self.process_scroll.saturating_sub(delta.unsigned_abs());
131 } else {
132 let max_scroll = self.selected_process_count().saturating_sub(1);
133 self.process_scroll = (self.process_scroll + delta as usize).min(max_scroll);
134 }
135 }
136
137 fn scroll_processes_to_start(&mut self) {
138 self.process_scroll = 0;
139 }
140
141 fn scroll_processes_to_end(&mut self) {
142 self.process_scroll = self.selected_process_count().saturating_sub(1);
143 }
144
145 fn selected_process_count(&self) -> usize {
146 self.selected_sample()
147 .map(|sample| sample.processes.len())
148 .unwrap_or(0)
149 }
150
151 fn selected_sample(&self) -> Option<&GpuSample> {
152 self.histories
153 .get(self.selected_gpu)
154 .and_then(History::latest)
155 }
156
157 fn render(&self, frame: &mut Frame) {
158 let area = frame.area();
159 if area.width < 40 || area.height < 10 {
160 frame.render_widget(
161 Paragraph::new("gpu-histop needs at least 40x10 terminal cells"),
162 area,
163 );
164 return;
165 }
166
167 let chunks = Layout::default()
168 .direction(Direction::Vertical)
169 .constraints([Constraint::Length(3), Constraint::Min(0)])
170 .split(area);
171
172 self.render_header(frame, chunks[0]);
173 self.render_body(frame, chunks[1]);
174 if self.process_popup_open {
175 self.render_process_popup(frame, area);
176 }
177 }
178
179 fn render_header(&self, frame: &mut Frame, area: Rect) {
180 let uptime = self.started.elapsed();
181 let sample_hz = hz(self.config.sample_interval);
182 let frame_hz = hz(self.config.frame_interval);
183 let status = Line::from(vec![
184 Span::styled(
185 "gpu-histop ",
186 Style::default()
187 .fg(Color::Cyan)
188 .add_modifier(Modifier::BOLD),
189 ),
190 Span::raw(format!(
191 "{} GPU(s) | {} backend | sample {:.1} Hz | draw {:.1} Hz | view {} | stored {} | up {}",
192 self.devices.len(),
193 self.backend_label,
194 sample_hz,
195 frame_hz,
196 format_duration(self.window()),
197 self.total_samples(),
198 format_duration(uptime),
199 )),
200 ]);
201
202 let controls = if let Some(error) = &self.last_error {
203 Line::from(vec![
204 Span::styled("last sampler error: ", Style::default().fg(Color::Red)),
205 Span::raw(truncate(error, area.width.saturating_sub(20) as usize)),
206 ])
207 } else {
208 let controls = if self.process_popup_open {
209 "q quit | Esc/p close processes | Up/Down scroll | Tab switch GPU"
210 } else {
211 "q/Esc quit | Up/Down select GPU | p/Enter processes | +/- zoom | Braille min/max history"
212 };
213 Line::from(controls)
214 };
215
216 let paragraph = Paragraph::new(vec![status, controls]).block(
217 Block::default()
218 .borders(Borders::BOTTOM)
219 .border_style(Color::DarkGray),
220 );
221 frame.render_widget(paragraph, area);
222 }
223
224 fn render_body(&self, frame: &mut Frame, area: Rect) {
225 if self.devices.is_empty() {
226 frame.render_widget(Paragraph::new("no GPUs found"), area);
227 return;
228 }
229
230 let constraints = vec![Constraint::Ratio(1, self.devices.len() as u32); self.devices.len()];
231 let rows = Layout::default()
232 .direction(Direction::Vertical)
233 .constraints(constraints)
234 .split(area);
235
236 for (index, device) in self.devices.iter().enumerate() {
237 self.render_gpu_panel(
238 frame,
239 rows[index],
240 device,
241 &self.histories[index],
242 index == self.selected_gpu,
243 );
244 }
245 }
246
247 fn render_gpu_panel(
248 &self,
249 frame: &mut Frame,
250 area: Rect,
251 device: &GpuInfo,
252 history: &History,
253 selected: bool,
254 ) {
255 if area.height < 3 || area.width < 12 {
256 return;
257 }
258
259 let title = format!(
260 " {}GPU {} {}{} ",
261 if selected { ">" } else { "" },
262 device.id,
263 device.name,
264 device
265 .uuid
266 .as_deref()
267 .map(|uuid| format!(" ({})", short_uuid(uuid)))
268 .unwrap_or_default()
269 );
270 let border_style = if selected {
271 Style::default()
272 .fg(Color::Cyan)
273 .add_modifier(Modifier::BOLD)
274 } else {
275 Style::default().fg(Color::DarkGray)
276 };
277 let block = Block::default()
278 .borders(Borders::ALL)
279 .border_style(border_style)
280 .title(title);
281 let inner = block.inner(area);
282 frame.render_widget(block, area);
283
284 if inner.height == 0 {
285 return;
286 }
287
288 let sections = Layout::default()
289 .direction(Direction::Vertical)
290 .constraints([Constraint::Length(2), Constraint::Min(0)])
291 .split(inner);
292
293 let summary = Paragraph::new(vec![
294 summary_line(history.latest()),
295 process_line(history.latest(), sections[0].width),
296 ]);
297 frame.render_widget(summary, sections[0]);
298
299 if sections[1].height < 3 {
300 return;
301 }
302
303 self.render_charts(frame, sections[1], history);
304 }
305
306 fn render_charts(&self, frame: &mut Frame, area: Rect, history: &History) {
307 let now = Instant::now();
308 let window = self.window().min(history.retention());
309
310 if area.width >= 110 {
311 let chunks = split_even(area, Direction::Horizontal, MetricKind::ALL.len());
312 for (metric, chunk) in MetricKind::ALL.into_iter().zip(chunks.iter()) {
313 frame.render_widget(
314 BrailleChart {
315 history,
316 metric,
317 now,
318 window,
319 },
320 *chunk,
321 );
322 }
323 } else if area.height >= 12 {
324 let rows = split_even(area, Direction::Vertical, 2);
325 let top = split_even(rows[0], Direction::Horizontal, 3);
326 let bottom = split_even(rows[1], Direction::Horizontal, 3);
327 for (metric, chunk) in MetricKind::ALL[..3].iter().copied().zip(top.iter()) {
328 frame.render_widget(
329 BrailleChart {
330 history,
331 metric,
332 now,
333 window,
334 },
335 *chunk,
336 );
337 }
338 for (metric, chunk) in MetricKind::ALL[3..].iter().copied().zip(bottom.iter()) {
339 frame.render_widget(
340 BrailleChart {
341 history,
342 metric,
343 now,
344 window,
345 },
346 *chunk,
347 );
348 }
349 } else {
350 let compact = [MetricKind::GpuUtil, MetricKind::VramUsed, MetricKind::Power];
351 let chunks = split_even(area, Direction::Horizontal, compact.len());
352 for (metric, chunk) in compact.into_iter().zip(chunks.iter()) {
353 frame.render_widget(
354 BrailleChart {
355 history,
356 metric,
357 now,
358 window,
359 },
360 *chunk,
361 );
362 }
363 }
364 }
365
366 fn total_samples(&self) -> usize {
367 self.histories.iter().map(History::len).sum()
368 }
369
370 fn render_process_popup(&self, frame: &mut Frame, area: Rect) {
371 let Some(device) = self.devices.get(self.selected_gpu) else {
372 return;
373 };
374
375 let popup_area = centered_rect(area, 88, 74);
376 let title = format!(" GPU {} Processes: {} ", device.id, device.name);
377 let block = Block::default()
378 .borders(Borders::ALL)
379 .border_style(Style::default().fg(Color::Cyan))
380 .title(title);
381 let inner = block.inner(popup_area);
382
383 frame.render_widget(Clear, popup_area);
384 frame.render_widget(block, popup_area);
385
386 if inner.width == 0 || inner.height == 0 {
387 return;
388 }
389
390 let lines = process_popup_lines(
391 device,
392 self.selected_sample(),
393 inner.width,
394 inner.height,
395 self.process_scroll,
396 self.selected_gpu,
397 self.devices.len(),
398 );
399 frame.render_widget(Paragraph::new(lines), inner);
400 }
401}
402
403pub fn run(terminal: &mut ratatui::DefaultTerminal, mut app: App) -> Result<()> {
404 loop {
405 let frame_started = Instant::now();
406 app.ingest();
407 terminal.draw(|frame| app.render(frame))?;
408
409 let timeout = app
410 .config
411 .frame_interval
412 .saturating_sub(frame_started.elapsed());
413 if event::poll(timeout)? && handle_event(event::read()?, &mut app) {
414 return Ok(());
415 }
416 }
417}
418
419fn handle_event(event: Event, app: &mut App) -> bool {
420 match event {
421 Event::Key(key) if is_press(key) => handle_key_event(key, app),
422 _ => false,
423 }
424}
425
426fn handle_key_event(key: KeyEvent, app: &mut App) -> bool {
427 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
428 return true;
429 }
430
431 if app.process_popup_open {
432 return match key.code {
433 KeyCode::Char('q') => true,
434 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('p') => {
435 app.close_process_popup();
436 false
437 }
438 KeyCode::Down | KeyCode::Char('j') => {
439 app.scroll_processes_by(1);
440 false
441 }
442 KeyCode::Up | KeyCode::Char('k') => {
443 app.scroll_processes_by(-1);
444 false
445 }
446 KeyCode::PageDown => {
447 app.scroll_processes_by(8);
448 false
449 }
450 KeyCode::PageUp => {
451 app.scroll_processes_by(-8);
452 false
453 }
454 KeyCode::Home => {
455 app.scroll_processes_to_start();
456 false
457 }
458 KeyCode::End => {
459 app.scroll_processes_to_end();
460 false
461 }
462 KeyCode::Tab | KeyCode::Char('n') => {
463 app.select_next_gpu();
464 false
465 }
466 KeyCode::BackTab | KeyCode::Char('N') => {
467 app.select_previous_gpu();
468 false
469 }
470 _ => false,
471 };
472 }
473
474 match key.code {
475 KeyCode::Char('q') | KeyCode::Esc => true,
476 KeyCode::Char('+') | KeyCode::Char('=') => {
477 app.zoom_in();
478 false
479 }
480 KeyCode::Char('-') | KeyCode::Char('_') => {
481 app.zoom_out();
482 false
483 }
484 KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
485 app.select_next_gpu();
486 false
487 }
488 KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
489 app.select_previous_gpu();
490 false
491 }
492 KeyCode::Enter | KeyCode::Char('p') => {
493 app.open_process_popup();
494 false
495 }
496 _ => false,
497 }
498}
499
500fn is_press(key: KeyEvent) -> bool {
501 key.kind == KeyEventKind::Press
502}
503
504fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
505 let width = percent_len(area.width, percent_x).clamp(area.width.min(60), area.width);
506 let height = percent_len(area.height, percent_y).clamp(area.height.min(12), area.height);
507 Rect {
508 x: area.x + area.width.saturating_sub(width) / 2,
509 y: area.y + area.height.saturating_sub(height) / 2,
510 width,
511 height,
512 }
513}
514
515fn percent_len(value: u16, percent: u16) -> u16 {
516 ((value as u32 * percent as u32) / 100) as u16
517}
518
519fn split_even(area: Rect, direction: Direction, count: usize) -> Vec<Rect> {
520 let constraints = vec![Constraint::Ratio(1, count as u32); count];
521 Layout::default()
522 .direction(direction)
523 .constraints(constraints)
524 .split(area)
525 .to_vec()
526}
527
528fn summary_line(sample: Option<&GpuSample>) -> Line<'static> {
529 let Some(sample) = sample else {
530 return Line::from(vec![Span::styled(
531 "waiting for samples",
532 Style::default().fg(Color::DarkGray),
533 )]);
534 };
535
536 Line::from(vec![
537 metric_span("GPU", sample.gpu_util_percent, "%", Color::Cyan),
538 Span::raw(" "),
539 metric_span("MEM", sample.mem_util_percent, "%", Color::Green),
540 Span::raw(" "),
541 Span::raw(format!(
542 "VRAM {}",
543 vram_summary(sample.vram_used_bytes, sample.vram_total_bytes)
544 )),
545 Span::raw(" "),
546 metric_span("PWR", sample.power_watts, "W", Color::Magenta),
547 power_limit_span(sample.power_limit_watts),
548 Span::raw(" "),
549 metric_span("TEMP", sample.temperature_celsius, "C", Color::Red),
550 Span::raw(" "),
551 metric_span("FAN", sample.fan_percent, "%", Color::Blue),
552 Span::raw(" "),
553 Span::raw(format!(
554 "CLK {}/{} MHz",
555 whole_or_na(sample.graphics_clock_mhz),
556 whole_or_na(sample.memory_clock_mhz)
557 )),
558 Span::raw(" "),
559 Span::raw(format!(
560 "PROC {}",
561 sample
562 .compute_processes
563 .map_or_else(|| "n/a".to_owned(), |count| format!("{count} total"))
564 )),
565 ])
566}
567
568fn process_line(sample: Option<&GpuSample>, width: u16) -> Line<'static> {
569 let Some(sample) = sample else {
570 return Line::from("");
571 };
572
573 if sample.processes.is_empty() {
574 return Line::from(vec![
575 Span::styled("PROC", Style::default().fg(Color::DarkGray)),
576 Span::raw(" none"),
577 ]);
578 }
579
580 let shown = sample.processes.iter().take(4).collect::<Vec<_>>();
581 let hidden = sample.processes.len().saturating_sub(shown.len());
582 let mut rendered = format!(
583 "PROC {}: {}",
584 sample.processes.len(),
585 shown
586 .iter()
587 .map(|process| format_process(process))
588 .collect::<Vec<_>>()
589 .join(" | ")
590 );
591 if hidden > 0 {
592 rendered.push_str(&format!(" | +{hidden}"));
593 }
594
595 Line::from(Span::styled(
596 truncate(&rendered, width as usize),
597 Style::default().fg(Color::Gray),
598 ))
599}
600
601fn process_popup_lines(
602 device: &GpuInfo,
603 sample: Option<&GpuSample>,
604 width: u16,
605 height: u16,
606 scroll: usize,
607 selected_gpu: usize,
608 gpu_count: usize,
609) -> Vec<Line<'static>> {
610 let mut lines = Vec::new();
611 let max_lines = height as usize;
612 if max_lines == 0 {
613 return lines;
614 }
615
616 let process_count = sample.map(|sample| sample.processes.len()).unwrap_or(0);
617 let last_sample = sample
618 .map(|sample| format!("sample age {}", format_duration(sample.at.elapsed())))
619 .unwrap_or_else(|| "waiting for sample".to_owned());
620 lines.push(Line::from(Span::styled(
621 truncate(
622 &format!(
623 "GPU {} | {} | {} | {} process(es)",
624 device.id, device.name, last_sample, process_count
625 ),
626 width as usize,
627 ),
628 Style::default()
629 .fg(Color::Cyan)
630 .add_modifier(Modifier::BOLD),
631 )));
632
633 let Some(sample) = sample else {
634 push_footer_line(
635 &mut lines,
636 width,
637 selected_gpu,
638 gpu_count,
639 0,
640 0,
641 process_count,
642 );
643 return trim_lines(lines, max_lines);
644 };
645
646 if sample.processes.is_empty() {
647 lines.push(Line::from(""));
648 lines.push(Line::from(Span::styled(
649 "No NVML compute, graphics, or MPS processes reported for this GPU.",
650 Style::default().fg(Color::Gray),
651 )));
652 push_footer_line(
653 &mut lines,
654 width,
655 selected_gpu,
656 gpu_count,
657 0,
658 0,
659 process_count,
660 );
661 return trim_lines(lines, max_lines);
662 }
663
664 let rows_available = max_lines.saturating_sub(3);
665 let max_start = sample.processes.len().saturating_sub(rows_available);
666 let start = scroll.min(max_start);
667 let end = (start + rows_available).min(sample.processes.len());
668
669 lines.push(process_table_header(width));
670 for process in &sample.processes[start..end] {
671 lines.push(process_table_row(process, width));
672 }
673 push_footer_line(
674 &mut lines,
675 width,
676 selected_gpu,
677 gpu_count,
678 if rows_available == 0 { 0 } else { start },
679 end,
680 process_count,
681 );
682 trim_lines(lines, max_lines)
683}
684
685fn process_table_header(width: u16) -> Line<'static> {
686 let spec = process_column_spec(width);
687 let header = if spec.mig_width > 0 {
688 format!(
689 "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {:<mig_w$} {}",
690 "TYPE",
691 "PID",
692 "USER",
693 "GPU MEM",
694 "MIG",
695 "COMMAND",
696 type_w = spec.type_width,
697 pid_w = spec.pid_width,
698 user_w = spec.user_width,
699 mem_w = spec.memory_width,
700 mig_w = spec.mig_width
701 )
702 } else {
703 format!(
704 "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {}",
705 "TYPE",
706 "PID",
707 "USER",
708 "GPU MEM",
709 "COMMAND",
710 type_w = spec.type_width,
711 pid_w = spec.pid_width,
712 user_w = spec.user_width,
713 mem_w = spec.memory_width
714 )
715 };
716 Line::from(Span::styled(
717 truncate(&header, width as usize),
718 Style::default()
719 .fg(Color::DarkGray)
720 .add_modifier(Modifier::BOLD),
721 ))
722}
723
724fn process_table_row(process: &GpuProcess, width: u16) -> Line<'static> {
725 let spec = process_column_spec(width);
726 let kind = process.kind_label();
727 let user = process.user.as_deref().unwrap_or("?");
728 let memory = process
729 .used_gpu_memory_bytes
730 .map(format_bytes_compact)
731 .unwrap_or_else(|| "n/a".to_owned());
732 let mig = process_mig_label(process);
733 let command = process.command.as_deref().unwrap_or("?");
734
735 let row = if spec.mig_width > 0 {
736 format!(
737 "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {:<mig_w$} {}",
738 fit_cell(&kind, spec.type_width),
739 process.pid,
740 fit_cell(user, spec.user_width),
741 fit_cell(&memory, spec.memory_width),
742 fit_cell(&mig, spec.mig_width),
743 truncate(command, spec.command_width),
744 type_w = spec.type_width,
745 pid_w = spec.pid_width,
746 user_w = spec.user_width,
747 mem_w = spec.memory_width,
748 mig_w = spec.mig_width
749 )
750 } else {
751 format!(
752 "{:<type_w$} {:>pid_w$} {:<user_w$} {:>mem_w$} {}",
753 fit_cell(&kind, spec.type_width),
754 process.pid,
755 fit_cell(user, spec.user_width),
756 fit_cell(&memory, spec.memory_width),
757 truncate(command, spec.command_width),
758 type_w = spec.type_width,
759 pid_w = spec.pid_width,
760 user_w = spec.user_width,
761 mem_w = spec.memory_width
762 )
763 };
764
765 Line::from(Span::styled(
766 truncate(&row, width as usize),
767 process_kind_style(process),
768 ))
769}
770
771#[derive(Debug, Clone, Copy)]
772struct ProcessColumnSpec {
773 type_width: usize,
774 pid_width: usize,
775 user_width: usize,
776 memory_width: usize,
777 mig_width: usize,
778 command_width: usize,
779}
780
781fn process_column_spec(width: u16) -> ProcessColumnSpec {
782 let total = width as usize;
783 let type_width = 5;
784 let pid_width = 8;
785 let user_width = if total >= 96 { 14 } else { 10 };
786 let memory_width = 8;
787 let mig_width = if total >= 88 { 11 } else { 0 };
788 let separators = if mig_width > 0 { 5 } else { 4 };
789 let fixed = type_width + pid_width + user_width + memory_width + mig_width + separators;
790 let command_width = total.saturating_sub(fixed).max(8);
791
792 ProcessColumnSpec {
793 type_width,
794 pid_width,
795 user_width,
796 memory_width,
797 mig_width,
798 command_width,
799 }
800}
801
802fn process_kind_style(process: &GpuProcess) -> Style {
803 let color = if process.kinds.contains(&GpuProcessKind::Graphics) {
804 Color::Yellow
805 } else if process.kinds.contains(&GpuProcessKind::Mps) {
806 Color::Magenta
807 } else {
808 Color::Green
809 };
810 Style::default().fg(color)
811}
812
813fn push_footer_line(
814 lines: &mut Vec<Line<'static>>,
815 width: u16,
816 selected_gpu: usize,
817 gpu_count: usize,
818 start: usize,
819 end: usize,
820 total: usize,
821) {
822 let range = if total == 0 {
823 "0-0/0".to_owned()
824 } else if end == 0 {
825 format!("0-0/{total}")
826 } else {
827 format!("{}-{}/{}", start + 1, end, total)
828 };
829 lines.push(Line::from(Span::styled(
830 truncate(
831 &format!(
832 "GPU {}/{} | showing {} | Up/Down/PgUp/PgDn scroll | Tab GPU | Esc close | q quit",
833 selected_gpu + 1,
834 gpu_count,
835 range
836 ),
837 width as usize,
838 ),
839 Style::default().fg(Color::DarkGray),
840 )));
841}
842
843fn trim_lines(mut lines: Vec<Line<'static>>, max_lines: usize) -> Vec<Line<'static>> {
844 lines.truncate(max_lines);
845 lines
846}
847
848fn format_process(process: &GpuProcess) -> String {
849 let user = process.user.as_deref().unwrap_or("?");
850 let command = process
851 .command
852 .as_deref()
853 .map(compact_command)
854 .unwrap_or_else(|| "?".to_owned());
855 let memory = process
856 .used_gpu_memory_bytes
857 .map(format_bytes_compact)
858 .unwrap_or_else(|| "mem n/a".to_owned());
859 let mig = process_mig_label(process);
860 let mig = if mig.is_empty() {
861 String::new()
862 } else {
863 format!(" {mig}")
864 };
865
866 format!(
867 "{} pid={} user={} {} {}{}",
868 process.kind_label(),
869 process.pid,
870 user,
871 memory,
872 command,
873 mig
874 )
875}
876
877fn process_mig_label(process: &GpuProcess) -> String {
878 match (process.gpu_instance_id, process.compute_instance_id) {
879 (Some(gpu), Some(compute)) => format!("gi={gpu}/ci={compute}"),
880 (Some(gpu), None) => format!("gi={gpu}"),
881 _ => String::new(),
882 }
883}
884
885fn fit_cell(value: &str, width: usize) -> String {
886 truncate(value, width)
887}
888
889fn metric_span(
890 label: &'static str,
891 value: Option<f64>,
892 unit: &'static str,
893 color: Color,
894) -> Span<'static> {
895 let rendered = value
896 .map(|v| format!("{label} {v:.0}{unit}"))
897 .unwrap_or_else(|| format!("{label} n/a"));
898 Span::styled(rendered, Style::default().fg(color))
899}
900
901fn power_limit_span(limit: Option<f64>) -> Span<'static> {
902 limit
903 .map(|limit| Span::raw(format!("/{limit:.0}W")))
904 .unwrap_or_else(|| Span::raw(""))
905}
906
907fn vram_summary(used: Option<u64>, total: Option<u64>) -> String {
908 match (used, total) {
909 (Some(used), Some(total)) if total > 0 => format!(
910 "{:.1}/{:.1} GiB {:>3.0}%",
911 bytes_to_gib(used),
912 bytes_to_gib(total),
913 used as f64 * 100.0 / total as f64
914 ),
915 (Some(used), _) => format!("{:.1} GiB used", bytes_to_gib(used)),
916 _ => "n/a".to_owned(),
917 }
918}
919
920fn bytes_to_gib(bytes: u64) -> f64 {
921 bytes as f64 / 1024.0 / 1024.0 / 1024.0
922}
923
924fn format_bytes_compact(bytes: u64) -> String {
925 let gib = bytes_to_gib(bytes);
926 if gib >= 10.0 {
927 format!("{gib:.0}GiB")
928 } else if gib >= 1.0 {
929 format!("{gib:.1}GiB")
930 } else {
931 format!("{:.0}MiB", bytes as f64 / 1024.0 / 1024.0)
932 }
933}
934
935fn compact_command(command: &str) -> String {
936 let command = command.trim();
937 if command.is_empty() {
938 return "?".to_owned();
939 }
940
941 let mut parts = command.split_whitespace();
942 let executable = parts.next().unwrap_or(command);
943 let executable = executable.rsplit('/').next().unwrap_or(executable);
944 let rest = parts.take(2).collect::<Vec<_>>().join(" ");
945 if rest.is_empty() {
946 executable.to_owned()
947 } else {
948 format!("{executable} {rest}")
949 }
950}
951
952fn whole_or_na(value: Option<f64>) -> String {
953 value
954 .map(|v| format!("{v:.0}"))
955 .unwrap_or_else(|| "n/a".to_owned())
956}
957
958fn short_uuid(uuid: &str) -> &str {
959 uuid.rsplit('-').next().unwrap_or(uuid)
960}
961
962fn hz(duration: Duration) -> f64 {
963 1.0 / duration.as_secs_f64().max(0.001)
964}
965
966fn format_duration(duration: Duration) -> String {
967 let seconds = duration.as_secs();
968 if seconds < 60 {
969 format!("{seconds}s")
970 } else if seconds < 3600 {
971 format!("{}m{:02}s", seconds / 60, seconds % 60)
972 } else {
973 format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60)
974 }
975}
976
977fn truncate(value: &str, max_len: usize) -> String {
978 if max_len == 0 {
979 return String::new();
980 }
981 if value.chars().count() <= max_len {
982 return value.to_owned();
983 }
984 if max_len <= 3 {
985 return ".".repeat(max_len);
986 }
987 value.chars().take(max_len - 3).collect::<String>() + "..."
988}
989
990const WINDOWS: [Duration; 8] = [
991 Duration::from_secs(10),
992 Duration::from_secs(30),
993 Duration::from_secs(60),
994 Duration::from_secs(5 * 60),
995 Duration::from_secs(10 * 60),
996 Duration::from_secs(30 * 60),
997 Duration::from_secs(60 * 60),
998 Duration::from_secs(3 * 60 * 60),
999];
1000
1001fn closest_window(target: Duration) -> usize {
1002 WINDOWS
1003 .iter()
1004 .enumerate()
1005 .min_by_key(|(_, window)| window.abs_diff(target))
1006 .map(|(index, _)| index)
1007 .unwrap_or(2)
1008}