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 two halves:
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.
18//!
19//! Render delegates to the pure [`Peers::view_for`] so the snapshot
20//! tests in `tests/s6_peers_view.rs` pin every classification edge
21//! without launching a TUI.
22
23use color_eyre::Result;
24use ratatui::{
25    Frame,
26    layout::{Constraint, Layout, Rect},
27    style::{Color, Modifier, Style},
28    text::{Line, Span},
29    widgets::{Block, Borders, Paragraph},
30};
31use tokio::sync::watch;
32
33use super::Component;
34use crate::action::Action;
35use crate::watch::TopologySnapshot;
36
37use bee::debug::{BinInfo, PeerInfo, Topology};
38
39/// Kademlia bins per Bee build.
40pub const BIN_COUNT: usize = 32;
41/// `pkg/topology/kademlia/kademlia.go:54` — saturation threshold.
42/// Connected peer counts below this in a relevant bin (bin ≤ depth)
43/// are reported as Starving.
44pub const SATURATION_PEERS: u64 = 8;
45/// `pkg/topology/kademlia/kademlia.go:55` — over-saturation threshold.
46/// Connected peer counts above this in any bin are reported as Over.
47pub const OVER_SATURATION_PEERS: u64 = 18;
48/// Relaxation factor for "far" bins. Bins more than this many positions
49/// past the kademlia depth are expected to be sparse and don't flag as
50/// Starving even if their connected count is below saturation.
51const FAR_BIN_RELAXATION: u8 = 4;
52
53/// Tri-state bin saturation classification.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum BinSaturation {
56    /// `connected == 0` and the bin is far from depth — expected to
57    /// be empty.
58    Empty,
59    /// `connected < SATURATION_PEERS` for a bin at or near the depth.
60    /// Operator should add peers (manual `connect`, more uptime).
61    Starving,
62    /// Connected count is in the safe band `[8, 18]`.
63    Healthy,
64    /// Connected count exceeds `OVER_SATURATION_PEERS`. Bee will trim
65    /// oldest entries; harmless but unusual for distant bins.
66    Over,
67}
68
69impl BinSaturation {
70    fn color(self) -> Color {
71        match self {
72            Self::Empty => Color::DarkGray,
73            Self::Starving => Color::Red,
74            Self::Healthy => Color::Green,
75            Self::Over => Color::Yellow,
76        }
77    }
78    fn label(self) -> &'static str {
79        match self {
80            Self::Empty => "—",
81            Self::Starving => "✗ STARVING",
82            Self::Healthy => "✓",
83            Self::Over => "⚠ over",
84        }
85    }
86}
87
88/// One row of the bin saturation strip.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct BinStripRow {
91    pub bin: u8,
92    pub population: u64,
93    pub connected: u64,
94    pub status: BinSaturation,
95    /// `true` if this bin is at or below the current kademlia depth —
96    /// the only bins where Starving carries an operator alert. Far
97    /// bins with low population are normal.
98    pub is_relevant: bool,
99}
100
101/// One row of the peer table.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PeerRow {
104    pub bin: u8,
105    pub peer_short: String,
106    /// `"in"` / `"out"` from the per-peer session direction. `"?"` if
107    /// metrics not yet populated.
108    pub direction: &'static str,
109    /// Latency formatted as `"Xms"` from the EWMA value (Bee returns
110    /// nanoseconds). `"—"` if metrics absent.
111    pub latency: String,
112    pub healthy: bool,
113    /// Per-peer reachability string from MetricSnapshotView. Empty if
114    /// metrics absent.
115    pub reachability: String,
116}
117
118/// Aggregated view fed to the renderer and snapshot tests.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct PeersView {
121    pub bins: Vec<BinStripRow>,
122    pub peers: Vec<PeerRow>,
123    pub depth: u8,
124    pub population: i64,
125    pub connected: i64,
126    pub reachability: String,
127    pub network_availability: String,
128    /// Number of connected light-node peers, separate from the
129    /// 32-bin breakdown.
130    pub light_connected: u64,
131}
132
133pub struct Peers {
134    rx: watch::Receiver<TopologySnapshot>,
135    snapshot: TopologySnapshot,
136}
137
138impl Peers {
139    pub fn new(rx: watch::Receiver<TopologySnapshot>) -> Self {
140        let snapshot = rx.borrow().clone();
141        Self { rx, snapshot }
142    }
143
144    fn pull_latest(&mut self) {
145        self.snapshot = self.rx.borrow().clone();
146    }
147
148    /// Pure, snapshot-driven view computation. Exposed for snapshot
149    /// tests in `tests/s6_peers_view.rs`.
150    pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
151        let t = snap.topology.as_ref()?;
152        let bins = bin_strip_rows(t);
153        let peers = peer_rows(t);
154        Some(PeersView {
155            bins,
156            peers,
157            depth: t.depth,
158            population: t.population,
159            connected: t.connected,
160            reachability: t.reachability.clone(),
161            network_availability: t.network_availability.clone(),
162            light_connected: t.light_nodes.connected,
163        })
164    }
165}
166
167fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
168    t.bins
169        .iter()
170        .enumerate()
171        .map(|(i, b)| {
172            let bin = i as u8;
173            let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
174            BinStripRow {
175                bin,
176                population: b.population,
177                connected: b.connected,
178                status: classify_bin(b, bin, t.depth),
179                is_relevant,
180            }
181        })
182        .collect()
183}
184
185fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
186    if b.connected > OVER_SATURATION_PEERS {
187        return BinSaturation::Over;
188    }
189    if b.connected >= SATURATION_PEERS {
190        return BinSaturation::Healthy;
191    }
192    // Below saturation: starving only if this bin is at or near the
193    // current depth. Far bins are expected to be sparse.
194    if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
195        BinSaturation::Starving
196    } else {
197        BinSaturation::Empty
198    }
199}
200
201fn peer_rows(t: &Topology) -> Vec<PeerRow> {
202    let mut out: Vec<PeerRow> = Vec::new();
203    for (i, b) in t.bins.iter().enumerate() {
204        let bin = i as u8;
205        for p in &b.connected_peers {
206            out.push(make_peer_row(bin, p));
207        }
208    }
209    // Stable order: bin asc, then peer overlay asc — so the table
210    // doesn't shuffle every poll tick when populations don't change.
211    out.sort_by(|a, b| {
212        a.bin
213            .cmp(&b.bin)
214            .then_with(|| a.peer_short.cmp(&b.peer_short))
215    });
216    out
217}
218
219fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
220    let peer_short = short_overlay(&p.address);
221    let (direction, latency, healthy, reachability) = match &p.metrics {
222        Some(m) => {
223            let direction = match m.session_connection_direction.as_str() {
224                "inbound" => "in",
225                "outbound" => "out",
226                _ => "?",
227            };
228            let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
229            let latency = if m.latency_ewma > 0 {
230                format!("{latency_ms:.0}ms")
231            } else {
232                "—".into()
233            };
234            (direction, latency, m.healthy, m.reachability.clone())
235        }
236        None => ("?", "—".into(), false, String::new()),
237    };
238    PeerRow {
239        bin,
240        peer_short,
241        direction,
242        latency,
243        healthy,
244        reachability,
245    }
246}
247
248fn short_overlay(s: &str) -> String {
249    let trimmed = s.trim_start_matches("0x");
250    if trimmed.len() > 10 {
251        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
252    } else {
253        trimmed.to_string()
254    }
255}
256
257impl Component for Peers {
258    fn update(&mut self, action: Action) -> Result<Option<Action>> {
259        if matches!(action, Action::Tick) {
260            self.pull_latest();
261        }
262        Ok(None)
263    }
264
265    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
266        let chunks = Layout::vertical([
267            Constraint::Length(3), // header
268            Constraint::Length(20), // bin strip (32 lines + header)
269            Constraint::Min(0),    // peer table
270            Constraint::Length(1), // footer
271        ])
272        .split(area);
273
274        // Header
275        let header_l1 = Line::from(vec![Span::styled(
276            "PEERS / TOPOLOGY",
277            Style::default().add_modifier(Modifier::BOLD),
278        )]);
279        let mut header_l2 = Vec::new();
280        if let Some(err) = &self.snapshot.last_error {
281            header_l2.push(Span::styled(
282                format!("error: {err}"),
283                Style::default().fg(Color::Red),
284            ));
285        } else if !self.snapshot.is_loaded() {
286            header_l2.push(Span::styled(
287                "loading…",
288                Style::default().fg(Color::DarkGray),
289            ));
290        }
291        frame.render_widget(
292            Paragraph::new(vec![header_l1, Line::from(header_l2)])
293                .block(Block::default().borders(Borders::BOTTOM)),
294            chunks[0],
295        );
296
297        let view = match Self::view_for(&self.snapshot) {
298            Some(v) => v,
299            None => {
300                frame.render_widget(
301                    Paragraph::new(Span::styled(
302                        "  topology not loaded yet",
303                        Style::default()
304                            .fg(Color::DarkGray)
305                            .add_modifier(Modifier::ITALIC),
306                    )),
307                    chunks[1],
308                );
309                return Ok(());
310            }
311        };
312
313        // Bin strip
314        let mut strip_lines: Vec<Line> = vec![
315            Line::from(vec![
316                Span::styled(
317                    format!(
318                        "  depth {} · connected {} / known {} · reachability {} · net {}",
319                        view.depth,
320                        view.connected,
321                        view.population,
322                        if view.reachability.is_empty() {
323                            "?".to_string()
324                        } else {
325                            view.reachability.clone()
326                        },
327                        if view.network_availability.is_empty() {
328                            "?".to_string()
329                        } else {
330                            view.network_availability.clone()
331                        },
332                    ),
333                    Style::default().fg(Color::DarkGray),
334                ),
335            ]),
336            Line::from(Span::styled(
337                "  BIN  POP  CONN  BAR              STATUS",
338                Style::default()
339                    .fg(Color::DarkGray)
340                    .add_modifier(Modifier::BOLD),
341            )),
342        ];
343        for r in &view.bins {
344            // Skip far empty bins past the relaxation window — too much
345            // noise to render all 32 when only the first ~12 matter.
346            if !r.is_relevant && r.population == 0 {
347                continue;
348            }
349            let bar = bin_bar(r.connected as usize, 12);
350            strip_lines.push(Line::from(vec![
351                Span::raw("  "),
352                Span::styled(
353                    format!("{:>3} ", r.bin),
354                    Style::default().add_modifier(Modifier::BOLD),
355                ),
356                Span::raw(format!("{:>4} ", r.population)),
357                Span::raw(format!("{:>4}  ", r.connected)),
358                Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
359                Span::raw(" "),
360                Span::styled(
361                    r.status.label(),
362                    Style::default()
363                        .fg(r.status.color())
364                        .add_modifier(Modifier::BOLD),
365                ),
366            ]));
367        }
368        if view.light_connected > 0 {
369            strip_lines.push(Line::from(vec![
370                Span::raw("  "),
371                Span::styled(
372                    format!(" light  —  {}    (separate from main bins)", view.light_connected),
373                    Style::default().fg(Color::DarkGray),
374                ),
375            ]));
376        }
377        frame.render_widget(
378            Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
379            chunks[1],
380        );
381
382        // Peer table
383        let mut peer_lines: Vec<Line> = vec![Line::from(Span::styled(
384            "  BIN  PEER          DIR  LATENCY   HEALTHY  REACHABILITY",
385            Style::default()
386                .fg(Color::DarkGray)
387                .add_modifier(Modifier::BOLD),
388        ))];
389        if view.peers.is_empty() {
390            peer_lines.push(Line::from(Span::styled(
391                "  (no connected peers reported)",
392                Style::default()
393                    .fg(Color::DarkGray)
394                    .add_modifier(Modifier::ITALIC),
395            )));
396        } else {
397            for p in &view.peers {
398                let healthy_glyph = if p.healthy { "✓" } else { "✗" };
399                let healthy_style = if p.healthy {
400                    Style::default().fg(Color::Green)
401                } else {
402                    Style::default().fg(Color::Red)
403                };
404                peer_lines.push(Line::from(vec![
405                    Span::raw("  "),
406                    Span::raw(format!("{:>3}  ", p.bin)),
407                    Span::raw(format!("{:<13} ", p.peer_short)),
408                    Span::raw(format!("{:<4} ", p.direction)),
409                    Span::raw(format!("{:<8}  ", p.latency)),
410                    Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
411                    Span::raw(p.reachability.clone()),
412                ]));
413            }
414        }
415        frame.render_widget(Paragraph::new(peer_lines), chunks[2]);
416
417        // Footer
418        frame.render_widget(
419            Paragraph::new(Line::from(vec![
420                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
421                Span::raw(" switch screen  "),
422                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
423                Span::raw(" quit  "),
424                Span::styled(
425                    format!("thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"),
426                    Style::default().fg(Color::DarkGray),
427                ),
428            ])),
429            chunks[3],
430        );
431
432        Ok(())
433    }
434}
435
436/// Width-bounded ASCII bar showing connected count, capped at
437/// [`OVER_SATURATION_PEERS`] for visual scale.
438fn bin_bar(connected: usize, width: usize) -> String {
439    let scale = OVER_SATURATION_PEERS as usize;
440    let filled = connected.min(scale) * width / scale.max(1);
441    let mut bar = String::with_capacity(width);
442    for _ in 0..filled.min(width) {
443        bar.push('▇');
444    }
445    for _ in filled.min(width)..width {
446        bar.push('░');
447    }
448    bar
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn bin(population: u64, connected: u64) -> BinInfo {
456        BinInfo {
457            population,
458            connected,
459            ..BinInfo::default()
460        }
461    }
462
463    #[test]
464    fn classify_below_saturation_in_relevant_bin_is_starving() {
465        // bin 4, depth 8 → bin <= depth + 4 = 12 → relevant → starving.
466        assert_eq!(
467            classify_bin(&bin(5, 3), 4, 8),
468            BinSaturation::Starving
469        );
470    }
471
472    #[test]
473    fn classify_below_saturation_in_far_bin_is_empty() {
474        // bin 20, depth 8 → bin > depth + 4 = 12 → far → empty.
475        assert_eq!(
476            classify_bin(&bin(0, 0), 20, 8),
477            BinSaturation::Empty
478        );
479    }
480
481    #[test]
482    fn classify_in_safe_band_is_healthy() {
483        assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
484        assert_eq!(
485            classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
486            BinSaturation::Healthy
487        );
488    }
489
490    #[test]
491    fn classify_over_threshold_is_over() {
492        assert_eq!(
493            classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
494            BinSaturation::Over
495        );
496    }
497
498    #[test]
499    fn short_overlay_truncates() {
500        let s = short_overlay("0xabcdef0123456789abcdef0123456789");
501        assert!(s.contains('…'));
502        assert!(s.starts_with("abcdef"));
503    }
504
505    #[test]
506    fn bin_bar_caps_at_oversaturation() {
507        let bar_full = bin_bar(50, 12);
508        assert_eq!(bar_full, "▇".repeat(12));
509        let bar_empty = bin_bar(0, 12);
510        assert_eq!(bar_empty, "░".repeat(12));
511    }
512}