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, Settlement, 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, PartialEq, Eq)]
135pub struct PeersView {
136 pub bins: Vec<BinStripRow>,
137 pub peers: Vec<PeerRow>,
138 pub depth: u8,
139 pub population: i64,
140 pub connected: i64,
141 pub reachability: String,
142 pub network_availability: String,
143 pub light_connected: u64,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum DrillField<T: Clone + PartialEq + Eq> {
152 Ok(T),
153 Err(String),
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct PeerDrillView {
160 pub peer_overlay: String,
161 pub bin: Option<u8>,
162 pub balance: DrillField<String>,
165 pub ping: DrillField<String>,
168 pub settlement_received: DrillField<String>,
170 pub settlement_sent: DrillField<String>,
172 pub last_received_cheque: DrillField<Option<String>>,
174 pub last_sent_cheque: DrillField<Option<String>>,
176}
177
178#[derive(Debug, Clone)]
182pub struct PeerDrillFetch {
183 pub balance: std::result::Result<Balance, String>,
184 pub cheques: std::result::Result<PeerCheques, String>,
185 pub settlement: std::result::Result<Settlement, String>,
186 pub ping: std::result::Result<String, String>,
187}
188
189#[derive(Debug, Clone)]
193pub enum DrillState {
194 Idle,
195 Loading {
196 peer: String,
197 bin: Option<u8>,
198 },
199 Loaded {
200 view: PeerDrillView,
201 },
202}
203
204type DrillFetchResult = (String, PeerDrillFetch);
205
206pub struct Peers {
207 client: Arc<ApiClient>,
208 rx: watch::Receiver<TopologySnapshot>,
209 snapshot: TopologySnapshot,
210 selected: usize,
211 scroll_offset: usize,
216 drill: DrillState,
217 fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
218 fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
219}
220
221impl Peers {
222 pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<TopologySnapshot>) -> Self {
223 let snapshot = rx.borrow().clone();
224 let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
225 Self {
226 client,
227 rx,
228 snapshot,
229 selected: 0,
230 scroll_offset: 0,
231 drill: DrillState::Idle,
232 fetch_tx,
233 fetch_rx,
234 }
235 }
236
237 fn pull_latest(&mut self) {
238 self.snapshot = self.rx.borrow().clone();
239 let n = self.peer_rows_cached().len();
240 if n == 0 {
241 self.selected = 0;
242 } else if self.selected >= n {
243 self.selected = n - 1;
244 }
245 }
246
247 fn peer_rows_cached(&self) -> Vec<PeerRow> {
248 Self::view_for(&self.snapshot)
249 .map(|v| v.peers)
250 .unwrap_or_default()
251 }
252
253 fn drain_fetches(&mut self) {
258 while let Ok((peer, fetch)) = self.fetch_rx.try_recv() {
259 let pending_peer = match &self.drill {
260 DrillState::Loading { peer: p, .. } => p.clone(),
261 _ => continue, };
263 if pending_peer != peer {
264 continue;
265 }
266 let bin = match &self.drill {
267 DrillState::Loading { bin, .. } => *bin,
268 _ => None,
269 };
270 let view = Self::compute_peer_drill_view(&peer, bin, &fetch);
271 self.drill = DrillState::Loaded { view };
272 }
273 }
274
275 pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
278 let t = snap.topology.as_ref()?;
279 let bins = bin_strip_rows(t);
280 let peers = peer_rows(t);
281 Some(PeersView {
282 bins,
283 peers,
284 depth: t.depth,
285 population: t.population,
286 connected: t.connected,
287 reachability: t.reachability.clone(),
288 network_availability: t.network_availability.clone(),
289 light_connected: t.light_nodes.connected,
290 })
291 }
292
293 pub fn compute_peer_drill_view(
297 peer: &str,
298 bin: Option<u8>,
299 fetch: &PeerDrillFetch,
300 ) -> PeerDrillView {
301 let balance = match &fetch.balance {
302 Ok(b) => DrillField::Ok(format_plur_signed(&b.balance)),
303 Err(e) => DrillField::Err(e.clone()),
304 };
305 let ping = match &fetch.ping {
306 Ok(s) => DrillField::Ok(s.clone()),
307 Err(e) => DrillField::Err(e.clone()),
308 };
309 let (settlement_received, settlement_sent) = match &fetch.settlement {
310 Ok(s) => (
311 DrillField::Ok(format_opt_plur(s.received.as_ref())),
312 DrillField::Ok(format_opt_plur(s.sent.as_ref())),
313 ),
314 Err(e) => (
315 DrillField::Err(e.clone()),
316 DrillField::Err(e.clone()),
317 ),
318 };
319 let (last_received_cheque, last_sent_cheque) = match &fetch.cheques {
320 Ok(c) => (
321 DrillField::Ok(c.last_received.as_ref().map(|q| format_opt_plur(q.payout.as_ref()))),
322 DrillField::Ok(c.last_sent.as_ref().map(|q| format_opt_plur(q.payout.as_ref()))),
323 ),
324 Err(e) => (
325 DrillField::Err(e.clone()),
326 DrillField::Err(e.clone()),
327 ),
328 };
329 PeerDrillView {
330 peer_overlay: peer.to_string(),
331 bin,
332 balance,
333 ping,
334 settlement_received,
335 settlement_sent,
336 last_received_cheque,
337 last_sent_cheque,
338 }
339 }
340
341 fn maybe_start_drill(&mut self) {
345 let peers = self.peer_rows_cached();
346 if peers.is_empty() {
347 return;
348 }
349 let i = self.selected.min(peers.len() - 1);
350 let row = &peers[i];
351 let peer = row.peer_full.clone();
352 let bin = Some(row.bin);
353 if let DrillState::Loading { peer: pending, .. } = &self.drill {
354 if *pending == peer {
355 return;
356 }
357 }
358 let client = self.client.clone();
359 let tx = self.fetch_tx.clone();
360 let peer_for_task = peer.clone();
361 tokio::spawn(async move {
362 let bee = client.bee();
363 let debug = bee.debug();
364 let (balance, cheques, settlement, ping) = tokio::join!(
370 debug.peer_balance(&peer_for_task),
371 debug.peer_cheques(&peer_for_task),
372 debug.peer_settlement(&peer_for_task),
373 debug.ping_peer(&peer_for_task),
374 );
375 let fetch = PeerDrillFetch {
376 balance: balance.map_err(|e| e.to_string()),
377 cheques: cheques.map_err(|e| e.to_string()),
378 settlement: settlement.map_err(|e| e.to_string()),
379 ping: ping.map_err(|e| e.to_string()),
380 };
381 let _ = tx.send((peer_for_task, fetch));
382 });
383 self.drill = DrillState::Loading { peer, bin };
384 }
385}
386
387fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
388 t.bins
389 .iter()
390 .enumerate()
391 .map(|(i, b)| {
392 let bin = i as u8;
393 let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
394 BinStripRow {
395 bin,
396 population: b.population,
397 connected: b.connected,
398 status: classify_bin(b, bin, t.depth),
399 is_relevant,
400 }
401 })
402 .collect()
403}
404
405fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
406 if b.connected > OVER_SATURATION_PEERS {
407 return BinSaturation::Over;
408 }
409 if b.connected >= SATURATION_PEERS {
410 return BinSaturation::Healthy;
411 }
412 if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
415 BinSaturation::Starving
416 } else {
417 BinSaturation::Empty
418 }
419}
420
421fn peer_rows(t: &Topology) -> Vec<PeerRow> {
422 let mut out: Vec<PeerRow> = Vec::new();
423 for (i, b) in t.bins.iter().enumerate() {
424 let bin = i as u8;
425 for p in &b.connected_peers {
426 out.push(make_peer_row(bin, p));
427 }
428 }
429 out.sort_by(|a, b| {
432 a.bin
433 .cmp(&b.bin)
434 .then_with(|| a.peer_short.cmp(&b.peer_short))
435 });
436 out
437}
438
439fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
440 let peer_short = short_overlay(&p.address);
441 let peer_full = p.address.trim_start_matches("0x").to_string();
442 let (direction, latency, healthy, reachability) = match &p.metrics {
443 Some(m) => {
444 let direction = match m.session_connection_direction.as_str() {
445 "inbound" => "in",
446 "outbound" => "out",
447 _ => "?",
448 };
449 let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
450 let latency = if m.latency_ewma > 0 {
451 format!("{latency_ms:.0}ms")
452 } else {
453 "—".into()
454 };
455 (direction, latency, m.healthy, m.reachability.clone())
456 }
457 None => ("?", "—".into(), false, String::new()),
458 };
459 PeerRow {
460 bin,
461 peer_short,
462 peer_full,
463 direction,
464 latency,
465 healthy,
466 reachability,
467 }
468}
469
470fn short_overlay(s: &str) -> String {
471 let trimmed = s.trim_start_matches("0x");
472 if trimmed.len() > 10 {
473 format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
474 } else {
475 trimmed.to_string()
476 }
477}
478
479fn format_plur_signed(plur: &BigInt) -> String {
483 let zero = BigInt::from(0);
484 let neg = plur < &zero;
485 let abs = if neg { -plur.clone() } else { plur.clone() };
486 let scale = BigInt::from(10u64).pow(16);
487 let whole = &abs / &scale;
488 let frac = &abs % &scale;
489 let frac_4 = &frac / BigInt::from(10u64).pow(12);
490 let sign = if neg { "-" } else { "+" };
491 format!("{sign}BZZ {whole}.{frac_4:0>4}")
492}
493
494fn format_opt_plur(plur: Option<&BigInt>) -> String {
495 match plur {
496 Some(p) => format_plur_signed(p).trim_start_matches('+').to_string(),
497 None => "—".to_string(),
498 }
499}
500
501impl Component for Peers {
502 fn update(&mut self, action: Action) -> Result<Option<Action>> {
503 if matches!(action, Action::Tick) {
504 self.pull_latest();
505 self.drain_fetches();
506 }
507 Ok(None)
508 }
509
510 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
511 if matches!(self.drill, DrillState::Loaded { .. } | DrillState::Loading { .. })
515 && matches!(key.code, KeyCode::Esc)
516 {
517 self.drill = DrillState::Idle;
518 return Ok(None);
519 }
520 match key.code {
521 KeyCode::Char('j') | KeyCode::Down => {
522 let n = self.peer_rows_cached().len();
523 if n > 0 && self.selected + 1 < n {
524 self.selected += 1;
525 }
526 }
527 KeyCode::Char('k') | KeyCode::Up => {
528 self.selected = self.selected.saturating_sub(1);
529 }
530 KeyCode::Enter => {
531 self.maybe_start_drill();
532 }
533 _ => {}
534 }
535 Ok(None)
536 }
537
538 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
539 let chunks = Layout::vertical([
540 Constraint::Length(3), Constraint::Length(20), Constraint::Min(0), Constraint::Length(1), ])
545 .split(area);
546
547 let mut header_l1 = vec![Span::styled(
549 "PEERS / TOPOLOGY",
550 Style::default().add_modifier(Modifier::BOLD),
551 )];
552 if let DrillState::Loaded { view } = &self.drill {
553 header_l1.push(Span::raw(format!(" · drill {}", short_overlay(&view.peer_overlay))));
554 } else if let DrillState::Loading { peer, .. } = &self.drill {
555 header_l1.push(Span::raw(format!(" · drill {} (loading)", short_overlay(peer))));
556 }
557 let header_l1 = Line::from(header_l1);
558 let mut header_l2 = Vec::new();
559 let t = theme::active();
560 if let Some(err) = &self.snapshot.last_error {
561 let (color, msg) = theme::classify_header_error(err);
562 header_l2.push(Span::styled(msg, Style::default().fg(color)));
563 } else if !self.snapshot.is_loaded() {
564 header_l2.push(Span::styled(
565 "loading…",
566 Style::default().fg(t.dim),
567 ));
568 }
569 frame.render_widget(
570 Paragraph::new(vec![header_l1, Line::from(header_l2)])
571 .block(Block::default().borders(Borders::BOTTOM)),
572 chunks[0],
573 );
574
575 let view = match Self::view_for(&self.snapshot) {
576 Some(v) => v,
577 None => {
578 frame.render_widget(
579 Paragraph::new(Span::styled(
580 " topology not loaded yet",
581 Style::default()
582 .fg(t.dim)
583 .add_modifier(Modifier::ITALIC),
584 )),
585 chunks[1],
586 );
587 return Ok(());
588 }
589 };
590
591 let mut strip_lines: Vec<Line> = vec![
593 Line::from(vec![
594 Span::styled(
595 format!(
596 " depth {} · connected {} / known {} · reachability {} · net {}",
597 view.depth,
598 view.connected,
599 view.population,
600 if view.reachability.is_empty() {
601 "?".to_string()
602 } else {
603 view.reachability.clone()
604 },
605 if view.network_availability.is_empty() {
606 "?".to_string()
607 } else {
608 view.network_availability.clone()
609 },
610 ),
611 Style::default().fg(t.dim),
612 ),
613 ]),
614 Line::from(Span::styled(
615 " BIN POP CONN BAR STATUS",
616 Style::default()
617 .fg(t.dim)
618 .add_modifier(Modifier::BOLD),
619 )),
620 ];
621 for r in &view.bins {
622 if !r.is_relevant && r.population == 0 {
625 continue;
626 }
627 let bar = bin_bar(r.connected as usize, 12);
628 strip_lines.push(Line::from(vec![
629 Span::raw(" "),
630 Span::styled(
631 format!("{:>3} ", r.bin),
632 Style::default().add_modifier(Modifier::BOLD),
633 ),
634 Span::raw(format!("{:>4} ", r.population)),
635 Span::raw(format!("{:>4} ", r.connected)),
636 Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
637 Span::raw(" "),
638 Span::styled(
639 r.status.label(),
640 Style::default()
641 .fg(r.status.color())
642 .add_modifier(Modifier::BOLD),
643 ),
644 ]));
645 }
646 if view.light_connected > 0 {
647 strip_lines.push(Line::from(vec![
648 Span::raw(" "),
649 Span::styled(
650 format!(" light — {} (separate from main bins)", view.light_connected),
651 Style::default().fg(t.dim),
652 ),
653 ]));
654 }
655 frame.render_widget(
656 Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
657 chunks[1],
658 );
659
660 match &self.drill {
662 DrillState::Idle => self.draw_peer_table(frame, chunks[2], &view.peers),
663 DrillState::Loading { peer, .. } => {
664 let msg = Line::from(vec![
665 Span::raw(" fetching peer drill for "),
666 Span::styled(
667 short_overlay(peer),
668 Style::default().add_modifier(Modifier::BOLD),
669 ),
670 Span::raw("… (Esc cancel)"),
671 ]);
672 frame.render_widget(Paragraph::new(msg), chunks[2]);
673 }
674 DrillState::Loaded { view: drill_view } => {
675 self.draw_peer_drill(frame, chunks[2], drill_view);
676 }
677 }
678
679 let footer = match &self.drill {
681 DrillState::Idle => Line::from(vec![
682 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
683 Span::raw(" switch screen "),
684 Span::styled(" ↑↓/jk ", Style::default().fg(Color::Black).bg(Color::White)),
685 Span::raw(" select "),
686 Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
687 Span::raw(" drill "),
688 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
689 Span::raw(" quit "),
690 Span::styled(
691 format!("thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"),
692 Style::default().fg(t.dim),
693 ),
694 ]),
695 _ => Line::from(vec![
696 Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
697 Span::raw(" close drill "),
698 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
699 Span::raw(" switch screen "),
700 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
701 Span::raw(" quit "),
702 ]),
703 };
704 frame.render_widget(Paragraph::new(footer), chunks[3]);
705
706 Ok(())
707 }
708}
709
710impl Peers {
711 fn draw_peer_table(&mut self, frame: &mut Frame, area: Rect, peers: &[PeerRow]) {
712 use ratatui::layout::{Constraint, Layout};
713
714 let t = theme::active();
715
716 let table_chunks = Layout::vertical([
720 Constraint::Length(1),
721 Constraint::Min(0),
722 ])
723 .split(area);
724
725 frame.render_widget(
726 Paragraph::new(Line::from(Span::styled(
727 " BIN PEER DIR LATENCY HEALTHY REACHABILITY",
728 Style::default()
729 .fg(t.dim)
730 .add_modifier(Modifier::BOLD),
731 ))),
732 table_chunks[0],
733 );
734
735 if peers.is_empty() {
736 frame.render_widget(
737 Paragraph::new(Line::from(Span::styled(
738 " (no connected peers reported)",
739 Style::default()
740 .fg(t.dim)
741 .add_modifier(Modifier::ITALIC),
742 ))),
743 table_chunks[1],
744 );
745 return;
746 }
747
748 let mut peer_lines: Vec<Line> = Vec::with_capacity(peers.len());
749 for (i, p) in peers.iter().enumerate() {
750 let g = theme::active().glyphs;
751 let healthy_glyph = if p.healthy { g.pass } else { g.fail };
752 let healthy_style = if p.healthy {
753 Style::default().fg(t.pass)
754 } else {
755 Style::default().fg(t.fail)
756 };
757 let cursor = if i == self.selected {
758 format!("{} ", t.glyphs.cursor)
759 } else {
760 " ".to_string()
761 };
762 peer_lines.push(Line::from(vec![
763 Span::styled(
764 cursor,
765 Style::default()
766 .fg(if i == self.selected { t.accent } else { t.dim })
767 .add_modifier(Modifier::BOLD),
768 ),
769 Span::raw(format!("{:>3} ", p.bin)),
770 Span::raw(format!("{:<13} ", p.peer_short)),
771 Span::raw(format!("{:<4} ", p.direction)),
772 Span::raw(format!("{:<8} ", p.latency)),
773 Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
774 Span::raw(p.reachability.clone()),
775 ]));
776 }
777
778 let body = table_chunks[1];
779 let visible_rows = body.height as usize;
780 self.scroll_offset = super::scroll::clamp_scroll(
781 self.selected,
782 self.scroll_offset,
783 visible_rows,
784 peer_lines.len(),
785 );
786 frame.render_widget(
787 Paragraph::new(peer_lines.clone()).scroll((self.scroll_offset as u16, 0)),
788 body,
789 );
790 super::scroll::render_scrollbar(
791 frame,
792 body,
793 self.scroll_offset,
794 visible_rows,
795 peer_lines.len(),
796 );
797 }
798
799 fn draw_peer_drill(&self, frame: &mut Frame, area: Rect, view: &PeerDrillView) {
800 let t = theme::active();
801 let mut lines: Vec<Line> = Vec::new();
802 let bin_label = view
803 .bin
804 .map(|b| format!("bin {b}"))
805 .unwrap_or_else(|| "bin ?".into());
806 lines.push(Line::from(vec![
807 Span::raw(" peer "),
808 Span::styled(
809 view.peer_overlay.clone(),
810 Style::default().add_modifier(Modifier::BOLD),
811 ),
812 Span::raw(" "),
813 Span::styled(bin_label, Style::default().fg(t.dim)),
814 ]));
815 lines.push(Line::from(""));
816 lines.push(drill_field_line("balance ", &view.balance, t));
817 lines.push(drill_field_line("ping rtt ", &view.ping, t));
818 lines.push(drill_field_line(
819 "settle recv ",
820 &view.settlement_received,
821 t,
822 ));
823 lines.push(drill_field_line(
824 "settle sent ",
825 &view.settlement_sent,
826 t,
827 ));
828 lines.push(drill_field_optional_line(
829 "cheque last in ",
830 &view.last_received_cheque,
831 t,
832 ));
833 lines.push(drill_field_optional_line(
834 "cheque last out",
835 &view.last_sent_cheque,
836 t,
837 ));
838 lines.push(Line::from(""));
839 lines.push(Line::from(Span::styled(
840 " (Esc to dismiss · figures are point-in-time, not live-updating)",
841 Style::default()
842 .fg(t.dim)
843 .add_modifier(Modifier::ITALIC),
844 )));
845 frame.render_widget(Paragraph::new(lines), area);
846 }
847}
848
849fn drill_field_line(label: &str, field: &DrillField<String>, t: &theme::Theme) -> Line<'static> {
850 match field {
851 DrillField::Ok(v) => Line::from(vec![
852 Span::raw(" "),
853 Span::styled(label.to_string(), Style::default().fg(t.dim)),
854 Span::raw(" "),
855 Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
856 ]),
857 DrillField::Err(e) => Line::from(vec![
858 Span::raw(" "),
859 Span::styled(label.to_string(), Style::default().fg(t.dim)),
860 Span::raw(" "),
861 Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
862 ]),
863 }
864}
865
866fn drill_field_optional_line(
867 label: &str,
868 field: &DrillField<Option<String>>,
869 t: &theme::Theme,
870) -> Line<'static> {
871 match field {
872 DrillField::Ok(Some(v)) => Line::from(vec![
873 Span::raw(" "),
874 Span::styled(label.to_string(), Style::default().fg(t.dim)),
875 Span::raw(" "),
876 Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
877 ]),
878 DrillField::Ok(None) => Line::from(vec![
879 Span::raw(" "),
880 Span::styled(label.to_string(), Style::default().fg(t.dim)),
881 Span::raw(" "),
882 Span::styled(
883 "(no cheque yet)",
884 Style::default()
885 .fg(t.dim)
886 .add_modifier(Modifier::ITALIC),
887 ),
888 ]),
889 DrillField::Err(e) => Line::from(vec![
890 Span::raw(" "),
891 Span::styled(label.to_string(), Style::default().fg(t.dim)),
892 Span::raw(" "),
893 Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
894 ]),
895 }
896}
897
898fn bin_bar(connected: usize, width: usize) -> String {
901 let scale = OVER_SATURATION_PEERS as usize;
902 let filled = connected.min(scale) * width / scale.max(1);
903 let mut bar = String::with_capacity(width);
904 for _ in 0..filled.min(width) {
905 bar.push('▇');
906 }
907 for _ in filled.min(width)..width {
908 bar.push('░');
909 }
910 bar
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916
917 fn bin(population: u64, connected: u64) -> BinInfo {
918 BinInfo {
919 population,
920 connected,
921 ..BinInfo::default()
922 }
923 }
924
925 #[test]
926 fn classify_below_saturation_in_relevant_bin_is_starving() {
927 assert_eq!(
929 classify_bin(&bin(5, 3), 4, 8),
930 BinSaturation::Starving
931 );
932 }
933
934 #[test]
935 fn classify_below_saturation_in_far_bin_is_empty() {
936 assert_eq!(
938 classify_bin(&bin(0, 0), 20, 8),
939 BinSaturation::Empty
940 );
941 }
942
943 #[test]
944 fn classify_in_safe_band_is_healthy() {
945 assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
946 assert_eq!(
947 classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
948 BinSaturation::Healthy
949 );
950 }
951
952 #[test]
953 fn classify_over_threshold_is_over() {
954 assert_eq!(
955 classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
956 BinSaturation::Over
957 );
958 }
959
960 #[test]
961 fn short_overlay_truncates() {
962 let s = short_overlay("0xabcdef0123456789abcdef0123456789");
963 assert!(s.contains('…'));
964 assert!(s.starts_with("abcdef"));
965 }
966
967 #[test]
968 fn bin_bar_caps_at_oversaturation() {
969 let bar_full = bin_bar(50, 12);
970 assert_eq!(bar_full, "▇".repeat(12));
971 let bar_empty = bin_bar(0, 12);
972 assert_eq!(bar_empty, "░".repeat(12));
973 }
974
975 #[test]
976 fn drill_view_all_ok() {
977 let cheque_in = bee::debug::Cheque {
979 beneficiary: "0x".into(),
980 chequebook: "0x".into(),
981 payout: Some(BigInt::from(500_000_000_000_000u64)),
982 };
983 let fetch = PeerDrillFetch {
984 balance: Ok(Balance {
985 peer: "abcd".into(),
986 balance: BigInt::from(123_400_000_000_000_000i64),
987 }),
988 cheques: Ok(PeerCheques {
989 peer: "abcd".into(),
990 last_received: Some(cheque_in),
991 last_sent: None,
992 }),
993 settlement: Ok(Settlement {
994 peer: "abcd".into(),
995 received: Some(BigInt::from(900_000_000_000_000_000u64)),
996 sent: Some(BigInt::from(100_000_000_000_000_000u64)),
997 }),
998 ping: Ok("4.21ms".into()),
999 };
1000 let view = Peers::compute_peer_drill_view("abcd1234", Some(7), &fetch);
1001 assert_eq!(view.bin, Some(7));
1002 match &view.balance {
1003 DrillField::Ok(s) => assert!(s.contains("BZZ")),
1004 _ => panic!("expected ok balance"),
1005 }
1006 match &view.last_received_cheque {
1007 DrillField::Ok(Some(s)) => assert!(s.contains("0.0500")),
1008 _ => panic!("expected received cheque payout"),
1009 }
1010 match &view.last_sent_cheque {
1011 DrillField::Ok(None) => {}
1012 _ => panic!("expected None for sent cheque"),
1013 }
1014 }
1015
1016 #[test]
1017 fn drill_view_partial_failure_keeps_other_fields() {
1018 let fetch = PeerDrillFetch {
1021 balance: Ok(Balance {
1022 peer: "x".into(),
1023 balance: BigInt::from(0),
1024 }),
1025 cheques: Err("404".into()),
1026 settlement: Err("503 Node is syncing".into()),
1027 ping: Ok("12ms".into()),
1028 };
1029 let view = Peers::compute_peer_drill_view("xxxx", None, &fetch);
1030 assert!(matches!(view.balance, DrillField::Ok(_)));
1031 assert!(matches!(view.ping, DrillField::Ok(_)));
1032 assert!(matches!(view.settlement_received, DrillField::Err(_)));
1033 assert!(matches!(view.last_received_cheque, DrillField::Err(_)));
1034 }
1035
1036 #[test]
1037 fn format_plur_signed_handles_zero_and_negative() {
1038 assert_eq!(format_plur_signed(&BigInt::from(0)), "+BZZ 0.0000");
1039 assert_eq!(
1040 format_plur_signed(&BigInt::from(-5_000_000_000_000_000i64)),
1041 "-BZZ 0.5000"
1042 );
1043 }
1044}