Skip to main content

cbtop/bricks/panels/
gpu.rs

1//! GPU panel brick (Layer 3)
2//!
3//! Displays GPU metrics from GpuCollectorBrick (Genchi Genbutsu: real data).
4//!
5//! Integrates with trueno-gpu/CUPTI for:
6//! - GPU utilization (SM activity)
7//! - VRAM usage (used/total)
8//! - Temperature monitoring
9//! - Power consumption
10
11use crate::brick::{Brick, BrickAssertion, BrickBudget, BrickVerification};
12use crate::bricks::collectors::gpu::GpuMetrics;
13use presentar_core::{Canvas, Point, Rect, TextStyle, Widget};
14use presentar_terminal::{BrailleGraph, GraphMode, Theme};
15use std::any::Any;
16
17/// GPU panel displaying real-time GPU metrics
18pub struct GpuPanelBrick {
19    /// GPU utilization history for graph
20    pub gpu_data: Vec<f64>,
21    /// Theme for styling
22    pub theme: Theme,
23    /// Current GPU metrics from collector
24    pub current_metrics: Option<GpuMetrics>,
25    /// Temperature (from nvidia-smi or CUPTI when available)
26    pub temperature_c: Option<u32>,
27    /// Power usage in watts (from nvidia-smi or CUPTI when available)
28    pub power_watts: Option<u32>,
29    /// Power limit in watts
30    pub power_limit_watts: Option<u32>,
31}
32
33impl GpuPanelBrick {
34    pub fn new() -> Self {
35        Self {
36            gpu_data: Vec::new(),
37            theme: Theme::tokyo_night(),
38            current_metrics: None,
39            temperature_c: None,
40            power_watts: None,
41            power_limit_watts: None,
42        }
43    }
44
45    /// Update panel with metrics from GpuCollectorBrick
46    pub fn update_from_metrics(&mut self, metrics: &GpuMetrics) {
47        // Add utilization to history (keep last 120 samples)
48        self.gpu_data.push(metrics.utilization_gpu as f64);
49        if self.gpu_data.len() > 120 {
50            self.gpu_data.remove(0);
51        }
52        self.current_metrics = Some(metrics.clone());
53    }
54
55    /// Update temperature (from nvidia-smi or CUPTI)
56    pub fn update_temperature(&mut self, temp_c: u32) {
57        self.temperature_c = Some(temp_c);
58    }
59
60    /// Update power metrics (from nvidia-smi or CUPTI)
61    pub fn update_power(&mut self, watts: u32, limit_watts: u32) {
62        self.power_watts = Some(watts);
63        self.power_limit_watts = Some(limit_watts);
64    }
65
66    pub fn paint(&self, canvas: &mut dyn Canvas, width: f32, _height: f32) {
67        let label_style = TextStyle {
68            color: self.theme.foreground,
69            ..Default::default()
70        };
71        let dim_style = TextStyle {
72            color: self.theme.dim,
73            ..Default::default()
74        };
75
76        canvas.draw_text("GPU Monitor", Point::new(2.0, 2.0), &label_style);
77
78        // Main graph
79        if !self.gpu_data.is_empty() {
80            let gpu_usage = self.gpu_data.last().copied().unwrap_or(0.0);
81            let mut graph = BrailleGraph::new(self.gpu_data.clone())
82                .with_color(self.theme.gpu_color(gpu_usage))
83                .with_range(0.0, 100.0)
84                .with_mode(GraphMode::Braille);
85            graph.layout(Rect::new(2.0, 3.0, width - 4.0, 8.0));
86            graph.paint(canvas);
87        }
88
89        // GPU info - real data from collector when available
90        let (device_name, vram_used_gb, vram_total_gb, util_pct) =
91            if let Some(ref m) = self.current_metrics {
92                (
93                    m.device_name.as_str(),
94                    m.memory_used_mb as f64 / 1024.0,
95                    m.memory_total_mb as f64 / 1024.0,
96                    m.utilization_gpu as f64,
97                )
98            } else {
99                ("No GPU detected", 0.0, 0.0, 0.0)
100            };
101
102        let data_source = if self.current_metrics.is_some() {
103            "CUPTI"
104        } else {
105            "none"
106        };
107        canvas.draw_text(
108            &format!("GPU Info ({})", data_source),
109            Point::new(2.0, 12.0),
110            &label_style,
111        );
112
113        canvas.draw_text("Device: ", Point::new(2.0, 13.0), &dim_style);
114        canvas.draw_text(device_name, Point::new(10.0, 13.0), &label_style);
115
116        // VRAM with memory gradient
117        canvas.draw_text("VRAM: ", Point::new(2.0, 14.0), &dim_style);
118        let vram_pct = if vram_total_gb > 0.0 {
119            (vram_used_gb / vram_total_gb) * 100.0
120        } else {
121            0.0
122        };
123        let vram_style = TextStyle {
124            color: self.theme.memory_color(vram_pct),
125            ..Default::default()
126        };
127        canvas.draw_text(
128            &format!(
129                "{:.1} / {:.1} GB ({:.0}%)",
130                vram_used_gb, vram_total_gb, vram_pct
131            ),
132            Point::new(8.0, 14.0),
133            &vram_style,
134        );
135
136        // Utilization
137        canvas.draw_text("Util: ", Point::new(2.0, 15.0), &dim_style);
138        let util_style = TextStyle {
139            color: self.theme.gpu_color(util_pct),
140            ..Default::default()
141        };
142        canvas.draw_text(
143            &format!("{:.0}%", util_pct),
144            Point::new(8.0, 15.0),
145            &util_style,
146        );
147
148        // Temperature display with color gradient
149        canvas.draw_text("Temp: ", Point::new(16.0, 15.0), &dim_style);
150        let temp = self.temperature_c.unwrap_or(0);
151        let temp_style = TextStyle {
152            color: self.theme.temp_color(temp as f64, 100.0),
153            ..Default::default()
154        };
155        canvas.draw_text(&format!("{} C", temp), Point::new(22.0, 15.0), &temp_style);
156
157        // Power with cpu gradient (reuse for power)
158        canvas.draw_text("Power: ", Point::new(2.0, 16.0), &dim_style);
159        let power = self.power_watts.unwrap_or(0);
160        let power_limit = self.power_limit_watts.unwrap_or(1);
161        let power_pct = (power as f64 / power_limit as f64) * 100.0;
162        let power_style = TextStyle {
163            color: self.theme.cpu_color(power_pct),
164            ..Default::default()
165        };
166        canvas.draw_text(
167            &format!("{}W / {}W ({:.0}%)", power, power_limit, power_pct),
168            Point::new(9.0, 16.0),
169            &power_style,
170        );
171    }
172}
173
174impl Brick for GpuPanelBrick {
175    fn brick_name(&self) -> &'static str {
176        "gpu_panel"
177    }
178
179    fn assertions(&self) -> Vec<BrickAssertion> {
180        vec![
181            BrickAssertion::MinWidth(40),
182            BrickAssertion::MinHeight(15),
183            BrickAssertion::max_latency_ms(8),
184        ]
185    }
186
187    fn budget(&self) -> BrickBudget {
188        BrickBudget::FRAME_60FPS
189    }
190
191    fn verify(&self) -> BrickVerification {
192        let mut v = BrickVerification::new();
193        for assertion in self.assertions() {
194            v.check(&assertion);
195        }
196        v
197    }
198
199    fn as_any(&self) -> &dyn Any {
200        self
201    }
202}
203
204impl Default for GpuPanelBrick {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use presentar_core::RecordingCanvas;
214    use std::time::Instant;
215
216    #[test]
217    fn test_gpu_panel_brick_name() {
218        let panel = GpuPanelBrick::new();
219        assert_eq!(panel.brick_name(), "gpu_panel");
220    }
221
222    #[test]
223    fn test_gpu_panel_paint_empty() {
224        let panel = GpuPanelBrick::new();
225        let mut canvas = RecordingCanvas::new();
226
227        panel.paint(&mut canvas, 80.0, 24.0);
228
229        // Should draw header text even with no data
230        assert!(!canvas.is_empty());
231        assert!(canvas.command_count() >= 1);
232    }
233
234    #[test]
235    fn test_gpu_panel_paint_with_data() {
236        let mut panel = GpuPanelBrick::new();
237
238        // Add some GPU data
239        let metrics = GpuMetrics {
240            timestamp: Instant::now(),
241            device_index: 0,
242            device_name: "RTX 4090".to_string(),
243            utilization_gpu: 75,
244            memory_used_mb: 8192,
245            memory_total_mb: 24576,
246        };
247        panel.update_from_metrics(&metrics);
248        panel.update_temperature(72);
249        panel.update_power(250, 350);
250
251        let mut canvas = RecordingCanvas::new();
252        panel.paint(&mut canvas, 80.0, 24.0);
253
254        // Should have more commands with data
255        assert!(canvas.command_count() >= 5);
256    }
257
258    #[test]
259    fn test_gpu_panel_paint_with_graph() {
260        let mut panel = GpuPanelBrick::new();
261
262        // Add multiple data points for graph
263        for i in 0..10 {
264            let metrics = GpuMetrics {
265                timestamp: Instant::now(),
266                device_index: 0,
267                device_name: "Test GPU".to_string(),
268                utilization_gpu: (i * 10) as u32,
269                memory_used_mb: 1000,
270                memory_total_mb: 2000,
271            };
272            panel.update_from_metrics(&metrics);
273        }
274
275        let mut canvas = RecordingCanvas::new();
276        panel.paint(&mut canvas, 80.0, 24.0);
277
278        // Graph rendering adds more commands
279        assert!(canvas.command_count() >= 5);
280    }
281
282    #[test]
283    fn test_gpu_panel_has_assertions() {
284        let panel = GpuPanelBrick::new();
285        assert!(!panel.assertions().is_empty());
286    }
287
288    #[test]
289    fn test_gpu_panel_update_from_metrics() {
290        let mut panel = GpuPanelBrick::new();
291
292        let metrics = GpuMetrics {
293            timestamp: Instant::now(),
294            device_index: 0,
295            device_name: "RTX 4090".to_string(),
296            utilization_gpu: 75,
297            memory_used_mb: 8192,
298            memory_total_mb: 24576,
299        };
300
301        panel.update_from_metrics(&metrics);
302
303        assert_eq!(panel.gpu_data.len(), 1);
304        assert_eq!(panel.gpu_data[0], 75.0);
305        assert!(panel.current_metrics.is_some());
306        let m = panel.current_metrics.as_ref().unwrap();
307        assert_eq!(m.device_name, "RTX 4090");
308    }
309
310    #[test]
311    fn test_gpu_panel_update_temperature() {
312        let mut panel = GpuPanelBrick::new();
313        panel.update_temperature(72);
314        assert_eq!(panel.temperature_c, Some(72));
315    }
316
317    #[test]
318    fn test_gpu_panel_update_power() {
319        let mut panel = GpuPanelBrick::new();
320        panel.update_power(250, 350);
321        assert_eq!(panel.power_watts, Some(250));
322        assert_eq!(panel.power_limit_watts, Some(350));
323    }
324
325    #[test]
326    fn test_gpu_panel_history_limit() {
327        let mut panel = GpuPanelBrick::new();
328
329        // Add 130 samples (should cap at 120)
330        for i in 0..130 {
331            let metrics = GpuMetrics {
332                timestamp: Instant::now(),
333                device_index: 0,
334                device_name: "Test GPU".to_string(),
335                utilization_gpu: (i % 100) as u32,
336                memory_used_mb: 1000,
337                memory_total_mb: 2000,
338            };
339            panel.update_from_metrics(&metrics);
340        }
341
342        assert_eq!(panel.gpu_data.len(), 120);
343        // First entry should be from i=10 (value 10.0), last from i=129 (value 29.0)
344        assert_eq!(panel.gpu_data[0], 10.0);
345        assert_eq!(panel.gpu_data[119], 29.0);
346    }
347
348    #[test]
349    fn test_gpu_panel_default() {
350        let panel = GpuPanelBrick::default();
351        assert!(panel.gpu_data.is_empty());
352        assert!(panel.current_metrics.is_none());
353        assert!(panel.temperature_c.is_none());
354        assert!(panel.power_watts.is_none());
355    }
356}