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::{Balance, BinInfo, PeerCheques, PeerInfo, PeerStatus, Settlement, Status, Topology};
49
50pub const BIN_COUNT: usize = 32;
52pub const SATURATION_PEERS: u64 = 8;
56pub const OVER_SATURATION_PEERS: u64 = 18;
59const FAR_BIN_RELAXATION: u8 = 4;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum BinSaturation {
67 Empty,
70 Starving,
73 Healthy,
75 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#[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 pub is_relevant: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct PeerRow {
116 pub bin: u8,
117 pub peer_short: String,
118 pub peer_full: String,
121 pub direction: &'static str,
124 pub latency: String,
127 pub healthy: bool,
128 pub reachability: String,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub struct SaturationSummary {
139 pub starving: usize,
142 pub over: usize,
144 pub relevant: usize,
147 pub worst_bin: Option<u8>,
151 pub worst_connected: u64,
153}
154
155impl SaturationSummary {
156 pub fn is_alert(&self) -> bool {
160 self.starving > 0
161 }
162}
163
164#[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 pub light_connected: u64,
177 pub saturation: SaturationSummary,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum DrillField<T: Clone + PartialEq + Eq> {
187 Ok(T),
188 Err(String),
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct PeerDrillView {
195 pub peer_overlay: String,
196 pub bin: Option<u8>,
197 pub balance: DrillField<String>,
200 pub ping: DrillField<String>,
203 pub settlement_received: DrillField<String>,
205 pub settlement_sent: DrillField<String>,
207 pub last_received_cheque: DrillField<Option<String>>,
209 pub last_sent_cheque: DrillField<Option<String>>,
211 pub storage_radius: DrillField<String>,
214 pub reserve_size: DrillField<String>,
215 pub pullsync_rate: DrillField<String>,
216 pub batch_commitment: DrillField<BatchCommitmentCell>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct BatchCommitmentCell {
227 pub formatted: String,
229 pub outlier: bool,
231}
232
233#[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#[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 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 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, };
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 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 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 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 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 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
488fn 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 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 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
600fn 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 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), Constraint::Length(20), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
669 .split(area);
670
671 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 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 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 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 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 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 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 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 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 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
1091fn 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
1212fn 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 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 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 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 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 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()); assert_eq!(s.over, 2);
1360 assert_eq!(s.starving, 0);
1361 }
1362
1363 #[test]
1364 fn drill_view_all_ok() {
1365 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 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, )));
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 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}