1use 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
52pub const BIN_COUNT: usize = 32;
54pub const SATURATION_PEERS: u64 = 8;
58pub const OVER_SATURATION_PEERS: u64 = 18;
61const FAR_BIN_RELAXATION: u8 = 4;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum BinSaturation {
69 Empty,
72 Starving,
75 Healthy,
77 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#[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 pub is_relevant: bool,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct PeerRow {
118 pub bin: u8,
119 pub peer_short: String,
120 pub peer_full: String,
123 pub direction: &'static str,
126 pub latency: String,
129 pub healthy: bool,
130 pub reachability: String,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140pub struct SaturationSummary {
141 pub starving: usize,
144 pub over: usize,
146 pub relevant: usize,
149 pub worst_bin: Option<u8>,
153 pub worst_connected: u64,
155}
156
157impl SaturationSummary {
158 pub fn is_alert(&self) -> bool {
162 self.starving > 0
163 }
164}
165
166#[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 pub light_connected: u64,
179 pub saturation: SaturationSummary,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum DrillField<T: Clone + PartialEq + Eq> {
189 Ok(T),
190 Err(String),
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct PeerDrillView {
197 pub peer_overlay: String,
198 pub bin: Option<u8>,
199 pub balance: DrillField<String>,
202 pub ping: DrillField<String>,
205 pub settlement_received: DrillField<String>,
207 pub settlement_sent: DrillField<String>,
209 pub last_received_cheque: DrillField<Option<String>>,
211 pub last_sent_cheque: DrillField<Option<String>>,
213 pub storage_radius: DrillField<String>,
216 pub reserve_size: DrillField<String>,
217 pub pullsync_rate: DrillField<String>,
218 pub batch_commitment: DrillField<BatchCommitmentCell>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct BatchCommitmentCell {
229 pub formatted: String,
231 pub outlier: bool,
233}
234
235#[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#[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 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 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, };
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 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 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 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 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 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
490fn 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 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 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
602fn 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 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), Constraint::Length(20), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
671 .split(area);
672
673 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 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 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 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 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 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 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 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 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 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
1085fn 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
1206fn 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 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 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 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 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 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()); assert_eq!(s.over, 2);
1354 assert_eq!(s.starving, 0);
1355 }
1356
1357 #[test]
1358 fn drill_view_all_ok() {
1359 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 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, )));
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 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}