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::{
49    Balance, BinInfo, PeerCheques, PeerInfo, PeerStatus, Settlement, Status, Topology,
50};
51
52/// Kademlia bins per Bee build.
53pub const BIN_COUNT: usize = 32;
54/// `pkg/topology/kademlia/kademlia.go:54` — saturation threshold.
55/// Connected peer counts below this in a relevant bin (bin ≤ depth)
56/// are reported as Starving.
57pub const SATURATION_PEERS: u64 = 8;
58/// `pkg/topology/kademlia/kademlia.go:55` — over-saturation threshold.
59/// Connected peer counts above this in any bin are reported as Over.
60pub const OVER_SATURATION_PEERS: u64 = 18;
61/// Relaxation factor for "far" bins. Bins more than this many positions
62/// past the kademlia depth are expected to be sparse and don't flag as
63/// Starving even if their connected count is below saturation.
64const FAR_BIN_RELAXATION: u8 = 4;
65
66/// Tri-state bin saturation classification.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum BinSaturation {
69    /// `connected == 0` and the bin is far from depth — expected to
70    /// be empty.
71    Empty,
72    /// `connected < SATURATION_PEERS` for a bin at or near the depth.
73    /// Operator should add peers (manual `connect`, more uptime).
74    Starving,
75    /// Connected count is in the safe band `[8, 18]`.
76    Healthy,
77    /// Connected count exceeds `OVER_SATURATION_PEERS`. Bee will trim
78    /// oldest entries; harmless but unusual for distant bins.
79    Over,
80}
81
82impl BinSaturation {
83    fn color(self) -> Color {
84        match self {
85            Self::Empty => theme::active().dim,
86            Self::Starving => theme::active().fail,
87            Self::Healthy => theme::active().pass,
88            Self::Over => theme::active().warn,
89        }
90    }
91    fn label(self) -> String {
92        let g = theme::active().glyphs;
93        match self {
94            Self::Empty => g.em_dash.to_string(),
95            Self::Starving => format!("{} STARVING", g.fail),
96            Self::Healthy => g.pass.to_string(),
97            Self::Over => format!("{} over", g.warn),
98        }
99    }
100}
101
102/// One row of the bin saturation strip.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct BinStripRow {
105    pub bin: u8,
106    pub population: u64,
107    pub connected: u64,
108    pub status: BinSaturation,
109    /// `true` if this bin is at or below the current kademlia depth —
110    /// the only bins where Starving carries an operator alert. Far
111    /// bins with low population are normal.
112    pub is_relevant: bool,
113}
114
115/// One row of the peer table.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct PeerRow {
118    pub bin: u8,
119    pub peer_short: String,
120    /// Full overlay address (un-shortened) — needed for drill
121    /// fetches. Short version is just for display.
122    pub peer_full: String,
123    /// `"in"` / `"out"` from the per-peer session direction. `"?"` if
124    /// metrics not yet populated.
125    pub direction: &'static str,
126    /// Latency formatted as `"Xms"` from the EWMA value (Bee returns
127    /// nanoseconds). `"—"` if metrics absent.
128    pub latency: String,
129    pub healthy: bool,
130    /// Per-peer reachability string from MetricSnapshotView. Empty if
131    /// metrics absent.
132    pub reachability: String,
133}
134
135/// Synthesised saturation rollup. Surfaces the operator-relevant
136/// alert state in one line so a healthy node looks healthy and a
137/// node with a starving bin doesn't require the operator to scan
138/// every row of the bin strip to find it.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140pub struct SaturationSummary {
141    /// Number of *relevant* bins (bin ≤ depth) flagged as Starving.
142    /// Far bins with low connectivity are normal and excluded.
143    pub starving: usize,
144    /// Number of bins flagged as Over (connected > 18).
145    pub over: usize,
146    /// Total relevant bins considered. Denominator for the
147    /// `starving / relevant` ratio.
148    pub relevant: usize,
149    /// The worst (lowest-connected) starving bin, if any. Lets the
150    /// header surface "bin 5 (2/8)" without forcing operators to
151    /// hunt for it.
152    pub worst_bin: Option<u8>,
153    /// `connected` count of `worst_bin`. `0` when no starving bins.
154    pub worst_connected: u64,
155}
156
157impl SaturationSummary {
158    /// `true` when the rollup signals an operator-actionable alert
159    /// — at least one starving relevant bin. Over-saturation is
160    /// not actionable (Bee trims surplus on its own).
161    pub fn is_alert(&self) -> bool {
162        self.starving > 0
163    }
164}
165
166/// Aggregated view fed to the renderer and snapshot tests.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct PeersView {
169    pub bins: Vec<BinStripRow>,
170    pub peers: Vec<PeerRow>,
171    pub depth: u8,
172    pub population: i64,
173    pub connected: i64,
174    pub reachability: String,
175    pub network_availability: String,
176    /// Number of connected light-node peers, separate from the
177    /// 32-bin breakdown.
178    pub light_connected: u64,
179    /// Single-glance summary of the bin-strip saturation state.
180    /// Rendered in the header so a starving bin shows up without
181    /// the operator scanning all 32 rows.
182    pub saturation: SaturationSummary,
183}
184
185/// Per-field outcome of a peer drill fetch. Each endpoint can fail
186/// independently — render each row with what it has.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum DrillField<T: Clone + PartialEq + Eq> {
189    Ok(T),
190    Err(String),
191}
192
193/// Aggregated drill view for the per-peer pane. Pure — computed
194/// from the six endpoint results without any I/O.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct PeerDrillView {
197    pub peer_overlay: String,
198    pub bin: Option<u8>,
199    /// Settlement balance — positive = peer owes us, negative = we
200    /// owe peer. Pre-formatted as `"BZZ X.XXXX"` with sign.
201    pub balance: DrillField<String>,
202    /// Round-trip time as Bee reports it (Go duration string,
203    /// e.g. `"5.0018ms"`).
204    pub ping: DrillField<String>,
205    /// Cumulative settlement received from this peer (BZZ).
206    pub settlement_received: DrillField<String>,
207    /// Cumulative settlement sent to this peer (BZZ).
208    pub settlement_sent: DrillField<String>,
209    /// Last received cheque payout (BZZ) or `None` if no cheques.
210    pub last_received_cheque: DrillField<Option<String>>,
211    /// Last sent cheque payout (BZZ) or `None` if no cheques.
212    pub last_sent_cheque: DrillField<Option<String>>,
213    /// Reserve-state snapshot fields from `/status/peers/<overlay>`.
214    /// All four come from the same row so they share a fail outcome.
215    pub storage_radius: DrillField<String>,
216    pub reserve_size: DrillField<String>,
217    pub pullsync_rate: DrillField<String>,
218    /// `batch_commitment` rendered with the outlier flag attached. A
219    /// `>5%` deviation from the local node's value paints red, mirrors
220    /// `bee-scripts/bad-status.sh`. `None` means we haven't seen the
221    /// peer's status row at all (peer not in the bulk response).
222    pub batch_commitment: DrillField<BatchCommitmentCell>,
223}
224
225/// Per-cell payload for the batch-commitment row: the formatted value
226/// + the outlier flag we colour by.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct BatchCommitmentCell {
229    /// Pre-formatted (e.g. `"99 715 645 440"` with thousands grouping).
230    pub formatted: String,
231    /// True when |peer - local| / local > 5%.
232    pub outlier: bool,
233}
234
235/// Bundle of the six endpoint results that feeds
236/// [`Peers::compute_peer_drill_view`]. Each field can fail
237/// independently. `peer_status` is `Ok(None)` when the peer was not
238/// present in the bulk `/status/peers` response (transient — a slow
239/// peer may not show up on every poll).
240#[derive(Debug, Clone)]
241pub struct PeerDrillFetch {
242    pub balance: std::result::Result<Balance, String>,
243    pub cheques: std::result::Result<PeerCheques, String>,
244    pub settlement: std::result::Result<Settlement, String>,
245    pub ping: std::result::Result<String, String>,
246    pub peer_status: std::result::Result<Option<PeerStatus>, String>,
247    pub local_status: std::result::Result<Status, String>,
248}
249
250/// Drill-pane state machine. `Idle` keeps the regular table
251/// rendered; the other variants replace the peer table with the
252/// drill view. `Loaded` boxes the view because PeerDrillView is
253/// substantially larger than the other variants.
254#[derive(Debug, Clone)]
255pub enum DrillState {
256    Idle,
257    Loading { peer: String, bin: Option<u8> },
258    Loaded { view: Box<PeerDrillView> },
259}
260
261type DrillFetchResult = (String, PeerDrillFetch);
262
263pub struct Peers {
264    client: Arc<ApiClient>,
265    rx: watch::Receiver<TopologySnapshot>,
266    snapshot: TopologySnapshot,
267    selected: usize,
268    /// Top row of the visible window in the peer table. Drives both
269    /// the `Paragraph::scroll` offset and the right-edge scrollbar
270    /// state. Updated lazily inside `draw_peer_table` to match the
271    /// area height we get at render time.
272    scroll_offset: usize,
273    drill: DrillState,
274    fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
275    fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
276}
277
278impl Peers {
279    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<TopologySnapshot>) -> Self {
280        let snapshot = rx.borrow().clone();
281        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
282        Self {
283            client,
284            rx,
285            snapshot,
286            selected: 0,
287            scroll_offset: 0,
288            drill: DrillState::Idle,
289            fetch_tx,
290            fetch_rx,
291        }
292    }
293
294    fn pull_latest(&mut self) {
295        self.snapshot = self.rx.borrow().clone();
296        let n = self.peer_rows_cached().len();
297        if n == 0 {
298            self.selected = 0;
299        } else if self.selected >= n {
300            self.selected = n - 1;
301        }
302    }
303
304    fn peer_rows_cached(&self) -> Vec<PeerRow> {
305        Self::view_for(&self.snapshot)
306            .map(|v| v.peers)
307            .unwrap_or_default()
308    }
309
310    /// Drain any drill fetches that completed since the last tick.
311    /// Late results from a since-cancelled drill (operator hit Esc
312    /// then Enter on a different peer before all four endpoints
313    /// came back) are dropped silently — `drill` already moved on.
314    fn drain_fetches(&mut self) {
315        while let Ok((peer, fetch)) = self.fetch_rx.try_recv() {
316            let pending_peer = match &self.drill {
317                DrillState::Loading { peer: p, .. } => p.clone(),
318                _ => continue, // user moved on; ignore
319            };
320            if pending_peer != peer {
321                continue;
322            }
323            let bin = match &self.drill {
324                DrillState::Loading { bin, .. } => *bin,
325                _ => None,
326            };
327            let view = Self::compute_peer_drill_view(&peer, bin, &fetch);
328            self.drill = DrillState::Loaded {
329                view: Box::new(view),
330            };
331        }
332    }
333
334    /// Pure, snapshot-driven view computation. Exposed for snapshot
335    /// tests in `tests/s6_peers_view.rs`.
336    pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
337        let t = snap.topology.as_ref()?;
338        let bins = bin_strip_rows(t);
339        let peers = peer_rows(t);
340        let saturation = compute_saturation_summary(&bins);
341        Some(PeersView {
342            bins,
343            peers,
344            depth: t.depth,
345            population: t.population,
346            connected: t.connected,
347            reachability: t.reachability.clone(),
348            network_availability: t.network_availability.clone(),
349            light_connected: t.light_nodes.connected,
350            saturation,
351        })
352    }
353
354    /// Pure compute path for the per-peer drill pane. Each field is
355    /// derived independently so a single failed endpoint doesn't
356    /// blank the view — operators still see whatever did come back.
357    pub fn compute_peer_drill_view(
358        peer: &str,
359        bin: Option<u8>,
360        fetch: &PeerDrillFetch,
361    ) -> PeerDrillView {
362        let balance = match &fetch.balance {
363            Ok(b) => DrillField::Ok(format_plur_signed(&b.balance)),
364            Err(e) => DrillField::Err(e.clone()),
365        };
366        let ping = match &fetch.ping {
367            Ok(s) => DrillField::Ok(s.clone()),
368            Err(e) => DrillField::Err(e.clone()),
369        };
370        let (settlement_received, settlement_sent) = match &fetch.settlement {
371            Ok(s) => (
372                DrillField::Ok(format_opt_plur(s.received.as_ref())),
373                DrillField::Ok(format_opt_plur(s.sent.as_ref())),
374            ),
375            Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
376        };
377        let (last_received_cheque, last_sent_cheque) = match &fetch.cheques {
378            Ok(c) => (
379                DrillField::Ok(
380                    c.last_received
381                        .as_ref()
382                        .map(|q| format_opt_plur(q.payout.as_ref())),
383                ),
384                DrillField::Ok(
385                    c.last_sent
386                        .as_ref()
387                        .map(|q| format_opt_plur(q.payout.as_ref())),
388                ),
389            ),
390            Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
391        };
392        let (storage_radius, reserve_size, pullsync_rate, batch_commitment) =
393            compute_reserve_state_fields(&fetch.peer_status, &fetch.local_status);
394
395        PeerDrillView {
396            peer_overlay: peer.to_string(),
397            bin,
398            balance,
399            ping,
400            settlement_received,
401            settlement_sent,
402            last_received_cheque,
403            last_sent_cheque,
404            storage_radius,
405            reserve_size,
406            pullsync_rate,
407            batch_commitment,
408        }
409    }
410
411    /// Spawn parallel fetches of the four per-peer endpoints. No-op
412    /// if the peer table is empty or a fetch is already in flight
413    /// for the same peer.
414    fn maybe_start_drill(&mut self) {
415        let peers = self.peer_rows_cached();
416        if peers.is_empty() {
417            return;
418        }
419        let i = self.selected.min(peers.len() - 1);
420        let row = &peers[i];
421        let peer = row.peer_full.clone();
422        let bin = Some(row.bin);
423        if let DrillState::Loading { peer: pending, .. } = &self.drill {
424            if *pending == peer {
425                return;
426            }
427        }
428        let client = self.client.clone();
429        let tx = self.fetch_tx.clone();
430        let peer_for_task = peer.clone();
431        tokio::spawn(async move {
432            let bee = client.bee();
433            let debug = bee.debug();
434            // Fan out all four requests in parallel — they're all
435            // small and independent. tokio::join! returns once every
436            // future has resolved (even if some errored), so the
437            // operator sees the full picture in one update rather
438            // than four flickers.
439            let (balance, cheques, settlement, ping, status_peers, local_status) = tokio::join!(
440                debug.peer_balance(&peer_for_task),
441                debug.peer_cheques(&peer_for_task),
442                debug.peer_settlement(&peer_for_task),
443                debug.ping_peer(&peer_for_task),
444                debug.status_peers(),
445                debug.status(),
446            );
447            // Filter the bulk /status/peers response down to the row
448            // matching this peer's overlay. Bee returns the overlay
449            // un-prefixed in `Status::overlay`, so a substring match
450            // against the operator-supplied peer string is fine even
451            // if one carries `0x` and the other doesn't.
452            let peer_status = status_peers
453                .map(|rows| {
454                    rows.into_iter()
455                        .find(|r| peer_for_task.contains(&r.status.overlay))
456                })
457                .map_err(|e| e.to_string());
458            let fetch = PeerDrillFetch {
459                balance: balance.map_err(|e| e.to_string()),
460                cheques: cheques.map_err(|e| e.to_string()),
461                settlement: settlement.map_err(|e| e.to_string()),
462                ping: ping.map_err(|e| e.to_string()),
463                peer_status,
464                local_status: local_status.map_err(|e| e.to_string()),
465            };
466            let _ = tx.send((peer_for_task, fetch));
467        });
468        self.drill = DrillState::Loading { peer, bin };
469    }
470}
471
472fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
473    t.bins
474        .iter()
475        .enumerate()
476        .map(|(i, b)| {
477            let bin = i as u8;
478            let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
479            BinStripRow {
480                bin,
481                population: b.population,
482                connected: b.connected,
483                status: classify_bin(b, bin, t.depth),
484                is_relevant,
485            }
486        })
487        .collect()
488}
489
490/// Reduce the per-bin classification to a single header summary.
491/// `relevant` counts bins at or below `depth + FAR_BIN_RELAXATION`
492/// — operators can't act on a far bin's emptiness so it doesn't
493/// belong in the "X of N relevant" denominator. `worst_bin` is the
494/// starving bin with the lowest connected count, breaking ties by
495/// the lowest bin number (closer to the network root).
496fn compute_saturation_summary(bins: &[BinStripRow]) -> SaturationSummary {
497    let mut summary = SaturationSummary::default();
498    let mut worst: Option<&BinStripRow> = None;
499    for row in bins {
500        if row.is_relevant {
501            summary.relevant += 1;
502        }
503        match row.status {
504            BinSaturation::Starving => {
505                summary.starving += 1;
506                let pick_this = match worst {
507                    None => true,
508                    Some(prev) => {
509                        row.connected < prev.connected
510                            || (row.connected == prev.connected && row.bin < prev.bin)
511                    }
512                };
513                if pick_this {
514                    worst = Some(row);
515                }
516            }
517            BinSaturation::Over => summary.over += 1,
518            BinSaturation::Empty | BinSaturation::Healthy => {}
519        }
520    }
521    if let Some(w) = worst {
522        summary.worst_bin = Some(w.bin);
523        summary.worst_connected = w.connected;
524    }
525    summary
526}
527
528fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
529    if b.connected > OVER_SATURATION_PEERS {
530        return BinSaturation::Over;
531    }
532    if b.connected >= SATURATION_PEERS {
533        return BinSaturation::Healthy;
534    }
535    // Below saturation: starving only if this bin is at or near the
536    // current depth. Far bins are expected to be sparse.
537    if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
538        BinSaturation::Starving
539    } else {
540        BinSaturation::Empty
541    }
542}
543
544fn peer_rows(t: &Topology) -> Vec<PeerRow> {
545    let mut out: Vec<PeerRow> = Vec::new();
546    for (i, b) in t.bins.iter().enumerate() {
547        let bin = i as u8;
548        for p in &b.connected_peers {
549            out.push(make_peer_row(bin, p));
550        }
551    }
552    // Stable order: bin asc, then peer overlay asc — so the table
553    // doesn't shuffle every poll tick when populations don't change.
554    out.sort_by(|a, b| {
555        a.bin
556            .cmp(&b.bin)
557            .then_with(|| a.peer_short.cmp(&b.peer_short))
558    });
559    out
560}
561
562fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
563    let peer_short = short_overlay(&p.address);
564    let peer_full = p.address.trim_start_matches("0x").to_string();
565    let (direction, latency, healthy, reachability) = match &p.metrics {
566        Some(m) => {
567            let direction = match m.session_connection_direction.as_str() {
568                "inbound" => "in",
569                "outbound" => "out",
570                _ => "?",
571            };
572            let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
573            let latency = if m.latency_ewma > 0 {
574                format!("{latency_ms:.0}ms")
575            } else {
576                "—".into()
577            };
578            (direction, latency, m.healthy, m.reachability.clone())
579        }
580        None => ("?", "—".into(), false, String::new()),
581    };
582    PeerRow {
583        bin,
584        peer_short,
585        peer_full,
586        direction,
587        latency,
588        healthy,
589        reachability,
590    }
591}
592
593fn short_overlay(s: &str) -> String {
594    let trimmed = s.trim_start_matches("0x");
595    if trimmed.len() > 10 {
596        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
597    } else {
598        trimmed.to_string()
599    }
600}
601
602/// Format a PLUR `BigInt` as a signed `"BZZ X.XXXX"` string. Mirrors
603/// the helper in `swap.rs` — duplicated here to keep peers.rs free
604/// of cross-module coupling and avoid widening the public API.
605fn format_plur_signed(plur: &BigInt) -> String {
606    let zero = BigInt::from(0);
607    let neg = plur < &zero;
608    let abs = if neg { -plur.clone() } else { plur.clone() };
609    let scale = BigInt::from(10u64).pow(16);
610    let whole = &abs / &scale;
611    let frac = &abs % &scale;
612    let frac_4 = &frac / BigInt::from(10u64).pow(12);
613    let sign = if neg { "-" } else { "+" };
614    format!("{sign}BZZ {whole}.{frac_4:0>4}")
615}
616
617fn format_opt_plur(plur: Option<&BigInt>) -> String {
618    match plur {
619        Some(p) => format_plur_signed(p).trim_start_matches('+').to_string(),
620        None => "—".to_string(),
621    }
622}
623
624impl Component for Peers {
625    fn update(&mut self, action: Action) -> Result<Option<Action>> {
626        if matches!(action, Action::Tick) {
627            self.pull_latest();
628            self.drain_fetches();
629        }
630        Ok(None)
631    }
632
633    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
634        // Drill mode: Esc dismisses; everything else is ignored
635        // (drill is read-only). Selection keys still work in Idle so
636        // the user can navigate before hitting Enter.
637        if matches!(
638            self.drill,
639            DrillState::Loaded { .. } | DrillState::Loading { .. }
640        ) && matches!(key.code, KeyCode::Esc)
641        {
642            self.drill = DrillState::Idle;
643            return Ok(None);
644        }
645        match key.code {
646            KeyCode::Char('j') | KeyCode::Down => {
647                let n = self.peer_rows_cached().len();
648                if n > 0 && self.selected + 1 < n {
649                    self.selected += 1;
650                }
651            }
652            KeyCode::Char('k') | KeyCode::Up => {
653                self.selected = self.selected.saturating_sub(1);
654            }
655            KeyCode::Enter => {
656                self.maybe_start_drill();
657            }
658            _ => {}
659        }
660        Ok(None)
661    }
662
663    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
664        let chunks = Layout::vertical([
665            Constraint::Length(3),  // header
666            Constraint::Length(20), // bin strip (32 lines + header)
667            Constraint::Min(0),     // peer table OR drill
668            Constraint::Length(1),  // selected detail (full overlay)
669            Constraint::Length(1),  // footer
670        ])
671        .split(area);
672
673        // Header
674        let t = theme::active();
675        let mut header_l1 = vec![Span::styled(
676            "PEERS / TOPOLOGY",
677            Style::default().add_modifier(Modifier::BOLD),
678        )];
679        if let DrillState::Loaded { view } = &self.drill {
680            // Full peer overlay so operators can click-drag to copy
681            // for support threads / explorer lookups. The 64-char
682            // hex fits cleanly on this line for typical terminals.
683            header_l1.push(Span::raw("   · drill "));
684            header_l1.push(Span::styled(
685                view.peer_overlay.clone(),
686                Style::default().fg(t.info),
687            ));
688        } else if let DrillState::Loading { peer, .. } = &self.drill {
689            header_l1.push(Span::raw("   · drill "));
690            header_l1.push(Span::styled(peer.clone(), Style::default().fg(t.info)));
691            header_l1.push(Span::raw(" (loading)"));
692        }
693        let header_l1 = Line::from(header_l1);
694        let mut header_l2 = Vec::new();
695        if let Some(err) = &self.snapshot.last_error {
696            let (color, msg) = theme::classify_header_error(err);
697            header_l2.push(Span::styled(msg, Style::default().fg(color)));
698        } else if !self.snapshot.is_loaded() {
699            header_l2.push(Span::styled(
700                format!("{} loading…", theme::spinner_glyph()),
701                Style::default().fg(t.dim),
702            ));
703        } else if let Some(view) = Self::view_for(&self.snapshot) {
704            // Saturation rollup line. Healthy node = single-glance
705            // green; starving node surfaces the worst bin in red so
706            // operators don't have to scan all 32 rows of the strip.
707            let s = view.saturation;
708            if s.is_alert() {
709                let mut spans = vec![
710                    Span::styled(
711                        format!("  {} STARVING ", t.glyphs.fail),
712                        Style::default().fg(t.fail).add_modifier(Modifier::BOLD),
713                    ),
714                    Span::raw(format!("{} of {} relevant bins", s.starving, s.relevant)),
715                ];
716                if let Some(b) = s.worst_bin {
717                    spans.push(Span::raw(format!(
718                        " · worst bin {b} ({}/{})",
719                        s.worst_connected, SATURATION_PEERS
720                    )));
721                }
722                if s.over > 0 {
723                    spans.push(Span::styled(
724                        format!("  · {} over-saturated", s.over),
725                        Style::default().fg(t.warn),
726                    ));
727                }
728                header_l2.extend(spans);
729            } else {
730                header_l2.push(Span::styled(
731                    format!(
732                        "  {} all {} relevant bins healthy",
733                        t.glyphs.pass, s.relevant
734                    ),
735                    Style::default().fg(t.pass),
736                ));
737                if s.over > 0 {
738                    header_l2.push(Span::styled(
739                        format!(" · {} over-saturated", s.over),
740                        Style::default().fg(t.warn),
741                    ));
742                }
743            }
744        }
745        frame.render_widget(
746            Paragraph::new(vec![header_l1, Line::from(header_l2)])
747                .block(Block::default().borders(Borders::BOTTOM)),
748            chunks[0],
749        );
750
751        let view = match Self::view_for(&self.snapshot) {
752            Some(v) => v,
753            None => {
754                frame.render_widget(
755                    Paragraph::new(Span::styled(
756                        "  topology not loaded yet",
757                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
758                    )),
759                    chunks[1],
760                );
761                return Ok(());
762            }
763        };
764
765        // Bin strip
766        let mut strip_lines: Vec<Line> = vec![
767            Line::from(vec![Span::styled(
768                format!(
769                    "  depth {} · connected {} / known {} · reachability {} · net {}",
770                    view.depth,
771                    view.connected,
772                    view.population,
773                    if view.reachability.is_empty() {
774                        "?".to_string()
775                    } else {
776                        view.reachability.clone()
777                    },
778                    if view.network_availability.is_empty() {
779                        "?".to_string()
780                    } else {
781                        view.network_availability.clone()
782                    },
783                ),
784                Style::default().fg(t.dim),
785            )]),
786            Line::from(Span::styled(
787                "  BIN  POP  CONN  BAR              STATUS",
788                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
789            )),
790        ];
791        for r in &view.bins {
792            // Skip far empty bins past the relaxation window — too much
793            // noise to render all 32 when only the first ~12 matter.
794            if !r.is_relevant && r.population == 0 {
795                continue;
796            }
797            let bar = bin_bar(r.connected as usize, 12);
798            strip_lines.push(Line::from(vec![
799                Span::raw("  "),
800                Span::styled(
801                    format!("{:>3} ", r.bin),
802                    Style::default().add_modifier(Modifier::BOLD),
803                ),
804                Span::raw(format!("{:>4} ", r.population)),
805                Span::raw(format!("{:>4}  ", r.connected)),
806                Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
807                Span::raw(" "),
808                Span::styled(
809                    r.status.label(),
810                    Style::default()
811                        .fg(r.status.color())
812                        .add_modifier(Modifier::BOLD),
813                ),
814            ]));
815        }
816        if view.light_connected > 0 {
817            strip_lines.push(Line::from(vec![
818                Span::raw("  "),
819                Span::styled(
820                    format!(
821                        " light  —  {}    (separate from main bins)",
822                        view.light_connected
823                    ),
824                    Style::default().fg(t.dim),
825                ),
826            ]));
827        }
828        frame.render_widget(
829            Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
830            chunks[1],
831        );
832
833        // Peer table OR drill
834        match &self.drill {
835            DrillState::Idle => self.draw_peer_table(frame, chunks[2], &view.peers),
836            DrillState::Loading { peer, .. } => {
837                let msg = Line::from(vec![
838                    Span::raw("  fetching peer drill for "),
839                    Span::styled(
840                        short_overlay(peer),
841                        Style::default().add_modifier(Modifier::BOLD),
842                    ),
843                    Span::raw("…   (Esc cancel)"),
844                ]);
845                frame.render_widget(Paragraph::new(msg), chunks[2]);
846            }
847            DrillState::Loaded { view: drill_view } => {
848                self.draw_peer_drill(frame, chunks[2], drill_view);
849            }
850        }
851
852        // Selected detail — full peer overlay of the highlighted
853        // table row, click-drag selectable. Suppressed during drill
854        // since the drill header already prints the full overlay.
855        if matches!(self.drill, DrillState::Idle) && !view.peers.is_empty() {
856            let i = self.selected.min(view.peers.len() - 1);
857            let row = &view.peers[i];
858            let detail = Line::from(vec![
859                Span::styled("  selected: ", Style::default().fg(t.dim)),
860                Span::styled(row.peer_full.clone(), Style::default().fg(t.info)),
861                Span::raw("  bin "),
862                Span::styled(row.bin.to_string(), Style::default().fg(t.dim)),
863            ]);
864            frame.render_widget(Paragraph::new(detail), chunks[3]);
865        }
866
867        // Footer
868        let footer = match &self.drill {
869            DrillState::Idle => Line::from(vec![
870                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
871                Span::raw(" switch screen  "),
872                Span::styled(
873                    " ↑↓/jk ",
874                    Style::default().fg(Color::Black).bg(Color::White),
875                ),
876                Span::raw(" select  "),
877                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
878                Span::raw(" drill  "),
879                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
880                Span::raw(" help  "),
881                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
882                Span::raw(" quit  "),
883                Span::styled(
884                    format!(
885                        "thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"
886                    ),
887                    Style::default().fg(t.dim),
888                ),
889            ]),
890            _ => Line::from(vec![
891                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
892                Span::raw(" close drill  "),
893                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
894                Span::raw(" switch screen  "),
895                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
896                Span::raw(" help  "),
897                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
898                Span::raw(" quit "),
899            ]),
900        };
901        frame.render_widget(Paragraph::new(footer), chunks[4]);
902
903        Ok(())
904    }
905}
906
907impl Peers {
908    fn draw_peer_table(&mut self, frame: &mut Frame, area: Rect, peers: &[PeerRow]) {
909        use ratatui::layout::{Constraint, Layout};
910
911        let t = theme::active();
912
913        // Two-row split: pinned column header + scrollable body. The
914        // header doesn't scroll out from under the cursor, which is
915        // what operators expect after using k9s / lazygit.
916        let table_chunks =
917            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
918
919        frame.render_widget(
920            Paragraph::new(Line::from(Span::styled(
921                "   BIN  PEER          DIR  LATENCY   HEALTHY  REACHABILITY",
922                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
923            ))),
924            table_chunks[0],
925        );
926
927        if peers.is_empty() {
928            frame.render_widget(
929                Paragraph::new(Line::from(Span::styled(
930                    "   (no connected peers reported)",
931                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
932                ))),
933                table_chunks[1],
934            );
935            return;
936        }
937
938        let mut peer_lines: Vec<Line> = Vec::with_capacity(peers.len());
939        for (i, p) in peers.iter().enumerate() {
940            let g = theme::active().glyphs;
941            let healthy_glyph = if p.healthy { g.pass } else { g.fail };
942            let healthy_style = if p.healthy {
943                Style::default().fg(t.pass)
944            } else {
945                Style::default().fg(t.fail)
946            };
947            let cursor = if i == self.selected {
948                format!("{} ", t.glyphs.cursor)
949            } else {
950                "  ".to_string()
951            };
952            peer_lines.push(Line::from(vec![
953                Span::styled(
954                    cursor,
955                    Style::default()
956                        .fg(if i == self.selected { t.accent } else { t.dim })
957                        .add_modifier(Modifier::BOLD),
958                ),
959                Span::raw(format!("{:>3}  ", p.bin)),
960                Span::raw(format!("{:<13} ", p.peer_short)),
961                Span::raw(format!("{:<4} ", p.direction)),
962                Span::raw(format!("{:<8}  ", p.latency)),
963                Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
964                Span::raw(p.reachability.clone()),
965            ]));
966        }
967
968        let body = table_chunks[1];
969        let visible_rows = body.height as usize;
970        self.scroll_offset = super::scroll::clamp_scroll(
971            self.selected,
972            self.scroll_offset,
973            visible_rows,
974            peer_lines.len(),
975        );
976        frame.render_widget(
977            Paragraph::new(peer_lines.clone()).scroll((self.scroll_offset as u16, 0)),
978            body,
979        );
980        super::scroll::render_scrollbar(
981            frame,
982            body,
983            self.scroll_offset,
984            visible_rows,
985            peer_lines.len(),
986        );
987    }
988
989    fn draw_peer_drill(&self, frame: &mut Frame, area: Rect, view: &PeerDrillView) {
990        let t = theme::active();
991        let mut lines: Vec<Line> = Vec::new();
992        let bin_label = view
993            .bin
994            .map(|b| format!("bin {b}"))
995            .unwrap_or_else(|| "bin ?".into());
996        lines.push(Line::from(vec![
997            Span::raw("  peer "),
998            Span::styled(
999                view.peer_overlay.clone(),
1000                Style::default().add_modifier(Modifier::BOLD),
1001            ),
1002            Span::raw("   "),
1003            Span::styled(bin_label, Style::default().fg(t.dim)),
1004        ]));
1005        lines.push(Line::from(""));
1006        lines.push(drill_field_line("balance        ", &view.balance, t));
1007        lines.push(drill_field_line("ping rtt       ", &view.ping, t));
1008        lines.push(drill_field_line(
1009            "settle recv    ",
1010            &view.settlement_received,
1011            t,
1012        ));
1013        lines.push(drill_field_line(
1014            "settle sent    ",
1015            &view.settlement_sent,
1016            t,
1017        ));
1018        lines.push(drill_field_optional_line(
1019            "cheque last in ",
1020            &view.last_received_cheque,
1021            t,
1022        ));
1023        lines.push(drill_field_optional_line(
1024            "cheque last out",
1025            &view.last_sent_cheque,
1026            t,
1027        ));
1028        lines.push(Line::from(""));
1029        // Reserve-state rows from /status/peers — same row a peer
1030        // would be flagged on by `bee-scripts/bad-status.sh`. These
1031        // surface together because they all derive from one row of
1032        // the bulk response.
1033        lines.push(drill_field_line("storage radius ", &view.storage_radius, t));
1034        lines.push(drill_field_line("reserve size   ", &view.reserve_size, t));
1035        lines.push(drill_field_line("pullsync rate  ", &view.pullsync_rate, t));
1036        lines.push(drill_batch_commitment_line(
1037            "batch commit   ",
1038            &view.batch_commitment,
1039            t,
1040        ));
1041        lines.push(Line::from(""));
1042        lines.push(Line::from(Span::styled(
1043            "  (Esc to dismiss · figures are point-in-time, not live-updating)",
1044            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
1045        )));
1046        frame.render_widget(Paragraph::new(lines), area);
1047    }
1048}
1049
1050fn drill_batch_commitment_line(
1051    label: &str,
1052    field: &DrillField<BatchCommitmentCell>,
1053    t: &theme::Theme,
1054) -> Line<'static> {
1055    match field {
1056        DrillField::Ok(cell) => {
1057            let value_style = if cell.outlier {
1058                Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
1059            } else {
1060                Style::default().add_modifier(Modifier::BOLD)
1061            };
1062            let mut spans = vec![
1063                Span::raw("  "),
1064                Span::styled(label.to_string(), Style::default().fg(t.dim)),
1065                Span::raw("  "),
1066                Span::styled(cell.formatted.clone(), value_style),
1067            ];
1068            if cell.outlier {
1069                spans.push(Span::styled(
1070                    "  (>5% off local — outlier)",
1071                    Style::default().fg(t.fail).add_modifier(Modifier::ITALIC),
1072                ));
1073            }
1074            Line::from(spans)
1075        }
1076        DrillField::Err(e) => Line::from(vec![
1077            Span::raw("  "),
1078            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1079            Span::raw("  "),
1080            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
1081        ]),
1082    }
1083}
1084
1085/// Compute the four reserve-state cells (`storage_radius`,
1086/// `reserve_size`, `pullsync_rate`, `batch_commitment`) from the
1087/// `/status/peers` row + the local node's `/status`. Pure — no I/O,
1088/// no allocation beyond the formatted strings.
1089///
1090/// Outlier rule for `batch_commitment`: |peer - local| / local > 5%
1091/// paints red. Mirrors the filter in `bee-scripts/bad-status.sh` so
1092/// operators reading both tools see the same set of warnings.
1093fn compute_reserve_state_fields(
1094    peer: &std::result::Result<Option<PeerStatus>, String>,
1095    local: &std::result::Result<Status, String>,
1096) -> (
1097    DrillField<String>,
1098    DrillField<String>,
1099    DrillField<String>,
1100    DrillField<BatchCommitmentCell>,
1101) {
1102    let peer_status = match peer {
1103        Ok(Some(p)) => &p.status,
1104        Ok(None) => {
1105            let msg = "(no /status/peers row for this overlay)".to_string();
1106            return (
1107                DrillField::Err(msg.clone()),
1108                DrillField::Err(msg.clone()),
1109                DrillField::Err(msg.clone()),
1110                DrillField::Err(msg),
1111            );
1112        }
1113        Err(e) => {
1114            return (
1115                DrillField::Err(e.clone()),
1116                DrillField::Err(e.clone()),
1117                DrillField::Err(e.clone()),
1118                DrillField::Err(e.clone()),
1119            );
1120        }
1121    };
1122    let storage_radius = DrillField::Ok(peer_status.storage_radius.to_string());
1123    let reserve_size = DrillField::Ok(format_thousands(peer_status.reserve_size));
1124    let pullsync_rate = DrillField::Ok(format!("{:.2} chunks/s", peer_status.pullsync_rate));
1125    let outlier = match local {
1126        Ok(l) if l.batch_commitment > 0 => {
1127            let delta = (peer_status.batch_commitment - l.batch_commitment).abs() as f64;
1128            (delta / l.batch_commitment as f64) > 0.05
1129        }
1130        _ => false,
1131    };
1132    let batch_commitment = DrillField::Ok(BatchCommitmentCell {
1133        formatted: format_thousands(peer_status.batch_commitment),
1134        outlier,
1135    });
1136    (
1137        storage_radius,
1138        reserve_size,
1139        pullsync_rate,
1140        batch_commitment,
1141    )
1142}
1143
1144fn format_thousands(n: i64) -> String {
1145    let s = n.abs().to_string();
1146    let mut out = String::with_capacity(s.len() + s.len() / 3);
1147    if n < 0 {
1148        out.push('-');
1149    }
1150    for (i, ch) in s.chars().enumerate() {
1151        if i > 0 && (s.len() - i) % 3 == 0 {
1152            out.push(' ');
1153        }
1154        out.push(ch);
1155    }
1156    out
1157}
1158
1159fn drill_field_line(label: &str, field: &DrillField<String>, t: &theme::Theme) -> Line<'static> {
1160    match field {
1161        DrillField::Ok(v) => Line::from(vec![
1162            Span::raw("  "),
1163            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1164            Span::raw("  "),
1165            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
1166        ]),
1167        DrillField::Err(e) => Line::from(vec![
1168            Span::raw("  "),
1169            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1170            Span::raw("  "),
1171            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
1172        ]),
1173    }
1174}
1175
1176fn drill_field_optional_line(
1177    label: &str,
1178    field: &DrillField<Option<String>>,
1179    t: &theme::Theme,
1180) -> Line<'static> {
1181    match field {
1182        DrillField::Ok(Some(v)) => Line::from(vec![
1183            Span::raw("  "),
1184            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1185            Span::raw("  "),
1186            Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
1187        ]),
1188        DrillField::Ok(None) => Line::from(vec![
1189            Span::raw("  "),
1190            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1191            Span::raw("  "),
1192            Span::styled(
1193                "(no cheque yet)",
1194                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
1195            ),
1196        ]),
1197        DrillField::Err(e) => Line::from(vec![
1198            Span::raw("  "),
1199            Span::styled(label.to_string(), Style::default().fg(t.dim)),
1200            Span::raw("  "),
1201            Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
1202        ]),
1203    }
1204}
1205
1206/// Width-bounded ASCII bar showing connected count, capped at
1207/// [`OVER_SATURATION_PEERS`] for visual scale.
1208fn bin_bar(connected: usize, width: usize) -> String {
1209    let scale = OVER_SATURATION_PEERS as usize;
1210    let filled = connected.min(scale) * width / scale.max(1);
1211    let mut bar = String::with_capacity(width);
1212    for _ in 0..filled.min(width) {
1213        bar.push('▇');
1214    }
1215    for _ in filled.min(width)..width {
1216        bar.push('░');
1217    }
1218    bar
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224
1225    fn bin(population: u64, connected: u64) -> BinInfo {
1226        BinInfo {
1227            population,
1228            connected,
1229            ..BinInfo::default()
1230        }
1231    }
1232
1233    #[test]
1234    fn classify_below_saturation_in_relevant_bin_is_starving() {
1235        // bin 4, depth 8 → bin <= depth + 4 = 12 → relevant → starving.
1236        assert_eq!(classify_bin(&bin(5, 3), 4, 8), BinSaturation::Starving);
1237    }
1238
1239    #[test]
1240    fn classify_below_saturation_in_far_bin_is_empty() {
1241        // bin 20, depth 8 → bin > depth + 4 = 12 → far → empty.
1242        assert_eq!(classify_bin(&bin(0, 0), 20, 8), BinSaturation::Empty);
1243    }
1244
1245    #[test]
1246    fn classify_in_safe_band_is_healthy() {
1247        assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
1248        assert_eq!(
1249            classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
1250            BinSaturation::Healthy
1251        );
1252    }
1253
1254    #[test]
1255    fn classify_over_threshold_is_over() {
1256        assert_eq!(
1257            classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
1258            BinSaturation::Over
1259        );
1260    }
1261
1262    #[test]
1263    fn short_overlay_truncates() {
1264        let s = short_overlay("0xabcdef0123456789abcdef0123456789");
1265        assert!(s.contains('…'));
1266        assert!(s.starts_with("abcdef"));
1267    }
1268
1269    #[test]
1270    fn bin_bar_caps_at_oversaturation() {
1271        let bar_full = bin_bar(50, 12);
1272        assert_eq!(bar_full, "▇".repeat(12));
1273        let bar_empty = bin_bar(0, 12);
1274        assert_eq!(bar_empty, "░".repeat(12));
1275    }
1276
1277    fn strip_row(bin: u8, connected: u64, status: BinSaturation, is_relevant: bool) -> BinStripRow {
1278        BinStripRow {
1279            bin,
1280            population: connected,
1281            connected,
1282            status,
1283            is_relevant,
1284        }
1285    }
1286
1287    #[test]
1288    fn saturation_summary_all_healthy_has_no_alert() {
1289        let bins = vec![
1290            strip_row(0, 10, BinSaturation::Healthy, true),
1291            strip_row(1, 12, BinSaturation::Healthy, true),
1292            strip_row(2, 14, BinSaturation::Healthy, true),
1293        ];
1294        let s = compute_saturation_summary(&bins);
1295        assert!(!s.is_alert());
1296        assert_eq!(s.starving, 0);
1297        assert_eq!(s.over, 0);
1298        assert_eq!(s.relevant, 3);
1299        assert_eq!(s.worst_bin, None);
1300    }
1301
1302    #[test]
1303    fn saturation_summary_picks_lowest_connected_starving_bin() {
1304        // bins 1 (3/8) and 4 (2/8) starving; bin 4 is worse.
1305        let bins = vec![
1306            strip_row(0, 10, BinSaturation::Healthy, true),
1307            strip_row(1, 3, BinSaturation::Starving, true),
1308            strip_row(2, 12, BinSaturation::Healthy, true),
1309            strip_row(4, 2, BinSaturation::Starving, true),
1310        ];
1311        let s = compute_saturation_summary(&bins);
1312        assert!(s.is_alert());
1313        assert_eq!(s.starving, 2);
1314        assert_eq!(s.worst_bin, Some(4));
1315        assert_eq!(s.worst_connected, 2);
1316    }
1317
1318    #[test]
1319    fn saturation_summary_breaks_ties_by_lowest_bin() {
1320        // Two bins at 0 connected; bin 1 wins because it's closer
1321        // to the network root (lower bin number).
1322        let bins = vec![
1323            strip_row(7, 0, BinSaturation::Starving, true),
1324            strip_row(1, 0, BinSaturation::Starving, true),
1325        ];
1326        let s = compute_saturation_summary(&bins);
1327        assert_eq!(s.worst_bin, Some(1));
1328    }
1329
1330    #[test]
1331    fn saturation_summary_excludes_far_bins_from_relevant_count() {
1332        // Far bins (is_relevant=false) shouldn't count toward
1333        // the "X of N relevant" denominator even when populated.
1334        let bins = vec![
1335            strip_row(0, 10, BinSaturation::Healthy, true),
1336            strip_row(1, 10, BinSaturation::Healthy, true),
1337            strip_row(20, 0, BinSaturation::Empty, false),
1338            strip_row(21, 0, BinSaturation::Empty, false),
1339        ];
1340        let s = compute_saturation_summary(&bins);
1341        assert_eq!(s.relevant, 2);
1342    }
1343
1344    #[test]
1345    fn saturation_summary_counts_over_separately() {
1346        let bins = vec![
1347            strip_row(0, 10, BinSaturation::Healthy, true),
1348            strip_row(1, 25, BinSaturation::Over, true),
1349            strip_row(2, 30, BinSaturation::Over, true),
1350        ];
1351        let s = compute_saturation_summary(&bins);
1352        assert!(!s.is_alert()); // over-sat alone isn't an alert
1353        assert_eq!(s.over, 2);
1354        assert_eq!(s.starving, 0);
1355    }
1356
1357    #[test]
1358    fn drill_view_all_ok() {
1359        // 5 × 10^14 PLUR = 0.05 BZZ — typical cheque magnitude.
1360        let cheque_in = bee::debug::Cheque {
1361            beneficiary: "0x".into(),
1362            chequebook: "0x".into(),
1363            payout: Some(BigInt::from(500_000_000_000_000u64)),
1364        };
1365        let fetch = PeerDrillFetch {
1366            balance: Ok(Balance {
1367                peer: "abcd".into(),
1368                balance: BigInt::from(123_400_000_000_000_000i64),
1369            }),
1370            cheques: Ok(PeerCheques {
1371                peer: "abcd".into(),
1372                last_received: Some(cheque_in),
1373                last_sent: None,
1374            }),
1375            settlement: Ok(Settlement {
1376                peer: "abcd".into(),
1377                received: Some(BigInt::from(900_000_000_000_000_000u64)),
1378                sent: Some(BigInt::from(100_000_000_000_000_000u64)),
1379            }),
1380            ping: Ok("4.21ms".into()),
1381            peer_status: Ok(None),
1382            local_status: Err("not under test here".into()),
1383        };
1384        let view = Peers::compute_peer_drill_view("abcd1234", Some(7), &fetch);
1385        assert_eq!(view.bin, Some(7));
1386        match &view.balance {
1387            DrillField::Ok(s) => assert!(s.contains("BZZ")),
1388            _ => panic!("expected ok balance"),
1389        }
1390        match &view.last_received_cheque {
1391            DrillField::Ok(Some(s)) => assert!(s.contains("0.0500")),
1392            _ => panic!("expected received cheque payout"),
1393        }
1394        match &view.last_sent_cheque {
1395            DrillField::Ok(None) => {}
1396            _ => panic!("expected None for sent cheque"),
1397        }
1398    }
1399
1400    #[test]
1401    fn drill_view_partial_failure_keeps_other_fields() {
1402        // Settlement endpoint failed, the rest succeeded — drill view
1403        // must still surface what came back rather than blanking.
1404        let fetch = PeerDrillFetch {
1405            balance: Ok(Balance {
1406                peer: "x".into(),
1407                balance: BigInt::from(0),
1408            }),
1409            cheques: Err("404".into()),
1410            settlement: Err("503 Node is syncing".into()),
1411            ping: Ok("12ms".into()),
1412            peer_status: Err("503".into()),
1413            local_status: Err("503".into()),
1414        };
1415        let view = Peers::compute_peer_drill_view("xxxx", None, &fetch);
1416        assert!(matches!(view.balance, DrillField::Ok(_)));
1417        assert!(matches!(view.ping, DrillField::Ok(_)));
1418        assert!(matches!(view.settlement_received, DrillField::Err(_)));
1419        assert!(matches!(view.last_received_cheque, DrillField::Err(_)));
1420    }
1421
1422    #[test]
1423    fn format_plur_signed_handles_zero_and_negative() {
1424        assert_eq!(format_plur_signed(&BigInt::from(0)), "+BZZ 0.0000");
1425        assert_eq!(
1426            format_plur_signed(&BigInt::from(-5_000_000_000_000_000i64)),
1427            "-BZZ 0.5000"
1428        );
1429    }
1430
1431    fn local_status_with_commitment(commitment: i64) -> Status {
1432        Status {
1433            batch_commitment: commitment,
1434            ..Status::default()
1435        }
1436    }
1437
1438    fn peer_status_with(
1439        overlay: &str,
1440        storage_radius: i64,
1441        reserve_size: i64,
1442        pullsync_rate: f64,
1443        batch_commitment: i64,
1444    ) -> PeerStatus {
1445        PeerStatus {
1446            status: Status {
1447                overlay: overlay.into(),
1448                storage_radius,
1449                reserve_size,
1450                pullsync_rate,
1451                batch_commitment,
1452                ..Status::default()
1453            },
1454            request_failed: false,
1455        }
1456    }
1457
1458    #[test]
1459    fn batch_commitment_within_5pct_is_not_outlier() {
1460        let local = Ok(local_status_with_commitment(99_715_645_440));
1461        let peer = Ok(Some(peer_status_with(
1462            "abcd",
1463            8,
1464            420_000,
1465            12.5,
1466            99_700_000_000,
1467        )));
1468        let (radius, reserve, rate, commit) = compute_reserve_state_fields(&peer, &local);
1469        assert!(matches!(radius, DrillField::Ok(ref s) if s == "8"));
1470        assert!(matches!(reserve, DrillField::Ok(ref s) if s.contains("420")));
1471        assert!(matches!(rate, DrillField::Ok(ref s) if s.starts_with("12.50")));
1472        match commit {
1473            DrillField::Ok(cell) => {
1474                assert!(!cell.outlier, "0.015% delta should not flag outlier");
1475                assert!(cell.formatted.contains("99 700"));
1476            }
1477            _ => panic!("expected ok commit"),
1478        }
1479    }
1480
1481    #[test]
1482    fn batch_commitment_above_5pct_is_outlier() {
1483        let local = Ok(local_status_with_commitment(99_715_645_440));
1484        let peer = Ok(Some(peer_status_with(
1485            "abcd",
1486            8,
1487            420_000,
1488            12.5,
1489            50_000_000_000, // ~50% off → outlier
1490        )));
1491        let (_, _, _, commit) = compute_reserve_state_fields(&peer, &local);
1492        match commit {
1493            DrillField::Ok(cell) => assert!(cell.outlier, "50% delta should flag outlier"),
1494            _ => panic!("expected ok commit"),
1495        }
1496    }
1497
1498    #[test]
1499    fn missing_peer_status_row_renders_as_err_consistently() {
1500        let local = Ok(local_status_with_commitment(99_715_645_440));
1501        let peer = Ok(None);
1502        let (radius, reserve, rate, commit) = compute_reserve_state_fields(&peer, &local);
1503        assert!(matches!(radius, DrillField::Err(_)));
1504        assert!(matches!(reserve, DrillField::Err(_)));
1505        assert!(matches!(rate, DrillField::Err(_)));
1506        assert!(matches!(commit, DrillField::Err(_)));
1507    }
1508
1509    #[test]
1510    fn local_status_failure_does_not_flag_outlier() {
1511        // When we can't read our own /status, we can't decide; treat
1512        // as non-outlier (don't paint red without evidence).
1513        let local: std::result::Result<Status, String> = Err("503".into());
1514        let peer = Ok(Some(peer_status_with("abcd", 8, 420_000, 12.5, 1)));
1515        let (_, _, _, commit) = compute_reserve_state_fields(&peer, &local);
1516        match commit {
1517            DrillField::Ok(cell) => assert!(!cell.outlier),
1518            _ => panic!("expected ok commit"),
1519        }
1520    }
1521
1522    #[test]
1523    fn format_thousands_handles_typical_values() {
1524        assert_eq!(format_thousands(0), "0");
1525        assert_eq!(format_thousands(123), "123");
1526        assert_eq!(format_thousands(1_234), "1 234");
1527        assert_eq!(format_thousands(99_715_645_440), "99 715 645 440");
1528        assert_eq!(format_thousands(-1_234), "-1 234");
1529    }
1530}