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