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 { peer: String, bin: Option<u8> },
196    Loaded { view: PeerDrillView },
197}
198
199type DrillFetchResult = (String, PeerDrillFetch);
200
201pub struct Peers {
202    client: Arc<ApiClient>,
203    rx: watch::Receiver<TopologySnapshot>,
204    snapshot: TopologySnapshot,
205    selected: usize,
206    /// Top row of the visible window in the peer table. Drives both
207    /// the `Paragraph::scroll` offset and the right-edge scrollbar
208    /// state. Updated lazily inside `draw_peer_table` to match the
209    /// area height we get at render time.
210    scroll_offset: usize,
211    drill: DrillState,
212    fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
213    fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
214}
215
216impl Peers {
217    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<TopologySnapshot>) -> Self {
218        let snapshot = rx.borrow().clone();
219        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
220        Self {
221            client,
222            rx,
223            snapshot,
224            selected: 0,
225            scroll_offset: 0,
226            drill: DrillState::Idle,
227            fetch_tx,
228            fetch_rx,
229        }
230    }
231
232    fn pull_latest(&mut self) {
233        self.snapshot = self.rx.borrow().clone();
234        let n = self.peer_rows_cached().len();
235        if n == 0 {
236            self.selected = 0;
237        } else if self.selected >= n {
238            self.selected = n - 1;
239        }
240    }
241
242    fn peer_rows_cached(&self) -> Vec<PeerRow> {
243        Self::view_for(&self.snapshot)
244            .map(|v| v.peers)
245            .unwrap_or_default()
246    }
247
248    /// Drain any drill fetches that completed since the last tick.
249    /// Late results from a since-cancelled drill (operator hit Esc
250    /// then Enter on a different peer before all four endpoints
251    /// came back) are dropped silently — `drill` already moved on.
252    fn drain_fetches(&mut self) {
253        while let Ok((peer, fetch)) = self.fetch_rx.try_recv() {
254            let pending_peer = match &self.drill {
255                DrillState::Loading { peer: p, .. } => p.clone(),
256                _ => continue, // user moved on; ignore
257            };
258            if pending_peer != peer {
259                continue;
260            }
261            let bin = match &self.drill {
262                DrillState::Loading { bin, .. } => *bin,
263                _ => None,
264            };
265            let view = Self::compute_peer_drill_view(&peer, bin, &fetch);
266            self.drill = DrillState::Loaded { view };
267        }
268    }
269
270    /// Pure, snapshot-driven view computation. Exposed for snapshot
271    /// tests in `tests/s6_peers_view.rs`.
272    pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
273        let t = snap.topology.as_ref()?;
274        let bins = bin_strip_rows(t);
275        let peers = peer_rows(t);
276        Some(PeersView {
277            bins,
278            peers,
279            depth: t.depth,
280            population: t.population,
281            connected: t.connected,
282            reachability: t.reachability.clone(),
283            network_availability: t.network_availability.clone(),
284            light_connected: t.light_nodes.connected,
285        })
286    }
287
288    /// Pure compute path for the per-peer drill pane. Each field is
289    /// derived independently so a single failed endpoint doesn't
290    /// blank the view — operators still see whatever did come back.
291    pub fn compute_peer_drill_view(
292        peer: &str,
293        bin: Option<u8>,
294        fetch: &PeerDrillFetch,
295    ) -> PeerDrillView {
296        let balance = match &fetch.balance {
297            Ok(b) => DrillField::Ok(format_plur_signed(&b.balance)),
298            Err(e) => DrillField::Err(e.clone()),
299        };
300        let ping = match &fetch.ping {
301            Ok(s) => DrillField::Ok(s.clone()),
302            Err(e) => DrillField::Err(e.clone()),
303        };
304        let (settlement_received, settlement_sent) = match &fetch.settlement {
305            Ok(s) => (
306                DrillField::Ok(format_opt_plur(s.received.as_ref())),
307                DrillField::Ok(format_opt_plur(s.sent.as_ref())),
308            ),
309            Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
310        };
311        let (last_received_cheque, last_sent_cheque) = match &fetch.cheques {
312            Ok(c) => (
313                DrillField::Ok(
314                    c.last_received
315                        .as_ref()
316                        .map(|q| format_opt_plur(q.payout.as_ref())),
317                ),
318                DrillField::Ok(
319                    c.last_sent
320                        .as_ref()
321                        .map(|q| format_opt_plur(q.payout.as_ref())),
322                ),
323            ),
324            Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
325        };
326        PeerDrillView {
327            peer_overlay: peer.to_string(),
328            bin,
329            balance,
330            ping,
331            settlement_received,
332            settlement_sent,
333            last_received_cheque,
334            last_sent_cheque,
335        }
336    }
337
338    /// Spawn parallel fetches of the four per-peer endpoints. No-op
339    /// if the peer table is empty or a fetch is already in flight
340    /// for the same peer.
341    fn maybe_start_drill(&mut self) {
342        let peers = self.peer_rows_cached();
343        if peers.is_empty() {
344            return;
345        }
346        let i = self.selected.min(peers.len() - 1);
347        let row = &peers[i];
348        let peer = row.peer_full.clone();
349        let bin = Some(row.bin);
350        if let DrillState::Loading { peer: pending, .. } = &self.drill {
351            if *pending == peer {
352                return;
353            }
354        }
355        let client = self.client.clone();
356        let tx = self.fetch_tx.clone();
357        let peer_for_task = peer.clone();
358        tokio::spawn(async move {
359            let bee = client.bee();
360            let debug = bee.debug();
361            // Fan out all four requests in parallel — they're all
362            // small and independent. tokio::join! returns once every
363            // future has resolved (even if some errored), so the
364            // operator sees the full picture in one update rather
365            // than four flickers.
366            let (balance, cheques, settlement, ping) = tokio::join!(
367                debug.peer_balance(&peer_for_task),
368                debug.peer_cheques(&peer_for_task),
369                debug.peer_settlement(&peer_for_task),
370                debug.ping_peer(&peer_for_task),
371            );
372            let fetch = PeerDrillFetch {
373                balance: balance.map_err(|e| e.to_string()),
374                cheques: cheques.map_err(|e| e.to_string()),
375                settlement: settlement.map_err(|e| e.to_string()),
376                ping: ping.map_err(|e| e.to_string()),
377            };
378            let _ = tx.send((peer_for_task, fetch));
379        });
380        self.drill = DrillState::Loading { peer, bin };
381    }
382}
383
384fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
385    t.bins
386        .iter()
387        .enumerate()
388        .map(|(i, b)| {
389            let bin = i as u8;
390            let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
391            BinStripRow {
392                bin,
393                population: b.population,
394                connected: b.connected,
395                status: classify_bin(b, bin, t.depth),
396                is_relevant,
397            }
398        })
399        .collect()
400}
401
402fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
403    if b.connected > OVER_SATURATION_PEERS {
404        return BinSaturation::Over;
405    }
406    if b.connected >= SATURATION_PEERS {
407        return BinSaturation::Healthy;
408    }
409    // Below saturation: starving only if this bin is at or near the
410    // current depth. Far bins are expected to be sparse.
411    if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
412        BinSaturation::Starving
413    } else {
414        BinSaturation::Empty
415    }
416}
417
418fn peer_rows(t: &Topology) -> Vec<PeerRow> {
419    let mut out: Vec<PeerRow> = Vec::new();
420    for (i, b) in t.bins.iter().enumerate() {
421        let bin = i as u8;
422        for p in &b.connected_peers {
423            out.push(make_peer_row(bin, p));
424        }
425    }
426    // Stable order: bin asc, then peer overlay asc — so the table
427    // doesn't shuffle every poll tick when populations don't change.
428    out.sort_by(|a, b| {
429        a.bin
430            .cmp(&b.bin)
431            .then_with(|| a.peer_short.cmp(&b.peer_short))
432    });
433    out
434}
435
436fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
437    let peer_short = short_overlay(&p.address);
438    let peer_full = p.address.trim_start_matches("0x").to_string();
439    let (direction, latency, healthy, reachability) = match &p.metrics {
440        Some(m) => {
441            let direction = match m.session_connection_direction.as_str() {
442                "inbound" => "in",
443                "outbound" => "out",
444                _ => "?",
445            };
446            let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
447            let latency = if m.latency_ewma > 0 {
448                format!("{latency_ms:.0}ms")
449            } else {
450                "—".into()
451            };
452            (direction, latency, m.healthy, m.reachability.clone())
453        }
454        None => ("?", "—".into(), false, String::new()),
455    };
456    PeerRow {
457        bin,
458        peer_short,
459        peer_full,
460        direction,
461        latency,
462        healthy,
463        reachability,
464    }
465}
466
467fn short_overlay(s: &str) -> String {
468    let trimmed = s.trim_start_matches("0x");
469    if trimmed.len() > 10 {
470        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
471    } else {
472        trimmed.to_string()
473    }
474}
475
476/// Format a PLUR `BigInt` as a signed `"BZZ X.XXXX"` string. Mirrors
477/// the helper in `swap.rs` — duplicated here to keep peers.rs free
478/// of cross-module coupling and avoid widening the public API.
479fn format_plur_signed(plur: &BigInt) -> String {
480    let zero = BigInt::from(0);
481    let neg = plur < &zero;
482    let abs = if neg { -plur.clone() } else { plur.clone() };
483    let scale = BigInt::from(10u64).pow(16);
484    let whole = &abs / &scale;
485    let frac = &abs % &scale;
486    let frac_4 = &frac / BigInt::from(10u64).pow(12);
487    let sign = if neg { "-" } else { "+" };
488    format!("{sign}BZZ {whole}.{frac_4:0>4}")
489}
490
491fn format_opt_plur(plur: Option<&BigInt>) -> String {
492    match plur {
493        Some(p) => format_plur_signed(p).trim_start_matches('+').to_string(),
494        None => "—".to_string(),
495    }
496}
497
498impl Component for Peers {
499    fn update(&mut self, action: Action) -> Result<Option<Action>> {
500        if matches!(action, Action::Tick) {
501            self.pull_latest();
502            self.drain_fetches();
503        }
504        Ok(None)
505    }
506
507    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
508        // Drill mode: Esc dismisses; everything else is ignored
509        // (drill is read-only). Selection keys still work in Idle so
510        // the user can navigate before hitting Enter.
511        if matches!(
512            self.drill,
513            DrillState::Loaded { .. } | DrillState::Loading { .. }
514        ) && matches!(key.code, KeyCode::Esc)
515        {
516            self.drill = DrillState::Idle;
517            return Ok(None);
518        }
519        match key.code {
520            KeyCode::Char('j') | KeyCode::Down => {
521                let n = self.peer_rows_cached().len();
522                if n > 0 && self.selected + 1 < n {
523                    self.selected += 1;
524                }
525            }
526            KeyCode::Char('k') | KeyCode::Up => {
527                self.selected = self.selected.saturating_sub(1);
528            }
529            KeyCode::Enter => {
530                self.maybe_start_drill();
531            }
532            _ => {}
533        }
534        Ok(None)
535    }
536
537    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
538        let chunks = Layout::vertical([
539            Constraint::Length(3),  // header
540            Constraint::Length(20), // bin strip (32 lines + header)
541            Constraint::Min(0),     // peer table OR drill
542            Constraint::Length(1),  // footer
543        ])
544        .split(area);
545
546        // Header
547        let mut header_l1 = vec![Span::styled(
548            "PEERS / TOPOLOGY",
549            Style::default().add_modifier(Modifier::BOLD),
550        )];
551        if let DrillState::Loaded { view } = &self.drill {
552            header_l1.push(Span::raw(format!(
553                "   · drill {}",
554                short_overlay(&view.peer_overlay)
555            )));
556        } else if let DrillState::Loading { peer, .. } = &self.drill {
557            header_l1.push(Span::raw(format!(
558                "   · drill {} (loading)",
559                short_overlay(peer)
560            )));
561        }
562        let header_l1 = Line::from(header_l1);
563        let mut header_l2 = Vec::new();
564        let t = theme::active();
565        if let Some(err) = &self.snapshot.last_error {
566            let (color, msg) = theme::classify_header_error(err);
567            header_l2.push(Span::styled(msg, Style::default().fg(color)));
568        } else if !self.snapshot.is_loaded() {
569            header_l2.push(Span::styled(
570                format!("{} loading…", theme::spinner_glyph()),
571                Style::default().fg(t.dim),
572            ));
573        }
574        frame.render_widget(
575            Paragraph::new(vec![header_l1, Line::from(header_l2)])
576                .block(Block::default().borders(Borders::BOTTOM)),
577            chunks[0],
578        );
579
580        let view = match Self::view_for(&self.snapshot) {
581            Some(v) => v,
582            None => {
583                frame.render_widget(
584                    Paragraph::new(Span::styled(
585                        "  topology not loaded yet",
586                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
587                    )),
588                    chunks[1],
589                );
590                return Ok(());
591            }
592        };
593
594        // Bin strip
595        let mut strip_lines: Vec<Line> = vec![
596            Line::from(vec![Span::styled(
597                format!(
598                    "  depth {} · connected {} / known {} · reachability {} · net {}",
599                    view.depth,
600                    view.connected,
601                    view.population,
602                    if view.reachability.is_empty() {
603                        "?".to_string()
604                    } else {
605                        view.reachability.clone()
606                    },
607                    if view.network_availability.is_empty() {
608                        "?".to_string()
609                    } else {
610                        view.network_availability.clone()
611                    },
612                ),
613                Style::default().fg(t.dim),
614            )]),
615            Line::from(Span::styled(
616                "  BIN  POP  CONN  BAR              STATUS",
617                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
618            )),
619        ];
620        for r in &view.bins {
621            // Skip far empty bins past the relaxation window — too much
622            // noise to render all 32 when only the first ~12 matter.
623            if !r.is_relevant && r.population == 0 {
624                continue;
625            }
626            let bar = bin_bar(r.connected as usize, 12);
627            strip_lines.push(Line::from(vec![
628                Span::raw("  "),
629                Span::styled(
630                    format!("{:>3} ", r.bin),
631                    Style::default().add_modifier(Modifier::BOLD),
632                ),
633                Span::raw(format!("{:>4} ", r.population)),
634                Span::raw(format!("{:>4}  ", r.connected)),
635                Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
636                Span::raw(" "),
637                Span::styled(
638                    r.status.label(),
639                    Style::default()
640                        .fg(r.status.color())
641                        .add_modifier(Modifier::BOLD),
642                ),
643            ]));
644        }
645        if view.light_connected > 0 {
646            strip_lines.push(Line::from(vec![
647                Span::raw("  "),
648                Span::styled(
649                    format!(
650                        " light  —  {}    (separate from main bins)",
651                        view.light_connected
652                    ),
653                    Style::default().fg(t.dim),
654                ),
655            ]));
656        }
657        frame.render_widget(
658            Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
659            chunks[1],
660        );
661
662        // Peer table OR drill
663        match &self.drill {
664            DrillState::Idle => self.draw_peer_table(frame, chunks[2], &view.peers),
665            DrillState::Loading { peer, .. } => {
666                let msg = Line::from(vec![
667                    Span::raw("  fetching peer drill for "),
668                    Span::styled(
669                        short_overlay(peer),
670                        Style::default().add_modifier(Modifier::BOLD),
671                    ),
672                    Span::raw("…   (Esc cancel)"),
673                ]);
674                frame.render_widget(Paragraph::new(msg), chunks[2]);
675            }
676            DrillState::Loaded { view: drill_view } => {
677                self.draw_peer_drill(frame, chunks[2], drill_view);
678            }
679        }
680
681        // Footer
682        let footer = match &self.drill {
683            DrillState::Idle => Line::from(vec![
684                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
685                Span::raw(" switch screen  "),
686                Span::styled(
687                    " ↑↓/jk ",
688                    Style::default().fg(Color::Black).bg(Color::White),
689                ),
690                Span::raw(" select  "),
691                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
692                Span::raw(" drill  "),
693                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
694                Span::raw(" help  "),
695                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
696                Span::raw(" quit  "),
697                Span::styled(
698                    format!(
699                        "thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"
700                    ),
701                    Style::default().fg(t.dim),
702                ),
703            ]),
704            _ => Line::from(vec![
705                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
706                Span::raw(" close drill  "),
707                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
708                Span::raw(" switch screen  "),
709                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
710                Span::raw(" help  "),
711                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
712                Span::raw(" quit "),
713            ]),
714        };
715        frame.render_widget(Paragraph::new(footer), chunks[3]);
716
717        Ok(())
718    }
719}
720
721impl Peers {
722    fn draw_peer_table(&mut self, frame: &mut Frame, area: Rect, peers: &[PeerRow]) {
723        use ratatui::layout::{Constraint, Layout};
724
725        let t = theme::active();
726
727        // Two-row split: pinned column header + scrollable body. The
728        // header doesn't scroll out from under the cursor, which is
729        // what operators expect after using k9s / lazygit.
730        let table_chunks =
731            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
732
733        frame.render_widget(
734            Paragraph::new(Line::from(Span::styled(
735                "   BIN  PEER          DIR  LATENCY   HEALTHY  REACHABILITY",
736                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
737            ))),
738            table_chunks[0],
739        );
740
741        if peers.is_empty() {
742            frame.render_widget(
743                Paragraph::new(Line::from(Span::styled(
744                    "   (no connected peers reported)",
745                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
746                ))),
747                table_chunks[1],
748            );
749            return;
750        }
751
752        let mut peer_lines: Vec<Line> = Vec::with_capacity(peers.len());
753        for (i, p) in peers.iter().enumerate() {
754            let g = theme::active().glyphs;
755            let healthy_glyph = if p.healthy { g.pass } else { g.fail };
756            let healthy_style = if p.healthy {
757                Style::default().fg(t.pass)
758            } else {
759                Style::default().fg(t.fail)
760            };
761            let cursor = if i == self.selected {
762                format!("{} ", t.glyphs.cursor)
763            } else {
764                "  ".to_string()
765            };
766            peer_lines.push(Line::from(vec![
767                Span::styled(
768                    cursor,
769                    Style::default()
770                        .fg(if i == self.selected { t.accent } else { t.dim })
771                        .add_modifier(Modifier::BOLD),
772                ),
773                Span::raw(format!("{:>3}  ", p.bin)),
774                Span::raw(format!("{:<13} ", p.peer_short)),
775                Span::raw(format!("{:<4} ", p.direction)),
776                Span::raw(format!("{:<8}  ", p.latency)),
777                Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
778                Span::raw(p.reachability.clone()),
779            ]));
780        }
781
782        let body = table_chunks[1];
783        let visible_rows = body.height as usize;
784        self.scroll_offset = super::scroll::clamp_scroll(
785            self.selected,
786            self.scroll_offset,
787            visible_rows,
788            peer_lines.len(),
789        );
790        frame.render_widget(
791            Paragraph::new(peer_lines.clone()).scroll((self.scroll_offset as u16, 0)),
792            body,
793        );
794        super::scroll::render_scrollbar(
795            frame,
796            body,
797            self.scroll_offset,
798            visible_rows,
799            peer_lines.len(),
800        );
801    }
802
803    fn draw_peer_drill(&self, frame: &mut Frame, area: Rect, view: &PeerDrillView) {
804        let t = theme::active();
805        let mut lines: Vec<Line> = Vec::new();
806        let bin_label = view
807            .bin
808            .map(|b| format!("bin {b}"))
809            .unwrap_or_else(|| "bin ?".into());
810        lines.push(Line::from(vec![
811            Span::raw("  peer "),
812            Span::styled(
813                view.peer_overlay.clone(),
814                Style::default().add_modifier(Modifier::BOLD),
815            ),
816            Span::raw("   "),
817            Span::styled(bin_label, Style::default().fg(t.dim)),
818        ]));
819        lines.push(Line::from(""));
820        lines.push(drill_field_line("balance        ", &view.balance, t));
821        lines.push(drill_field_line("ping rtt       ", &view.ping, t));
822        lines.push(drill_field_line(
823            "settle recv    ",
824            &view.settlement_received,
825            t,
826        ));
827        lines.push(drill_field_line(
828            "settle sent    ",
829            &view.settlement_sent,
830            t,
831        ));
832        lines.push(drill_field_optional_line(
833            "cheque last in ",
834            &view.last_received_cheque,
835            t,
836        ));
837        lines.push(drill_field_optional_line(
838            "cheque last out",
839            &view.last_sent_cheque,
840            t,
841        ));
842        lines.push(Line::from(""));
843        lines.push(Line::from(Span::styled(
844            "  (Esc to dismiss · figures are point-in-time, not live-updating)",
845            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
846        )));
847        frame.render_widget(Paragraph::new(lines), area);
848    }
849}
850
851fn drill_field_line(label: &str, field: &DrillField<String>, t: &theme::Theme) -> Line<'static> {
852    match field {
853        DrillField::Ok(v) => Line::from(vec![
854            Span::raw("  "),
855            Span::styled(label.to_string(), Style::default().fg(t.dim)),
856            Span::raw("  "),
857            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
858        ]),
859        DrillField::Err(e) => Line::from(vec![
860            Span::raw("  "),
861            Span::styled(label.to_string(), Style::default().fg(t.dim)),
862            Span::raw("  "),
863            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
864        ]),
865    }
866}
867
868fn drill_field_optional_line(
869    label: &str,
870    field: &DrillField<Option<String>>,
871    t: &theme::Theme,
872) -> Line<'static> {
873    match field {
874        DrillField::Ok(Some(v)) => Line::from(vec![
875            Span::raw("  "),
876            Span::styled(label.to_string(), Style::default().fg(t.dim)),
877            Span::raw("  "),
878            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
879        ]),
880        DrillField::Ok(None) => Line::from(vec![
881            Span::raw("  "),
882            Span::styled(label.to_string(), Style::default().fg(t.dim)),
883            Span::raw("  "),
884            Span::styled(
885                "(no cheque yet)",
886                Style::default().fg(t.dim).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!(classify_bin(&bin(5, 3), 4, 8), BinSaturation::Starving);
929    }
930
931    #[test]
932    fn classify_below_saturation_in_far_bin_is_empty() {
933        // bin 20, depth 8 → bin > depth + 4 = 12 → far → empty.
934        assert_eq!(classify_bin(&bin(0, 0), 20, 8), BinSaturation::Empty);
935    }
936
937    #[test]
938    fn classify_in_safe_band_is_healthy() {
939        assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
940        assert_eq!(
941            classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
942            BinSaturation::Healthy
943        );
944    }
945
946    #[test]
947    fn classify_over_threshold_is_over() {
948        assert_eq!(
949            classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
950            BinSaturation::Over
951        );
952    }
953
954    #[test]
955    fn short_overlay_truncates() {
956        let s = short_overlay("0xabcdef0123456789abcdef0123456789");
957        assert!(s.contains('…'));
958        assert!(s.starts_with("abcdef"));
959    }
960
961    #[test]
962    fn bin_bar_caps_at_oversaturation() {
963        let bar_full = bin_bar(50, 12);
964        assert_eq!(bar_full, "▇".repeat(12));
965        let bar_empty = bin_bar(0, 12);
966        assert_eq!(bar_empty, "░".repeat(12));
967    }
968
969    #[test]
970    fn drill_view_all_ok() {
971        // 5 × 10^14 PLUR = 0.05 BZZ — typical cheque magnitude.
972        let cheque_in = bee::debug::Cheque {
973            beneficiary: "0x".into(),
974            chequebook: "0x".into(),
975            payout: Some(BigInt::from(500_000_000_000_000u64)),
976        };
977        let fetch = PeerDrillFetch {
978            balance: Ok(Balance {
979                peer: "abcd".into(),
980                balance: BigInt::from(123_400_000_000_000_000i64),
981            }),
982            cheques: Ok(PeerCheques {
983                peer: "abcd".into(),
984                last_received: Some(cheque_in),
985                last_sent: None,
986            }),
987            settlement: Ok(Settlement {
988                peer: "abcd".into(),
989                received: Some(BigInt::from(900_000_000_000_000_000u64)),
990                sent: Some(BigInt::from(100_000_000_000_000_000u64)),
991            }),
992            ping: Ok("4.21ms".into()),
993        };
994        let view = Peers::compute_peer_drill_view("abcd1234", Some(7), &fetch);
995        assert_eq!(view.bin, Some(7));
996        match &view.balance {
997            DrillField::Ok(s) => assert!(s.contains("BZZ")),
998            _ => panic!("expected ok balance"),
999        }
1000        match &view.last_received_cheque {
1001            DrillField::Ok(Some(s)) => assert!(s.contains("0.0500")),
1002            _ => panic!("expected received cheque payout"),
1003        }
1004        match &view.last_sent_cheque {
1005            DrillField::Ok(None) => {}
1006            _ => panic!("expected None for sent cheque"),
1007        }
1008    }
1009
1010    #[test]
1011    fn drill_view_partial_failure_keeps_other_fields() {
1012        // Settlement endpoint failed, the rest succeeded — drill view
1013        // must still surface what came back rather than blanking.
1014        let fetch = PeerDrillFetch {
1015            balance: Ok(Balance {
1016                peer: "x".into(),
1017                balance: BigInt::from(0),
1018            }),
1019            cheques: Err("404".into()),
1020            settlement: Err("503 Node is syncing".into()),
1021            ping: Ok("12ms".into()),
1022        };
1023        let view = Peers::compute_peer_drill_view("xxxx", None, &fetch);
1024        assert!(matches!(view.balance, DrillField::Ok(_)));
1025        assert!(matches!(view.ping, DrillField::Ok(_)));
1026        assert!(matches!(view.settlement_received, DrillField::Err(_)));
1027        assert!(matches!(view.last_received_cheque, DrillField::Err(_)));
1028    }
1029
1030    #[test]
1031    fn format_plur_signed_handles_zero_and_negative() {
1032        assert_eq!(format_plur_signed(&BigInt::from(0)), "+BZZ 0.0000");
1033        assert_eq!(
1034            format_plur_signed(&BigInt::from(-5_000_000_000_000_000i64)),
1035            "-BZZ 0.5000"
1036        );
1037    }
1038}