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/// Synthesised saturation rollup. Surfaces the operator-relevant
134/// alert state in one line so a healthy node looks healthy and a
135/// node with a starving bin doesn't require the operator to scan
136/// every row of the bin strip to find it.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub struct SaturationSummary {
139    /// Number of *relevant* bins (bin ≤ depth) flagged as Starving.
140    /// Far bins with low connectivity are normal and excluded.
141    pub starving: usize,
142    /// Number of bins flagged as Over (connected > 18).
143    pub over: usize,
144    /// Total relevant bins considered. Denominator for the
145    /// `starving / relevant` ratio.
146    pub relevant: usize,
147    /// The worst (lowest-connected) starving bin, if any. Lets the
148    /// header surface "bin 5 (2/8)" without forcing operators to
149    /// hunt for it.
150    pub worst_bin: Option<u8>,
151    /// `connected` count of `worst_bin`. `0` when no starving bins.
152    pub worst_connected: u64,
153}
154
155impl SaturationSummary {
156    /// `true` when the rollup signals an operator-actionable alert
157    /// — at least one starving relevant bin. Over-saturation is
158    /// not actionable (Bee trims surplus on its own).
159    pub fn is_alert(&self) -> bool {
160        self.starving > 0
161    }
162}
163
164/// Aggregated view fed to the renderer and snapshot tests.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct PeersView {
167    pub bins: Vec<BinStripRow>,
168    pub peers: Vec<PeerRow>,
169    pub depth: u8,
170    pub population: i64,
171    pub connected: i64,
172    pub reachability: String,
173    pub network_availability: String,
174    /// Number of connected light-node peers, separate from the
175    /// 32-bin breakdown.
176    pub light_connected: u64,
177    /// Single-glance summary of the bin-strip saturation state.
178    /// Rendered in the header so a starving bin shows up without
179    /// the operator scanning all 32 rows.
180    pub saturation: SaturationSummary,
181}
182
183/// Per-field outcome of a peer drill fetch. Each endpoint can fail
184/// independently — render each row with what it has.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum DrillField<T: Clone + PartialEq + Eq> {
187    Ok(T),
188    Err(String),
189}
190
191/// Aggregated drill view for the per-peer pane. Pure — computed
192/// from the four endpoint results without any I/O.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct PeerDrillView {
195    pub peer_overlay: String,
196    pub bin: Option<u8>,
197    /// Settlement balance — positive = peer owes us, negative = we
198    /// owe peer. Pre-formatted as `"BZZ X.XXXX"` with sign.
199    pub balance: DrillField<String>,
200    /// Round-trip time as Bee reports it (Go duration string,
201    /// e.g. `"5.0018ms"`).
202    pub ping: DrillField<String>,
203    /// Cumulative settlement received from this peer (BZZ).
204    pub settlement_received: DrillField<String>,
205    /// Cumulative settlement sent to this peer (BZZ).
206    pub settlement_sent: DrillField<String>,
207    /// Last received cheque payout (BZZ) or `None` if no cheques.
208    pub last_received_cheque: DrillField<Option<String>>,
209    /// Last sent cheque payout (BZZ) or `None` if no cheques.
210    pub last_sent_cheque: DrillField<Option<String>>,
211}
212
213/// Bundle of the four endpoint results that feeds
214/// [`Peers::compute_peer_drill_view`]. Each field can fail
215/// independently.
216#[derive(Debug, Clone)]
217pub struct PeerDrillFetch {
218    pub balance: std::result::Result<Balance, String>,
219    pub cheques: std::result::Result<PeerCheques, String>,
220    pub settlement: std::result::Result<Settlement, String>,
221    pub ping: std::result::Result<String, String>,
222}
223
224/// Drill-pane state machine. `Idle` keeps the regular table
225/// rendered; the other variants replace the peer table with the
226/// drill view.
227#[derive(Debug, Clone)]
228pub enum DrillState {
229    Idle,
230    Loading { peer: String, bin: Option<u8> },
231    Loaded { view: PeerDrillView },
232}
233
234type DrillFetchResult = (String, PeerDrillFetch);
235
236pub struct Peers {
237    client: Arc<ApiClient>,
238    rx: watch::Receiver<TopologySnapshot>,
239    snapshot: TopologySnapshot,
240    selected: usize,
241    /// Top row of the visible window in the peer table. Drives both
242    /// the `Paragraph::scroll` offset and the right-edge scrollbar
243    /// state. Updated lazily inside `draw_peer_table` to match the
244    /// area height we get at render time.
245    scroll_offset: usize,
246    drill: DrillState,
247    fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
248    fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
249}
250
251impl Peers {
252    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<TopologySnapshot>) -> Self {
253        let snapshot = rx.borrow().clone();
254        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
255        Self {
256            client,
257            rx,
258            snapshot,
259            selected: 0,
260            scroll_offset: 0,
261            drill: DrillState::Idle,
262            fetch_tx,
263            fetch_rx,
264        }
265    }
266
267    fn pull_latest(&mut self) {
268        self.snapshot = self.rx.borrow().clone();
269        let n = self.peer_rows_cached().len();
270        if n == 0 {
271            self.selected = 0;
272        } else if self.selected >= n {
273            self.selected = n - 1;
274        }
275    }
276
277    fn peer_rows_cached(&self) -> Vec<PeerRow> {
278        Self::view_for(&self.snapshot)
279            .map(|v| v.peers)
280            .unwrap_or_default()
281    }
282
283    /// Drain any drill fetches that completed since the last tick.
284    /// Late results from a since-cancelled drill (operator hit Esc
285    /// then Enter on a different peer before all four endpoints
286    /// came back) are dropped silently — `drill` already moved on.
287    fn drain_fetches(&mut self) {
288        while let Ok((peer, fetch)) = self.fetch_rx.try_recv() {
289            let pending_peer = match &self.drill {
290                DrillState::Loading { peer: p, .. } => p.clone(),
291                _ => continue, // user moved on; ignore
292            };
293            if pending_peer != peer {
294                continue;
295            }
296            let bin = match &self.drill {
297                DrillState::Loading { bin, .. } => *bin,
298                _ => None,
299            };
300            let view = Self::compute_peer_drill_view(&peer, bin, &fetch);
301            self.drill = DrillState::Loaded { view };
302        }
303    }
304
305    /// Pure, snapshot-driven view computation. Exposed for snapshot
306    /// tests in `tests/s6_peers_view.rs`.
307    pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
308        let t = snap.topology.as_ref()?;
309        let bins = bin_strip_rows(t);
310        let peers = peer_rows(t);
311        let saturation = compute_saturation_summary(&bins);
312        Some(PeersView {
313            bins,
314            peers,
315            depth: t.depth,
316            population: t.population,
317            connected: t.connected,
318            reachability: t.reachability.clone(),
319            network_availability: t.network_availability.clone(),
320            light_connected: t.light_nodes.connected,
321            saturation,
322        })
323    }
324
325    /// Pure compute path for the per-peer drill pane. Each field is
326    /// derived independently so a single failed endpoint doesn't
327    /// blank the view — operators still see whatever did come back.
328    pub fn compute_peer_drill_view(
329        peer: &str,
330        bin: Option<u8>,
331        fetch: &PeerDrillFetch,
332    ) -> PeerDrillView {
333        let balance = match &fetch.balance {
334            Ok(b) => DrillField::Ok(format_plur_signed(&b.balance)),
335            Err(e) => DrillField::Err(e.clone()),
336        };
337        let ping = match &fetch.ping {
338            Ok(s) => DrillField::Ok(s.clone()),
339            Err(e) => DrillField::Err(e.clone()),
340        };
341        let (settlement_received, settlement_sent) = match &fetch.settlement {
342            Ok(s) => (
343                DrillField::Ok(format_opt_plur(s.received.as_ref())),
344                DrillField::Ok(format_opt_plur(s.sent.as_ref())),
345            ),
346            Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
347        };
348        let (last_received_cheque, last_sent_cheque) = match &fetch.cheques {
349            Ok(c) => (
350                DrillField::Ok(
351                    c.last_received
352                        .as_ref()
353                        .map(|q| format_opt_plur(q.payout.as_ref())),
354                ),
355                DrillField::Ok(
356                    c.last_sent
357                        .as_ref()
358                        .map(|q| format_opt_plur(q.payout.as_ref())),
359                ),
360            ),
361            Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
362        };
363        PeerDrillView {
364            peer_overlay: peer.to_string(),
365            bin,
366            balance,
367            ping,
368            settlement_received,
369            settlement_sent,
370            last_received_cheque,
371            last_sent_cheque,
372        }
373    }
374
375    /// Spawn parallel fetches of the four per-peer endpoints. No-op
376    /// if the peer table is empty or a fetch is already in flight
377    /// for the same peer.
378    fn maybe_start_drill(&mut self) {
379        let peers = self.peer_rows_cached();
380        if peers.is_empty() {
381            return;
382        }
383        let i = self.selected.min(peers.len() - 1);
384        let row = &peers[i];
385        let peer = row.peer_full.clone();
386        let bin = Some(row.bin);
387        if let DrillState::Loading { peer: pending, .. } = &self.drill {
388            if *pending == peer {
389                return;
390            }
391        }
392        let client = self.client.clone();
393        let tx = self.fetch_tx.clone();
394        let peer_for_task = peer.clone();
395        tokio::spawn(async move {
396            let bee = client.bee();
397            let debug = bee.debug();
398            // Fan out all four requests in parallel — they're all
399            // small and independent. tokio::join! returns once every
400            // future has resolved (even if some errored), so the
401            // operator sees the full picture in one update rather
402            // than four flickers.
403            let (balance, cheques, settlement, ping) = tokio::join!(
404                debug.peer_balance(&peer_for_task),
405                debug.peer_cheques(&peer_for_task),
406                debug.peer_settlement(&peer_for_task),
407                debug.ping_peer(&peer_for_task),
408            );
409            let fetch = PeerDrillFetch {
410                balance: balance.map_err(|e| e.to_string()),
411                cheques: cheques.map_err(|e| e.to_string()),
412                settlement: settlement.map_err(|e| e.to_string()),
413                ping: ping.map_err(|e| e.to_string()),
414            };
415            let _ = tx.send((peer_for_task, fetch));
416        });
417        self.drill = DrillState::Loading { peer, bin };
418    }
419}
420
421fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
422    t.bins
423        .iter()
424        .enumerate()
425        .map(|(i, b)| {
426            let bin = i as u8;
427            let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
428            BinStripRow {
429                bin,
430                population: b.population,
431                connected: b.connected,
432                status: classify_bin(b, bin, t.depth),
433                is_relevant,
434            }
435        })
436        .collect()
437}
438
439/// Reduce the per-bin classification to a single header summary.
440/// `relevant` counts bins at or below `depth + FAR_BIN_RELAXATION`
441/// — operators can't act on a far bin's emptiness so it doesn't
442/// belong in the "X of N relevant" denominator. `worst_bin` is the
443/// starving bin with the lowest connected count, breaking ties by
444/// the lowest bin number (closer to the network root).
445fn compute_saturation_summary(bins: &[BinStripRow]) -> SaturationSummary {
446    let mut summary = SaturationSummary::default();
447    let mut worst: Option<&BinStripRow> = None;
448    for row in bins {
449        if row.is_relevant {
450            summary.relevant += 1;
451        }
452        match row.status {
453            BinSaturation::Starving => {
454                summary.starving += 1;
455                let pick_this = match worst {
456                    None => true,
457                    Some(prev) => {
458                        row.connected < prev.connected
459                            || (row.connected == prev.connected && row.bin < prev.bin)
460                    }
461                };
462                if pick_this {
463                    worst = Some(row);
464                }
465            }
466            BinSaturation::Over => summary.over += 1,
467            BinSaturation::Empty | BinSaturation::Healthy => {}
468        }
469    }
470    if let Some(w) = worst {
471        summary.worst_bin = Some(w.bin);
472        summary.worst_connected = w.connected;
473    }
474    summary
475}
476
477fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
478    if b.connected > OVER_SATURATION_PEERS {
479        return BinSaturation::Over;
480    }
481    if b.connected >= SATURATION_PEERS {
482        return BinSaturation::Healthy;
483    }
484    // Below saturation: starving only if this bin is at or near the
485    // current depth. Far bins are expected to be sparse.
486    if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
487        BinSaturation::Starving
488    } else {
489        BinSaturation::Empty
490    }
491}
492
493fn peer_rows(t: &Topology) -> Vec<PeerRow> {
494    let mut out: Vec<PeerRow> = Vec::new();
495    for (i, b) in t.bins.iter().enumerate() {
496        let bin = i as u8;
497        for p in &b.connected_peers {
498            out.push(make_peer_row(bin, p));
499        }
500    }
501    // Stable order: bin asc, then peer overlay asc — so the table
502    // doesn't shuffle every poll tick when populations don't change.
503    out.sort_by(|a, b| {
504        a.bin
505            .cmp(&b.bin)
506            .then_with(|| a.peer_short.cmp(&b.peer_short))
507    });
508    out
509}
510
511fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
512    let peer_short = short_overlay(&p.address);
513    let peer_full = p.address.trim_start_matches("0x").to_string();
514    let (direction, latency, healthy, reachability) = match &p.metrics {
515        Some(m) => {
516            let direction = match m.session_connection_direction.as_str() {
517                "inbound" => "in",
518                "outbound" => "out",
519                _ => "?",
520            };
521            let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
522            let latency = if m.latency_ewma > 0 {
523                format!("{latency_ms:.0}ms")
524            } else {
525                "—".into()
526            };
527            (direction, latency, m.healthy, m.reachability.clone())
528        }
529        None => ("?", "—".into(), false, String::new()),
530    };
531    PeerRow {
532        bin,
533        peer_short,
534        peer_full,
535        direction,
536        latency,
537        healthy,
538        reachability,
539    }
540}
541
542fn short_overlay(s: &str) -> String {
543    let trimmed = s.trim_start_matches("0x");
544    if trimmed.len() > 10 {
545        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
546    } else {
547        trimmed.to_string()
548    }
549}
550
551/// Format a PLUR `BigInt` as a signed `"BZZ X.XXXX"` string. Mirrors
552/// the helper in `swap.rs` — duplicated here to keep peers.rs free
553/// of cross-module coupling and avoid widening the public API.
554fn format_plur_signed(plur: &BigInt) -> String {
555    let zero = BigInt::from(0);
556    let neg = plur < &zero;
557    let abs = if neg { -plur.clone() } else { plur.clone() };
558    let scale = BigInt::from(10u64).pow(16);
559    let whole = &abs / &scale;
560    let frac = &abs % &scale;
561    let frac_4 = &frac / BigInt::from(10u64).pow(12);
562    let sign = if neg { "-" } else { "+" };
563    format!("{sign}BZZ {whole}.{frac_4:0>4}")
564}
565
566fn format_opt_plur(plur: Option<&BigInt>) -> String {
567    match plur {
568        Some(p) => format_plur_signed(p).trim_start_matches('+').to_string(),
569        None => "—".to_string(),
570    }
571}
572
573impl Component for Peers {
574    fn update(&mut self, action: Action) -> Result<Option<Action>> {
575        if matches!(action, Action::Tick) {
576            self.pull_latest();
577            self.drain_fetches();
578        }
579        Ok(None)
580    }
581
582    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
583        // Drill mode: Esc dismisses; everything else is ignored
584        // (drill is read-only). Selection keys still work in Idle so
585        // the user can navigate before hitting Enter.
586        if matches!(
587            self.drill,
588            DrillState::Loaded { .. } | DrillState::Loading { .. }
589        ) && matches!(key.code, KeyCode::Esc)
590        {
591            self.drill = DrillState::Idle;
592            return Ok(None);
593        }
594        match key.code {
595            KeyCode::Char('j') | KeyCode::Down => {
596                let n = self.peer_rows_cached().len();
597                if n > 0 && self.selected + 1 < n {
598                    self.selected += 1;
599                }
600            }
601            KeyCode::Char('k') | KeyCode::Up => {
602                self.selected = self.selected.saturating_sub(1);
603            }
604            KeyCode::Enter => {
605                self.maybe_start_drill();
606            }
607            _ => {}
608        }
609        Ok(None)
610    }
611
612    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
613        let chunks = Layout::vertical([
614            Constraint::Length(3),  // header
615            Constraint::Length(20), // bin strip (32 lines + header)
616            Constraint::Min(0),     // peer table OR drill
617            Constraint::Length(1),  // selected detail (full overlay)
618            Constraint::Length(1),  // footer
619        ])
620        .split(area);
621
622        // Header
623        let t = theme::active();
624        let mut header_l1 = vec![Span::styled(
625            "PEERS / TOPOLOGY",
626            Style::default().add_modifier(Modifier::BOLD),
627        )];
628        if let DrillState::Loaded { view } = &self.drill {
629            // Full peer overlay so operators can click-drag to copy
630            // for support threads / explorer lookups. The 64-char
631            // hex fits cleanly on this line for typical terminals.
632            header_l1.push(Span::raw("   · drill "));
633            header_l1.push(Span::styled(
634                view.peer_overlay.clone(),
635                Style::default().fg(t.info),
636            ));
637        } else if let DrillState::Loading { peer, .. } = &self.drill {
638            header_l1.push(Span::raw("   · drill "));
639            header_l1.push(Span::styled(peer.clone(), Style::default().fg(t.info)));
640            header_l1.push(Span::raw(" (loading)"));
641        }
642        let header_l1 = Line::from(header_l1);
643        let mut header_l2 = Vec::new();
644        if let Some(err) = &self.snapshot.last_error {
645            let (color, msg) = theme::classify_header_error(err);
646            header_l2.push(Span::styled(msg, Style::default().fg(color)));
647        } else if !self.snapshot.is_loaded() {
648            header_l2.push(Span::styled(
649                format!("{} loading…", theme::spinner_glyph()),
650                Style::default().fg(t.dim),
651            ));
652        } else if let Some(view) = Self::view_for(&self.snapshot) {
653            // Saturation rollup line. Healthy node = single-glance
654            // green; starving node surfaces the worst bin in red so
655            // operators don't have to scan all 32 rows of the strip.
656            let s = view.saturation;
657            if s.is_alert() {
658                let mut spans = vec![
659                    Span::styled(
660                        format!("  {} STARVING ", t.glyphs.fail),
661                        Style::default().fg(t.fail).add_modifier(Modifier::BOLD),
662                    ),
663                    Span::raw(format!("{} of {} relevant bins", s.starving, s.relevant)),
664                ];
665                if let Some(b) = s.worst_bin {
666                    spans.push(Span::raw(format!(
667                        " · worst bin {b} ({}/{})",
668                        s.worst_connected, SATURATION_PEERS
669                    )));
670                }
671                if s.over > 0 {
672                    spans.push(Span::styled(
673                        format!("  · {} over-saturated", s.over),
674                        Style::default().fg(t.warn),
675                    ));
676                }
677                header_l2.extend(spans);
678            } else {
679                header_l2.push(Span::styled(
680                    format!(
681                        "  {} all {} relevant bins healthy",
682                        t.glyphs.pass, s.relevant
683                    ),
684                    Style::default().fg(t.pass),
685                ));
686                if s.over > 0 {
687                    header_l2.push(Span::styled(
688                        format!(" · {} over-saturated", s.over),
689                        Style::default().fg(t.warn),
690                    ));
691                }
692            }
693        }
694        frame.render_widget(
695            Paragraph::new(vec![header_l1, Line::from(header_l2)])
696                .block(Block::default().borders(Borders::BOTTOM)),
697            chunks[0],
698        );
699
700        let view = match Self::view_for(&self.snapshot) {
701            Some(v) => v,
702            None => {
703                frame.render_widget(
704                    Paragraph::new(Span::styled(
705                        "  topology not loaded yet",
706                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
707                    )),
708                    chunks[1],
709                );
710                return Ok(());
711            }
712        };
713
714        // Bin strip
715        let mut strip_lines: Vec<Line> = vec![
716            Line::from(vec![Span::styled(
717                format!(
718                    "  depth {} · connected {} / known {} · reachability {} · net {}",
719                    view.depth,
720                    view.connected,
721                    view.population,
722                    if view.reachability.is_empty() {
723                        "?".to_string()
724                    } else {
725                        view.reachability.clone()
726                    },
727                    if view.network_availability.is_empty() {
728                        "?".to_string()
729                    } else {
730                        view.network_availability.clone()
731                    },
732                ),
733                Style::default().fg(t.dim),
734            )]),
735            Line::from(Span::styled(
736                "  BIN  POP  CONN  BAR              STATUS",
737                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
738            )),
739        ];
740        for r in &view.bins {
741            // Skip far empty bins past the relaxation window — too much
742            // noise to render all 32 when only the first ~12 matter.
743            if !r.is_relevant && r.population == 0 {
744                continue;
745            }
746            let bar = bin_bar(r.connected as usize, 12);
747            strip_lines.push(Line::from(vec![
748                Span::raw("  "),
749                Span::styled(
750                    format!("{:>3} ", r.bin),
751                    Style::default().add_modifier(Modifier::BOLD),
752                ),
753                Span::raw(format!("{:>4} ", r.population)),
754                Span::raw(format!("{:>4}  ", r.connected)),
755                Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
756                Span::raw(" "),
757                Span::styled(
758                    r.status.label(),
759                    Style::default()
760                        .fg(r.status.color())
761                        .add_modifier(Modifier::BOLD),
762                ),
763            ]));
764        }
765        if view.light_connected > 0 {
766            strip_lines.push(Line::from(vec![
767                Span::raw("  "),
768                Span::styled(
769                    format!(
770                        " light  —  {}    (separate from main bins)",
771                        view.light_connected
772                    ),
773                    Style::default().fg(t.dim),
774                ),
775            ]));
776        }
777        frame.render_widget(
778            Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
779            chunks[1],
780        );
781
782        // Peer table OR drill
783        match &self.drill {
784            DrillState::Idle => self.draw_peer_table(frame, chunks[2], &view.peers),
785            DrillState::Loading { peer, .. } => {
786                let msg = Line::from(vec![
787                    Span::raw("  fetching peer drill for "),
788                    Span::styled(
789                        short_overlay(peer),
790                        Style::default().add_modifier(Modifier::BOLD),
791                    ),
792                    Span::raw("…   (Esc cancel)"),
793                ]);
794                frame.render_widget(Paragraph::new(msg), chunks[2]);
795            }
796            DrillState::Loaded { view: drill_view } => {
797                self.draw_peer_drill(frame, chunks[2], drill_view);
798            }
799        }
800
801        // Selected detail — full peer overlay of the highlighted
802        // table row, click-drag selectable. Suppressed during drill
803        // since the drill header already prints the full overlay.
804        if matches!(self.drill, DrillState::Idle) && !view.peers.is_empty() {
805            let i = self.selected.min(view.peers.len() - 1);
806            let row = &view.peers[i];
807            let detail = Line::from(vec![
808                Span::styled("  selected: ", Style::default().fg(t.dim)),
809                Span::styled(row.peer_full.clone(), Style::default().fg(t.info)),
810                Span::raw("  bin "),
811                Span::styled(row.bin.to_string(), Style::default().fg(t.dim)),
812            ]);
813            frame.render_widget(Paragraph::new(detail), chunks[3]);
814        }
815
816        // Footer
817        let footer = match &self.drill {
818            DrillState::Idle => Line::from(vec![
819                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
820                Span::raw(" switch screen  "),
821                Span::styled(
822                    " ↑↓/jk ",
823                    Style::default().fg(Color::Black).bg(Color::White),
824                ),
825                Span::raw(" select  "),
826                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
827                Span::raw(" drill  "),
828                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
829                Span::raw(" help  "),
830                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
831                Span::raw(" quit  "),
832                Span::styled(
833                    format!(
834                        "thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"
835                    ),
836                    Style::default().fg(t.dim),
837                ),
838            ]),
839            _ => Line::from(vec![
840                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
841                Span::raw(" close drill  "),
842                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
843                Span::raw(" switch screen  "),
844                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
845                Span::raw(" help  "),
846                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
847                Span::raw(" quit "),
848            ]),
849        };
850        frame.render_widget(Paragraph::new(footer), chunks[4]);
851
852        Ok(())
853    }
854}
855
856impl Peers {
857    fn draw_peer_table(&mut self, frame: &mut Frame, area: Rect, peers: &[PeerRow]) {
858        use ratatui::layout::{Constraint, Layout};
859
860        let t = theme::active();
861
862        // Two-row split: pinned column header + scrollable body. The
863        // header doesn't scroll out from under the cursor, which is
864        // what operators expect after using k9s / lazygit.
865        let table_chunks =
866            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
867
868        frame.render_widget(
869            Paragraph::new(Line::from(Span::styled(
870                "   BIN  PEER          DIR  LATENCY   HEALTHY  REACHABILITY",
871                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
872            ))),
873            table_chunks[0],
874        );
875
876        if peers.is_empty() {
877            frame.render_widget(
878                Paragraph::new(Line::from(Span::styled(
879                    "   (no connected peers reported)",
880                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
881                ))),
882                table_chunks[1],
883            );
884            return;
885        }
886
887        let mut peer_lines: Vec<Line> = Vec::with_capacity(peers.len());
888        for (i, p) in peers.iter().enumerate() {
889            let g = theme::active().glyphs;
890            let healthy_glyph = if p.healthy { g.pass } else { g.fail };
891            let healthy_style = if p.healthy {
892                Style::default().fg(t.pass)
893            } else {
894                Style::default().fg(t.fail)
895            };
896            let cursor = if i == self.selected {
897                format!("{} ", t.glyphs.cursor)
898            } else {
899                "  ".to_string()
900            };
901            peer_lines.push(Line::from(vec![
902                Span::styled(
903                    cursor,
904                    Style::default()
905                        .fg(if i == self.selected { t.accent } else { t.dim })
906                        .add_modifier(Modifier::BOLD),
907                ),
908                Span::raw(format!("{:>3}  ", p.bin)),
909                Span::raw(format!("{:<13} ", p.peer_short)),
910                Span::raw(format!("{:<4} ", p.direction)),
911                Span::raw(format!("{:<8}  ", p.latency)),
912                Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
913                Span::raw(p.reachability.clone()),
914            ]));
915        }
916
917        let body = table_chunks[1];
918        let visible_rows = body.height as usize;
919        self.scroll_offset = super::scroll::clamp_scroll(
920            self.selected,
921            self.scroll_offset,
922            visible_rows,
923            peer_lines.len(),
924        );
925        frame.render_widget(
926            Paragraph::new(peer_lines.clone()).scroll((self.scroll_offset as u16, 0)),
927            body,
928        );
929        super::scroll::render_scrollbar(
930            frame,
931            body,
932            self.scroll_offset,
933            visible_rows,
934            peer_lines.len(),
935        );
936    }
937
938    fn draw_peer_drill(&self, frame: &mut Frame, area: Rect, view: &PeerDrillView) {
939        let t = theme::active();
940        let mut lines: Vec<Line> = Vec::new();
941        let bin_label = view
942            .bin
943            .map(|b| format!("bin {b}"))
944            .unwrap_or_else(|| "bin ?".into());
945        lines.push(Line::from(vec![
946            Span::raw("  peer "),
947            Span::styled(
948                view.peer_overlay.clone(),
949                Style::default().add_modifier(Modifier::BOLD),
950            ),
951            Span::raw("   "),
952            Span::styled(bin_label, Style::default().fg(t.dim)),
953        ]));
954        lines.push(Line::from(""));
955        lines.push(drill_field_line("balance        ", &view.balance, t));
956        lines.push(drill_field_line("ping rtt       ", &view.ping, t));
957        lines.push(drill_field_line(
958            "settle recv    ",
959            &view.settlement_received,
960            t,
961        ));
962        lines.push(drill_field_line(
963            "settle sent    ",
964            &view.settlement_sent,
965            t,
966        ));
967        lines.push(drill_field_optional_line(
968            "cheque last in ",
969            &view.last_received_cheque,
970            t,
971        ));
972        lines.push(drill_field_optional_line(
973            "cheque last out",
974            &view.last_sent_cheque,
975            t,
976        ));
977        lines.push(Line::from(""));
978        lines.push(Line::from(Span::styled(
979            "  (Esc to dismiss · figures are point-in-time, not live-updating)",
980            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
981        )));
982        frame.render_widget(Paragraph::new(lines), area);
983    }
984}
985
986fn drill_field_line(label: &str, field: &DrillField<String>, t: &theme::Theme) -> Line<'static> {
987    match field {
988        DrillField::Ok(v) => Line::from(vec![
989            Span::raw("  "),
990            Span::styled(label.to_string(), Style::default().fg(t.dim)),
991            Span::raw("  "),
992            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
993        ]),
994        DrillField::Err(e) => Line::from(vec![
995            Span::raw("  "),
996            Span::styled(label.to_string(), Style::default().fg(t.dim)),
997            Span::raw("  "),
998            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
999        ]),
1000    }
1001}
1002
1003fn drill_field_optional_line(
1004    label: &str,
1005    field: &DrillField<Option<String>>,
1006    t: &theme::Theme,
1007) -> Line<'static> {
1008    match field {
1009        DrillField::Ok(Some(v)) => Line::from(vec![
1010            Span::raw("  "),
1011            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1012            Span::raw("  "),
1013            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
1014        ]),
1015        DrillField::Ok(None) => Line::from(vec![
1016            Span::raw("  "),
1017            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1018            Span::raw("  "),
1019            Span::styled(
1020                "(no cheque yet)",
1021                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
1022            ),
1023        ]),
1024        DrillField::Err(e) => Line::from(vec![
1025            Span::raw("  "),
1026            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1027            Span::raw("  "),
1028            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
1029        ]),
1030    }
1031}
1032
1033/// Width-bounded ASCII bar showing connected count, capped at
1034/// [`OVER_SATURATION_PEERS`] for visual scale.
1035fn bin_bar(connected: usize, width: usize) -> String {
1036    let scale = OVER_SATURATION_PEERS as usize;
1037    let filled = connected.min(scale) * width / scale.max(1);
1038    let mut bar = String::with_capacity(width);
1039    for _ in 0..filled.min(width) {
1040        bar.push('▇');
1041    }
1042    for _ in filled.min(width)..width {
1043        bar.push('░');
1044    }
1045    bar
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051
1052    fn bin(population: u64, connected: u64) -> BinInfo {
1053        BinInfo {
1054            population,
1055            connected,
1056            ..BinInfo::default()
1057        }
1058    }
1059
1060    #[test]
1061    fn classify_below_saturation_in_relevant_bin_is_starving() {
1062        // bin 4, depth 8 → bin <= depth + 4 = 12 → relevant → starving.
1063        assert_eq!(classify_bin(&bin(5, 3), 4, 8), BinSaturation::Starving);
1064    }
1065
1066    #[test]
1067    fn classify_below_saturation_in_far_bin_is_empty() {
1068        // bin 20, depth 8 → bin > depth + 4 = 12 → far → empty.
1069        assert_eq!(classify_bin(&bin(0, 0), 20, 8), BinSaturation::Empty);
1070    }
1071
1072    #[test]
1073    fn classify_in_safe_band_is_healthy() {
1074        assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
1075        assert_eq!(
1076            classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
1077            BinSaturation::Healthy
1078        );
1079    }
1080
1081    #[test]
1082    fn classify_over_threshold_is_over() {
1083        assert_eq!(
1084            classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
1085            BinSaturation::Over
1086        );
1087    }
1088
1089    #[test]
1090    fn short_overlay_truncates() {
1091        let s = short_overlay("0xabcdef0123456789abcdef0123456789");
1092        assert!(s.contains('…'));
1093        assert!(s.starts_with("abcdef"));
1094    }
1095
1096    #[test]
1097    fn bin_bar_caps_at_oversaturation() {
1098        let bar_full = bin_bar(50, 12);
1099        assert_eq!(bar_full, "▇".repeat(12));
1100        let bar_empty = bin_bar(0, 12);
1101        assert_eq!(bar_empty, "░".repeat(12));
1102    }
1103
1104    fn strip_row(bin: u8, connected: u64, status: BinSaturation, is_relevant: bool) -> BinStripRow {
1105        BinStripRow {
1106            bin,
1107            population: connected,
1108            connected,
1109            status,
1110            is_relevant,
1111        }
1112    }
1113
1114    #[test]
1115    fn saturation_summary_all_healthy_has_no_alert() {
1116        let bins = vec![
1117            strip_row(0, 10, BinSaturation::Healthy, true),
1118            strip_row(1, 12, BinSaturation::Healthy, true),
1119            strip_row(2, 14, BinSaturation::Healthy, true),
1120        ];
1121        let s = compute_saturation_summary(&bins);
1122        assert!(!s.is_alert());
1123        assert_eq!(s.starving, 0);
1124        assert_eq!(s.over, 0);
1125        assert_eq!(s.relevant, 3);
1126        assert_eq!(s.worst_bin, None);
1127    }
1128
1129    #[test]
1130    fn saturation_summary_picks_lowest_connected_starving_bin() {
1131        // bins 1 (3/8) and 4 (2/8) starving; bin 4 is worse.
1132        let bins = vec![
1133            strip_row(0, 10, BinSaturation::Healthy, true),
1134            strip_row(1, 3, BinSaturation::Starving, true),
1135            strip_row(2, 12, BinSaturation::Healthy, true),
1136            strip_row(4, 2, BinSaturation::Starving, true),
1137        ];
1138        let s = compute_saturation_summary(&bins);
1139        assert!(s.is_alert());
1140        assert_eq!(s.starving, 2);
1141        assert_eq!(s.worst_bin, Some(4));
1142        assert_eq!(s.worst_connected, 2);
1143    }
1144
1145    #[test]
1146    fn saturation_summary_breaks_ties_by_lowest_bin() {
1147        // Two bins at 0 connected; bin 1 wins because it's closer
1148        // to the network root (lower bin number).
1149        let bins = vec![
1150            strip_row(7, 0, BinSaturation::Starving, true),
1151            strip_row(1, 0, BinSaturation::Starving, true),
1152        ];
1153        let s = compute_saturation_summary(&bins);
1154        assert_eq!(s.worst_bin, Some(1));
1155    }
1156
1157    #[test]
1158    fn saturation_summary_excludes_far_bins_from_relevant_count() {
1159        // Far bins (is_relevant=false) shouldn't count toward
1160        // the "X of N relevant" denominator even when populated.
1161        let bins = vec![
1162            strip_row(0, 10, BinSaturation::Healthy, true),
1163            strip_row(1, 10, BinSaturation::Healthy, true),
1164            strip_row(20, 0, BinSaturation::Empty, false),
1165            strip_row(21, 0, BinSaturation::Empty, false),
1166        ];
1167        let s = compute_saturation_summary(&bins);
1168        assert_eq!(s.relevant, 2);
1169    }
1170
1171    #[test]
1172    fn saturation_summary_counts_over_separately() {
1173        let bins = vec![
1174            strip_row(0, 10, BinSaturation::Healthy, true),
1175            strip_row(1, 25, BinSaturation::Over, true),
1176            strip_row(2, 30, BinSaturation::Over, true),
1177        ];
1178        let s = compute_saturation_summary(&bins);
1179        assert!(!s.is_alert()); // over-sat alone isn't an alert
1180        assert_eq!(s.over, 2);
1181        assert_eq!(s.starving, 0);
1182    }
1183
1184    #[test]
1185    fn drill_view_all_ok() {
1186        // 5 × 10^14 PLUR = 0.05 BZZ — typical cheque magnitude.
1187        let cheque_in = bee::debug::Cheque {
1188            beneficiary: "0x".into(),
1189            chequebook: "0x".into(),
1190            payout: Some(BigInt::from(500_000_000_000_000u64)),
1191        };
1192        let fetch = PeerDrillFetch {
1193            balance: Ok(Balance {
1194                peer: "abcd".into(),
1195                balance: BigInt::from(123_400_000_000_000_000i64),
1196            }),
1197            cheques: Ok(PeerCheques {
1198                peer: "abcd".into(),
1199                last_received: Some(cheque_in),
1200                last_sent: None,
1201            }),
1202            settlement: Ok(Settlement {
1203                peer: "abcd".into(),
1204                received: Some(BigInt::from(900_000_000_000_000_000u64)),
1205                sent: Some(BigInt::from(100_000_000_000_000_000u64)),
1206            }),
1207            ping: Ok("4.21ms".into()),
1208        };
1209        let view = Peers::compute_peer_drill_view("abcd1234", Some(7), &fetch);
1210        assert_eq!(view.bin, Some(7));
1211        match &view.balance {
1212            DrillField::Ok(s) => assert!(s.contains("BZZ")),
1213            _ => panic!("expected ok balance"),
1214        }
1215        match &view.last_received_cheque {
1216            DrillField::Ok(Some(s)) => assert!(s.contains("0.0500")),
1217            _ => panic!("expected received cheque payout"),
1218        }
1219        match &view.last_sent_cheque {
1220            DrillField::Ok(None) => {}
1221            _ => panic!("expected None for sent cheque"),
1222        }
1223    }
1224
1225    #[test]
1226    fn drill_view_partial_failure_keeps_other_fields() {
1227        // Settlement endpoint failed, the rest succeeded — drill view
1228        // must still surface what came back rather than blanking.
1229        let fetch = PeerDrillFetch {
1230            balance: Ok(Balance {
1231                peer: "x".into(),
1232                balance: BigInt::from(0),
1233            }),
1234            cheques: Err("404".into()),
1235            settlement: Err("503 Node is syncing".into()),
1236            ping: Ok("12ms".into()),
1237        };
1238        let view = Peers::compute_peer_drill_view("xxxx", None, &fetch);
1239        assert!(matches!(view.balance, DrillField::Ok(_)));
1240        assert!(matches!(view.ping, DrillField::Ok(_)));
1241        assert!(matches!(view.settlement_received, DrillField::Err(_)));
1242        assert!(matches!(view.last_received_cheque, DrillField::Err(_)));
1243    }
1244
1245    #[test]
1246    fn format_plur_signed_handles_zero_and_negative() {
1247        assert_eq!(format_plur_signed(&BigInt::from(0)), "+BZZ 0.0000");
1248        assert_eq!(
1249            format_plur_signed(&BigInt::from(-5_000_000_000_000_000i64)),
1250            "-BZZ 0.5000"
1251        );
1252    }
1253}