Skip to main content

bee_tui/components/
pins.rs

1//! S11 — Pins screen (`docs/PLAN.md` § 8.S11; Tier 3.A).
2//!
3//! Promotes the `:pins-check` command's read-only output into a
4//! sortable table view. The list of pinned references comes from
5//! `/pins` (polled on the slow tier; pin sets are
6//! operator-driven). Per-pin integrity comes from `/pins/check`,
7//! which walks the entire chunk graph for each pin — too expensive
8//! to auto-poll. The cockpit triggers it on demand when the
9//! operator presses `Enter` (single pin) or `c` (all pins).
10//!
11//! ## Render path
12//!
13//! Pure [`Pins::view_for`] turns `(snapshot, check_state, sort_mode)`
14//! into a [`PinsView`]. The renderer just walks the view's `rows`
15//! and the snapshot tests in `tests/s11_pins_view.rs` pin sort
16//! ordering / count summaries / unhealthy-flag rendering without
17//! launching a TUI.
18//!
19//! ## What's intentionally out of scope (v1)
20//!
21//! - **No pin/unpin actions.** Adding a pin is a write op gated by
22//!   the same "no funds-bearing actions" rule as cashout / topup;
23//!   if you want to pin something, do it from `swarm-cli`.
24//! - **No download retry.** Unhealthy pins are surfaced; fixing
25//!   them (re-uploading missing chunks) is operator territory.
26
27use std::collections::HashMap;
28use std::sync::Arc;
29
30use color_eyre::Result;
31use crossterm::event::{KeyCode, KeyEvent};
32use ratatui::{
33    Frame,
34    layout::{Constraint, Layout, Rect},
35    style::{Color, Modifier, Style},
36    text::{Line, Span},
37    widgets::{Block, Borders, Paragraph},
38};
39use tokio::sync::{mpsc, watch};
40
41use super::Component;
42use crate::action::Action;
43use crate::api::ApiClient;
44use crate::theme;
45use crate::watch::PinsSnapshot;
46
47use bee::api::PinIntegrity;
48use bee::swarm::Reference;
49
50/// Per-pin integrity-check status. `Idle` = operator hasn't asked
51/// for a check yet; `Checking` = a `/pins/check` call is in flight.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum CheckState {
54    Idle,
55    Checking,
56    Ok {
57        total: u64,
58        missing: u64,
59        invalid: u64,
60    },
61    Failed(String),
62}
63
64impl CheckState {
65    /// `true` when we have a result and any chunks are missing or
66    /// invalid. `Idle`/`Checking`/`Failed` return `false` — they
67    /// don't claim health one way or the other.
68    pub fn is_unhealthy(&self) -> bool {
69        matches!(self, Self::Ok { missing, invalid, .. } if *missing > 0 || *invalid > 0)
70    }
71    /// `true` when the pin checked clean.
72    pub fn is_healthy(&self) -> bool {
73        matches!(
74            self,
75            Self::Ok {
76                missing: 0,
77                invalid: 0,
78                ..
79            }
80        )
81    }
82}
83
84/// One row of the pins table. `reference_short` is the standard
85/// `prefix…suffix` form for table display; the renderer can fall
86/// back to the full hex for click-drag copy in a footer line.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct PinRow {
89    pub reference: Reference,
90    pub reference_short: String,
91    pub check: CheckState,
92}
93
94/// How the rows are ordered. Operator cycles via `s`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum SortMode {
97    /// Bee's response order (the default — gives operators the same
98    /// order they'd see from `curl /pins`).
99    Reference,
100    /// Unhealthy → unchecked → healthy. Surfaces the rows that
101    /// matter for an operator who suspects local chunk loss.
102    BadFirst,
103    /// Largest pins first by `total` chunk count. Useful for
104    /// understanding which pin set dominates local reserve usage.
105    TotalChunks,
106}
107
108impl SortMode {
109    fn next(self) -> Self {
110        match self {
111            Self::Reference => Self::BadFirst,
112            Self::BadFirst => Self::TotalChunks,
113            Self::TotalChunks => Self::Reference,
114        }
115    }
116    fn label(self) -> &'static str {
117        match self {
118            Self::Reference => "ref order",
119            Self::BadFirst => "bad first",
120            Self::TotalChunks => "by size",
121        }
122    }
123}
124
125/// View fed to the renderer. Pure transform of
126/// `(snapshot, check_state, sort_mode)`.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct PinsView {
129    pub rows: Vec<PinRow>,
130    pub sort: SortMode,
131    pub total_pins: usize,
132    pub healthy: usize,
133    pub unhealthy: usize,
134    pub unchecked: usize,
135}
136
137type FetchResult = (Reference, std::result::Result<PinIntegrity, String>);
138
139pub struct Pins {
140    client: Arc<ApiClient>,
141    rx: watch::Receiver<PinsSnapshot>,
142    snapshot: PinsSnapshot,
143    /// Per-reference check state. Survives snapshot refreshes — we
144    /// don't want a fresh `/pins` poll to wipe the integrity result
145    /// the operator just spent 30s computing.
146    checks: HashMap<Reference, CheckState>,
147    selected: usize,
148    scroll_offset: usize,
149    sort: SortMode,
150    fetch_tx: mpsc::UnboundedSender<FetchResult>,
151    fetch_rx: mpsc::UnboundedReceiver<FetchResult>,
152}
153
154impl Pins {
155    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<PinsSnapshot>) -> Self {
156        let snapshot = rx.borrow().clone();
157        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
158        Self {
159            client,
160            rx,
161            snapshot,
162            checks: HashMap::new(),
163            selected: 0,
164            scroll_offset: 0,
165            sort: SortMode::Reference,
166            fetch_tx,
167            fetch_rx,
168        }
169    }
170
171    fn pull_latest(&mut self) {
172        self.snapshot = self.rx.borrow().clone();
173        // Clamp the cursor if the pin set shrank.
174        let n = self.snapshot.pins.len();
175        if n == 0 {
176            self.selected = 0;
177        } else if self.selected >= n {
178            self.selected = n - 1;
179        }
180    }
181
182    /// Drain integrity-check fetches that completed since the last
183    /// tick. Late results are still applied — pins are stable
184    /// across snapshot refreshes so a stale-looking result is still
185    /// useful (vs. the S2 drill, which races a different batch).
186    fn drain_fetches(&mut self) {
187        while let Ok((reference, result)) = self.fetch_rx.try_recv() {
188            let next = match result {
189                Ok(p) => CheckState::Ok {
190                    total: p.total,
191                    missing: p.missing,
192                    invalid: p.invalid,
193                },
194                Err(e) => CheckState::Failed(e),
195            };
196            self.checks.insert(reference, next);
197        }
198    }
199
200    /// Pure view builder for snapshot tests + the renderer.
201    pub fn view_for(
202        snap: &PinsSnapshot,
203        checks: &HashMap<Reference, CheckState>,
204        sort: SortMode,
205    ) -> PinsView {
206        let mut rows: Vec<PinRow> = snap
207            .pins
208            .iter()
209            .map(|r| {
210                let check = checks.get(r).cloned().unwrap_or(CheckState::Idle);
211                PinRow {
212                    reference: r.clone(),
213                    reference_short: short_ref(&r.to_hex()),
214                    check,
215                }
216            })
217            .collect();
218
219        match sort {
220            SortMode::Reference => {} // already in Bee's response order
221            SortMode::BadFirst => {
222                rows.sort_by_key(|r| match &r.check {
223                    CheckState::Ok {
224                        missing, invalid, ..
225                    } if *missing > 0 || *invalid > 0 => 0,
226                    CheckState::Failed(_) => 1,
227                    CheckState::Idle => 2,
228                    CheckState::Checking => 3,
229                    CheckState::Ok { .. } => 4,
230                });
231            }
232            SortMode::TotalChunks => {
233                rows.sort_by_key(|r| match &r.check {
234                    CheckState::Ok { total, .. } => std::cmp::Reverse(*total),
235                    _ => std::cmp::Reverse(0),
236                });
237            }
238        }
239
240        let mut healthy = 0;
241        let mut unhealthy = 0;
242        let mut unchecked = 0;
243        for r in &rows {
244            if r.check.is_healthy() {
245                healthy += 1;
246            } else if r.check.is_unhealthy() {
247                unhealthy += 1;
248            } else if matches!(r.check, CheckState::Idle) {
249                unchecked += 1;
250            }
251        }
252
253        PinsView {
254            total_pins: rows.len(),
255            rows,
256            sort,
257            healthy,
258            unhealthy,
259            unchecked,
260        }
261    }
262
263    /// Spawn a `/pins/check?ref=X` request for the row under the
264    /// cursor. No-op if a check is already in flight for that ref.
265    fn check_selected(&mut self) {
266        if self.snapshot.pins.is_empty() {
267            return;
268        }
269        let i = self.selected.min(self.snapshot.pins.len() - 1);
270        let reference = self.snapshot.pins[i].clone();
271        if matches!(self.checks.get(&reference), Some(CheckState::Checking)) {
272            return;
273        }
274        self.checks.insert(reference.clone(), CheckState::Checking);
275        let client = self.client.clone();
276        let tx = self.fetch_tx.clone();
277        let task_ref = reference.clone();
278        tokio::spawn(async move {
279            let r = client
280                .bee()
281                .api()
282                .check_pins(Some(&task_ref))
283                .await
284                .map_err(|e| e.to_string())
285                .and_then(|mut entries| {
286                    entries
287                        .pop()
288                        .ok_or_else(|| "Bee returned no integrity entry".to_string())
289                });
290            let _ = tx.send((task_ref, r));
291        });
292    }
293
294    /// `c` — kick off a check for every currently-Idle pin. Bounded
295    /// concurrency: we spawn one task per pin, but Bee serialises
296    /// them server-side anyway. Already-Checking / Ok / Failed pins
297    /// are skipped.
298    fn check_all(&mut self) {
299        let pending: Vec<Reference> = self
300            .snapshot
301            .pins
302            .iter()
303            .filter(|r| matches!(self.checks.get(*r), None | Some(CheckState::Idle)))
304            .cloned()
305            .collect();
306        for reference in pending {
307            self.checks.insert(reference.clone(), CheckState::Checking);
308            let client = self.client.clone();
309            let tx = self.fetch_tx.clone();
310            let task_ref = reference;
311            tokio::spawn(async move {
312                let r = client
313                    .bee()
314                    .api()
315                    .check_pins(Some(&task_ref))
316                    .await
317                    .map_err(|e| e.to_string())
318                    .and_then(|mut entries| {
319                        entries
320                            .pop()
321                            .ok_or_else(|| "Bee returned no integrity entry".to_string())
322                    });
323                let _ = tx.send((task_ref, r));
324            });
325        }
326    }
327}
328
329/// `prefix…suffix` form used in the table display. Long enough to
330/// disambiguate, short enough to leave room for the integrity columns.
331fn short_ref(hex: &str) -> String {
332    let trimmed = hex.trim_start_matches("0x");
333    if trimmed.len() > 14 {
334        format!("{}…{}", &trimmed[..8], &trimmed[trimmed.len() - 4..])
335    } else {
336        trimmed.to_string()
337    }
338}
339
340impl Component for Pins {
341    fn update(&mut self, action: Action) -> Result<Option<Action>> {
342        if matches!(action, Action::Tick) {
343            self.pull_latest();
344            self.drain_fetches();
345        }
346        Ok(None)
347    }
348
349    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
350        match key.code {
351            KeyCode::Char('j') | KeyCode::Down => {
352                let n = self.snapshot.pins.len();
353                if n > 0 && self.selected + 1 < n {
354                    self.selected += 1;
355                }
356            }
357            KeyCode::Char('k') | KeyCode::Up => {
358                self.selected = self.selected.saturating_sub(1);
359            }
360            KeyCode::Enter => {
361                self.check_selected();
362            }
363            KeyCode::Char('c') => {
364                self.check_all();
365            }
366            KeyCode::Char('s') => {
367                self.sort = self.sort.next();
368            }
369            _ => {}
370        }
371        Ok(None)
372    }
373
374    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
375        let chunks = Layout::vertical([
376            Constraint::Length(3), // header
377            Constraint::Min(0),    // body
378            Constraint::Length(1), // selected detail (full reference)
379            Constraint::Length(1), // footer
380        ])
381        .split(area);
382
383        let view = Self::view_for(&self.snapshot, &self.checks, self.sort);
384        let t = theme::active();
385
386        // Header
387        let header_l1 = Line::from(vec![
388            Span::styled("PINS", Style::default().add_modifier(Modifier::BOLD)),
389            Span::raw(format!("  {} pinned", view.total_pins)),
390            Span::raw("   "),
391            Span::styled(format!("✓ {}", view.healthy), Style::default().fg(t.pass)),
392            Span::raw("   "),
393            Span::styled(format!("✗ {}", view.unhealthy), Style::default().fg(t.fail)),
394            Span::raw("   "),
395            Span::styled(format!("? {}", view.unchecked), Style::default().fg(t.dim)),
396            Span::raw("   sort "),
397            Span::styled(view.sort.label(), Style::default().fg(t.info)),
398        ]);
399        let header_l2 = match &self.snapshot.last_error {
400            Some(err) => {
401                let (color, msg) = theme::classify_header_error(err);
402                Line::from(Span::styled(msg, Style::default().fg(color)))
403            }
404            None if !self.snapshot.is_loaded() => Line::from(Span::styled(
405                format!("{} loading…", theme::spinner_glyph()),
406                Style::default().fg(t.dim),
407            )),
408            None => Line::from(Span::styled(
409                "  Press Enter to integrity-check the highlighted pin, c for all, s to re-sort.",
410                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
411            )),
412        };
413        frame.render_widget(
414            Paragraph::new(vec![header_l1, header_l2])
415                .block(Block::default().borders(Borders::BOTTOM)),
416            chunks[0],
417        );
418
419        // Body — pinned column header + scrollable rows.
420        let body = chunks[1];
421        let table_chunks =
422            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(body);
423        frame.render_widget(
424            Paragraph::new(Line::from(Span::styled(
425                "   REFERENCE         TOTAL    MISSING    INVALID    STATUS",
426                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
427            ))),
428            table_chunks[0],
429        );
430
431        if view.rows.is_empty() {
432            frame.render_widget(
433                Paragraph::new(Line::from(Span::styled(
434                    "   (no pinned references — pin one with `swarm-cli pin add`)",
435                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
436                ))),
437                table_chunks[1],
438            );
439        } else {
440            let rows_area = table_chunks[1];
441            let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 2);
442            for (i, r) in view.rows.iter().enumerate() {
443                let cursor = if i == self.selected {
444                    format!("{} ", t.glyphs.cursor)
445                } else {
446                    "  ".to_string()
447                };
448                let (total, missing, invalid, status_text, status_color) = match &r.check {
449                    CheckState::Idle => (
450                        "—".to_string(),
451                        "—".to_string(),
452                        "—".to_string(),
453                        "? unchecked".to_string(),
454                        t.dim,
455                    ),
456                    CheckState::Checking => (
457                        "—".to_string(),
458                        "—".to_string(),
459                        "—".to_string(),
460                        format!("{} checking…", theme::spinner_glyph()),
461                        t.info,
462                    ),
463                    CheckState::Ok {
464                        total,
465                        missing,
466                        invalid,
467                    } => {
468                        let healthy = *missing == 0 && *invalid == 0;
469                        (
470                            total.to_string(),
471                            missing.to_string(),
472                            invalid.to_string(),
473                            if healthy {
474                                "✓ healthy".into()
475                            } else {
476                                "✗ degraded".into()
477                            },
478                            if healthy { t.pass } else { t.fail },
479                        )
480                    }
481                    CheckState::Failed(err) => (
482                        "—".to_string(),
483                        "—".to_string(),
484                        "—".to_string(),
485                        format!("✗ check failed: {err}"),
486                        t.fail,
487                    ),
488                };
489                lines.push(Line::from(vec![
490                    Span::styled(
491                        cursor,
492                        Style::default()
493                            .fg(if i == self.selected { t.accent } else { t.dim })
494                            .add_modifier(Modifier::BOLD),
495                    ),
496                    Span::styled(
497                        format!("{:<18}", r.reference_short),
498                        Style::default().add_modifier(Modifier::BOLD),
499                    ),
500                    Span::raw(format!("{total:>6}     ")),
501                    Span::raw(format!("{missing:>6}     ")),
502                    Span::raw(format!("{invalid:>6}     ")),
503                    Span::styled(
504                        status_text,
505                        Style::default()
506                            .fg(status_color)
507                            .add_modifier(Modifier::BOLD),
508                    ),
509                ]));
510            }
511
512            // Visual-line scroll based on row selection — same pattern
513            // as S6 / S2.
514            let visible_rows = rows_area.height as usize;
515            self.scroll_offset = super::scroll::clamp_scroll(
516                self.selected,
517                self.scroll_offset,
518                visible_rows,
519                lines.len(),
520            );
521            frame.render_widget(
522                Paragraph::new(lines.clone()).scroll((self.scroll_offset as u16, 0)),
523                rows_area,
524            );
525            super::scroll::render_scrollbar(
526                frame,
527                rows_area,
528                self.scroll_offset,
529                visible_rows,
530                lines.len(),
531            );
532        }
533
534        // Selected detail — full reference of the highlighted row,
535        // click-drag selectable so operators can copy without
536        // shrinking the column or sacrificing the table layout.
537        if !view.rows.is_empty() {
538            let i = self.selected.min(view.rows.len() - 1);
539            let row = &view.rows[i];
540            let detail = Line::from(vec![
541                Span::styled("  selected: ", Style::default().fg(t.dim)),
542                Span::styled(row.reference.to_hex(), Style::default().fg(t.info)),
543            ]);
544            frame.render_widget(Paragraph::new(detail), chunks[2]);
545        }
546
547        // Footer
548        let footer = Line::from(vec![
549            Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
550            Span::raw(" switch screen  "),
551            Span::styled(
552                " ↑↓/jk ",
553                Style::default().fg(Color::Black).bg(Color::White),
554            ),
555            Span::raw(" select  "),
556            Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
557            Span::raw(" check pin  "),
558            Span::styled(" c ", Style::default().fg(Color::Black).bg(Color::White)),
559            Span::raw(" check all  "),
560            Span::styled(" s ", Style::default().fg(Color::Black).bg(Color::White)),
561            Span::raw(" sort  "),
562            Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
563            Span::raw(" help  "),
564            Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
565            Span::raw(" quit "),
566        ]);
567        frame.render_widget(Paragraph::new(footer), chunks[3]);
568
569        Ok(())
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn r(byte: u8) -> Reference {
578        Reference::new(&[byte; 32]).unwrap()
579    }
580
581    fn ok(total: u64, missing: u64, invalid: u64) -> CheckState {
582        CheckState::Ok {
583            total,
584            missing,
585            invalid,
586        }
587    }
588
589    #[test]
590    fn check_state_health_predicates() {
591        assert!(ok(10, 0, 0).is_healthy());
592        assert!(!ok(10, 0, 0).is_unhealthy());
593        assert!(ok(10, 1, 0).is_unhealthy());
594        assert!(ok(10, 0, 1).is_unhealthy());
595        assert!(!CheckState::Idle.is_healthy());
596        assert!(!CheckState::Idle.is_unhealthy());
597        assert!(!CheckState::Checking.is_unhealthy());
598    }
599
600    #[test]
601    fn view_for_empty_snapshot_renders_zero_counts() {
602        let snap = PinsSnapshot::default();
603        let view = Pins::view_for(&snap, &HashMap::new(), SortMode::Reference);
604        assert_eq!(view.total_pins, 0);
605        assert_eq!(view.healthy, 0);
606        assert_eq!(view.unhealthy, 0);
607        assert_eq!(view.unchecked, 0);
608    }
609
610    #[test]
611    fn view_for_counts_health_buckets() {
612        let snap = PinsSnapshot {
613            pins: vec![r(1), r(2), r(3), r(4)],
614            ..PinsSnapshot::default()
615        };
616        let mut checks = HashMap::new();
617        checks.insert(r(1), ok(100, 0, 0)); // healthy
618        checks.insert(r(2), ok(100, 5, 0)); // unhealthy
619        checks.insert(r(3), CheckState::Failed("nope".into())); // not unchecked, not healthy
620        // r(4) → unchecked (no entry)
621        let view = Pins::view_for(&snap, &checks, SortMode::Reference);
622        assert_eq!(view.total_pins, 4);
623        assert_eq!(view.healthy, 1);
624        assert_eq!(view.unhealthy, 1);
625        assert_eq!(view.unchecked, 1);
626    }
627
628    #[test]
629    fn view_for_default_sort_preserves_response_order() {
630        let snap = PinsSnapshot {
631            pins: vec![r(3), r(1), r(2)],
632            ..PinsSnapshot::default()
633        };
634        let view = Pins::view_for(&snap, &HashMap::new(), SortMode::Reference);
635        assert_eq!(view.rows[0].reference, r(3));
636        assert_eq!(view.rows[1].reference, r(1));
637        assert_eq!(view.rows[2].reference, r(2));
638    }
639
640    #[test]
641    fn view_for_bad_first_surfaces_unhealthy_then_failed_then_unchecked_then_healthy() {
642        let snap = PinsSnapshot {
643            pins: vec![r(1), r(2), r(3), r(4), r(5)],
644            ..PinsSnapshot::default()
645        };
646        let mut checks = HashMap::new();
647        checks.insert(r(1), ok(10, 0, 0)); // healthy
648        checks.insert(r(2), ok(10, 1, 0)); // unhealthy → first
649        checks.insert(r(3), CheckState::Failed("e".into())); // failed → second
650        checks.insert(r(4), CheckState::Checking); // checking → fourth
651        // r(5) unchecked → third
652        let view = Pins::view_for(&snap, &checks, SortMode::BadFirst);
653        let order: Vec<_> = view.rows.iter().map(|r| r.reference.clone()).collect();
654        assert_eq!(order, vec![r(2), r(3), r(5), r(4), r(1)]);
655    }
656
657    #[test]
658    fn view_for_total_chunks_sorts_descending_with_unchecked_last() {
659        let snap = PinsSnapshot {
660            pins: vec![r(1), r(2), r(3), r(4)],
661            ..PinsSnapshot::default()
662        };
663        let mut checks = HashMap::new();
664        checks.insert(r(1), ok(50, 0, 0));
665        checks.insert(r(2), ok(500, 0, 0));
666        checks.insert(r(3), ok(5, 0, 0));
667        // r(4) → unchecked (counts as 0 → goes last)
668        let view = Pins::view_for(&snap, &checks, SortMode::TotalChunks);
669        let order: Vec<_> = view.rows.iter().map(|r| r.reference.clone()).collect();
670        // r(2)=500, r(1)=50, r(3)=5, r(4)=unchecked
671        assert_eq!(order, vec![r(2), r(1), r(3), r(4)]);
672    }
673
674    #[test]
675    fn sort_mode_cycles() {
676        assert_eq!(SortMode::Reference.next(), SortMode::BadFirst);
677        assert_eq!(SortMode::BadFirst.next(), SortMode::TotalChunks);
678        assert_eq!(SortMode::TotalChunks.next(), SortMode::Reference);
679    }
680
681    #[test]
682    fn short_ref_keeps_short_strings_intact() {
683        assert_eq!(short_ref("abcd"), "abcd");
684        assert_eq!(short_ref("0x1234"), "1234");
685        assert_eq!(
686            short_ref("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"),
687            "aabbccdd…8899"
688        );
689    }
690}