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