Skip to main content

bee_tui/components/
watchlist.rs

1//! S13 — Durability Watchlist screen.
2//!
3//! Each `:durability-check <ref>` invocation is recorded here as a row
4//! that records the result + the wall-clock age. The list is bounded
5//! to the most-recent N entries (default 50) so the screen stays
6//! useful under heavy operator-driven probing.
7//!
8//! ## Render path
9//!
10//! Pure [`Watchlist::view_for`] turns `(rows, selected)` into a
11//! [`WatchlistView`]. The component owns the `RingBuffer<Row>` and
12//! the cursor; new rows are pushed by `App` when an async durability
13//! check completes.
14
15use std::collections::VecDeque;
16use std::time::SystemTime;
17
18use color_eyre::Result;
19use crossterm::event::{KeyCode, KeyEvent};
20use ratatui::{
21    Frame,
22    layout::{Constraint, Layout, Rect},
23    style::{Color, Modifier, Style},
24    text::{Line, Span},
25    widgets::{Block, Borders, Paragraph},
26};
27
28use super::Component;
29use crate::action::Action;
30use crate::durability::DurabilityResult;
31use crate::theme;
32
33const MAX_ROWS: usize = 50;
34
35/// One row in the watchlist. Cloneable so the view can be assembled
36/// without borrowing the component's storage.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WatchlistRow {
39    pub reference_hex: String,
40    pub status_label: String,
41    /// `true` when this row's check completed cleanly; drives green
42    /// vs red paint in the renderer.
43    pub healthy: bool,
44    /// Pre-formatted breakdown: "12 total · 0 lost · 0 errors · 412ms".
45    pub detail: String,
46    /// Wall-clock seconds since `started_at` at view-build time.
47    pub age_seconds: u64,
48    pub root_is_manifest: bool,
49}
50
51/// View fed to the renderer + snapshot tests.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct WatchlistView {
54    pub rows: Vec<WatchlistRow>,
55    pub healthy_count: usize,
56    pub unhealthy_count: usize,
57}
58
59pub struct Watchlist {
60    rows: VecDeque<DurabilityResult>,
61    selected: usize,
62}
63
64impl Default for Watchlist {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl Watchlist {
71    pub fn new() -> Self {
72        Self {
73            rows: VecDeque::with_capacity(MAX_ROWS),
74            selected: 0,
75        }
76    }
77
78    /// Push a fresh durability-check result onto the front of the
79    /// list. Bounded — when the ring is full the oldest entry is
80    /// evicted. The cursor stays anchored on whatever the operator
81    /// was looking at, unless the eviction happened to push it off
82    /// the end.
83    pub fn record(&mut self, result: DurabilityResult) {
84        // Newest at the front; oldest evicted at the back.
85        if self.rows.len() == MAX_ROWS {
86            self.rows.pop_back();
87        }
88        self.rows.push_front(result);
89        if self.selected >= self.rows.len() && !self.rows.is_empty() {
90            self.selected = self.rows.len() - 1;
91        }
92    }
93
94    /// Pure view builder for snapshot tests + the renderer.
95    pub fn view_for(rows: &VecDeque<DurabilityResult>, now: SystemTime) -> WatchlistView {
96        let mut healthy = 0;
97        let mut unhealthy = 0;
98        let view_rows: Vec<WatchlistRow> = rows
99            .iter()
100            .map(|r| {
101                let h = r.is_healthy();
102                if h {
103                    healthy += 1;
104                } else {
105                    unhealthy += 1;
106                }
107                let age = now
108                    .duration_since(r.started_at)
109                    .map(|d| d.as_secs())
110                    .unwrap_or(0);
111                let detail = format!(
112                    "{} total · {} lost · {} errors · {}ms{}",
113                    r.chunks_total,
114                    r.chunks_lost,
115                    r.chunks_errors,
116                    r.duration_ms,
117                    if r.truncated { " · truncated" } else { "" },
118                );
119                WatchlistRow {
120                    reference_hex: r.reference.to_hex(),
121                    status_label: if h {
122                        "OK".to_string()
123                    } else {
124                        "UNHEALTHY".to_string()
125                    },
126                    healthy: h,
127                    detail,
128                    age_seconds: age,
129                    root_is_manifest: r.root_is_manifest,
130                }
131            })
132            .collect();
133        WatchlistView {
134            rows: view_rows,
135            healthy_count: healthy,
136            unhealthy_count: unhealthy,
137        }
138    }
139
140    fn cached_view(&self) -> WatchlistView {
141        Self::view_for(&self.rows, SystemTime::now())
142    }
143}
144
145impl Component for Watchlist {
146    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
147        Some(self)
148    }
149
150    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
151        match key.code {
152            KeyCode::Up | KeyCode::Char('k') => {
153                self.selected = self.selected.saturating_sub(1);
154            }
155            KeyCode::Down | KeyCode::Char('j') => {
156                if !self.rows.is_empty() && self.selected + 1 < self.rows.len() {
157                    self.selected += 1;
158                }
159            }
160            _ => {}
161        }
162        Ok(None)
163    }
164
165    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
166        let t = theme::active();
167        let view = self.cached_view();
168        // 4-row split: header / body / detail / footer.
169        let chunks = Layout::vertical([
170            Constraint::Length(2),
171            Constraint::Min(0),
172            Constraint::Length(1),
173            Constraint::Length(1),
174        ])
175        .split(area);
176
177        // Header: counts.
178        let header = if view.rows.is_empty() {
179            Line::from(Span::styled(
180                "no durability checks yet — type :durability-check <ref> to record one",
181                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
182            ))
183        } else {
184            Line::from(vec![
185                Span::styled(
186                    format!(" {} ", view.rows.len()),
187                    Style::default().fg(t.dim),
188                ),
189                Span::raw("checks · "),
190                Span::styled(
191                    format!("{} ", view.healthy_count),
192                    Style::default().fg(t.pass).add_modifier(Modifier::BOLD),
193                ),
194                Span::raw("healthy · "),
195                Span::styled(
196                    format!("{} ", view.unhealthy_count),
197                    if view.unhealthy_count == 0 {
198                        Style::default().fg(t.dim)
199                    } else {
200                        Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
201                    },
202                ),
203                Span::raw("unhealthy"),
204            ])
205        };
206        frame.render_widget(
207            Paragraph::new(header).block(Block::default().borders(Borders::BOTTOM)),
208            chunks[0],
209        );
210
211        // Body: rows.
212        let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 1);
213        if view.rows.is_empty() {
214            // Empty state already covered by header.
215        } else {
216            if self.selected >= view.rows.len() {
217                self.selected = view.rows.len() - 1;
218            }
219            for (i, row) in view.rows.iter().enumerate() {
220                let cursor_marker = if i == self.selected { "▸ " } else { "  " };
221                let status_style = if row.healthy {
222                    Style::default().fg(t.pass).add_modifier(Modifier::BOLD)
223                } else {
224                    Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
225                };
226                let kind = if row.root_is_manifest {
227                    "manifest"
228                } else {
229                    "chunk   "
230                };
231                lines.push(Line::from(vec![
232                    Span::styled(cursor_marker.to_string(), Style::default().fg(t.accent)),
233                    Span::styled(
234                        format!("{:<10}", row.status_label),
235                        status_style,
236                    ),
237                    Span::raw("  "),
238                    Span::styled(kind.to_string(), Style::default().fg(t.dim)),
239                    Span::raw("  "),
240                    Span::raw(short_hex(&row.reference_hex, 8)),
241                    Span::raw("  "),
242                    Span::styled(row.detail.clone(), Style::default().fg(t.dim)),
243                    Span::raw("  "),
244                    Span::styled(
245                        format!("{}s ago", row.age_seconds),
246                        Style::default().fg(t.dim),
247                    ),
248                ]));
249            }
250        }
251        frame.render_widget(Paragraph::new(lines), chunks[1]);
252
253        // Detail: full ref of cursored row for click-drag copy.
254        if !view.rows.is_empty() {
255            let row = &view.rows[self.selected.min(view.rows.len() - 1)];
256            frame.render_widget(
257                Paragraph::new(Line::from(vec![
258                    Span::styled("  selected: ", Style::default().fg(t.dim)),
259                    Span::styled(row.reference_hex.clone(), Style::default().fg(t.info)),
260                ])),
261                chunks[2],
262            );
263        }
264
265        // Footer.
266        let footer = Line::from(vec![
267            Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
268            Span::raw(" switch screen  "),
269            Span::styled(
270                " ↑↓/jk ",
271                Style::default().fg(Color::Black).bg(Color::White),
272            ),
273            Span::raw(" select  "),
274            Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
275            Span::raw(" help  "),
276            Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
277            Span::raw(" quit  "),
278            Span::styled(
279                ":durability-check <ref> to record",
280                Style::default().fg(t.dim),
281            ),
282        ]);
283        frame.render_widget(Paragraph::new(footer), chunks[3]);
284        Ok(())
285    }
286}
287
288fn short_hex(s: &str, n: usize) -> String {
289    if s.len() <= n * 2 + 1 {
290        s.to_string()
291    } else {
292        format!("{}…{}", &s[..n], &s[s.len() - n..])
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use bee::swarm::Reference;
300    use std::time::Duration;
301
302    fn make_result(healthy: bool, secs_ago: u64) -> DurabilityResult {
303        DurabilityResult {
304            reference: Reference::from_hex(&"a".repeat(64)).unwrap(),
305            started_at: SystemTime::now() - Duration::from_secs(secs_ago),
306            duration_ms: 200,
307            chunks_total: 4,
308            chunks_lost: if healthy { 0 } else { 1 },
309            chunks_errors: 0,
310            root_is_manifest: true,
311            truncated: false,
312        }
313    }
314
315    #[test]
316    fn empty_view_has_zero_rows() {
317        let rows = VecDeque::new();
318        let v = Watchlist::view_for(&rows, SystemTime::now());
319        assert_eq!(v.rows.len(), 0);
320        assert_eq!(v.healthy_count, 0);
321        assert_eq!(v.unhealthy_count, 0);
322    }
323
324    #[test]
325    fn view_counts_healthy_and_unhealthy_separately() {
326        let mut rows = VecDeque::new();
327        rows.push_back(make_result(true, 10));
328        rows.push_back(make_result(false, 20));
329        rows.push_back(make_result(true, 30));
330        let v = Watchlist::view_for(&rows, SystemTime::now());
331        assert_eq!(v.healthy_count, 2);
332        assert_eq!(v.unhealthy_count, 1);
333        assert_eq!(v.rows.len(), 3);
334    }
335
336    #[test]
337    fn record_evicts_oldest_when_full() {
338        let mut wl = Watchlist::new();
339        for i in 0..MAX_ROWS + 5 {
340            let r = make_result(true, i as u64);
341            wl.record(r);
342        }
343        assert_eq!(wl.rows.len(), MAX_ROWS);
344    }
345
346    #[test]
347    fn record_pushes_newest_to_front() {
348        let mut wl = Watchlist::new();
349        wl.record(make_result(true, 100));
350        wl.record(make_result(false, 50));
351        let v = wl.cached_view();
352        assert!(v.rows[0].status_label.contains("UNHEALTHY"));
353        assert!(v.rows[1].status_label.contains("OK"));
354    }
355
356    #[test]
357    fn view_age_increases_with_time_since_started() {
358        let mut rows = VecDeque::new();
359        rows.push_back(make_result(true, 60));
360        let v = Watchlist::view_for(&rows, SystemTime::now());
361        assert!(v.rows[0].age_seconds >= 60);
362    }
363
364    #[test]
365    fn short_hex_truncates_long_strings() {
366        let long = "a".repeat(64);
367        let s = short_hex(&long, 8);
368        assert!(s.contains('…'));
369    }
370}