Skip to main content

presentar_terminal/widgets/
containers_panel.rs

1//! `ContainersPanel` widget for Docker/Podman container monitoring.
2//!
3//! Displays running containers with status, CPU, and memory usage.
4
5use presentar_core::{
6    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
7    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
8};
9use std::any::Any;
10use std::time::Duration;
11
12/// Container state.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum ContainerState {
15    #[default]
16    Running,
17    Paused,
18    Stopped,
19    Restarting,
20    Dead,
21}
22
23impl ContainerState {
24    /// Get status indicator.
25    pub fn indicator(&self) -> char {
26        match self {
27            Self::Running => '●',
28            Self::Paused => '◐',
29            Self::Stopped => '○',
30            Self::Restarting => '↻',
31            Self::Dead => '✕',
32        }
33    }
34
35    /// Get status color.
36    pub fn color(&self) -> Color {
37        match self {
38            Self::Running => Color::new(0.4, 0.9, 0.4, 1.0), // Green
39            Self::Paused => Color::new(1.0, 0.8, 0.2, 1.0),  // Yellow
40            Self::Stopped => Color::new(0.5, 0.5, 0.5, 1.0), // Gray
41            Self::Restarting => Color::new(0.4, 0.6, 1.0, 1.0), // Blue
42            Self::Dead => Color::new(1.0, 0.3, 0.3, 1.0),    // Red
43        }
44    }
45}
46
47/// A container entry.
48#[derive(Debug, Clone)]
49pub struct ContainerEntry {
50    /// Container name.
51    pub name: String,
52    /// Container ID (short).
53    pub id: String,
54    /// Container state.
55    pub state: ContainerState,
56    /// CPU usage percentage.
57    pub cpu_percent: f32,
58    /// Memory usage in bytes.
59    pub memory_bytes: u64,
60    /// Memory limit in bytes (0 = no limit).
61    pub memory_limit: u64,
62    /// Image name.
63    pub image: String,
64}
65
66impl ContainerEntry {
67    /// Create a new container entry.
68    #[must_use]
69    pub fn new(name: impl Into<String>, id: impl Into<String>) -> Self {
70        Self {
71            name: name.into(),
72            id: id.into(),
73            state: ContainerState::Running,
74            cpu_percent: 0.0,
75            memory_bytes: 0,
76            memory_limit: 0,
77            image: String::new(),
78        }
79    }
80
81    /// Set container state.
82    #[must_use]
83    pub fn with_state(mut self, state: ContainerState) -> Self {
84        self.state = state;
85        self
86    }
87
88    /// Set CPU percentage.
89    #[must_use]
90    pub fn with_cpu(mut self, cpu: f32) -> Self {
91        self.cpu_percent = cpu;
92        self
93    }
94
95    /// Set memory usage.
96    #[must_use]
97    pub fn with_memory(mut self, used: u64, limit: u64) -> Self {
98        self.memory_bytes = used;
99        self.memory_limit = limit;
100        self
101    }
102
103    /// Set image name.
104    #[must_use]
105    pub fn with_image(mut self, image: impl Into<String>) -> Self {
106        self.image = image.into();
107        self
108    }
109
110    /// Format memory for display.
111    pub fn memory_display(&self) -> String {
112        let mb = self.memory_bytes as f64 / 1_048_576.0;
113        if mb >= 1024.0 {
114            format!("{:.1}G", mb / 1024.0)
115        } else {
116            format!("{mb:.0}M")
117        }
118    }
119
120    /// Get memory percentage.
121    pub fn memory_percent(&self) -> Option<f32> {
122        if self.memory_limit > 0 {
123            Some((self.memory_bytes as f64 / self.memory_limit as f64 * 100.0) as f32)
124        } else {
125            None
126        }
127    }
128}
129
130/// Containers panel displaying Docker/Podman containers.
131#[derive(Debug, Clone)]
132pub struct ContainersPanel {
133    /// Container entries.
134    containers: Vec<ContainerEntry>,
135    /// Show only running containers.
136    running_only: bool,
137    /// Max containers to show.
138    max_containers: usize,
139    /// Compact mode (single line per container).
140    compact: bool,
141    /// Cached bounds.
142    bounds: Rect,
143}
144
145impl Default for ContainersPanel {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl ContainersPanel {
152    /// Create a new containers panel.
153    #[must_use]
154    pub fn new() -> Self {
155        Self {
156            containers: Vec::new(),
157            running_only: true,
158            max_containers: 5,
159            compact: true,
160            bounds: Rect::default(),
161        }
162    }
163
164    /// Add a container.
165    pub fn add_container(&mut self, container: ContainerEntry) {
166        self.containers.push(container);
167    }
168
169    /// Set all containers.
170    #[must_use]
171    pub fn with_containers(mut self, containers: Vec<ContainerEntry>) -> Self {
172        self.containers = containers;
173        self
174    }
175
176    /// Show only running containers.
177    #[must_use]
178    pub fn running_only(mut self, only: bool) -> Self {
179        self.running_only = only;
180        self
181    }
182
183    /// Set max containers to show.
184    #[must_use]
185    pub fn max_containers(mut self, max: usize) -> Self {
186        self.max_containers = max;
187        self
188    }
189
190    /// Enable compact mode.
191    #[must_use]
192    pub fn compact(mut self, compact: bool) -> Self {
193        self.compact = compact;
194        self
195    }
196
197    /// Get running container count.
198    pub fn running_count(&self) -> usize {
199        self.containers
200            .iter()
201            .filter(|c| c.state == ContainerState::Running)
202            .count()
203    }
204
205    /// Get total container count.
206    pub fn total_count(&self) -> usize {
207        self.containers.len()
208    }
209
210    /// Get visible containers (filtered).
211    fn visible_containers(&self) -> impl Iterator<Item = &ContainerEntry> {
212        self.containers
213            .iter()
214            .filter(|c| !self.running_only || c.state == ContainerState::Running)
215            .take(self.max_containers)
216    }
217
218    /// Draw a container line.
219    fn draw_container(
220        &self,
221        canvas: &mut dyn Canvas,
222        container: &ContainerEntry,
223        x: f32,
224        y: f32,
225        width: f32,
226    ) {
227        // State indicator
228        canvas.draw_text(
229            &container.state.indicator().to_string(),
230            Point::new(x, y),
231            &TextStyle {
232                color: container.state.color(),
233                ..Default::default()
234            },
235        );
236
237        // Container name (truncated)
238        let max_name = ((width - 20.0) / 2.0) as usize;
239        let name = if container.name.len() > max_name {
240            format!("{}...", &container.name[..max_name.saturating_sub(3)])
241        } else {
242            container.name.clone()
243        };
244
245        canvas.draw_text(
246            &name,
247            Point::new(x + 2.0, y),
248            &TextStyle {
249                color: Color::WHITE,
250                ..Default::default()
251            },
252        );
253
254        // CPU and Memory
255        let stats = format!(
256            "{:4.1}% {:>5}",
257            container.cpu_percent,
258            container.memory_display()
259        );
260        canvas.draw_text(
261            &stats,
262            Point::new(x + width - 13.0, y),
263            &TextStyle {
264                color: Color::new(0.7, 0.7, 0.7, 1.0),
265                ..Default::default()
266            },
267        );
268    }
269}
270
271impl Brick for ContainersPanel {
272    fn brick_name(&self) -> &'static str {
273        "containers_panel"
274    }
275
276    fn assertions(&self) -> &[BrickAssertion] {
277        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
278        ASSERTIONS
279    }
280
281    fn budget(&self) -> BrickBudget {
282        BrickBudget::uniform(8)
283    }
284
285    fn verify(&self) -> BrickVerification {
286        BrickVerification {
287            passed: vec![BrickAssertion::max_latency_ms(8)],
288            failed: vec![],
289            verification_time: Duration::from_micros(25),
290        }
291    }
292
293    fn to_html(&self) -> String {
294        String::new()
295    }
296
297    fn to_css(&self) -> String {
298        String::new()
299    }
300}
301
302impl Widget for ContainersPanel {
303    fn type_id(&self) -> TypeId {
304        TypeId::of::<Self>()
305    }
306
307    fn measure(&self, constraints: Constraints) -> Size {
308        let visible = self.visible_containers().count();
309        let height = (visible as f32).max(1.0).min(constraints.max_height);
310        Size::new(constraints.max_width, height)
311    }
312
313    fn layout(&mut self, bounds: Rect) -> LayoutResult {
314        self.bounds = bounds;
315        LayoutResult {
316            size: Size::new(bounds.width, bounds.height),
317        }
318    }
319
320    fn paint(&self, canvas: &mut dyn Canvas) {
321        if self.bounds.width < 10.0 || self.bounds.height < 1.0 {
322            return;
323        }
324
325        let mut y = self.bounds.y;
326        let x = self.bounds.x;
327
328        // Draw visible containers
329        for container in self.visible_containers() {
330            if y >= self.bounds.y + self.bounds.height {
331                break;
332            }
333            self.draw_container(canvas, container, x, y, self.bounds.width);
334            y += 1.0;
335        }
336
337        // If no containers, show message
338        if self.containers.is_empty() {
339            canvas.draw_text(
340                "No containers",
341                Point::new(x, self.bounds.y),
342                &TextStyle {
343                    color: Color::new(0.5, 0.5, 0.5, 1.0),
344                    ..Default::default()
345                },
346            );
347        }
348    }
349
350    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
351        None
352    }
353
354    fn children(&self) -> &[Box<dyn Widget>] {
355        &[]
356    }
357
358    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
359        &mut []
360    }
361}
362
363#[cfg(test)]
364#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_container_state_indicator() {
370        assert_eq!(ContainerState::Running.indicator(), '●');
371        assert_eq!(ContainerState::Paused.indicator(), '◐');
372        assert_eq!(ContainerState::Stopped.indicator(), '○');
373    }
374
375    #[test]
376    fn test_container_entry_memory() {
377        let entry = ContainerEntry::new("nginx", "abc123")
378            .with_memory(256 * 1024 * 1024, 512 * 1024 * 1024);
379        assert_eq!(entry.memory_display(), "256M");
380        assert!((entry.memory_percent().unwrap() - 50.0).abs() < 0.1);
381    }
382
383    #[test]
384    fn test_container_entry_memory_gb() {
385        let entry = ContainerEntry::new("postgres", "def456")
386            .with_memory(2 * 1024 * 1024 * 1024, 4 * 1024 * 1024 * 1024);
387        assert_eq!(entry.memory_display(), "2.0G");
388    }
389
390    #[test]
391    fn test_panel_running_count() {
392        let mut panel = ContainersPanel::new();
393        panel.add_container(ContainerEntry::new("nginx", "a").with_state(ContainerState::Running));
394        panel.add_container(ContainerEntry::new("redis", "b").with_state(ContainerState::Running));
395        panel.add_container(ContainerEntry::new("old", "c").with_state(ContainerState::Stopped));
396
397        assert_eq!(panel.running_count(), 2);
398        assert_eq!(panel.total_count(), 3);
399    }
400
401    #[test]
402    fn test_panel_builder() {
403        let panel = ContainersPanel::new()
404            .running_only(false)
405            .max_containers(10)
406            .compact(false);
407
408        assert!(!panel.running_only);
409        assert_eq!(panel.max_containers, 10);
410        assert!(!panel.compact);
411    }
412
413    #[test]
414    fn test_container_state_all_indicators() {
415        assert_eq!(ContainerState::Restarting.indicator(), '↻');
416        assert_eq!(ContainerState::Dead.indicator(), '✕');
417    }
418
419    #[test]
420    fn test_container_state_all_colors() {
421        // Test all states return valid colors
422        for state in [
423            ContainerState::Running,
424            ContainerState::Paused,
425            ContainerState::Stopped,
426            ContainerState::Restarting,
427            ContainerState::Dead,
428        ] {
429            let color = state.color();
430            assert!(color.r >= 0.0 && color.r <= 1.0);
431            assert!(color.g >= 0.0 && color.g <= 1.0);
432            assert!(color.b >= 0.0 && color.b <= 1.0);
433        }
434    }
435
436    #[test]
437    fn test_container_entry_with_cpu() {
438        let entry = ContainerEntry::new("nginx", "abc123").with_cpu(45.5);
439        assert_eq!(entry.cpu_percent, 45.5);
440    }
441
442    #[test]
443    fn test_container_entry_with_image() {
444        let entry = ContainerEntry::new("nginx", "abc123").with_image("nginx:latest");
445        assert_eq!(entry.image, "nginx:latest");
446    }
447
448    #[test]
449    fn test_container_entry_no_memory_limit() {
450        let entry = ContainerEntry::new("nginx", "abc123").with_memory(256 * 1024 * 1024, 0);
451        assert!(entry.memory_percent().is_none());
452    }
453
454    #[test]
455    fn test_containers_panel_with_containers() {
456        let containers = vec![
457            ContainerEntry::new("nginx", "a").with_state(ContainerState::Running),
458            ContainerEntry::new("redis", "b").with_state(ContainerState::Paused),
459        ];
460        let panel = ContainersPanel::new().with_containers(containers);
461        assert_eq!(panel.total_count(), 2);
462    }
463
464    #[test]
465    fn test_containers_panel_brick_traits() {
466        let panel = ContainersPanel::new();
467        assert_eq!(panel.brick_name(), "containers_panel");
468        assert!(!panel.assertions().is_empty());
469        assert!(panel.budget().paint_ms > 0);
470        assert!(panel.verify().is_valid());
471        assert!(panel.to_html().is_empty());
472        assert!(panel.to_css().is_empty());
473    }
474
475    #[test]
476    fn test_containers_panel_widget_traits() {
477        let mut panel = ContainersPanel::new()
478            .with_containers(vec![
479                ContainerEntry::new("nginx", "a").with_state(ContainerState::Running)
480            ]);
481
482        // Measure
483        let size = panel.measure(Constraints {
484            min_width: 0.0,
485            min_height: 0.0,
486            max_width: 80.0,
487            max_height: 20.0,
488        });
489        assert!(size.width > 0.0);
490        assert!(size.height > 0.0);
491
492        // Layout
493        let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
494        assert_eq!(result.size.width, 80.0);
495
496        // Type ID
497        assert_eq!(Widget::type_id(&panel), TypeId::of::<ContainersPanel>());
498
499        // Event
500        assert!(panel
501            .event(&Event::key_down(presentar_core::Key::Enter))
502            .is_none());
503
504        // Children
505        assert!(panel.children().is_empty());
506        assert!(panel.children_mut().is_empty());
507    }
508
509    #[test]
510    fn test_containers_panel_paint() {
511        use crate::direct::{CellBuffer, DirectTerminalCanvas};
512
513        let containers = vec![
514            ContainerEntry::new("nginx", "abc123")
515                .with_state(ContainerState::Running)
516                .with_cpu(15.5)
517                .with_memory(256 * 1024 * 1024, 512 * 1024 * 1024),
518            ContainerEntry::new("very_long_container_name_here", "def456")
519                .with_state(ContainerState::Paused)
520                .with_cpu(2.0)
521                .with_memory(128 * 1024 * 1024, 256 * 1024 * 1024),
522        ];
523
524        let mut panel = ContainersPanel::new().with_containers(containers);
525        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
526
527        let mut buffer = CellBuffer::new(60, 10);
528        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
529        panel.paint(&mut canvas);
530    }
531
532    #[test]
533    fn test_containers_panel_paint_empty() {
534        use crate::direct::{CellBuffer, DirectTerminalCanvas};
535
536        let mut panel = ContainersPanel::new();
537        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
538
539        let mut buffer = CellBuffer::new(60, 10);
540        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
541        panel.paint(&mut canvas);
542    }
543
544    #[test]
545    fn test_containers_panel_paint_small_bounds() {
546        use crate::direct::{CellBuffer, DirectTerminalCanvas};
547
548        let containers =
549            vec![ContainerEntry::new("nginx", "abc").with_state(ContainerState::Running)];
550
551        let mut panel = ContainersPanel::new().with_containers(containers);
552        panel.layout(Rect::new(0.0, 0.0, 5.0, 0.5)); // Too small
553
554        let mut buffer = CellBuffer::new(5, 1);
555        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
556        panel.paint(&mut canvas); // Should early return
557    }
558
559    #[test]
560    fn test_containers_panel_running_only_filter() {
561        let containers = vec![
562            ContainerEntry::new("nginx", "a").with_state(ContainerState::Running),
563            ContainerEntry::new("redis", "b").with_state(ContainerState::Stopped),
564            ContainerEntry::new("postgres", "c").with_state(ContainerState::Running),
565        ];
566
567        let panel = ContainersPanel::new()
568            .with_containers(containers)
569            .running_only(true);
570
571        assert_eq!(panel.visible_containers().count(), 2);
572    }
573
574    #[test]
575    fn test_containers_panel_show_all() {
576        let containers = vec![
577            ContainerEntry::new("nginx", "a").with_state(ContainerState::Running),
578            ContainerEntry::new("redis", "b").with_state(ContainerState::Stopped),
579        ];
580
581        let panel = ContainersPanel::new()
582            .with_containers(containers)
583            .running_only(false);
584
585        assert_eq!(panel.visible_containers().count(), 2);
586    }
587
588    #[test]
589    fn test_containers_panel_default() {
590        let panel = ContainersPanel::default();
591        assert!(panel.running_only);
592        assert!(panel.compact);
593        assert_eq!(panel.max_containers, 5);
594    }
595
596    #[test]
597    fn test_container_state_default() {
598        let state = ContainerState::default();
599        assert_eq!(state, ContainerState::Running);
600    }
601
602    #[test]
603    fn test_containers_panel_max_limit() {
604        let containers: Vec<_> = (0..10)
605            .map(|i| ContainerEntry::new(format!("container{}", i), format!("{}", i)))
606            .collect();
607
608        let panel = ContainersPanel::new()
609            .with_containers(containers)
610            .max_containers(3);
611
612        assert_eq!(panel.visible_containers().count(), 3);
613    }
614}