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 corrupt_segment = if r.chunks_corrupt > 0 || r.bmt_verified {
112                    format!(" · {} corrupt", r.chunks_corrupt)
113                } else {
114                    String::new()
115                };
116                let swarmscan_segment = match r.swarmscan_seen {
117                    Some(true) => " · scan: seen",
118                    Some(false) => " · scan: NOT seen",
119                    None => "",
120                };
121                let detail = format!(
122                    "{} total · {} lost · {} errors{} · {}ms{}{}{}",
123                    r.chunks_total,
124                    r.chunks_lost,
125                    r.chunks_errors,
126                    corrupt_segment,
127                    r.duration_ms,
128                    if r.bmt_verified { " · BMT" } else { "" },
129                    swarmscan_segment,
130                    if r.truncated { " · truncated" } else { "" },
131                );
132                WatchlistRow {
133                    reference_hex: r.reference.to_hex(),
134                    status_label: if h {
135                        "OK".to_string()
136                    } else {
137                        "UNHEALTHY".to_string()
138                    },
139                    healthy: h,
140                    detail,
141                    age_seconds: age,
142                    root_is_manifest: r.root_is_manifest,
143                }
144            })
145            .collect();
146        WatchlistView {
147            rows: view_rows,
148            healthy_count: healthy,
149            unhealthy_count: unhealthy,
150        }
151    }
152
153    fn cached_view(&self) -> WatchlistView {
154        Self::view_for(&self.rows, SystemTime::now())
155    }
156}
157
158impl Component for Watchlist {
159    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
160        Some(self)
161    }
162
163    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
164        match key.code {
165            KeyCode::Up | KeyCode::Char('k') => {
166                self.selected = self.selected.saturating_sub(1);
167            }
168            KeyCode::Down | KeyCode::Char('j')
169                if !self.rows.is_empty() && self.selected + 1 < self.rows.len() =>
170            {
171                self.selected += 1;
172            }
173            _ => {}
174        }
175        Ok(None)
176    }
177
178    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
179        let t = theme::active();
180        let view = self.cached_view();
181        // 4-row split: header / body / detail / footer.
182        let chunks = Layout::vertical([
183            Constraint::Length(2),
184            Constraint::Min(0),
185            Constraint::Length(1),
186            Constraint::Length(1),
187        ])
188        .split(area);
189
190        // Header: counts.
191        let header = if view.rows.is_empty() {
192            Line::from(Span::styled(
193                "no durability checks yet — type :durability-check <ref> to record one",
194                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
195            ))
196        } else {
197            Line::from(vec![
198                Span::styled(format!(" {} ", view.rows.len()), Style::default().fg(t.dim)),
199                Span::raw("checks · "),
200                Span::styled(
201                    format!("{} ", view.healthy_count),
202                    Style::default().fg(t.pass).add_modifier(Modifier::BOLD),
203                ),
204                Span::raw("healthy · "),
205                Span::styled(
206                    format!("{} ", view.unhealthy_count),
207                    if view.unhealthy_count == 0 {
208                        Style::default().fg(t.dim)
209                    } else {
210                        Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
211                    },
212                ),
213                Span::raw("unhealthy"),
214            ])
215        };
216        frame.render_widget(
217            Paragraph::new(header).block(Block::default().borders(Borders::BOTTOM)),
218            chunks[0],
219        );
220
221        // Body: rows.
222        let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 1);
223        if view.rows.is_empty() {
224            // Empty state already covered by header.
225        } else {
226            if self.selected >= view.rows.len() {
227                self.selected = view.rows.len() - 1;
228            }
229            for (i, row) in view.rows.iter().enumerate() {
230                let cursor_marker = if i == self.selected { "▸ " } else { "  " };
231                let status_style = if row.healthy {
232                    Style::default().fg(t.pass).add_modifier(Modifier::BOLD)
233                } else {
234                    Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
235                };
236                let kind = if row.root_is_manifest {
237                    "manifest"
238                } else {
239                    "chunk   "
240                };
241                lines.push(Line::from(vec![
242                    Span::styled(cursor_marker.to_string(), Style::default().fg(t.accent)),
243                    Span::styled(format!("{:<10}", row.status_label), status_style),
244                    Span::raw("  "),
245                    Span::styled(kind.to_string(), Style::default().fg(t.dim)),
246                    Span::raw("  "),
247                    Span::raw(short_hex(&row.reference_hex, 8)),
248                    Span::raw("  "),
249                    Span::styled(row.detail.clone(), Style::default().fg(t.dim)),
250                    Span::raw("  "),
251                    Span::styled(
252                        format!("{}s ago", row.age_seconds),
253                        Style::default().fg(t.dim),
254                    ),
255                ]));
256            }
257        }
258        frame.render_widget(Paragraph::new(lines), chunks[1]);
259
260        // Detail: full ref of cursored row for click-drag copy.
261        if !view.rows.is_empty() {
262            let row = &view.rows[self.selected.min(view.rows.len() - 1)];
263            frame.render_widget(
264                Paragraph::new(Line::from(vec![
265                    Span::styled("  selected: ", Style::default().fg(t.dim)),
266                    Span::styled(row.reference_hex.clone(), Style::default().fg(t.info)),
267                ])),
268                chunks[2],
269            );
270        }
271
272        // Footer.
273        let footer = Line::from(vec![
274            Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
275            Span::raw(" switch screen  "),
276            Span::styled(
277                " ↑↓/jk ",
278                Style::default().fg(Color::Black).bg(Color::White),
279            ),
280            Span::raw(" select  "),
281            Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
282            Span::raw(" help  "),
283            Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
284            Span::raw(" quit  "),
285            Span::styled(
286                ":durability-check <ref> to record",
287                Style::default().fg(t.dim),
288            ),
289        ]);
290        frame.render_widget(Paragraph::new(footer), chunks[3]);
291        Ok(())
292    }
293}
294
295fn short_hex(s: &str, n: usize) -> String {
296    if s.len() <= n * 2 + 1 {
297        s.to_string()
298    } else {
299        format!("{}…{}", &s[..n], &s[s.len() - n..])
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use bee::swarm::Reference;
307    use std::time::Duration;
308
309    fn make_result(healthy: bool, secs_ago: u64) -> DurabilityResult {
310        DurabilityResult {
311            reference: Reference::from_hex(&"a".repeat(64)).unwrap(),
312            started_at: SystemTime::now() - Duration::from_secs(secs_ago),
313            duration_ms: 200,
314            chunks_total: 4,
315            chunks_lost: if healthy { 0 } else { 1 },
316            chunks_errors: 0,
317            chunks_corrupt: 0,
318            root_is_manifest: true,
319            truncated: false,
320            bmt_verified: true,
321            swarmscan_seen: None,
322        }
323    }
324
325    #[test]
326    fn empty_view_has_zero_rows() {
327        let rows = VecDeque::new();
328        let v = Watchlist::view_for(&rows, SystemTime::now());
329        assert_eq!(v.rows.len(), 0);
330        assert_eq!(v.healthy_count, 0);
331        assert_eq!(v.unhealthy_count, 0);
332    }
333
334    #[test]
335    fn view_counts_healthy_and_unhealthy_separately() {
336        let mut rows = VecDeque::new();
337        rows.push_back(make_result(true, 10));
338        rows.push_back(make_result(false, 20));
339        rows.push_back(make_result(true, 30));
340        let v = Watchlist::view_for(&rows, SystemTime::now());
341        assert_eq!(v.healthy_count, 2);
342        assert_eq!(v.unhealthy_count, 1);
343        assert_eq!(v.rows.len(), 3);
344    }
345
346    #[test]
347    fn record_evicts_oldest_when_full() {
348        let mut wl = Watchlist::new();
349        for i in 0..MAX_ROWS + 5 {
350            let r = make_result(true, i as u64);
351            wl.record(r);
352        }
353        assert_eq!(wl.rows.len(), MAX_ROWS);
354    }
355
356    #[test]
357    fn record_pushes_newest_to_front() {
358        let mut wl = Watchlist::new();
359        wl.record(make_result(true, 100));
360        wl.record(make_result(false, 50));
361        let v = wl.cached_view();
362        assert!(v.rows[0].status_label.contains("UNHEALTHY"));
363        assert!(v.rows[1].status_label.contains("OK"));
364    }
365
366    #[test]
367    fn view_age_increases_with_time_since_started() {
368        let mut rows = VecDeque::new();
369        rows.push_back(make_result(true, 60));
370        let v = Watchlist::view_for(&rows, SystemTime::now());
371        assert!(v.rows[0].age_seconds >= 60);
372    }
373
374    #[test]
375    fn short_hex_truncates_long_strings() {
376        let long = "a".repeat(64);
377        let s = short_hex(&long, 8);
378        assert!(s.contains('…'));
379    }
380}