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