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 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 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 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 let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 1);
217 if view.rows.is_empty() {
218 } 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 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 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}