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