Skip to main content

bee_tui/components/
peers.rs

1//! S6 — Peers + bin saturation screen (`docs/PLAN.md` § 8.S6).
2//!
3//! Driven by [`crate::watch::TopologySnapshot`] (`/topology` @ 5 s).
4//! The screen has three states, switched by key:
5//!
6//! 1. **Bin saturation strip** — one row per Kademlia bin (0..=31)
7//!    with population vs the bee-go saturation thresholds
8//!    (`SaturationPeers=8`, `OverSaturationPeers=18` per
9//!    `pkg/topology/kademlia/kademlia.go:54-55`). Bins at or below
10//!    the current Kademlia depth flag as Starving when they fall
11//!    short of saturation — that's the operator-pain headline this
12//!    screen exists to surface (no other tool in the ecosystem
13//!    derives this).
14//! 2. **Peer table** — flattened view of every connected peer
15//!    sourced from each bin's `connectedPeers`. One row per peer
16//!    with bin, overlay short, session direction, latency, healthy,
17//!    and per-peer reachability. Selection (↑↓/jk) highlights one
18//!    row; `↵` opens the per-peer drill pane.
19//! 3. **Peer drill** — fans out four parallel fetches (`peer_balance`,
20//!    `peer_cheques`, `peer_settlement`, `ping_peer`) for the
21//!    selected peer and renders the results inline. `Esc` returns
22//!    to the table.
23//!
24//! Pure compute paths (`view_for`, `compute_peer_drill_view`) are
25//! exposed for the snapshot tests in `tests/s6_peers_view.rs` and
26//! `tests/s6_peers_drill.rs`.
27
28use std::sync::Arc;
29
30use color_eyre::Result;
31use crossterm::event::{KeyCode, KeyEvent};
32use num_bigint::BigInt;
33use ratatui::{
34    Frame,
35    layout::{Constraint, Layout, Rect},
36    style::{Color, Modifier, Style},
37    text::{Line, Span},
38    widgets::{Block, Borders, Paragraph},
39};
40use tokio::sync::{mpsc, watch};
41
42use super::Component;
43use crate::action::Action;
44use crate::api::ApiClient;
45use crate::theme;
46use crate::watch::TopologySnapshot;
47
48use bee::debug::{Balance, BinInfo, PeerCheques, PeerInfo, Settlement, Topology};
49
50/// Kademlia bins per Bee build.
51pub const BIN_COUNT: usize = 32;
52/// `pkg/topology/kademlia/kademlia.go:54` — saturation threshold.
53/// Connected peer counts below this in a relevant bin (bin ≤ depth)
54/// are reported as Starving.
55pub const SATURATION_PEERS: u64 = 8;
56/// `pkg/topology/kademlia/kademlia.go:55` — over-saturation threshold.
57/// Connected peer counts above this in any bin are reported as Over.
58pub const OVER_SATURATION_PEERS: u64 = 18;
59/// Relaxation factor for "far" bins. Bins more than this many positions
60/// past the kademlia depth are expected to be sparse and don't flag as
61/// Starving even if their connected count is below saturation.
62const FAR_BIN_RELAXATION: u8 = 4;
63
64/// Tri-state bin saturation classification.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum BinSaturation {
67    /// `connected == 0` and the bin is far from depth — expected to
68    /// be empty.
69    Empty,
70    /// `connected < SATURATION_PEERS` for a bin at or near the depth.
71    /// Operator should add peers (manual `connect`, more uptime).
72    Starving,
73    /// Connected count is in the safe band `[8, 18]`.
74    Healthy,
75    /// Connected count exceeds `OVER_SATURATION_PEERS`. Bee will trim
76    /// oldest entries; harmless but unusual for distant bins.
77    Over,
78}
79
80impl BinSaturation {
81    fn color(self) -> Color {
82        match self {
83            Self::Empty => theme::active().dim,
84            Self::Starving => theme::active().fail,
85            Self::Healthy => theme::active().pass,
86            Self::Over => theme::active().warn,
87        }
88    }
89    fn label(self) -> String {
90        let g = theme::active().glyphs;
91        match self {
92            Self::Empty => g.em_dash.to_string(),
93            Self::Starving => format!("{} STARVING", g.fail),
94            Self::Healthy => g.pass.to_string(),
95            Self::Over => format!("{} over", g.warn),
96        }
97    }
98}
99
100/// One row of the bin saturation strip.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct BinStripRow {
103    pub bin: u8,
104    pub population: u64,
105    pub connected: u64,
106    pub status: BinSaturation,
107    /// `true` if this bin is at or below the current kademlia depth —
108    /// the only bins where Starving carries an operator alert. Far
109    /// bins with low population are normal.
110    pub is_relevant: bool,
111}
112
113/// One row of the peer table.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct PeerRow {
116    pub bin: u8,
117    pub peer_short: String,
118    /// Full overlay address (un-shortened) — needed for drill
119    /// fetches. Short version is just for display.
120    pub peer_full: String,
121    /// `"in"` / `"out"` from the per-peer session direction. `"?"` if
122    /// metrics not yet populated.
123    pub direction: &'static str,
124    /// Latency formatted as `"Xms"` from the EWMA value (Bee returns
125    /// nanoseconds). `"—"` if metrics absent.
126    pub latency: String,
127    pub healthy: bool,
128    /// Per-peer reachability string from MetricSnapshotView. Empty if
129    /// metrics absent.
130    pub reachability: String,
131}
132
133/// Aggregated view fed to the renderer and snapshot tests.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct PeersView {
136    pub bins: Vec<BinStripRow>,
137    pub peers: Vec<PeerRow>,
138    pub depth: u8,
139    pub population: i64,
140    pub connected: i64,
141    pub reachability: String,
142    pub network_availability: String,
143    /// Number of connected light-node peers, separate from the
144    /// 32-bin breakdown.
145    pub light_connected: u64,
146}
147
148/// Per-field outcome of a peer drill fetch. Each endpoint can fail
149/// independently — render each row with what it has.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum DrillField<T: Clone + PartialEq + Eq> {
152    Ok(T),
153    Err(String),
154}
155
156/// Aggregated drill view for the per-peer pane. Pure — computed
157/// from the four endpoint results without any I/O.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct PeerDrillView {
160    pub peer_overlay: String,
161    pub bin: Option<u8>,
162    /// Settlement balance — positive = peer owes us, negative = we
163    /// owe peer. Pre-formatted as `"BZZ X.XXXX"` with sign.
164    pub balance: DrillField<String>,
165    /// Round-trip time as Bee reports it (Go duration string,
166    /// e.g. `"5.0018ms"`).
167    pub ping: DrillField<String>,
168    /// Cumulative settlement received from this peer (BZZ).
169    pub settlement_received: DrillField<String>,
170    /// Cumulative settlement sent to this peer (BZZ).
171    pub settlement_sent: DrillField<String>,
172    /// Last received cheque payout (BZZ) or `None` if no cheques.
173    pub last_received_cheque: DrillField<Option<String>>,
174    /// Last sent cheque payout (BZZ) or `None` if no cheques.
175    pub last_sent_cheque: DrillField<Option<String>>,
176}
177
178/// Bundle of the four endpoint results that feeds
179/// [`Peers::compute_peer_drill_view`]. Each field can fail
180/// independently.
181#[derive(Debug, Clone)]
182pub struct PeerDrillFetch {
183    pub balance: std::result::Result<Balance, String>,
184    pub cheques: std::result::Result<PeerCheques, String>,
185    pub settlement: std::result::Result<Settlement, String>,
186    pub ping: std::result::Result<String, String>,
187}
188
189/// Drill-pane state machine. `Idle` keeps the regular table
190/// rendered; the other variants replace the peer table with the
191/// drill view.
192#[derive(Debug, Clone)]
193pub enum DrillState {
194    Idle,
195    Loading {
196        peer: String,
197        bin: Option<u8>,
198    },
199    Loaded {
200        view: PeerDrillView,
201    },
202}
203
204type DrillFetchResult = (String, PeerDrillFetch);
205
206pub struct Peers {
207    client: Arc<ApiClient>,
208    rx: watch::Receiver<TopologySnapshot>,
209    snapshot: TopologySnapshot,
210    selected: usize,
211    /// Top row of the visible window in the peer table. Drives both
212    /// the `Paragraph::scroll` offset and the right-edge scrollbar
213    /// state. Updated lazily inside `draw_peer_table` to match the
214    /// area height we get at render time.
215    scroll_offset: usize,
216    drill: DrillState,
217    fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
218    fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
219}
220
221impl Peers {
222    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<TopologySnapshot>) -> Self {
223        let snapshot = rx.borrow().clone();
224        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
225        Self {
226            client,
227            rx,
228            snapshot,
229            selected: 0,
230            scroll_offset: 0,
231            drill: DrillState::Idle,
232            fetch_tx,
233            fetch_rx,
234        }
235    }
236
237    fn pull_latest(&mut self) {
238        self.snapshot = self.rx.borrow().clone();
239        let n = self.peer_rows_cached().len();
240        if n == 0 {
241            self.selected = 0;
242        } else if self.selected >= n {
243            self.selected = n - 1;
244        }
245    }
246
247    fn peer_rows_cached(&self) -> Vec<PeerRow> {
248        Self::view_for(&self.snapshot)
249            .map(|v| v.peers)
250            .unwrap_or_default()
251    }
252
253    /// Drain any drill fetches that completed since the last tick.
254    /// Late results from a since-cancelled drill (operator hit Esc
255    /// then Enter on a different peer before all four endpoints
256    /// came back) are dropped silently — `drill` already moved on.
257    fn drain_fetches(&mut self) {
258        while let Ok((peer, fetch)) = self.fetch_rx.try_recv() {
259            let pending_peer = match &self.drill {
260                DrillState::Loading { peer: p, .. } => p.clone(),
261                _ => continue, // user moved on; ignore
262            };
263            if pending_peer != peer {
264                continue;
265            }
266            let bin = match &self.drill {
267                DrillState::Loading { bin, .. } => *bin,
268                _ => None,
269            };
270            let view = Self::compute_peer_drill_view(&peer, bin, &fetch);
271            self.drill = DrillState::Loaded { view };
272        }
273    }
274
275    /// Pure, snapshot-driven view computation. Exposed for snapshot
276    /// tests in `tests/s6_peers_view.rs`.
277    pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
278        let t = snap.topology.as_ref()?;
279        let bins = bin_strip_rows(t);
280        let peers = peer_rows(t);
281        Some(PeersView {
282            bins,
283            peers,
284            depth: t.depth,
285            population: t.population,
286            connected: t.connected,
287            reachability: t.reachability.clone(),
288            network_availability: t.network_availability.clone(),
289            light_connected: t.light_nodes.connected,
290        })
291    }
292
293    /// Pure compute path for the per-peer drill pane. Each field is
294    /// derived independently so a single failed endpoint doesn't
295    /// blank the view — operators still see whatever did come back.
296    pub fn compute_peer_drill_view(
297        peer: &str,
298        bin: Option<u8>,
299        fetch: &PeerDrillFetch,
300    ) -> PeerDrillView {
301        let balance = match &fetch.balance {
302            Ok(b) => DrillField::Ok(format_plur_signed(&b.balance)),
303            Err(e) => DrillField::Err(e.clone()),
304        };
305        let ping = match &fetch.ping {
306            Ok(s) => DrillField::Ok(s.clone()),
307            Err(e) => DrillField::Err(e.clone()),
308        };
309        let (settlement_received, settlement_sent) = match &fetch.settlement {
310            Ok(s) => (
311                DrillField::Ok(format_opt_plur(s.received.as_ref())),
312                DrillField::Ok(format_opt_plur(s.sent.as_ref())),
313            ),
314            Err(e) => (
315                DrillField::Err(e.clone()),
316                DrillField::Err(e.clone()),
317            ),
318        };
319        let (last_received_cheque, last_sent_cheque) = match &fetch.cheques {
320            Ok(c) => (
321                DrillField::Ok(c.last_received.as_ref().map(|q| format_opt_plur(q.payout.as_ref()))),
322                DrillField::Ok(c.last_sent.as_ref().map(|q| format_opt_plur(q.payout.as_ref()))),
323            ),
324            Err(e) => (
325                DrillField::Err(e.clone()),
326                DrillField::Err(e.clone()),
327            ),
328        };
329        PeerDrillView {
330            peer_overlay: peer.to_string(),
331            bin,
332            balance,
333            ping,
334            settlement_received,
335            settlement_sent,
336            last_received_cheque,
337            last_sent_cheque,
338        }
339    }
340
341    /// Spawn parallel fetches of the four per-peer endpoints. No-op
342    /// if the peer table is empty or a fetch is already in flight
343    /// for the same peer.
344    fn maybe_start_drill(&mut self) {
345        let peers = self.peer_rows_cached();
346        if peers.is_empty() {
347            return;
348        }
349        let i = self.selected.min(peers.len() - 1);
350        let row = &peers[i];
351        let peer = row.peer_full.clone();
352        let bin = Some(row.bin);
353        if let DrillState::Loading { peer: pending, .. } = &self.drill {
354            if *pending == peer {
355                return;
356            }
357        }
358        let client = self.client.clone();
359        let tx = self.fetch_tx.clone();
360        let peer_for_task = peer.clone();
361        tokio::spawn(async move {
362            let bee = client.bee();
363            let debug = bee.debug();
364            // Fan out all four requests in parallel — they're all
365            // small and independent. tokio::join! returns once every
366            // future has resolved (even if some errored), so the
367            // operator sees the full picture in one update rather
368            // than four flickers.
369            let (balance, cheques, settlement, ping) = tokio::join!(
370                debug.peer_balance(&peer_for_task),
371                debug.peer_cheques(&peer_for_task),
372                debug.peer_settlement(&peer_for_task),
373                debug.ping_peer(&peer_for_task),
374            );
375            let fetch = PeerDrillFetch {
376                balance: balance.map_err(|e| e.to_string()),
377                cheques: cheques.map_err(|e| e.to_string()),
378                settlement: settlement.map_err(|e| e.to_string()),
379                ping: ping.map_err(|e| e.to_string()),
380            };
381            let _ = tx.send((peer_for_task, fetch));
382        });
383        self.drill = DrillState::Loading { peer, bin };
384    }
385}
386
387fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
388    t.bins
389        .iter()
390        .enumerate()
391        .map(|(i, b)| {
392            let bin = i as u8;
393            let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
394            BinStripRow {
395                bin,
396                population: b.population,
397                connected: b.connected,
398                status: classify_bin(b, bin, t.depth),
399                is_relevant,
400            }
401        })
402        .collect()
403}
404
405fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
406    if b.connected > OVER_SATURATION_PEERS {
407        return BinSaturation::Over;
408    }
409    if b.connected >= SATURATION_PEERS {
410        return BinSaturation::Healthy;
411    }
412    // Below saturation: starving only if this bin is at or near the
413    // current depth. Far bins are expected to be sparse.
414    if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
415        BinSaturation::Starving
416    } else {
417        BinSaturation::Empty
418    }
419}
420
421fn peer_rows(t: &Topology) -> Vec<PeerRow> {
422    let mut out: Vec<PeerRow> = Vec::new();
423    for (i, b) in t.bins.iter().enumerate() {
424        let bin = i as u8;
425        for p in &b.connected_peers {
426            out.push(make_peer_row(bin, p));
427        }
428    }
429    // Stable order: bin asc, then peer overlay asc — so the table
430    // doesn't shuffle every poll tick when populations don't change.
431    out.sort_by(|a, b| {
432        a.bin
433            .cmp(&b.bin)
434            .then_with(|| a.peer_short.cmp(&b.peer_short))
435    });
436    out
437}
438
439fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
440    let peer_short = short_overlay(&p.address);
441    let peer_full = p.address.trim_start_matches("0x").to_string();
442    let (direction, latency, healthy, reachability) = match &p.metrics {
443        Some(m) => {
444            let direction = match m.session_connection_direction.as_str() {
445                "inbound" => "in",
446                "outbound" => "out",
447                _ => "?",
448            };
449            let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
450            let latency = if m.latency_ewma > 0 {
451                format!("{latency_ms:.0}ms")
452            } else {
453                "—".into()
454            };
455            (direction, latency, m.healthy, m.reachability.clone())
456        }
457        None => ("?", "—".into(), false, String::new()),
458    };
459    PeerRow {
460        bin,
461        peer_short,
462        peer_full,
463        direction,
464        latency,
465        healthy,
466        reachability,
467    }
468}
469
470fn short_overlay(s: &str) -> String {
471    let trimmed = s.trim_start_matches("0x");
472    if trimmed.len() > 10 {
473        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
474    } else {
475        trimmed.to_string()
476    }
477}
478
479/// Format a PLUR `BigInt` as a signed `"BZZ X.XXXX"` string. Mirrors
480/// the helper in `swap.rs` — duplicated here to keep peers.rs free
481/// of cross-module coupling and avoid widening the public API.
482fn format_plur_signed(plur: &BigInt) -> String {
483    let zero = BigInt::from(0);
484    let neg = plur < &zero;
485    let abs = if neg { -plur.clone() } else { plur.clone() };
486    let scale = BigInt::from(10u64).pow(16);
487    let whole = &abs / &scale;
488    let frac = &abs % &scale;
489    let frac_4 = &frac / BigInt::from(10u64).pow(12);
490    let sign = if neg { "-" } else { "+" };
491    format!("{sign}BZZ {whole}.{frac_4:0>4}")
492}
493
494fn format_opt_plur(plur: Option<&BigInt>) -> String {
495    match plur {
496        Some(p) => format_plur_signed(p).trim_start_matches('+').to_string(),
497        None => "—".to_string(),
498    }
499}
500
501impl Component for Peers {
502    fn update(&mut self, action: Action) -> Result<Option<Action>> {
503        if matches!(action, Action::Tick) {
504            self.pull_latest();
505            self.drain_fetches();
506        }
507        Ok(None)
508    }
509
510    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
511        // Drill mode: Esc dismisses; everything else is ignored
512        // (drill is read-only). Selection keys still work in Idle so
513        // the user can navigate before hitting Enter.
514        if matches!(self.drill, DrillState::Loaded { .. } | DrillState::Loading { .. })
515            && matches!(key.code, KeyCode::Esc)
516        {
517            self.drill = DrillState::Idle;
518            return Ok(None);
519        }
520        match key.code {
521            KeyCode::Char('j') | KeyCode::Down => {
522                let n = self.peer_rows_cached().len();
523                if n > 0 && self.selected + 1 < n {
524                    self.selected += 1;
525                }
526            }
527            KeyCode::Char('k') | KeyCode::Up => {
528                self.selected = self.selected.saturating_sub(1);
529            }
530            KeyCode::Enter => {
531                self.maybe_start_drill();
532            }
533            _ => {}
534        }
535        Ok(None)
536    }
537
538    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
539        let chunks = Layout::vertical([
540            Constraint::Length(3), // header
541            Constraint::Length(20), // bin strip (32 lines + header)
542            Constraint::Min(0),    // peer table OR drill
543            Constraint::Length(1), // footer
544        ])
545        .split(area);
546
547        // Header
548        let mut header_l1 = vec![Span::styled(
549            "PEERS / TOPOLOGY",
550            Style::default().add_modifier(Modifier::BOLD),
551        )];
552        if let DrillState::Loaded { view } = &self.drill {
553            header_l1.push(Span::raw(format!("   · drill {}", short_overlay(&view.peer_overlay))));
554        } else if let DrillState::Loading { peer, .. } = &self.drill {
555            header_l1.push(Span::raw(format!("   · drill {} (loading)", short_overlay(peer))));
556        }
557        let header_l1 = Line::from(header_l1);
558        let mut header_l2 = Vec::new();
559        let t = theme::active();
560        if let Some(err) = &self.snapshot.last_error {
561            let (color, msg) = theme::classify_header_error(err);
562            header_l2.push(Span::styled(msg, Style::default().fg(color)));
563        } else if !self.snapshot.is_loaded() {
564            header_l2.push(Span::styled(
565                "loading…",
566                Style::default().fg(t.dim),
567            ));
568        }
569        frame.render_widget(
570            Paragraph::new(vec![header_l1, Line::from(header_l2)])
571                .block(Block::default().borders(Borders::BOTTOM)),
572            chunks[0],
573        );
574
575        let view = match Self::view_for(&self.snapshot) {
576            Some(v) => v,
577            None => {
578                frame.render_widget(
579                    Paragraph::new(Span::styled(
580                        "  topology not loaded yet",
581                        Style::default()
582                            .fg(t.dim)
583                            .add_modifier(Modifier::ITALIC),
584                    )),
585                    chunks[1],
586                );
587                return Ok(());
588            }
589        };
590
591        // Bin strip
592        let mut strip_lines: Vec<Line> = vec![
593            Line::from(vec![
594                Span::styled(
595                    format!(
596                        "  depth {} · connected {} / known {} · reachability {} · net {}",
597                        view.depth,
598                        view.connected,
599                        view.population,
600                        if view.reachability.is_empty() {
601                            "?".to_string()
602                        } else {
603                            view.reachability.clone()
604                        },
605                        if view.network_availability.is_empty() {
606                            "?".to_string()
607                        } else {
608                            view.network_availability.clone()
609                        },
610                    ),
611                    Style::default().fg(t.dim),
612                ),
613            ]),
614            Line::from(Span::styled(
615                "  BIN  POP  CONN  BAR              STATUS",
616                Style::default()
617                    .fg(t.dim)
618                    .add_modifier(Modifier::BOLD),
619            )),
620        ];
621        for r in &view.bins {
622            // Skip far empty bins past the relaxation window — too much
623            // noise to render all 32 when only the first ~12 matter.
624            if !r.is_relevant && r.population == 0 {
625                continue;
626            }
627            let bar = bin_bar(r.connected as usize, 12);
628            strip_lines.push(Line::from(vec![
629                Span::raw("  "),
630                Span::styled(
631                    format!("{:>3} ", r.bin),
632                    Style::default().add_modifier(Modifier::BOLD),
633                ),
634                Span::raw(format!("{:>4} ", r.population)),
635                Span::raw(format!("{:>4}  ", r.connected)),
636                Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
637                Span::raw(" "),
638                Span::styled(
639                    r.status.label(),
640                    Style::default()
641                        .fg(r.status.color())
642                        .add_modifier(Modifier::BOLD),
643                ),
644            ]));
645        }
646        if view.light_connected > 0 {
647            strip_lines.push(Line::from(vec![
648                Span::raw("  "),
649                Span::styled(
650                    format!(" light  —  {}    (separate from main bins)", view.light_connected),
651                    Style::default().fg(t.dim),
652                ),
653            ]));
654        }
655        frame.render_widget(
656            Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
657            chunks[1],
658        );
659
660        // Peer table OR drill
661        match &self.drill {
662            DrillState::Idle => self.draw_peer_table(frame, chunks[2], &view.peers),
663            DrillState::Loading { peer, .. } => {
664                let msg = Line::from(vec![
665                    Span::raw("  fetching peer drill for "),
666                    Span::styled(
667                        short_overlay(peer),
668                        Style::default().add_modifier(Modifier::BOLD),
669                    ),
670                    Span::raw("…   (Esc cancel)"),
671                ]);
672                frame.render_widget(Paragraph::new(msg), chunks[2]);
673            }
674            DrillState::Loaded { view: drill_view } => {
675                self.draw_peer_drill(frame, chunks[2], drill_view);
676            }
677        }
678
679        // Footer
680        let footer = match &self.drill {
681            DrillState::Idle => Line::from(vec![
682                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
683                Span::raw(" switch screen  "),
684                Span::styled(" ↑↓/jk ", Style::default().fg(Color::Black).bg(Color::White)),
685                Span::raw(" select  "),
686                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
687                Span::raw(" drill  "),
688                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
689                Span::raw(" quit  "),
690                Span::styled(
691                    format!("thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"),
692                    Style::default().fg(t.dim),
693                ),
694            ]),
695            _ => Line::from(vec![
696                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
697                Span::raw(" close drill  "),
698                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
699                Span::raw(" switch screen  "),
700                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
701                Span::raw(" quit "),
702            ]),
703        };
704        frame.render_widget(Paragraph::new(footer), chunks[3]);
705
706        Ok(())
707    }
708}
709
710impl Peers {
711    fn draw_peer_table(&mut self, frame: &mut Frame, area: Rect, peers: &[PeerRow]) {
712        use ratatui::layout::{Constraint, Layout};
713
714        let t = theme::active();
715
716        // Two-row split: pinned column header + scrollable body. The
717        // header doesn't scroll out from under the cursor, which is
718        // what operators expect after using k9s / lazygit.
719        let table_chunks = Layout::vertical([
720            Constraint::Length(1),
721            Constraint::Min(0),
722        ])
723        .split(area);
724
725        frame.render_widget(
726            Paragraph::new(Line::from(Span::styled(
727                "   BIN  PEER          DIR  LATENCY   HEALTHY  REACHABILITY",
728                Style::default()
729                    .fg(t.dim)
730                    .add_modifier(Modifier::BOLD),
731            ))),
732            table_chunks[0],
733        );
734
735        if peers.is_empty() {
736            frame.render_widget(
737                Paragraph::new(Line::from(Span::styled(
738                    "   (no connected peers reported)",
739                    Style::default()
740                        .fg(t.dim)
741                        .add_modifier(Modifier::ITALIC),
742                ))),
743                table_chunks[1],
744            );
745            return;
746        }
747
748        let mut peer_lines: Vec<Line> = Vec::with_capacity(peers.len());
749        for (i, p) in peers.iter().enumerate() {
750            let g = theme::active().glyphs;
751            let healthy_glyph = if p.healthy { g.pass } else { g.fail };
752            let healthy_style = if p.healthy {
753                Style::default().fg(t.pass)
754            } else {
755                Style::default().fg(t.fail)
756            };
757            let cursor = if i == self.selected {
758                format!("{} ", t.glyphs.cursor)
759            } else {
760                "  ".to_string()
761            };
762            peer_lines.push(Line::from(vec![
763                Span::styled(
764                    cursor,
765                    Style::default()
766                        .fg(if i == self.selected { t.accent } else { t.dim })
767                        .add_modifier(Modifier::BOLD),
768                ),
769                Span::raw(format!("{:>3}  ", p.bin)),
770                Span::raw(format!("{:<13} ", p.peer_short)),
771                Span::raw(format!("{:<4} ", p.direction)),
772                Span::raw(format!("{:<8}  ", p.latency)),
773                Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
774                Span::raw(p.reachability.clone()),
775            ]));
776        }
777
778        let body = table_chunks[1];
779        let visible_rows = body.height as usize;
780        self.scroll_offset = super::scroll::clamp_scroll(
781            self.selected,
782            self.scroll_offset,
783            visible_rows,
784            peer_lines.len(),
785        );
786        frame.render_widget(
787            Paragraph::new(peer_lines.clone()).scroll((self.scroll_offset as u16, 0)),
788            body,
789        );
790        super::scroll::render_scrollbar(
791            frame,
792            body,
793            self.scroll_offset,
794            visible_rows,
795            peer_lines.len(),
796        );
797    }
798
799    fn draw_peer_drill(&self, frame: &mut Frame, area: Rect, view: &PeerDrillView) {
800        let t = theme::active();
801        let mut lines: Vec<Line> = Vec::new();
802        let bin_label = view
803            .bin
804            .map(|b| format!("bin {b}"))
805            .unwrap_or_else(|| "bin ?".into());
806        lines.push(Line::from(vec![
807            Span::raw("  peer "),
808            Span::styled(
809                view.peer_overlay.clone(),
810                Style::default().add_modifier(Modifier::BOLD),
811            ),
812            Span::raw("   "),
813            Span::styled(bin_label, Style::default().fg(t.dim)),
814        ]));
815        lines.push(Line::from(""));
816        lines.push(drill_field_line("balance        ", &view.balance, t));
817        lines.push(drill_field_line("ping rtt       ", &view.ping, t));
818        lines.push(drill_field_line(
819            "settle recv    ",
820            &view.settlement_received,
821            t,
822        ));
823        lines.push(drill_field_line(
824            "settle sent    ",
825            &view.settlement_sent,
826            t,
827        ));
828        lines.push(drill_field_optional_line(
829            "cheque last in ",
830            &view.last_received_cheque,
831            t,
832        ));
833        lines.push(drill_field_optional_line(
834            "cheque last out",
835            &view.last_sent_cheque,
836            t,
837        ));
838        lines.push(Line::from(""));
839        lines.push(Line::from(Span::styled(
840            "  (Esc to dismiss · figures are point-in-time, not live-updating)",
841            Style::default()
842                .fg(t.dim)
843                .add_modifier(Modifier::ITALIC),
844        )));
845        frame.render_widget(Paragraph::new(lines), area);
846    }
847}
848
849fn drill_field_line(label: &str, field: &DrillField<String>, t: &theme::Theme) -> Line<'static> {
850    match field {
851        DrillField::Ok(v) => Line::from(vec![
852            Span::raw("  "),
853            Span::styled(label.to_string(), Style::default().fg(t.dim)),
854            Span::raw("  "),
855            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
856        ]),
857        DrillField::Err(e) => Line::from(vec![
858            Span::raw("  "),
859            Span::styled(label.to_string(), Style::default().fg(t.dim)),
860            Span::raw("  "),
861            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
862        ]),
863    }
864}
865
866fn drill_field_optional_line(
867    label: &str,
868    field: &DrillField<Option<String>>,
869    t: &theme::Theme,
870) -> Line<'static> {
871    match field {
872        DrillField::Ok(Some(v)) => Line::from(vec![
873            Span::raw("  "),
874            Span::styled(label.to_string(), Style::default().fg(t.dim)),
875            Span::raw("  "),
876            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
877        ]),
878        DrillField::Ok(None) => Line::from(vec![
879            Span::raw("  "),
880            Span::styled(label.to_string(), Style::default().fg(t.dim)),
881            Span::raw("  "),
882            Span::styled(
883                "(no cheque yet)",
884                Style::default()
885                    .fg(t.dim)
886                    .add_modifier(Modifier::ITALIC),
887            ),
888        ]),
889        DrillField::Err(e) => Line::from(vec![
890            Span::raw("  "),
891            Span::styled(label.to_string(), Style::default().fg(t.dim)),
892            Span::raw("  "),
893            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
894        ]),
895    }
896}
897
898/// Width-bounded ASCII bar showing connected count, capped at
899/// [`OVER_SATURATION_PEERS`] for visual scale.
900fn bin_bar(connected: usize, width: usize) -> String {
901    let scale = OVER_SATURATION_PEERS as usize;
902    let filled = connected.min(scale) * width / scale.max(1);
903    let mut bar = String::with_capacity(width);
904    for _ in 0..filled.min(width) {
905        bar.push('▇');
906    }
907    for _ in filled.min(width)..width {
908        bar.push('░');
909    }
910    bar
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    fn bin(population: u64, connected: u64) -> BinInfo {
918        BinInfo {
919            population,
920            connected,
921            ..BinInfo::default()
922        }
923    }
924
925    #[test]
926    fn classify_below_saturation_in_relevant_bin_is_starving() {
927        // bin 4, depth 8 → bin <= depth + 4 = 12 → relevant → starving.
928        assert_eq!(
929            classify_bin(&bin(5, 3), 4, 8),
930            BinSaturation::Starving
931        );
932    }
933
934    #[test]
935    fn classify_below_saturation_in_far_bin_is_empty() {
936        // bin 20, depth 8 → bin > depth + 4 = 12 → far → empty.
937        assert_eq!(
938            classify_bin(&bin(0, 0), 20, 8),
939            BinSaturation::Empty
940        );
941    }
942
943    #[test]
944    fn classify_in_safe_band_is_healthy() {
945        assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
946        assert_eq!(
947            classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
948            BinSaturation::Healthy
949        );
950    }
951
952    #[test]
953    fn classify_over_threshold_is_over() {
954        assert_eq!(
955            classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
956            BinSaturation::Over
957        );
958    }
959
960    #[test]
961    fn short_overlay_truncates() {
962        let s = short_overlay("0xabcdef0123456789abcdef0123456789");
963        assert!(s.contains('…'));
964        assert!(s.starts_with("abcdef"));
965    }
966
967    #[test]
968    fn bin_bar_caps_at_oversaturation() {
969        let bar_full = bin_bar(50, 12);
970        assert_eq!(bar_full, "▇".repeat(12));
971        let bar_empty = bin_bar(0, 12);
972        assert_eq!(bar_empty, "░".repeat(12));
973    }
974
975    #[test]
976    fn drill_view_all_ok() {
977        // 5 × 10^14 PLUR = 0.05 BZZ — typical cheque magnitude.
978        let cheque_in = bee::debug::Cheque {
979            beneficiary: "0x".into(),
980            chequebook: "0x".into(),
981            payout: Some(BigInt::from(500_000_000_000_000u64)),
982        };
983        let fetch = PeerDrillFetch {
984            balance: Ok(Balance {
985                peer: "abcd".into(),
986                balance: BigInt::from(123_400_000_000_000_000i64),
987            }),
988            cheques: Ok(PeerCheques {
989                peer: "abcd".into(),
990                last_received: Some(cheque_in),
991                last_sent: None,
992            }),
993            settlement: Ok(Settlement {
994                peer: "abcd".into(),
995                received: Some(BigInt::from(900_000_000_000_000_000u64)),
996                sent: Some(BigInt::from(100_000_000_000_000_000u64)),
997            }),
998            ping: Ok("4.21ms".into()),
999        };
1000        let view = Peers::compute_peer_drill_view("abcd1234", Some(7), &fetch);
1001        assert_eq!(view.bin, Some(7));
1002        match &view.balance {
1003            DrillField::Ok(s) => assert!(s.contains("BZZ")),
1004            _ => panic!("expected ok balance"),
1005        }
1006        match &view.last_received_cheque {
1007            DrillField::Ok(Some(s)) => assert!(s.contains("0.0500")),
1008            _ => panic!("expected received cheque payout"),
1009        }
1010        match &view.last_sent_cheque {
1011            DrillField::Ok(None) => {}
1012            _ => panic!("expected None for sent cheque"),
1013        }
1014    }
1015
1016    #[test]
1017    fn drill_view_partial_failure_keeps_other_fields() {
1018        // Settlement endpoint failed, the rest succeeded — drill view
1019        // must still surface what came back rather than blanking.
1020        let fetch = PeerDrillFetch {
1021            balance: Ok(Balance {
1022                peer: "x".into(),
1023                balance: BigInt::from(0),
1024            }),
1025            cheques: Err("404".into()),
1026            settlement: Err("503 Node is syncing".into()),
1027            ping: Ok("12ms".into()),
1028        };
1029        let view = Peers::compute_peer_drill_view("xxxx", None, &fetch);
1030        assert!(matches!(view.balance, DrillField::Ok(_)));
1031        assert!(matches!(view.ping, DrillField::Ok(_)));
1032        assert!(matches!(view.settlement_received, DrillField::Err(_)));
1033        assert!(matches!(view.last_received_cheque, DrillField::Err(_)));
1034    }
1035
1036    #[test]
1037    fn format_plur_signed_handles_zero_and_negative() {
1038        assert_eq!(format_plur_signed(&BigInt::from(0)), "+BZZ 0.0000");
1039        assert_eq!(
1040            format_plur_signed(&BigInt::from(-5_000_000_000_000_000i64)),
1041            "-BZZ 0.5000"
1042        );
1043    }
1044}