1use std::collections::HashMap;
20use std::sync::mpsc;
21use std::time::Duration;
22
23use crossterm::event::{self, Event, KeyCode, KeyEventKind};
24use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
25use futures_util::StreamExt;
26use ratatui::layout::{Constraint, Direction, Layout, Rect};
27use ratatui::style::{Color, Modifier, Style};
28use ratatui::text::{Line, Span};
29use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState};
30use ratatui::{Frame, Terminal};
31
32use crate::{
33 config::DolConfig,
34 docker::{BollardDockerClient, Container, DockerClient, DockerEvent, MetricSample},
35 metrics::{BollardMetricsCollector, MetricsCollector},
36};
37
38const CONTAINER_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
40
41const METRICS_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
43
44const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(200);
46
47const HELP_TEXT: &str = "\
48 DOL Keyboard Help
49
50 [q/Esc] Quit
51 [↑/↓] or j/k Navigate rows
52 [s] Cycle sort column
53 [d] Toggle sort direction
54 [r] Force refresh
55 [/] Filter containers by name (top only)
56 [h] Toggle this help overlay
57 [Tab] Switch panel focus (dashboard only)
58 [c] Clear events (dashboard only)
59";
60
61fn state_color(state: &str) -> Color {
62 match state {
63 "running" => Color::Green,
64 "exited" | "dead" => Color::Red,
65 "paused" => Color::Yellow,
66 "restarting" => Color::Cyan,
67 "created" => Color::Blue,
68 _ => Color::White,
69 }
70}
71
72fn gauge_color(ratio: f64) -> Color {
73 if ratio > 0.80 {
74 Color::Red
75 } else if ratio > 0.50 {
76 Color::Yellow
77 } else {
78 Color::Green
79 }
80}
81
82fn event_action_color(action: &str) -> Color {
83 match action {
84 "start" | "restart" | "unpause" => Color::Green,
85 "die" | "kill" | "oom" | "destroy" => Color::Red,
86 "stop" | "pause" => Color::Yellow,
87 "create" | "pull" => Color::Cyan,
88 _ => Color::White,
89 }
90}
91
92async fn collect_metrics_map(collector: &impl MetricsCollector) -> HashMap<String, MetricSample> {
93 collector
94 .collect()
95 .await
96 .ok()
97 .unwrap_or_default()
98 .into_iter()
99 .map(|s| (s.container_name.clone(), s))
100 .collect()
101}
102
103fn is_container_state_change(action: &str) -> bool {
107 matches!(
108 action,
109 "create"
110 | "start"
111 | "die"
112 | "stop"
113 | "destroy"
114 | "kill"
115 | "restart"
116 | "pause"
117 | "unpause"
118 | "update"
119 )
120}
121
122#[allow(clippy::option_if_let_else)]
125fn format_event_time(event: &DockerEvent) -> String {
126 let time_raw = &event.time;
127 if let Ok(secs) = time_raw.parse::<u64>() {
128 let secs = if time_raw.len() >= 16 {
130 secs / 1_000_000_000
131 } else {
132 secs
133 };
134 let h = (secs / 3600) % 24;
135 let m = (secs / 60) % 60;
136 let s = secs % 60;
137 format!("{h:02}:{m:02}:{s:02}")
138 } else if time_raw.len() >= 19 {
139 time_raw
141 .get(11..19)
142 .map_or_else(|| "??:??:??".to_owned(), std::borrow::ToOwned::to_owned)
143 } else {
144 "??:??:??".to_owned()
145 }
146}
147
148fn spawn_event_listener(
151 docker: std::sync::Arc<BollardDockerClient>,
152 events_timeout: Duration,
153) -> mpsc::Receiver<ParsedEvent> {
154 let (tx, rx) = mpsc::channel();
155
156 tokio::spawn(async move {
157 let Ok(stream) = docker.events_stream(None, None).await else {
158 return;
159 };
160
161 let mut stream = stream;
162 loop {
163 let next = tokio::time::timeout(events_timeout, stream.next()).await;
164 let Ok(Some(Ok(event))) = next else { break };
165 let actor_id = if event.actor_id.len() > 12 {
166 event.actor_id[..12].to_owned()
167 } else {
168 event.actor_id.clone()
169 };
170 let parsed = ParsedEvent {
171 time: format_event_time(&event),
172 action: event.action,
173 actor_id,
174 container_name: event.container.unwrap_or_default(),
175 };
176 if tx.send(parsed).is_err() {
177 break; }
179 }
180 });
181
182 rx
183}
184
185async fn refresh_all(
188 docker: &BollardDockerClient,
189 metrics: &BollardMetricsCollector<BollardDockerClient>,
190 containers: &mut Vec<Container>,
191 metrics_map: &mut HashMap<String, MetricSample>,
192 last_refresh: &mut String,
193) -> Result<(), anyhow::Error> {
194 if let Ok(c) = docker.list_containers().await {
195 *containers = c;
196 }
197 *metrics_map = collect_metrics_map(metrics).await;
198 update_timestamp(last_refresh);
199 Ok(())
200}
201
202async fn refresh_metrics_only(
203 metrics: &impl MetricsCollector,
204 metrics_map: &mut HashMap<String, MetricSample>,
205 last_refresh: &mut String,
206) {
207 *metrics_map = collect_metrics_map(metrics).await;
208 update_timestamp(last_refresh);
209}
210
211fn update_timestamp(last_refresh: &mut String) {
212 use std::fmt::Write;
213 let now = chrono::Local::now();
214 let _ = write!(last_refresh, "{}", now.format("%H:%M:%S"));
215}
216
217fn draw_help_overlay(f: &mut Frame, area: Rect) {
218 let help_h = HELP_TEXT.lines().count() as u16 + 4;
219 let help_w = 44u16;
220 let x = area.x + (area.width.saturating_sub(help_w)) / 2;
221 let y = area.y + (area.height.saturating_sub(help_h)) / 2;
222
223 let popup_area = Rect::new(x, y, help_w, help_h);
224 f.render_widget(Clear, popup_area);
225
226 let help_para = Paragraph::new(HELP_TEXT)
227 .style(Style::default().fg(Color::White).bg(Color::Black))
228 .block(
229 Block::default()
230 .title(" Help ")
231 .title_alignment(ratatui::layout::Alignment::Center)
232 .borders(Borders::ALL)
233 .border_style(Style::default().fg(Color::Yellow)),
234 );
235 f.render_widget(help_para, popup_area);
236}
237
238fn format_mem(bytes: u64) -> String {
239 if bytes >= 1_000_000_000 {
240 format!("{:.1}G", bytes as f64 / 1_000_000_000.0)
241 } else if bytes >= 1_000_000 {
242 format!("{:.0}M", bytes as f64 / 1_000_000.0)
243 } else if bytes >= 1_000 {
244 format!("{:.0}K", bytes as f64 / 1_000.0)
245 } else {
246 format!("{bytes}B")
247 }
248}
249
250fn gauge_bar(ratio: f64, width: u16) -> String {
251 if width == 0 {
252 return String::new();
253 }
254 let filled = (ratio * f64::from(width)).round() as usize;
255 let filled = filled.min(width as usize);
256 let empty = width.saturating_sub(filled as u16) as usize;
257 "█".repeat(filled) + "░".repeat(empty).as_str()
258}
259
260pub async fn run_top(config: &DolConfig) -> anyhow::Result<()> {
263 let docker_arc = std::sync::Arc::new(BollardDockerClient::connect_with_config(config)?);
264 let docker = docker_arc.as_ref();
265 let metrics_collector =
266 BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker_arc), config);
267
268 terminal::enable_raw_mode()?;
269 let mut stdout = std::io::stdout();
270 crossterm::execute!(stdout, EnterAlternateScreen)?;
271 let mut terminal = Terminal::new(ratatui::backend::CrosstermBackend::new(stdout))?;
272
273 let mut table_state = TableState::default();
274 table_state.select(Some(0));
275 let mut sort_column: usize = 0;
276 let mut sort_desc: bool = false;
277 let mut containers: Vec<Container> = Vec::new();
278 let mut metrics_map: HashMap<String, MetricSample> = HashMap::new();
279 let mut should_quit = false;
280 let mut show_help = false;
281 let mut filter_text = String::new();
282 let mut in_filter_mode = false;
283 let mut last_refresh = String::new();
284
285 let events_timeout = Duration::from_secs(config.events_timeout.unwrap_or(30));
286
287 let event_rx = spawn_event_listener(std::sync::Arc::clone(&docker_arc), events_timeout);
289
290 let _ = refresh_all(
292 docker,
293 &metrics_collector,
294 &mut containers,
295 &mut metrics_map,
296 &mut last_refresh,
297 )
298 .await;
299 let mut last_metrics_refresh = std::time::Instant::now();
300 let mut last_container_refresh = std::time::Instant::now();
301
302 while !should_quit {
303 terminal.draw(|f| {
304 draw_top(
305 f,
306 &containers,
307 &metrics_map,
308 &mut table_state,
309 sort_column,
310 sort_desc,
311 &last_refresh,
312 show_help,
313 &filter_text,
314 in_filter_mode,
315 );
316 })?;
317
318 let mut container_changed = false;
324 while let Ok(event) = event_rx.try_recv() {
325 if is_container_state_change(&event.action) {
326 container_changed = true;
327 }
328 }
329
330 if container_changed {
331 let _ = refresh_all(
333 docker,
334 &metrics_collector,
335 &mut containers,
336 &mut metrics_map,
337 &mut last_refresh,
338 )
339 .await;
340 last_metrics_refresh = std::time::Instant::now();
341 last_container_refresh = std::time::Instant::now();
342 } else if last_container_refresh.elapsed() >= CONTAINER_REFRESH_INTERVAL {
343 let _ = refresh_all(
345 docker,
346 &metrics_collector,
347 &mut containers,
348 &mut metrics_map,
349 &mut last_refresh,
350 )
351 .await;
352 last_metrics_refresh = std::time::Instant::now();
353 last_container_refresh = std::time::Instant::now();
354 } else if last_metrics_refresh.elapsed() >= METRICS_REFRESH_INTERVAL {
355 refresh_metrics_only(&metrics_collector, &mut metrics_map, &mut last_refresh).await;
357 last_metrics_refresh = std::time::Instant::now();
358 }
359
360 if event::poll(EVENT_POLL_INTERVAL)?
362 && let Event::Key(key) = event::read()?
363 && key.kind == KeyEventKind::Press
364 {
365 match key.code {
366 KeyCode::Char('q') | KeyCode::Esc if !in_filter_mode => should_quit = true,
367 KeyCode::Char('h') if !in_filter_mode => show_help = !show_help,
368 KeyCode::Char('r') if !in_filter_mode => {
369 let _ = refresh_all(
370 docker,
371 &metrics_collector,
372 &mut containers,
373 &mut metrics_map,
374 &mut last_refresh,
375 )
376 .await;
377 last_metrics_refresh = std::time::Instant::now();
378 last_container_refresh = std::time::Instant::now();
379 }
380 KeyCode::Down | KeyCode::Char('j') if !in_filter_mode => {
381 let i = table_state.selected().unwrap_or(0);
382 let n = containers.len().saturating_sub(1);
383 table_state.select(Some(i.saturating_add(1).min(n)));
384 }
385 KeyCode::Up | KeyCode::Char('k') if !in_filter_mode => {
386 let i = table_state.selected().unwrap_or(0);
387 table_state.select(Some(i.saturating_sub(1)));
388 }
389 KeyCode::Char('s') if !in_filter_mode => {
390 sort_column = (sort_column + 1) % 4;
391 }
392 KeyCode::Char('d') if !in_filter_mode => {
393 sort_desc = !sort_desc;
394 }
395 KeyCode::Char('/') if !in_filter_mode => {
396 in_filter_mode = true;
397 filter_text.clear();
398 }
399 KeyCode::Char(c) if in_filter_mode => {
400 filter_text.push(c);
401 }
402 KeyCode::Backspace if in_filter_mode => {
403 filter_text.pop();
404 }
405 KeyCode::Enter | KeyCode::Char(' ') if in_filter_mode => {
406 in_filter_mode = false;
407 }
408 KeyCode::Esc if in_filter_mode => {
409 in_filter_mode = false;
410 filter_text.clear();
411 }
412 _ => {}
413 }
414 }
415 }
416
417 terminal::disable_raw_mode()?;
418 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
419 Ok(())
420}
421
422#[allow(clippy::too_many_arguments)]
423fn draw_top(
424 f: &mut Frame,
425 containers: &[Container],
426 metrics_map: &HashMap<String, MetricSample>,
427 table_state: &mut TableState,
428 sort_col: usize,
429 sort_desc: bool,
430 last_refresh: &str,
431 show_help: bool,
432 filter_text: &str,
433 in_filter_mode: bool,
434) {
435 let area = f.area();
436
437 let filtered: Vec<&Container> = if filter_text.is_empty() {
438 containers.iter().collect()
439 } else {
440 containers
441 .iter()
442 .filter(|c| c.name.to_lowercase().contains(&filter_text.to_lowercase()))
443 .collect()
444 };
445
446 let mut sorted: Vec<&Container> = filtered.clone();
447 sorted.sort_by(|a, b| {
448 let cmp = match sort_col {
449 0 => a.name.cmp(&b.name),
450 1 => a.image.cmp(&b.image),
451 2 => a.state.cmp(&b.state),
452 _ => a.status.cmp(&b.status),
453 };
454 if sort_desc { cmp.reverse() } else { cmp }
455 });
456
457 let chunks = Layout::default()
458 .direction(Direction::Vertical)
459 .constraints([
460 Constraint::Length(2),
461 Constraint::Min(0),
462 Constraint::Length(1),
463 ])
464 .split(area);
465
466 draw_summary_bar(f, chunks[0], containers, sort_col, sort_desc, last_refresh);
467 draw_container_table_top(f, chunks[1], &sorted, metrics_map, table_state);
468 draw_status_bar(
469 f,
470 chunks[2],
471 filtered.len(),
472 containers.len(),
473 in_filter_mode,
474 filter_text,
475 );
476
477 if show_help {
478 draw_help_overlay(f, area);
479 }
480}
481
482fn draw_summary_bar(
483 f: &mut Frame,
484 area: Rect,
485 containers: &[Container],
486 sort_col: usize,
487 sort_desc: bool,
488 last_refresh: &str,
489) {
490 let running = containers.iter().filter(|c| c.state == "running").count();
491 let exited = containers
492 .iter()
493 .filter(|c| c.state == "exited" || c.state == "dead")
494 .count();
495 let paused = containers.iter().filter(|c| c.state == "paused").count();
496 let other = containers.len().saturating_sub(running + exited + paused);
497
498 let sort_names = ["NAME", "IMAGE", "STATE", "STATUS"];
499 let arrow = if sort_desc { "▼" } else { "▲" };
500 let sort_label = format!("{arrow} {}", sort_names[sort_col]);
501
502 let mut spans = vec![
503 Span::styled(
504 format!(" {} ", containers.len()),
505 Style::default()
506 .fg(Color::White)
507 .add_modifier(Modifier::BOLD),
508 ),
509 Span::raw("total │ "),
510 Span::styled("●", Style::default().fg(Color::Green)),
511 Span::raw(format!(" {running} ")),
512 Span::styled("●", Style::default().fg(Color::Red)),
513 Span::raw(format!(" {exited} ")),
514 Span::styled("●", Style::default().fg(Color::Yellow)),
515 Span::raw(format!(" {paused} ")),
516 ];
517 if other > 0 {
518 spans.push(Span::styled("●", Style::default().fg(Color::Blue)));
519 spans.push(Span::raw(format!(" {other} │ ")));
520 } else {
521 spans.push(Span::raw(" │ "));
522 }
523 spans.push(Span::styled(sort_label, Style::default().fg(Color::Cyan)));
524 spans.push(Span::raw(" │ "));
525 spans.push(Span::raw(format!("refresh: {last_refresh}")));
526
527 let block = Block::default().style(Style::default().on_dark_gray());
528 f.render_widget(Paragraph::new(Line::from(spans)).block(block), area);
529}
530
531fn draw_container_table_top(
532 f: &mut Frame,
533 area: Rect,
534 sorted: &[&Container],
535 metrics_map: &HashMap<String, MetricSample>,
536 table_state: &mut TableState,
537) {
538 let gauge_w = 12u16.min(area.width.saturating_sub(80) / 2);
539
540 let rows: Vec<Row> = sorted
541 .iter()
542 .map(|c| {
543 let s_style = Style::default().fg(state_color(&c.state));
544
545 let metric = metrics_map.get(&c.name);
546 let cpu_pct = metric.and_then(|m| m.cpu_percent).unwrap_or(0.0);
547 let mem_used = metric.and_then(|m| m.memory_usage_bytes).unwrap_or(0);
548 let mem_limit = metric.and_then(|m| m.memory_limit_bytes).unwrap_or(1);
549 let mem_pct = if mem_limit > 0 {
550 (mem_used as f64 / mem_limit as f64) * 100.0
551 } else {
552 0.0
553 };
554 let rc = c.restart_count.unwrap_or(0);
555
556 let cpu_bar = gauge_bar(cpu_pct / 100.0, gauge_w);
557 let mem_bar = gauge_bar(mem_pct / 100.0, gauge_w);
558 let mem_str = format_mem(mem_used);
559
560 Row::new(vec![
561 Cell::from(Span::styled(
562 c.name.clone(),
563 Style::default().add_modifier(Modifier::BOLD),
564 )),
565 Cell::from(Span::styled(c.image.clone(), Style::default())),
566 Cell::from(Span::styled(
567 cpu_bar,
568 Style::default().fg(gauge_color(cpu_pct / 100.0)),
569 )),
570 Cell::from(Span::styled(
571 mem_bar,
572 Style::default().fg(gauge_color(mem_pct / 100.0)),
573 )),
574 Cell::from(Span::styled(
575 mem_str + " " + &format!("{mem_pct:5.1}%"),
576 Style::default().fg(gauge_color(mem_pct / 100.0)),
577 )),
578 Cell::from(Span::styled(c.state.clone(), s_style)),
579 Cell::from(Span::styled(c.status.clone(), Style::default())),
580 Cell::from(Span::styled(
581 format!("{rc}"),
582 if rc > 3 {
583 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
584 } else {
585 Style::default()
586 },
587 )),
588 ])
589 })
590 .collect();
591
592 let table = Table::new(
593 rows,
594 [
595 Constraint::Length(22),
596 Constraint::Length(22),
597 Constraint::Length(gauge_w + 2),
598 Constraint::Length(gauge_w + 2),
599 Constraint::Length(14),
600 Constraint::Length(10),
601 Constraint::Length(14),
602 Constraint::Length(4),
603 ],
604 )
605 .header(
606 Row::new(vec![
607 Cell::from(Span::styled(
608 "NAME",
609 Style::default().add_modifier(Modifier::BOLD),
610 )),
611 Cell::from(Span::styled(
612 "IMAGE",
613 Style::default().add_modifier(Modifier::BOLD),
614 )),
615 Cell::from(Span::styled(
616 "CPU",
617 Style::default().add_modifier(Modifier::BOLD),
618 )),
619 Cell::from(Span::styled(
620 "MEM",
621 Style::default().add_modifier(Modifier::BOLD),
622 )),
623 Cell::from(Span::styled(
624 "MEMORY",
625 Style::default().add_modifier(Modifier::BOLD),
626 )),
627 Cell::from(Span::styled(
628 "STATE",
629 Style::default().add_modifier(Modifier::BOLD),
630 )),
631 Cell::from(Span::styled(
632 "STATUS",
633 Style::default().add_modifier(Modifier::BOLD),
634 )),
635 Cell::from(Span::styled(
636 "RST",
637 Style::default().add_modifier(Modifier::BOLD),
638 )),
639 ])
640 .style(Style::default().fg(Color::Cyan)),
641 )
642 .block(
643 Block::default()
644 .title(format!(" Containers ({}) ", sorted.len()))
645 .borders(Borders::ALL),
646 )
647 .row_highlight_style(Style::default().bg(Color::DarkGray))
648 .highlight_symbol("> ");
649
650 f.render_stateful_widget(table, area, table_state);
651}
652
653fn draw_status_bar(
654 f: &mut Frame,
655 area: Rect,
656 shown: usize,
657 total: usize,
658 in_filter: bool,
659 filter_text: &str,
660) {
661 let text = if in_filter {
662 Line::from(Span::styled(
663 format!("/{filter_text}▌"),
664 Style::default()
665 .fg(Color::Yellow)
666 .add_modifier(Modifier::BOLD),
667 ))
668 } else if shown < total {
669 Line::from(Span::raw(format!(
670 "[↑↓] nav [s] sort [d] desc [/] filter [r] refresh [h] help [q] quit (showing {shown}/{total})",
671 )))
672 } else {
673 Line::from(Span::raw(
674 "[↑↓] nav [s] sort [d] desc [/] filter [r] refresh [h] help [q] quit",
675 ))
676 };
677
678 let block = Block::default().style(Style::default().on_dark_gray());
679 f.render_widget(Paragraph::new(text).block(block), area);
680}
681
682pub async fn run_dashboard(config: &DolConfig) -> anyhow::Result<()> {
685 let docker_arc = std::sync::Arc::new(BollardDockerClient::connect_with_config(config)?);
686 let docker = docker_arc.as_ref();
687 let metrics_collector =
688 BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker_arc), config);
689
690 terminal::enable_raw_mode()?;
691 let mut stdout = std::io::stdout();
692 crossterm::execute!(stdout, EnterAlternateScreen)?;
693 let mut terminal = Terminal::new(ratatui::backend::CrosstermBackend::new(stdout))?;
694
695 let mut containers: Vec<Container> = Vec::new();
696 let mut metrics_map: HashMap<String, MetricSample> = HashMap::new();
697 let mut events: Vec<ParsedEvent> = Vec::new();
698 let mut should_quit = false;
699 let mut selected_panel: usize = 0;
700 let mut show_help = false;
701 let mut last_refresh = String::new();
702
703 let events_timeout = Duration::from_secs(config.events_timeout.unwrap_or(30));
704
705 let event_rx = spawn_event_listener(std::sync::Arc::clone(&docker_arc), events_timeout);
707
708 let _ = refresh_all(
710 docker,
711 &metrics_collector,
712 &mut containers,
713 &mut metrics_map,
714 &mut last_refresh,
715 )
716 .await;
717 refresh_events(docker, &mut events, events_timeout).await;
718 let mut last_metrics_refresh = std::time::Instant::now();
719 let mut last_container_refresh = std::time::Instant::now();
720
721 while !should_quit {
722 terminal.draw(|f| {
723 draw_dashboard(
724 f,
725 &containers,
726 &metrics_map,
727 &events,
728 selected_panel,
729 &last_refresh,
730 show_help,
731 );
732 })?;
733
734 let mut container_changed = false;
740 while let Ok(event) = event_rx.try_recv() {
741 if is_container_state_change(&event.action) {
742 container_changed = true;
743 }
744 events.push(event);
745 }
746 if events.len() > 500 {
748 events.drain(0..events.len() - 500);
749 }
750
751 if container_changed {
752 let _ = refresh_all(
754 docker,
755 &metrics_collector,
756 &mut containers,
757 &mut metrics_map,
758 &mut last_refresh,
759 )
760 .await;
761 last_metrics_refresh = std::time::Instant::now();
762 last_container_refresh = std::time::Instant::now();
763 } else if last_container_refresh.elapsed() >= CONTAINER_REFRESH_INTERVAL {
764 let _ = refresh_all(
766 docker,
767 &metrics_collector,
768 &mut containers,
769 &mut metrics_map,
770 &mut last_refresh,
771 )
772 .await;
773 last_metrics_refresh = std::time::Instant::now();
774 last_container_refresh = std::time::Instant::now();
775 } else if last_metrics_refresh.elapsed() >= METRICS_REFRESH_INTERVAL {
776 refresh_metrics_only(&metrics_collector, &mut metrics_map, &mut last_refresh).await;
778 last_metrics_refresh = std::time::Instant::now();
779 }
780
781 if event::poll(EVENT_POLL_INTERVAL)?
783 && let Event::Key(key) = event::read()?
784 && key.kind == KeyEventKind::Press
785 {
786 match key.code {
787 KeyCode::Char('q') | KeyCode::Esc if !show_help => should_quit = true,
788 KeyCode::Char('h') => show_help = !show_help,
789 KeyCode::Char('r') => {
790 let _ = refresh_all(
791 docker,
792 &metrics_collector,
793 &mut containers,
794 &mut metrics_map,
795 &mut last_refresh,
796 )
797 .await;
798 refresh_events(docker, &mut events, events_timeout).await;
799 last_metrics_refresh = std::time::Instant::now();
800 last_container_refresh = std::time::Instant::now();
801 }
802 KeyCode::Tab => selected_panel = (selected_panel + 1) % 2,
803 KeyCode::Char('c') => events.clear(),
804 _ => {}
805 }
806 }
807 }
808
809 terminal::disable_raw_mode()?;
810 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
811 Ok(())
812}
813
814struct ParsedEvent {
815 time: String,
816 action: String,
817 actor_id: String,
818 container_name: String,
819}
820
821async fn refresh_events(
824 docker: &BollardDockerClient,
825 events: &mut Vec<ParsedEvent>,
826 events_timeout: Duration,
827) {
828 let now = std::time::SystemTime::now()
829 .duration_since(std::time::UNIX_EPOCH)
830 .unwrap_or_default()
831 .as_secs() as i64;
832 let since = now.saturating_sub(5);
833
834 let Ok(stream) = docker
835 .events_stream(Some(&since.to_string()), Some(&now.to_string()))
836 .await
837 else {
838 return;
839 };
840
841 let mut stream = stream;
842 loop {
843 let next = tokio::time::timeout(events_timeout, stream.next()).await;
844 let Ok(Some(Ok(event))) = next else { break };
845 let actor_id = if event.actor_id.len() > 12 {
846 event.actor_id[..12].to_owned()
847 } else {
848 event.actor_id.clone()
849 };
850 let pe = ParsedEvent {
851 time: format_event_time(&event),
852 action: event.action,
853 actor_id,
854 container_name: event.container.unwrap_or_default(),
855 };
856 if !events
857 .iter()
858 .any(|e| e.actor_id == pe.actor_id && e.action == pe.action && e.time == pe.time)
859 {
860 events.push(pe);
861 }
862 }
863 if events.len() > 200 {
864 events.drain(0..events.len() - 200);
865 }
866}
867
868fn draw_dashboard(
869 f: &mut Frame,
870 containers: &[Container],
871 metrics_map: &HashMap<String, MetricSample>,
872 events: &[ParsedEvent],
873 selected: usize,
874 last_refresh: &str,
875 show_help: bool,
876) {
877 let area = f.area();
878
879 let chunks = Layout::default()
880 .direction(Direction::Vertical)
881 .constraints([
882 Constraint::Length(2),
883 Constraint::Min(0),
884 Constraint::Length(10),
885 Constraint::Length(1),
886 ])
887 .split(area);
888
889 draw_dash_summary(f, chunks[0], containers, last_refresh);
890
891 let main_chunks = Layout::default()
892 .direction(Direction::Horizontal)
893 .constraints([Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)])
894 .split(chunks[1]);
895
896 draw_dash_container_panel(f, main_chunks[0], containers, metrics_map, selected == 0);
897 draw_dash_stats_panel(f, main_chunks[1], containers, selected == 1);
898 draw_dash_events_panel(f, chunks[2], events);
899
900 let status_text = Line::from(Span::raw(
901 "[Tab] panels [c] clear events [r] refresh [h] help [q] quit",
902 ));
903 f.render_widget(
904 Paragraph::new(status_text).block(Block::default().style(Style::default().on_dark_gray())),
905 chunks[3],
906 );
907
908 if show_help {
909 draw_help_overlay(f, area);
910 }
911}
912
913fn draw_dash_summary(f: &mut Frame, area: Rect, containers: &[Container], last_refresh: &str) {
914 let running = containers.iter().filter(|c| c.state == "running").count();
915 let exited = containers
916 .iter()
917 .filter(|c| c.state == "exited" || c.state == "dead")
918 .count();
919 let paused = containers.iter().filter(|c| c.state == "paused").count();
920 let other = containers.len().saturating_sub(running + exited + paused);
921
922 let mut spans = vec![
923 Span::styled(
924 " DOL Dashboard ",
925 Style::default()
926 .fg(Color::Cyan)
927 .add_modifier(Modifier::BOLD),
928 ),
929 Span::raw(" │ "),
930 Span::raw(format!("{} total", containers.len())),
931 Span::raw(" │ "),
932 Span::styled("●", Style::default().fg(Color::Green)),
933 Span::raw(format!(" {running} ")),
934 Span::styled("●", Style::default().fg(Color::Red)),
935 Span::raw(format!(" {exited} ")),
936 Span::styled("●", Style::default().fg(Color::Yellow)),
937 Span::raw(format!(" {paused} ")),
938 ];
939 if other > 0 {
940 spans.push(Span::styled("●", Style::default().fg(Color::Blue)));
941 spans.push(Span::raw(format!(" {other}")));
942 }
943 spans.push(Span::raw(" │ "));
944 spans.push(Span::raw(format!("refresh: {last_refresh}")));
945
946 let block = Block::default().style(Style::default().on_dark_gray());
947 f.render_widget(Paragraph::new(Line::from(spans)).block(block), area);
948}
949
950fn draw_dash_container_panel(
951 f: &mut Frame,
952 area: Rect,
953 containers: &[Container],
954 metrics_map: &HashMap<String, MetricSample>,
955 focused: bool,
956) {
957 let rows: Vec<Row> = containers
958 .iter()
959 .take(area.height.saturating_sub(3) as usize)
960 .map(|c| {
961 let s_style = Style::default().fg(state_color(&c.state));
962 let metric = metrics_map.get(&c.name);
963 let cpu_pct = metric.and_then(|m| m.cpu_percent).unwrap_or(0.0);
964 let mem_used = metric.and_then(|m| m.memory_usage_bytes).unwrap_or(0);
965 let mem_limit = metric.and_then(|m| m.memory_limit_bytes).unwrap_or(1);
966 let mem_pct = if mem_limit > 0 {
967 (mem_used as f64 / mem_limit as f64) * 100.0
968 } else {
969 0.0
970 };
971 let mem_str = format_mem(mem_used);
972
973 Row::new(vec![
974 Cell::from(Span::styled(
975 c.name.clone(),
976 Style::default().add_modifier(Modifier::BOLD),
977 )),
978 Cell::from(Span::styled(
979 format!("{cpu_pct:5.1}%"),
980 Style::default().fg(gauge_color(cpu_pct / 100.0)),
981 )),
982 Cell::from(Span::styled(
983 mem_str + " " + &format!("({mem_pct:.0}%)"),
984 Style::default().fg(gauge_color(mem_pct / 100.0)),
985 )),
986 Cell::from(Span::styled(c.state.clone(), s_style)),
987 ])
988 })
989 .collect();
990
991 let table = Table::new(
992 rows,
993 [
994 Constraint::Length(20),
995 Constraint::Length(8),
996 Constraint::Length(14),
997 Constraint::Length(10),
998 ],
999 )
1000 .header(
1001 Row::new(vec![
1002 Cell::from(Span::styled(
1003 "NAME",
1004 Style::default().add_modifier(Modifier::BOLD),
1005 )),
1006 Cell::from(Span::styled(
1007 "CPU",
1008 Style::default().add_modifier(Modifier::BOLD),
1009 )),
1010 Cell::from(Span::styled(
1011 "MEMORY",
1012 Style::default().add_modifier(Modifier::BOLD),
1013 )),
1014 Cell::from(Span::styled(
1015 "STATE",
1016 Style::default().add_modifier(Modifier::BOLD),
1017 )),
1018 ])
1019 .style(Style::default().fg(Color::Cyan)),
1020 )
1021 .block(
1022 Block::default()
1023 .title(format!(" Containers ({}) ", containers.len()))
1024 .borders(Borders::ALL)
1025 .border_style(if focused {
1026 Style::default().fg(Color::Yellow)
1027 } else {
1028 Style::default()
1029 }),
1030 );
1031
1032 f.render_widget(table, area);
1033}
1034
1035fn draw_dash_stats_panel(f: &mut Frame, area: Rect, containers: &[Container], focused: bool) {
1036 let running = containers.iter().filter(|c| c.state == "running").count();
1037 let exited = containers
1038 .iter()
1039 .filter(|c| c.state == "exited" || c.state == "dead")
1040 .count();
1041 let paused = containers.iter().filter(|c| c.state == "paused").count();
1042 let total = containers.len();
1043 let other = total.saturating_sub(running + exited + paused);
1044
1045 let mut image_counts: HashMap<&str, usize> = HashMap::new();
1046 for c in containers {
1047 *image_counts.entry(&c.image).or_insert(0) += 1;
1048 }
1049 let mut image_vec: Vec<(&str, usize)> = image_counts.into_iter().collect();
1050 image_vec.sort_by_key(|a| std::cmp::Reverse(a.1));
1051
1052 let max_w = 14usize;
1053 let mut lines = vec![
1054 Line::from(vec![Span::styled(
1055 " State Distribution ",
1056 Style::default().add_modifier(Modifier::BOLD),
1057 )]),
1058 Line::from(Span::raw("")),
1059 ];
1060
1061 for (label, count, color) in [
1062 ("running", running, Color::Green),
1063 ("exited", exited, Color::Red),
1064 ("paused", paused, Color::Yellow),
1065 ("other", other, Color::Blue),
1066 ] {
1067 let bar_w = if total > 0 {
1068 (count * max_w)
1069 .checked_div(total)
1070 .map_or(0, |v| v.max(1).min(max_w))
1071 } else {
1072 0
1073 };
1074 let bar = "█".repeat(bar_w);
1075 let pct = if total > 0 {
1076 (count * 100).checked_div(total).unwrap_or(0)
1077 } else {
1078 0
1079 };
1080 lines.push(Line::from(vec![
1081 Span::styled(format!(" {label:8} "), Style::default().fg(color)),
1082 Span::styled(bar, Style::default().fg(color)),
1083 Span::raw(format!(" {count} ({pct}%)")),
1084 ]));
1085 }
1086
1087 lines.push(Line::from(Span::raw("")));
1088 lines.push(Line::from(vec![Span::styled(
1089 " Top Images ",
1090 Style::default().add_modifier(Modifier::BOLD),
1091 )]));
1092 for (img, cnt) in image_vec.iter().take(6) {
1093 lines.push(Line::from(Span::raw(format!(" {img} x{cnt}"))));
1094 }
1095
1096 let block = Block::default()
1097 .title(" Stats ")
1098 .borders(Borders::ALL)
1099 .border_style(if focused {
1100 Style::default().fg(Color::Yellow)
1101 } else {
1102 Style::default()
1103 });
1104 f.render_widget(Paragraph::new(lines).block(block), area);
1105}
1106
1107fn draw_dash_events_panel(f: &mut Frame, area: Rect, events: &[ParsedEvent]) {
1108 let lines: Vec<Line> = events
1109 .iter()
1110 .rev()
1111 .take(area.height.saturating_sub(2) as usize)
1112 .map(|e| {
1113 let action_color = event_action_color(&e.action);
1114 Line::from(vec![
1115 Span::styled(
1116 format!(" {} ", e.time),
1117 Style::default().fg(Color::DarkGray),
1118 ),
1119 Span::styled(
1120 format!(" {:8} ", e.action),
1121 Style::default()
1122 .fg(action_color)
1123 .add_modifier(Modifier::BOLD),
1124 ),
1125 Span::styled(e.container_name.clone(), Style::default().fg(Color::White)),
1126 ])
1127 })
1128 .collect();
1129
1130 let block = Block::default()
1131 .title(format!(" Recent Events ({}) ", events.len()))
1132 .borders(Borders::ALL);
1133 f.render_widget(Paragraph::new(lines).block(block), area);
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139 use crate::docker::DockerEvent;
1140
1141 #[test]
1144 fn test_is_container_state_change_true_for_state_actions() {
1145 for action in &[
1146 "create", "start", "die", "stop", "destroy", "kill", "restart", "pause", "unpause",
1147 "update",
1148 ] {
1149 assert!(
1150 is_container_state_change(action),
1151 "{action} should be a state change"
1152 );
1153 }
1154 }
1155
1156 #[test]
1157 fn test_is_container_state_change_false_for_other_actions() {
1158 for action in &[
1159 "exec_create",
1160 "exec_start",
1161 "attach",
1162 "export",
1163 "pull",
1164 "push",
1165 "tag",
1166 "commit",
1167 ] {
1168 assert!(
1169 !is_container_state_change(action),
1170 "{action} should NOT be a state change"
1171 );
1172 }
1173 }
1174
1175 #[test]
1178 fn test_format_event_time_unix_seconds() {
1179 let event = DockerEvent {
1181 time: "5445".to_owned(),
1182 event_type: "container".to_owned(),
1183 action: "start".to_owned(),
1184 actor_id: "abc123".to_owned(),
1185 container: Some("test".to_owned()),
1186 image: Some("nginx".to_owned()),
1187 attributes: vec![],
1188 };
1189 assert_eq!(format_event_time(&event), "01:30:45");
1190 }
1191
1192 #[test]
1193 fn test_format_event_time_nanoseconds() {
1194 let event = DockerEvent {
1196 time: "5400000000000000".to_owned(),
1197 event_type: "container".to_owned(),
1198 action: "die".to_owned(),
1199 actor_id: "def456".to_owned(),
1200 container: Some("app".to_owned()),
1201 image: Some("python".to_owned()),
1202 attributes: vec![],
1203 };
1204 assert_eq!(format_event_time(&event), "12:00:00");
1205 }
1206
1207 #[test]
1208 fn test_format_event_time_iso_timestamp() {
1209 let event = DockerEvent {
1211 time: "2026-05-31T14:30:00.000000000Z".to_owned(),
1212 event_type: "container".to_owned(),
1213 action: "stop".to_owned(),
1214 actor_id: "ghi789".to_owned(),
1215 container: Some("db".to_owned()),
1216 image: Some("postgres".to_owned()),
1217 attributes: vec![],
1218 };
1219 assert_eq!(format_event_time(&event), "14:30:00");
1220 }
1221
1222 #[test]
1223 fn test_format_event_time_short_string() {
1224 let event = DockerEvent {
1226 time: "?".to_owned(),
1227 event_type: "container".to_owned(),
1228 action: "unknown".to_owned(),
1229 actor_id: "".to_owned(),
1230 container: None,
1231 image: None,
1232 attributes: vec![],
1233 };
1234 assert_eq!(format_event_time(&event), "??:??:??");
1235 }
1236
1237 #[test]
1240 fn test_format_mem_bytes() {
1241 assert_eq!(format_mem(0), "0B");
1242 assert_eq!(format_mem(500), "500B");
1243 assert_eq!(format_mem(1500), "2K");
1244 assert_eq!(format_mem(1_500_000), "2M");
1245 assert_eq!(format_mem(1_500_000_000), "1.5G");
1246 assert_eq!(format_mem(999), "999B");
1247 assert_eq!(format_mem(1_000_000_000), "1.0G");
1248 }
1249
1250 #[test]
1253 fn test_gauge_bar_full() {
1254 let bar = gauge_bar(1.0, 10);
1255 assert_eq!(bar.chars().count(), 10);
1256 assert!(bar.chars().all(|c| c == '█'));
1257 }
1258
1259 #[test]
1260 fn test_gauge_bar_empty() {
1261 let bar = gauge_bar(0.0, 10);
1262 assert_eq!(bar.chars().count(), 10);
1263 assert!(bar.chars().all(|c| c == '░'));
1264 }
1265
1266 #[test]
1267 fn test_gauge_bar_half() {
1268 let bar = gauge_bar(0.5, 10);
1269 assert_eq!(bar.chars().count(), 10);
1270 assert_eq!(bar.matches('█').count(), 5);
1271 assert_eq!(bar.matches('░').count(), 5);
1272 }
1273
1274 #[test]
1275 fn test_gauge_bar_zero_width() {
1276 assert_eq!(gauge_bar(0.5, 0), "");
1277 }
1278
1279 #[test]
1280 fn test_gauge_bar_clamps() {
1281 let bar = gauge_bar(2.0, 5);
1282 assert_eq!(bar.chars().count(), 5);
1283 assert!(bar.chars().all(|c| c == '█'));
1284 }
1285
1286 #[test]
1289 fn test_gauge_color_thresholds() {
1290 assert_eq!(gauge_color(0.0), Color::Green);
1291 assert_eq!(gauge_color(0.30), Color::Green);
1292 assert_eq!(gauge_color(0.50), Color::Green);
1293 assert_eq!(gauge_color(0.51), Color::Yellow);
1294 assert_eq!(gauge_color(0.75), Color::Yellow);
1295 assert_eq!(gauge_color(0.80), Color::Yellow);
1296 assert_eq!(gauge_color(0.81), Color::Red);
1297 assert_eq!(gauge_color(1.0), Color::Red);
1298 }
1299
1300 #[test]
1303 fn test_state_color_mapping() {
1304 assert_eq!(state_color("running"), Color::Green);
1305 assert_eq!(state_color("exited"), Color::Red);
1306 assert_eq!(state_color("dead"), Color::Red);
1307 assert_eq!(state_color("paused"), Color::Yellow);
1308 assert_eq!(state_color("restarting"), Color::Cyan);
1309 assert_eq!(state_color("created"), Color::Blue);
1310 assert_eq!(state_color("unknown"), Color::White);
1311 }
1312
1313 #[test]
1316 fn test_event_action_color_mapping() {
1317 assert_eq!(event_action_color("start"), Color::Green);
1318 assert_eq!(event_action_color("restart"), Color::Green);
1319 assert_eq!(event_action_color("unpause"), Color::Green);
1320 assert_eq!(event_action_color("die"), Color::Red);
1321 assert_eq!(event_action_color("kill"), Color::Red);
1322 assert_eq!(event_action_color("oom"), Color::Red);
1323 assert_eq!(event_action_color("destroy"), Color::Red);
1324 assert_eq!(event_action_color("stop"), Color::Yellow);
1325 assert_eq!(event_action_color("pause"), Color::Yellow);
1326 assert_eq!(event_action_color("create"), Color::Cyan);
1327 assert_eq!(event_action_color("pull"), Color::Cyan);
1328 assert_eq!(event_action_color("unknown_action"), Color::White);
1329 }
1330}