Skip to main content

dol/
dashboard.rs

1//! TUI dashboard and top-like container monitor.
2//!
3//! Uses [`ratatui`] and [`crossterm`] for terminal UI. Provides two modes:
4//!
5//! - **`dol top`** — live-updating container list with CPU/memory gauges
6//! - **`dol dashboard`** — multi-panel view with container list, stats, and
7//!   event stream
8//!
9//! Both modes listen to Docker events for real-time updates and fall back
10//! to periodic polling when the events stream is unavailable.
11//!
12//! # Example
13//!
14//! ```ignore
15//! dashboard::run_top(&config).await?;
16//! dashboard::run_dashboard(&config).await?;
17//! ```
18
19use 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
38/// Fallback interval for full container refresh when events listener fails.
39const CONTAINER_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
40
41/// Interval for lightweight metrics-only refresh.
42const METRICS_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
43
44/// Poll duration for non-blocking keyboard event reading.
45const 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
103/// Check if a Docker event action signals a container state change that
104/// would affect `docker ps -a` output. Only these actions trigger a full
105/// container-list refresh.
106fn 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/// Format a Docker event's time (Unix seconds or nanoseconds as string)
123/// into HH:MM:SS display format.
124#[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        // timeNano values are ~19 digits (nanoseconds), time values are ~10 digits (seconds)
129        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        // ISO timestamp: "2026-05-31T02:00:00.000000000Z"
140        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
148/// Spawn a background tokio task that listens to Docker events via the
149/// bollard API and sends parsed `ParsedEvent` values through a channel.
150fn 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; // receiver dropped → main loop ended
178            }
179        }
180    });
181
182    rx
183}
184
185// ── Shared helpers ─────────────────────────────────────────
186
187async 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
260// ── dol top ────────────────────────────────────────────────────
261
262pub 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    // Spawn a background Docker events listener via bollard API
288    let event_rx = spawn_event_listener(std::sync::Arc::clone(&docker_arc), events_timeout);
289
290    // Initial load
291    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        // ── Event-driven refresh (non-blocking) ──
319        // Drain all queued Docker events and refresh containers if a
320        // state-changing event occurred. If the events listener fails
321        // (e.g., docker not available), falls back to a periodic full
322        // refresh every 30 seconds.
323        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            // Full refresh: containers + metrics (triggered by Docker event)
332            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            // Fallback full refresh (in case the events listener failed)
344            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            // Periodic metrics-only refresh (lighter weight — no docker ps)
356            refresh_metrics_only(&metrics_collector, &mut metrics_map, &mut last_refresh).await;
357            last_metrics_refresh = std::time::Instant::now();
358        }
359
360        // ── Key events ──
361        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
682// ── dol dashboard ──────────────────────────────────────────────
683
684pub 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    // Spawn a background Docker events listener via bollard API
706    let event_rx = spawn_event_listener(std::sync::Arc::clone(&docker_arc), events_timeout);
707
708    // Initial load: containers + recent events
709    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        // ── Event-driven refresh (non-blocking) ──
735        // Drain all queued Docker events: add to the events panel and
736        // trigger a container refresh if a state-changing event occurred.
737        // Falls back to a periodic full refresh every 30 seconds if the
738        // events listener fails.
739        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        // Keep events buffer bounded
747        if events.len() > 500 {
748            events.drain(0..events.len() - 500);
749        }
750
751        if container_changed {
752            // Full refresh: containers + metrics (triggered by Docker event)
753            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            // Fallback full refresh (in case the events listener failed)
765            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            // Periodic metrics-only refresh (lighter weight)
777            refresh_metrics_only(&metrics_collector, &mut metrics_map, &mut last_refresh).await;
778            last_metrics_refresh = std::time::Instant::now();
779        }
780
781        // ── Key events ──
782        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
821/// Fetch recent Docker events (last ~5 seconds) via the bollard events API
822/// and append new unique events to the events buffer.
823async 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    // ── is_container_state_change ───────────────────────────────
1142
1143    #[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    // ── format_event_time ───────────────────────────────────────
1176
1177    #[test]
1178    fn test_format_event_time_unix_seconds() {
1179        // 10 digits = Unix seconds; 3600+1800+45 = 5445s = 01:30:45
1180        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        // >=16 digits = nanoseconds: 5,400,000,000,000,000 ns = 5,400,000 sec = 12:00:00
1195        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        // >=19 chars and non-numeric → extract HH:MM:SS from ISO
1210        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        // Short non-numeric string → ??
1225        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    // ── format_mem ──────────────────────────────────────────────
1238
1239    #[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    // ── gauge_bar ───────────────────────────────────────────────
1251
1252    #[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    // ── gauge_color ─────────────────────────────────────────────
1287
1288    #[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    // ── state_color ─────────────────────────────────────────────
1301
1302    #[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    // ── event_action_color ──────────────────────────────────────
1314
1315    #[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}