1use color_eyre::Result;
24use ratatui::{
25 Frame,
26 layout::{Constraint, Layout, Rect},
27 style::{Color, Modifier, Style},
28 text::{Line, Span},
29 widgets::{Block, Borders, Paragraph},
30};
31use tokio::sync::watch;
32
33use super::Component;
34use crate::action::Action;
35use crate::watch::TopologySnapshot;
36
37use bee::debug::{BinInfo, PeerInfo, Topology};
38
39pub const BIN_COUNT: usize = 32;
41pub const SATURATION_PEERS: u64 = 8;
45pub const OVER_SATURATION_PEERS: u64 = 18;
48const FAR_BIN_RELAXATION: u8 = 4;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum BinSaturation {
56 Empty,
59 Starving,
62 Healthy,
64 Over,
67}
68
69impl BinSaturation {
70 fn color(self) -> Color {
71 match self {
72 Self::Empty => Color::DarkGray,
73 Self::Starving => Color::Red,
74 Self::Healthy => Color::Green,
75 Self::Over => Color::Yellow,
76 }
77 }
78 fn label(self) -> &'static str {
79 match self {
80 Self::Empty => "—",
81 Self::Starving => "✗ STARVING",
82 Self::Healthy => "✓",
83 Self::Over => "⚠ over",
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct BinStripRow {
91 pub bin: u8,
92 pub population: u64,
93 pub connected: u64,
94 pub status: BinSaturation,
95 pub is_relevant: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PeerRow {
104 pub bin: u8,
105 pub peer_short: String,
106 pub direction: &'static str,
109 pub latency: String,
112 pub healthy: bool,
113 pub reachability: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct PeersView {
121 pub bins: Vec<BinStripRow>,
122 pub peers: Vec<PeerRow>,
123 pub depth: u8,
124 pub population: i64,
125 pub connected: i64,
126 pub reachability: String,
127 pub network_availability: String,
128 pub light_connected: u64,
131}
132
133pub struct Peers {
134 rx: watch::Receiver<TopologySnapshot>,
135 snapshot: TopologySnapshot,
136}
137
138impl Peers {
139 pub fn new(rx: watch::Receiver<TopologySnapshot>) -> Self {
140 let snapshot = rx.borrow().clone();
141 Self { rx, snapshot }
142 }
143
144 fn pull_latest(&mut self) {
145 self.snapshot = self.rx.borrow().clone();
146 }
147
148 pub fn view_for(snap: &TopologySnapshot) -> Option<PeersView> {
151 let t = snap.topology.as_ref()?;
152 let bins = bin_strip_rows(t);
153 let peers = peer_rows(t);
154 Some(PeersView {
155 bins,
156 peers,
157 depth: t.depth,
158 population: t.population,
159 connected: t.connected,
160 reachability: t.reachability.clone(),
161 network_availability: t.network_availability.clone(),
162 light_connected: t.light_nodes.connected,
163 })
164 }
165}
166
167fn bin_strip_rows(t: &Topology) -> Vec<BinStripRow> {
168 t.bins
169 .iter()
170 .enumerate()
171 .map(|(i, b)| {
172 let bin = i as u8;
173 let is_relevant = bin <= t.depth.saturating_add(FAR_BIN_RELAXATION);
174 BinStripRow {
175 bin,
176 population: b.population,
177 connected: b.connected,
178 status: classify_bin(b, bin, t.depth),
179 is_relevant,
180 }
181 })
182 .collect()
183}
184
185fn classify_bin(b: &BinInfo, bin: u8, depth: u8) -> BinSaturation {
186 if b.connected > OVER_SATURATION_PEERS {
187 return BinSaturation::Over;
188 }
189 if b.connected >= SATURATION_PEERS {
190 return BinSaturation::Healthy;
191 }
192 if bin <= depth.saturating_add(FAR_BIN_RELAXATION) {
195 BinSaturation::Starving
196 } else {
197 BinSaturation::Empty
198 }
199}
200
201fn peer_rows(t: &Topology) -> Vec<PeerRow> {
202 let mut out: Vec<PeerRow> = Vec::new();
203 for (i, b) in t.bins.iter().enumerate() {
204 let bin = i as u8;
205 for p in &b.connected_peers {
206 out.push(make_peer_row(bin, p));
207 }
208 }
209 out.sort_by(|a, b| {
212 a.bin
213 .cmp(&b.bin)
214 .then_with(|| a.peer_short.cmp(&b.peer_short))
215 });
216 out
217}
218
219fn make_peer_row(bin: u8, p: &PeerInfo) -> PeerRow {
220 let peer_short = short_overlay(&p.address);
221 let (direction, latency, healthy, reachability) = match &p.metrics {
222 Some(m) => {
223 let direction = match m.session_connection_direction.as_str() {
224 "inbound" => "in",
225 "outbound" => "out",
226 _ => "?",
227 };
228 let latency_ms = m.latency_ewma.max(0) as f64 / 1_000_000.0;
229 let latency = if m.latency_ewma > 0 {
230 format!("{latency_ms:.0}ms")
231 } else {
232 "—".into()
233 };
234 (direction, latency, m.healthy, m.reachability.clone())
235 }
236 None => ("?", "—".into(), false, String::new()),
237 };
238 PeerRow {
239 bin,
240 peer_short,
241 direction,
242 latency,
243 healthy,
244 reachability,
245 }
246}
247
248fn short_overlay(s: &str) -> String {
249 let trimmed = s.trim_start_matches("0x");
250 if trimmed.len() > 10 {
251 format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
252 } else {
253 trimmed.to_string()
254 }
255}
256
257impl Component for Peers {
258 fn update(&mut self, action: Action) -> Result<Option<Action>> {
259 if matches!(action, Action::Tick) {
260 self.pull_latest();
261 }
262 Ok(None)
263 }
264
265 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
266 let chunks = Layout::vertical([
267 Constraint::Length(3), Constraint::Length(20), Constraint::Min(0), Constraint::Length(1), ])
272 .split(area);
273
274 let header_l1 = Line::from(vec![Span::styled(
276 "PEERS / TOPOLOGY",
277 Style::default().add_modifier(Modifier::BOLD),
278 )]);
279 let mut header_l2 = Vec::new();
280 if let Some(err) = &self.snapshot.last_error {
281 header_l2.push(Span::styled(
282 format!("error: {err}"),
283 Style::default().fg(Color::Red),
284 ));
285 } else if !self.snapshot.is_loaded() {
286 header_l2.push(Span::styled(
287 "loading…",
288 Style::default().fg(Color::DarkGray),
289 ));
290 }
291 frame.render_widget(
292 Paragraph::new(vec![header_l1, Line::from(header_l2)])
293 .block(Block::default().borders(Borders::BOTTOM)),
294 chunks[0],
295 );
296
297 let view = match Self::view_for(&self.snapshot) {
298 Some(v) => v,
299 None => {
300 frame.render_widget(
301 Paragraph::new(Span::styled(
302 " topology not loaded yet",
303 Style::default()
304 .fg(Color::DarkGray)
305 .add_modifier(Modifier::ITALIC),
306 )),
307 chunks[1],
308 );
309 return Ok(());
310 }
311 };
312
313 let mut strip_lines: Vec<Line> = vec![
315 Line::from(vec![
316 Span::styled(
317 format!(
318 " depth {} · connected {} / known {} · reachability {} · net {}",
319 view.depth,
320 view.connected,
321 view.population,
322 if view.reachability.is_empty() {
323 "?".to_string()
324 } else {
325 view.reachability.clone()
326 },
327 if view.network_availability.is_empty() {
328 "?".to_string()
329 } else {
330 view.network_availability.clone()
331 },
332 ),
333 Style::default().fg(Color::DarkGray),
334 ),
335 ]),
336 Line::from(Span::styled(
337 " BIN POP CONN BAR STATUS",
338 Style::default()
339 .fg(Color::DarkGray)
340 .add_modifier(Modifier::BOLD),
341 )),
342 ];
343 for r in &view.bins {
344 if !r.is_relevant && r.population == 0 {
347 continue;
348 }
349 let bar = bin_bar(r.connected as usize, 12);
350 strip_lines.push(Line::from(vec![
351 Span::raw(" "),
352 Span::styled(
353 format!("{:>3} ", r.bin),
354 Style::default().add_modifier(Modifier::BOLD),
355 ),
356 Span::raw(format!("{:>4} ", r.population)),
357 Span::raw(format!("{:>4} ", r.connected)),
358 Span::styled(format!("{bar:<14}"), Style::default().fg(r.status.color())),
359 Span::raw(" "),
360 Span::styled(
361 r.status.label(),
362 Style::default()
363 .fg(r.status.color())
364 .add_modifier(Modifier::BOLD),
365 ),
366 ]));
367 }
368 if view.light_connected > 0 {
369 strip_lines.push(Line::from(vec![
370 Span::raw(" "),
371 Span::styled(
372 format!(" light — {} (separate from main bins)", view.light_connected),
373 Style::default().fg(Color::DarkGray),
374 ),
375 ]));
376 }
377 frame.render_widget(
378 Paragraph::new(strip_lines).block(Block::default().borders(Borders::BOTTOM)),
379 chunks[1],
380 );
381
382 let mut peer_lines: Vec<Line> = vec![Line::from(Span::styled(
384 " BIN PEER DIR LATENCY HEALTHY REACHABILITY",
385 Style::default()
386 .fg(Color::DarkGray)
387 .add_modifier(Modifier::BOLD),
388 ))];
389 if view.peers.is_empty() {
390 peer_lines.push(Line::from(Span::styled(
391 " (no connected peers reported)",
392 Style::default()
393 .fg(Color::DarkGray)
394 .add_modifier(Modifier::ITALIC),
395 )));
396 } else {
397 for p in &view.peers {
398 let healthy_glyph = if p.healthy { "✓" } else { "✗" };
399 let healthy_style = if p.healthy {
400 Style::default().fg(Color::Green)
401 } else {
402 Style::default().fg(Color::Red)
403 };
404 peer_lines.push(Line::from(vec![
405 Span::raw(" "),
406 Span::raw(format!("{:>3} ", p.bin)),
407 Span::raw(format!("{:<13} ", p.peer_short)),
408 Span::raw(format!("{:<4} ", p.direction)),
409 Span::raw(format!("{:<8} ", p.latency)),
410 Span::styled(format!("{healthy_glyph:<7} "), healthy_style),
411 Span::raw(p.reachability.clone()),
412 ]));
413 }
414 }
415 frame.render_widget(Paragraph::new(peer_lines), chunks[2]);
416
417 frame.render_widget(
419 Paragraph::new(Line::from(vec![
420 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
421 Span::raw(" switch screen "),
422 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
423 Span::raw(" quit "),
424 Span::styled(
425 format!("thresholds: {SATURATION_PEERS} saturate · {OVER_SATURATION_PEERS} over"),
426 Style::default().fg(Color::DarkGray),
427 ),
428 ])),
429 chunks[3],
430 );
431
432 Ok(())
433 }
434}
435
436fn bin_bar(connected: usize, width: usize) -> String {
439 let scale = OVER_SATURATION_PEERS as usize;
440 let filled = connected.min(scale) * width / scale.max(1);
441 let mut bar = String::with_capacity(width);
442 for _ in 0..filled.min(width) {
443 bar.push('▇');
444 }
445 for _ in filled.min(width)..width {
446 bar.push('░');
447 }
448 bar
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn bin(population: u64, connected: u64) -> BinInfo {
456 BinInfo {
457 population,
458 connected,
459 ..BinInfo::default()
460 }
461 }
462
463 #[test]
464 fn classify_below_saturation_in_relevant_bin_is_starving() {
465 assert_eq!(
467 classify_bin(&bin(5, 3), 4, 8),
468 BinSaturation::Starving
469 );
470 }
471
472 #[test]
473 fn classify_below_saturation_in_far_bin_is_empty() {
474 assert_eq!(
476 classify_bin(&bin(0, 0), 20, 8),
477 BinSaturation::Empty
478 );
479 }
480
481 #[test]
482 fn classify_in_safe_band_is_healthy() {
483 assert_eq!(classify_bin(&bin(15, 12), 4, 8), BinSaturation::Healthy);
484 assert_eq!(
485 classify_bin(&bin(8, SATURATION_PEERS), 4, 8),
486 BinSaturation::Healthy
487 );
488 }
489
490 #[test]
491 fn classify_over_threshold_is_over() {
492 assert_eq!(
493 classify_bin(&bin(25, OVER_SATURATION_PEERS + 1), 4, 8),
494 BinSaturation::Over
495 );
496 }
497
498 #[test]
499 fn short_overlay_truncates() {
500 let s = short_overlay("0xabcdef0123456789abcdef0123456789");
501 assert!(s.contains('…'));
502 assert!(s.starts_with("abcdef"));
503 }
504
505 #[test]
506 fn bin_bar_caps_at_oversaturation() {
507 let bar_full = bin_bar(50, 12);
508 assert_eq!(bar_full, "▇".repeat(12));
509 let bar_empty = bin_bar(0, 12);
510 assert_eq!(bar_empty, "░".repeat(12));
511 }
512}