1use 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#[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 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 pub fn color(&self) -> Color {
37 match self {
38 Self::Running => Color::new(0.4, 0.9, 0.4, 1.0), Self::Paused => Color::new(1.0, 0.8, 0.2, 1.0), Self::Stopped => Color::new(0.5, 0.5, 0.5, 1.0), Self::Restarting => Color::new(0.4, 0.6, 1.0, 1.0), Self::Dead => Color::new(1.0, 0.3, 0.3, 1.0), }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct ContainerEntry {
50 pub name: String,
52 pub id: String,
54 pub state: ContainerState,
56 pub cpu_percent: f32,
58 pub memory_bytes: u64,
60 pub memory_limit: u64,
62 pub image: String,
64}
65
66impl ContainerEntry {
67 #[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 #[must_use]
83 pub fn with_state(mut self, state: ContainerState) -> Self {
84 self.state = state;
85 self
86 }
87
88 #[must_use]
90 pub fn with_cpu(mut self, cpu: f32) -> Self {
91 self.cpu_percent = cpu;
92 self
93 }
94
95 #[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 #[must_use]
105 pub fn with_image(mut self, image: impl Into<String>) -> Self {
106 self.image = image.into();
107 self
108 }
109
110 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 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#[derive(Debug, Clone)]
132pub struct ContainersPanel {
133 containers: Vec<ContainerEntry>,
135 running_only: bool,
137 max_containers: usize,
139 compact: bool,
141 bounds: Rect,
143}
144
145impl Default for ContainersPanel {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl ContainersPanel {
152 #[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 pub fn add_container(&mut self, container: ContainerEntry) {
166 self.containers.push(container);
167 }
168
169 #[must_use]
171 pub fn with_containers(mut self, containers: Vec<ContainerEntry>) -> Self {
172 self.containers = containers;
173 self
174 }
175
176 #[must_use]
178 pub fn running_only(mut self, only: bool) -> Self {
179 self.running_only = only;
180 self
181 }
182
183 #[must_use]
185 pub fn max_containers(mut self, max: usize) -> Self {
186 self.max_containers = max;
187 self
188 }
189
190 #[must_use]
192 pub fn compact(mut self, compact: bool) -> Self {
193 self.compact = compact;
194 self
195 }
196
197 pub fn running_count(&self) -> usize {
199 self.containers
200 .iter()
201 .filter(|c| c.state == ContainerState::Running)
202 .count()
203 }
204
205 pub fn total_count(&self) -> usize {
207 self.containers.len()
208 }
209
210 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 fn draw_container(
220 &self,
221 canvas: &mut dyn Canvas,
222 container: &ContainerEntry,
223 x: f32,
224 y: f32,
225 width: f32,
226 ) {
227 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 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 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 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 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 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 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 let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
494 assert_eq!(result.size.width, 80.0);
495
496 assert_eq!(Widget::type_id(&panel), TypeId::of::<ContainersPanel>());
498
499 assert!(panel
501 .event(&Event::key_down(presentar_core::Key::Enter))
502 .is_none());
503
504 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)); let mut buffer = CellBuffer::new(5, 1);
555 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
556 panel.paint(&mut canvas); }
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}