1use 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#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WatchlistRow {
39 pub reference_hex: String,
40 pub status_label: String,
41 pub healthy: bool,
44 pub detail: String,
46 pub age_seconds: u64,
48 pub root_is_manifest: bool,
49}
50
51#[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 pub fn record(&mut self, result: DurabilityResult) {
84 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 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 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 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 let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 1);
223 if view.rows.is_empty() {
224 } 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 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 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}