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, 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}
212
213#[derive(Debug, Clone)]
217pub struct PeerDrillFetch {
218 pub balance: std::result::Result<Balance, String>,
219 pub cheques: std::result::Result<PeerCheques, String>,
220 pub settlement: std::result::Result<Settlement, String>,
221 pub ping: std::result::Result<String, String>,
222}
223
224#[derive(Debug, Clone)]
228pub enum DrillState {
229 Idle,
230 Loading { peer: String, bin: Option<u8> },
231 Loaded { view: PeerDrillView },
232}
233
234type DrillFetchResult = (String, PeerDrillFetch);
235
236pub struct Peers {
237 client: Arc<ApiClient>,
238 rx: watch::Receiver<TopologySnapshot>,
239 snapshot: TopologySnapshot,
240 selected: usize,
241 scroll_offset: usize,
246 drill: DrillState,
247 fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
248 fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
249}
250
251impl Peers {
252 pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<TopologySnapshot>) -> Self {
253 let snapshot = rx.borrow().clone();
254 let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
255 Self {
256 client,
257 rx,
258 snapshot,
259 selected: 0,
260 scroll_offset: 0,
261 drill: DrillState::Idle,
262 fetch_tx,
263 fetch_rx,
264 }
265 }
266
267 fn pull_latest(&mut self) {
268 self.snapshot = self.rx.borrow().clone();
269 let n = self.peer_rows_cached().len();
270 if n == 0 {
271 self.selected = 0;
272 } else if self.selected >= n {
273 self.selected = n - 1;
274 }
275 }
276
277 fn peer_rows_cached(&self) -> Vec<PeerRow> {
278 Self::view_for(&self.snapshot)
279 .map(|v| v.peers)
280 .unwrap_or_default()
281 }
282
283 fn drain_fetches(&mut self) {
288 while let Ok((peer, fetch)) = self.fetch_rx.try_recv() {
289 let pending_peer = match &self.drill {
290 DrillState::Loading { peer: p, .. } => p.clone(),
291 _ => continue, };
293 if pending_peer != peer {
294 continue;
295 }
296 let bin = match &self.drill {
297 DrillState::Loading { bin, .. } => *bin,
298 _ => None,
299 };
300 let view = Self::compute_peer_drill_view(&peer, bin, &fetch);
301 self.drill = DrillState::Loaded { view };
302 }
303 }
304
305 pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
308 let t = snap.topology.as_ref()?;
309 let bins = bin_strip_rows(t);
310 let peers = peer_rows(t);
311 let saturation = compute_saturation_summary(&bins);
312 Some(PeersView {
313 bins,
314 peers,
315 depth: t.depth,
316 population: t.population,
317 connected: t.connected,
318 reachability: t.reachability.clone(),
319 network_availability: t.network_availability.clone(),
320 light_connected: t.light_nodes.connected,
321 saturation,
322 })
323 }
324
325 pub fn compute_peer_drill_view(
329 peer: &str,
330 bin: Option<u8>,
331 fetch: &PeerDrillFetch,
332 ) -> PeerDrillView {
333 let balance = match &fetch.balance {
334 Ok(b) => DrillField::Ok(format_plur_signed(&b.balance)),
335 Err(e) => DrillField::Err(e.clone()),
336 };
337 let ping = match &fetch.ping {
338 Ok(s) => DrillField::Ok(s.clone()),
339 Err(e) => DrillField::Err(e.clone()),
340 };
341 let (settlement_received, settlement_sent) = match &fetch.settlement {
342 Ok(s) => (
343 DrillField::Ok(format_opt_plur(s.received.as_ref())),
344 DrillField::Ok(format_opt_plur(s.sent.as_ref())),
345 ),
346 Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
347 };
348 let (last_received_cheque, last_sent_cheque) = match &fetch.cheques {
349 Ok(c) => (
350 DrillField::Ok(
351 c.last_received
352 .as_ref()
353 .map(|q| format_opt_plur(q.payout.as_ref())),
354 ),
355 DrillField::Ok(
356 c.last_sent
357 .as_ref()
358 .map(|q| format_opt_plur(q.payout.as_ref())),
359 ),
360 ),
361 Err(e) => (DrillField::Err(e.clone()), DrillField::Err(e.clone())),
362 };
363 PeerDrillView {
364 peer_overlay: peer.to_string(),
365 bin,
366 balance,
367 ping,
368 settlement_received,
369 settlement_sent,
370 last_received_cheque,
371 last_sent_cheque,
372 }
373 }
374
375 fn maybe_start_drill(&mut self) {
379 let peers = self.peer_rows_cached();
380 if peers.is_empty() {
381 return;
382 }
383 let i = self.selected.min(peers.len() - 1);
384 let row = &peers[i];
385 let peer = row.peer_full.clone();
386 let bin = Some(row.bin);
387 if let DrillState::Loading { peer: pending, .. } = &self.drill {
388 if *pending == peer {
389 return;
390 }
391 }
392 let client = self.client.clone();
393 let tx = self.fetch_tx.clone();
394 let peer_for_task = peer.clone();
395 tokio::spawn(async move {
396 let bee = client.bee();
397 let debug = bee.debug();
398 let (balance, cheques, settlement, ping) = tokio::join!(
404 debug.peer_balance(&peer_for_task),
405 debug.peer_cheques(&peer_for_task),
406 debug.peer_settlement(&peer_for_task),
407 debug.ping_peer(&peer_for_task),
408 );
409 let fetch = PeerDrillFetch {
410 balance: balance.map_err(|e| e.to_string()),
411 cheques: cheques.map_err(|e| e.to_string()),
412 settlement: settlement.map_err(|e| e.to_string()),
413 ping: ping.map_err(|e| e.to_string()),
414 };
415 let _ = tx.send((peer_for_task, fetch));
416 });
417 self.drill = DrillState::Loading { peer, bin };
418 }
419}
420
421fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
422 t.bins
423 .iter()
424 .enumerate()
425 .map(|(i, b)| {
426 let bin = i as u8;
427 let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
428 BinStripRow {
429 bin,
430 population: b.population,
431 connected: b.connected,
432 status: classify_bin(b, bin, t.depth),
433 is_relevant,
434 }
435 })
436 .collect()
437}
438
439fn compute_saturation_summary(bins: &[BinStripRow]) -> SaturationSummary {
446 let mut summary = SaturationSummary::default();
447 let mut worst: Option<&BinStripRow> = None;
448 for row in bins {
449 if row.is_relevant {
450 summary.relevant += 1;
451 }
452 match row.status {
453 BinSaturation::Starving => {
454 summary.starving += 1;
455 let pick_this = match worst {
456 None => true,
457 Some(prev) => {
458 row.connected < prev.connected
459 || (row.connected == prev.connected && row.bin < prev.bin)
460 }
461 };
462 if pick_this {
463 worst = Some(row);
464 }
465 }
466 BinSaturation::Over => summary.over += 1,
467 BinSaturation::Empty | BinSaturation::Healthy => {}
468 }
469 }
470 if let Some(w) = worst {
471 summary.worst_bin = Some(w.bin);
472 summary.worst_connected = w.connected;
473 }
474 summary
475}
476
477fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
478 if b.connected > OVER_SATURATION_PEERS {
479 return BinSaturation::Over;
480 }
481 if b.connected >= SATURATION_PEERS {
482 return BinSaturation::Healthy;
483 }
484 if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
487 BinSaturation::Starving
488 } else {
489 BinSaturation::Empty
490 }
491}
492
493fn peer_rows(t: &Topology) -> Vec<PeerRow> {
494 let mut out: Vec<PeerRow> = Vec::new();
495 for (i, b) in t.bins.iter().enumerate() {
496 let bin = i as u8;
497 for p in &b.connected_peers {
498 out.push(make_peer_row(bin, p));
499 }
500 }
501 out.sort_by(|a, b| {
504 a.bin
505 .cmp(&b.bin)
506 .then_with(|| a.peer_short.cmp(&b.peer_short))
507 });
508 out
509}
510
511fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
512 let peer_short = short_overlay(&p.address);
513 let peer_full = p.address.trim_start_matches("0x").to_string();
514 let (direction, latency, healthy, reachability) = match &p.metrics {
515 Some(m) => {
516 let direction = match m.session_connection_direction.as_str() {
517 "inbound" => "in",
518 "outbound" => "out",
519 _ => "?",
520 };
521 let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
522 let latency = if m.latency_ewma > 0 {
523 format!("{latency_ms:.0}ms")
524 } else {
525 "—".into()
526 };
527 (direction, latency, m.healthy, m.reachability.clone())
528 }
529 None => ("?", "—".into(), false, String::new()),
530 };
531 PeerRow {
532 bin,
533 peer_short,
534 peer_full,
535 direction,
536 latency,
537 healthy,
538 reachability,
539 }
540}
541
542fn short_overlay(s: &str) -> String {
543 let trimmed = s.trim_start_matches("0x");
544 if trimmed.len() > 10 {
545 format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
546 } else {
547 trimmed.to_string()
548 }
549}
550
551fn format_plur_signed(plur: &BigInt) -> String {
555 let zero = BigInt::from(0);
556 let neg = plur < &zero;
557 let abs = if neg { -plur.clone() } else { plur.clone() };
558 let scale = BigInt::from(10u64).pow(16);
559 let whole = &abs / &scale;
560 let frac = &abs % &scale;
561 let frac_4 = &frac / BigInt::from(10u64).pow(12);
562 let sign = if neg { "-" } else { "+" };
563 format!("{sign}BZZ {whole}.{frac_4:0>4}")
564}
565
566fn format_opt_plur(plur: Option<&BigInt>) -> String {
567 match plur {
568 Some(p) => format_plur_signed(p).trim_start_matches('+').to_string(),
569 None => "—".to_string(),
570 }
571}
572
573impl Component for Peers {
574 fn update(&mut self, action: Action) -> Result<Option<Action>> {
575 if matches!(action, Action::Tick) {
576 self.pull_latest();
577 self.drain_fetches();
578 }
579 Ok(None)
580 }
581
582 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
583 if matches!(
587 self.drill,
588 DrillState::Loaded { .. } | DrillState::Loading { .. }
589 ) && matches!(key.code, KeyCode::Esc)
590 {
591 self.drill = DrillState::Idle;
592 return Ok(None);
593 }
594 match key.code {
595 KeyCode::Char('j') | KeyCode::Down => {
596 let n = self.peer_rows_cached().len();
597 if n > 0 && self.selected + 1 < n {
598 self.selected += 1;
599 }
600 }
601 KeyCode::Char('k') | KeyCode::Up => {
602 self.selected = self.selected.saturating_sub(1);
603 }
604 KeyCode::Enter => {
605 self.maybe_start_drill();
606 }
607 _ => {}
608 }
609 Ok(None)
610 }
611
612 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
613 let chunks = Layout::vertical([
614 Constraint::Length(3), Constraint::Length(20), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
620 .split(area);
621
622 let t = theme::active();
624 let mut header_l1 = vec![Span::styled(
625 "PEERS / TOPOLOGY",
626 Style::default().add_modifier(Modifier::BOLD),
627 )];
628 if let DrillState::Loaded { view } = &self.drill {
629 header_l1.push(Span::raw(" · drill "));
633 header_l1.push(Span::styled(
634 view.peer_overlay.clone(),
635 Style::default().fg(t.info),
636 ));
637 } else if let DrillState::Loading { peer, .. } = &self.drill {
638 header_l1.push(Span::raw(" · drill "));
639 header_l1.push(Span::styled(peer.clone(), Style::default().fg(t.info)));
640 header_l1.push(Span::raw(" (loading)"));
641 }
642 let header_l1 = Line::from(header_l1);
643 let mut header_l2 = Vec::new();
644 if let Some(err) = &self.snapshot.last_error {
645 let (color, msg) = theme::classify_header_error(err);
646 header_l2.push(Span::styled(msg, Style::default().fg(color)));
647 } else if !self.snapshot.is_loaded() {
648 header_l2.push(Span::styled(
649 format!("{} loading…", theme::spinner_glyph()),
650 Style::default().fg(t.dim),
651 ));
652 } else if let Some(view) = Self::view_for(&self.snapshot) {
653 let s = view.saturation;
657 if s.is_alert() {
658 let mut spans = vec![
659 Span::styled(
660 format!(" {} STARVING ", t.glyphs.fail),
661 Style::default().fg(t.fail).add_modifier(Modifier::BOLD),
662 ),
663 Span::raw(format!("{} of {} relevant bins", s.starving, s.relevant)),
664 ];
665 if let Some(b) = s.worst_bin {
666 spans.push(Span::raw(format!(
667 " · worst bin {b} ({}/{})",
668 s.worst_connected, SATURATION_PEERS
669 )));
670 }
671 if s.over > 0 {
672 spans.push(Span::styled(
673 format!(" · {} over-saturated", s.over),
674 Style::default().fg(t.warn),
675 ));
676 }
677 header_l2.extend(spans);
678 } else {
679 header_l2.push(Span::styled(
680 format!(
681 " {} all {} relevant bins healthy",
682 t.glyphs.pass, s.relevant
683 ),
684 Style::default().fg(t.pass),
685 ));
686 if s.over > 0 {
687 header_l2.push(Span::styled(
688 format!(" · {} over-saturated", s.over),
689 Style::default().fg(t.warn),
690 ));
691 }
692 }
693 }
694 frame.render_widget(
695 Paragraph::new(vec![header_l1, Line::from(header_l2)])
696 .block(Block::default().borders(Borders::BOTTOM)),
697 chunks[0],
698 );
699
700 let view = match Self::view_for(&self.snapshot) {
701 Some(v) => v,
702 None => {
703 frame.render_widget(
704 Paragraph::new(Span::styled(
705 " topology not loaded yet",
706 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
707 )),
708 chunks[1],
709 );
710 return Ok(());
711 }
712 };
713
714 let mut strip_lines: Vec<Line> = vec![
716 Line::from(vec![Span::styled(
717 format!(
718 " depth {} · connected {} / known {} · reachability {} · net {}",
719 view.depth,
720 view.connected,
721 view.population,
722 if view.reachability.is_empty() {
723 "?".to_string()
724 } else {
725 view.reachability.clone()
726 },
727 if view.network_availability.is_empty() {
728 "?".to_string()
729 } else {
730 view.network_availability.clone()
731 },
732 ),
733 Style::default().fg(t.dim),
734 )]),
735 Line::from(Span::styled(
736 " BIN POP CONN BAR STATUS",
737 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
738 )),
739 ];
740 for r in &view.bins {
741 if !r.is_relevant && r.population == 0 {
744 continue;
745 }
746 let bar = bin_bar(r.connected as usize, 12);
747 strip_lines.push(Line::from(vec![
748 Span::raw(" "),
749 Span::styled(
750 format!("{:>3} ", r.bin),
751 Style::default().add_modifier(Modifier::BOLD),
752 ),
753 Span::raw(format!("{:>4} ", r.population)),
754 Span::raw(format!("{:>4} ", r.connected)),
755 Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
756 Span::raw(" "),
757 Span::styled(
758 r.status.label(),
759 Style::default()
760 .fg(r.status.color())
761 .add_modifier(Modifier::BOLD),
762 ),
763 ]));
764 }
765 if view.light_connected > 0 {
766 strip_lines.push(Line::from(vec![
767 Span::raw(" "),
768 Span::styled(
769 format!(
770 " light — {} (separate from main bins)",
771 view.light_connected
772 ),
773 Style::default().fg(t.dim),
774 ),
775 ]));
776 }
777 frame.render_widget(
778 Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
779 chunks[1],
780 );
781
782 match &self.drill {
784 DrillState::Idle => self.draw_peer_table(frame, chunks[2], &view.peers),
785 DrillState::Loading { peer, .. } => {
786 let msg = Line::from(vec![
787 Span::raw(" fetching peer drill for "),
788 Span::styled(
789 short_overlay(peer),
790 Style::default().add_modifier(Modifier::BOLD),
791 ),
792 Span::raw("… (Esc cancel)"),
793 ]);
794 frame.render_widget(Paragraph::new(msg), chunks[2]);
795 }
796 DrillState::Loaded { view: drill_view } => {
797 self.draw_peer_drill(frame, chunks[2], drill_view);
798 }
799 }
800
801 if matches!(self.drill, DrillState::Idle) && !view.peers.is_empty() {
805 let i = self.selected.min(view.peers.len() - 1);
806 let row = &view.peers[i];
807 let detail = Line::from(vec![
808 Span::styled(" selected: ", Style::default().fg(t.dim)),
809 Span::styled(row.peer_full.clone(), Style::default().fg(t.info)),
810 Span::raw(" bin "),
811 Span::styled(row.bin.to_string(), Style::default().fg(t.dim)),
812 ]);
813 frame.render_widget(Paragraph::new(detail), chunks[3]);
814 }
815
816 let footer = match &self.drill {
818 DrillState::Idle => Line::from(vec![
819 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
820 Span::raw(" switch screen "),
821 Span::styled(
822 " ↑↓/jk ",
823 Style::default().fg(Color::Black).bg(Color::White),
824 ),
825 Span::raw(" select "),
826 Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
827 Span::raw(" drill "),
828 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
829 Span::raw(" help "),
830 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
831 Span::raw(" quit "),
832 Span::styled(
833 format!(
834 "thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"
835 ),
836 Style::default().fg(t.dim),
837 ),
838 ]),
839 _ => Line::from(vec![
840 Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
841 Span::raw(" close drill "),
842 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
843 Span::raw(" switch screen "),
844 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
845 Span::raw(" help "),
846 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
847 Span::raw(" quit "),
848 ]),
849 };
850 frame.render_widget(Paragraph::new(footer), chunks[4]);
851
852 Ok(())
853 }
854}
855
856impl Peers {
857 fn draw_peer_table(&mut self, frame: &mut Frame, area: Rect, peers: &[PeerRow]) {
858 use ratatui::layout::{Constraint, Layout};
859
860 let t = theme::active();
861
862 let table_chunks =
866 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
867
868 frame.render_widget(
869 Paragraph::new(Line::from(Span::styled(
870 " BIN PEER DIR LATENCY HEALTHY REACHABILITY",
871 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
872 ))),
873 table_chunks[0],
874 );
875
876 if peers.is_empty() {
877 frame.render_widget(
878 Paragraph::new(Line::from(Span::styled(
879 " (no connected peers reported)",
880 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
881 ))),
882 table_chunks[1],
883 );
884 return;
885 }
886
887 let mut peer_lines: Vec<Line> = Vec::with_capacity(peers.len());
888 for (i, p) in peers.iter().enumerate() {
889 let g = theme::active().glyphs;
890 let healthy_glyph = if p.healthy { g.pass } else { g.fail };
891 let healthy_style = if p.healthy {
892 Style::default().fg(t.pass)
893 } else {
894 Style::default().fg(t.fail)
895 };
896 let cursor = if i == self.selected {
897 format!("{} ", t.glyphs.cursor)
898 } else {
899 " ".to_string()
900 };
901 peer_lines.push(Line::from(vec![
902 Span::styled(
903 cursor,
904 Style::default()
905 .fg(if i == self.selected { t.accent } else { t.dim })
906 .add_modifier(Modifier::BOLD),
907 ),
908 Span::raw(format!("{:>3} ", p.bin)),
909 Span::raw(format!("{:<13} ", p.peer_short)),
910 Span::raw(format!("{:<4} ", p.direction)),
911 Span::raw(format!("{:<8} ", p.latency)),
912 Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
913 Span::raw(p.reachability.clone()),
914 ]));
915 }
916
917 let body = table_chunks[1];
918 let visible_rows = body.height as usize;
919 self.scroll_offset = super::scroll::clamp_scroll(
920 self.selected,
921 self.scroll_offset,
922 visible_rows,
923 peer_lines.len(),
924 );
925 frame.render_widget(
926 Paragraph::new(peer_lines.clone()).scroll((self.scroll_offset as u16, 0)),
927 body,
928 );
929 super::scroll::render_scrollbar(
930 frame,
931 body,
932 self.scroll_offset,
933 visible_rows,
934 peer_lines.len(),
935 );
936 }
937
938 fn draw_peer_drill(&self, frame: &mut Frame, area: Rect, view: &PeerDrillView) {
939 let t = theme::active();
940 let mut lines: Vec<Line> = Vec::new();
941 let bin_label = view
942 .bin
943 .map(|b| format!("bin {b}"))
944 .unwrap_or_else(|| "bin ?".into());
945 lines.push(Line::from(vec![
946 Span::raw(" peer "),
947 Span::styled(
948 view.peer_overlay.clone(),
949 Style::default().add_modifier(Modifier::BOLD),
950 ),
951 Span::raw(" "),
952 Span::styled(bin_label, Style::default().fg(t.dim)),
953 ]));
954 lines.push(Line::from(""));
955 lines.push(drill_field_line("balance ", &view.balance, t));
956 lines.push(drill_field_line("ping rtt ", &view.ping, t));
957 lines.push(drill_field_line(
958 "settle recv ",
959 &view.settlement_received,
960 t,
961 ));
962 lines.push(drill_field_line(
963 "settle sent ",
964 &view.settlement_sent,
965 t,
966 ));
967 lines.push(drill_field_optional_line(
968 "cheque last in ",
969 &view.last_received_cheque,
970 t,
971 ));
972 lines.push(drill_field_optional_line(
973 "cheque last out",
974 &view.last_sent_cheque,
975 t,
976 ));
977 lines.push(Line::from(""));
978 lines.push(Line::from(Span::styled(
979 " (Esc to dismiss · figures are point-in-time, not live-updating)",
980 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
981 )));
982 frame.render_widget(Paragraph::new(lines), area);
983 }
984}
985
986fn drill_field_line(label: &str, field: &DrillField<String>, t: &theme::Theme) -> Line<'static> {
987 match field {
988 DrillField::Ok(v) => Line::from(vec![
989 Span::raw(" "),
990 Span::styled(label.to_string(), Style::default().fg(t.dim)),
991 Span::raw(" "),
992 Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
993 ]),
994 DrillField::Err(e) => Line::from(vec![
995 Span::raw(" "),
996 Span::styled(label.to_string(), Style::default().fg(t.dim)),
997 Span::raw(" "),
998 Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
999 ]),
1000 }
1001}
1002
1003fn drill_field_optional_line(
1004 label: &str,
1005 field: &DrillField<Option<String>>,
1006 t: &theme::Theme,
1007) -> Line<'static> {
1008 match field {
1009 DrillField::Ok(Some(v)) => Line::from(vec![
1010 Span::raw(" "),
1011 Span::styled(label.to_string(), Style::default().fg(t.dim)),
1012 Span::raw(" "),
1013 Span::styled(v.clone(), Style::default().add_modifier(Modifier::BOLD)),
1014 ]),
1015 DrillField::Ok(None) => Line::from(vec![
1016 Span::raw(" "),
1017 Span::styled(label.to_string(), Style::default().fg(t.dim)),
1018 Span::raw(" "),
1019 Span::styled(
1020 "(no cheque yet)",
1021 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
1022 ),
1023 ]),
1024 DrillField::Err(e) => Line::from(vec![
1025 Span::raw(" "),
1026 Span::styled(label.to_string(), Style::default().fg(t.dim)),
1027 Span::raw(" "),
1028 Span::styled(format!("error: {e}"), Style::default().fg(t.fail)),
1029 ]),
1030 }
1031}
1032
1033fn bin_bar(connected: usize, width: usize) -> String {
1036 let scale = OVER_SATURATION_PEERS as usize;
1037 let filled = connected.min(scale) * width / scale.max(1);
1038 let mut bar = String::with_capacity(width);
1039 for _ in 0..filled.min(width) {
1040 bar.push('▇');
1041 }
1042 for _ in filled.min(width)..width {
1043 bar.push('░');
1044 }
1045 bar
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051
1052 fn bin(population: u64, connected: u64) -> BinInfo {
1053 BinInfo {
1054 population,
1055 connected,
1056 ..BinInfo::default()
1057 }
1058 }
1059
1060 #[test]
1061 fn classify_below_saturation_in_relevant_bin_is_starving() {
1062 assert_eq!(classify_bin(&bin(5, 3), 4, 8), BinSaturation::Starving);
1064 }
1065
1066 #[test]
1067 fn classify_below_saturation_in_far_bin_is_empty() {
1068 assert_eq!(classify_bin(&bin(0, 0), 20, 8), BinSaturation::Empty);
1070 }
1071
1072 #[test]
1073 fn classify_in_safe_band_is_healthy() {
1074 assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
1075 assert_eq!(
1076 classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
1077 BinSaturation::Healthy
1078 );
1079 }
1080
1081 #[test]
1082 fn classify_over_threshold_is_over() {
1083 assert_eq!(
1084 classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
1085 BinSaturation::Over
1086 );
1087 }
1088
1089 #[test]
1090 fn short_overlay_truncates() {
1091 let s = short_overlay("0xabcdef0123456789abcdef0123456789");
1092 assert!(s.contains('…'));
1093 assert!(s.starts_with("abcdef"));
1094 }
1095
1096 #[test]
1097 fn bin_bar_caps_at_oversaturation() {
1098 let bar_full = bin_bar(50, 12);
1099 assert_eq!(bar_full, "▇".repeat(12));
1100 let bar_empty = bin_bar(0, 12);
1101 assert_eq!(bar_empty, "░".repeat(12));
1102 }
1103
1104 fn strip_row(bin: u8, connected: u64, status: BinSaturation, is_relevant: bool) -> BinStripRow {
1105 BinStripRow {
1106 bin,
1107 population: connected,
1108 connected,
1109 status,
1110 is_relevant,
1111 }
1112 }
1113
1114 #[test]
1115 fn saturation_summary_all_healthy_has_no_alert() {
1116 let bins = vec![
1117 strip_row(0, 10, BinSaturation::Healthy, true),
1118 strip_row(1, 12, BinSaturation::Healthy, true),
1119 strip_row(2, 14, BinSaturation::Healthy, true),
1120 ];
1121 let s = compute_saturation_summary(&bins);
1122 assert!(!s.is_alert());
1123 assert_eq!(s.starving, 0);
1124 assert_eq!(s.over, 0);
1125 assert_eq!(s.relevant, 3);
1126 assert_eq!(s.worst_bin, None);
1127 }
1128
1129 #[test]
1130 fn saturation_summary_picks_lowest_connected_starving_bin() {
1131 let bins = vec![
1133 strip_row(0, 10, BinSaturation::Healthy, true),
1134 strip_row(1, 3, BinSaturation::Starving, true),
1135 strip_row(2, 12, BinSaturation::Healthy, true),
1136 strip_row(4, 2, BinSaturation::Starving, true),
1137 ];
1138 let s = compute_saturation_summary(&bins);
1139 assert!(s.is_alert());
1140 assert_eq!(s.starving, 2);
1141 assert_eq!(s.worst_bin, Some(4));
1142 assert_eq!(s.worst_connected, 2);
1143 }
1144
1145 #[test]
1146 fn saturation_summary_breaks_ties_by_lowest_bin() {
1147 let bins = vec![
1150 strip_row(7, 0, BinSaturation::Starving, true),
1151 strip_row(1, 0, BinSaturation::Starving, true),
1152 ];
1153 let s = compute_saturation_summary(&bins);
1154 assert_eq!(s.worst_bin, Some(1));
1155 }
1156
1157 #[test]
1158 fn saturation_summary_excludes_far_bins_from_relevant_count() {
1159 let bins = vec![
1162 strip_row(0, 10, BinSaturation::Healthy, true),
1163 strip_row(1, 10, BinSaturation::Healthy, true),
1164 strip_row(20, 0, BinSaturation::Empty, false),
1165 strip_row(21, 0, BinSaturation::Empty, false),
1166 ];
1167 let s = compute_saturation_summary(&bins);
1168 assert_eq!(s.relevant, 2);
1169 }
1170
1171 #[test]
1172 fn saturation_summary_counts_over_separately() {
1173 let bins = vec![
1174 strip_row(0, 10, BinSaturation::Healthy, true),
1175 strip_row(1, 25, BinSaturation::Over, true),
1176 strip_row(2, 30, BinSaturation::Over, true),
1177 ];
1178 let s = compute_saturation_summary(&bins);
1179 assert!(!s.is_alert()); assert_eq!(s.over, 2);
1181 assert_eq!(s.starving, 0);
1182 }
1183
1184 #[test]
1185 fn drill_view_all_ok() {
1186 let cheque_in = bee::debug::Cheque {
1188 beneficiary: "0x".into(),
1189 chequebook: "0x".into(),
1190 payout: Some(BigInt::from(500_000_000_000_000u64)),
1191 };
1192 let fetch = PeerDrillFetch {
1193 balance: Ok(Balance {
1194 peer: "abcd".into(),
1195 balance: BigInt::from(123_400_000_000_000_000i64),
1196 }),
1197 cheques: Ok(PeerCheques {
1198 peer: "abcd".into(),
1199 last_received: Some(cheque_in),
1200 last_sent: None,
1201 }),
1202 settlement: Ok(Settlement {
1203 peer: "abcd".into(),
1204 received: Some(BigInt::from(900_000_000_000_000_000u64)),
1205 sent: Some(BigInt::from(100_000_000_000_000_000u64)),
1206 }),
1207 ping: Ok("4.21ms".into()),
1208 };
1209 let view = Peers::compute_peer_drill_view("abcd1234", Some(7), &fetch);
1210 assert_eq!(view.bin, Some(7));
1211 match &view.balance {
1212 DrillField::Ok(s) => assert!(s.contains("BZZ")),
1213 _ => panic!("expected ok balance"),
1214 }
1215 match &view.last_received_cheque {
1216 DrillField::Ok(Some(s)) => assert!(s.contains("0.0500")),
1217 _ => panic!("expected received cheque payout"),
1218 }
1219 match &view.last_sent_cheque {
1220 DrillField::Ok(None) => {}
1221 _ => panic!("expected None for sent cheque"),
1222 }
1223 }
1224
1225 #[test]
1226 fn drill_view_partial_failure_keeps_other_fields() {
1227 let fetch = PeerDrillFetch {
1230 balance: Ok(Balance {
1231 peer: "x".into(),
1232 balance: BigInt::from(0),
1233 }),
1234 cheques: Err("404".into()),
1235 settlement: Err("503 Node is syncing".into()),
1236 ping: Ok("12ms".into()),
1237 };
1238 let view = Peers::compute_peer_drill_view("xxxx", None, &fetch);
1239 assert!(matches!(view.balance, DrillField::Ok(_)));
1240 assert!(matches!(view.ping, DrillField::Ok(_)));
1241 assert!(matches!(view.settlement_received, DrillField::Err(_)));
1242 assert!(matches!(view.last_received_cheque, DrillField::Err(_)));
1243 }
1244
1245 #[test]
1246 fn format_plur_signed_handles_zero_and_negative() {
1247 assert_eq!(format_plur_signed(&BigInt::from(0)), "+BZZ 0.0000");
1248 assert_eq!(
1249 format_plur_signed(&BigInt::from(-5_000_000_000_000_000i64)),
1250 "-BZZ 0.5000"
1251 );
1252 }
1253}