1use std::time::{Duration, Instant};
30
31use color_eyre::Result;
32use ratatui::{
33 Frame,
34 layout::{Constraint, Layout, Rect},
35 style::{Color, Modifier, Style},
36 text::{Line, Span},
37 widgets::{Block, Borders, Paragraph},
38};
39use tokio::sync::watch;
40
41use super::Component;
42use crate::action::Action;
43use crate::theme;
44use crate::watch::{NetworkSnapshot, TopologySnapshot};
45
46use bee::debug::{Addresses, Topology};
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ReachabilityStatus {
51 NotLoaded,
54 Public,
56 Private,
58 Other(String),
61}
62
63impl ReachabilityStatus {
64 fn from_api(s: &str) -> Self {
65 match s {
66 "" => Self::NotLoaded,
67 "Public" => Self::Public,
68 "Private" => Self::Private,
69 other => Self::Other(other.into()),
70 }
71 }
72 fn color(&self) -> Color {
73 match self {
74 Self::NotLoaded => theme::active().dim,
75 Self::Public => theme::active().pass,
76 Self::Private => theme::active().warn,
77 Self::Other(_) => theme::active().dim,
78 }
79 }
80 fn label(&self) -> String {
81 match self {
82 Self::NotLoaded => "(unknown)".into(),
83 Self::Public => "Public".into(),
84 Self::Private => "Private".into(),
85 Self::Other(s) => s.clone(),
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum AvailabilityStatus {
95 NotLoaded,
96 Available,
97 Unavailable,
98 Other(String),
99}
100
101impl AvailabilityStatus {
102 fn from_api(s: &str) -> Self {
103 match s {
104 "" => Self::NotLoaded,
105 "Available" => Self::Available,
106 "Unavailable" => Self::Unavailable,
107 other => Self::Other(other.into()),
108 }
109 }
110 fn color(&self) -> Color {
111 match self {
112 Self::NotLoaded => theme::active().dim,
113 Self::Available => theme::active().pass,
114 Self::Unavailable => theme::active().fail,
115 Self::Other(_) => theme::active().dim,
116 }
117 }
118 fn label(&self) -> String {
119 match self {
120 Self::NotLoaded => "(unknown)".into(),
121 Self::Available => "Available".into(),
122 Self::Unavailable => "Unavailable".into(),
123 Self::Other(s) => s.clone(),
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum UnderlayKind {
133 Public,
136 Private,
139 Unknown,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct UnderlayRow {
145 pub multiaddr: String,
146 pub kind: UnderlayKind,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct NetworkView {
152 pub overlay_short: String,
157 pub ethereum_short: String,
158 pub overlay_full: String,
162 pub ethereum_full: String,
164 pub underlays: Vec<UnderlayRow>,
165 pub inbound: u64,
166 pub outbound: u64,
167 pub reachability: ReachabilityStatus,
168 pub network_availability: AvailabilityStatus,
169}
170
171pub struct Network {
172 network_rx: watch::Receiver<NetworkSnapshot>,
173 topology_rx: watch::Receiver<TopologySnapshot>,
174 network: NetworkSnapshot,
175 topology: TopologySnapshot,
176 last_seen_reachability: Option<String>,
181 reachability_changed_at: Option<Instant>,
182}
183
184impl Network {
185 pub fn new(
186 network_rx: watch::Receiver<NetworkSnapshot>,
187 topology_rx: watch::Receiver<TopologySnapshot>,
188 ) -> Self {
189 let network = network_rx.borrow().clone();
190 let topology = topology_rx.borrow().clone();
191 Self {
192 network_rx,
193 topology_rx,
194 network,
195 topology,
196 last_seen_reachability: None,
197 reachability_changed_at: None,
198 }
199 }
200
201 fn pull_latest(&mut self) {
202 self.network = self.network_rx.borrow().clone();
203 self.topology = self.topology_rx.borrow().clone();
204 let current = self
207 .topology
208 .topology
209 .as_ref()
210 .map(|t| t.reachability.clone());
211 if current != self.last_seen_reachability {
212 self.last_seen_reachability = current;
213 self.reachability_changed_at = Some(Instant::now());
214 }
215 }
216
217 pub fn view_for(network: &NetworkSnapshot, topology: &TopologySnapshot) -> NetworkView {
220 let addresses = network.addresses.as_ref();
221 let topo = topology.topology.as_ref();
222 let underlays = addresses.map(underlay_rows).unwrap_or_default();
223 let (inbound, outbound) = topo.map(peer_direction_counts).unwrap_or((0, 0));
224 NetworkView {
225 overlay_short: addresses
226 .map(|a| short_hex(&a.overlay))
227 .unwrap_or_else(|| "—".into()),
228 ethereum_short: addresses
229 .map(|a| short_hex(&a.ethereum))
230 .unwrap_or_else(|| "—".into()),
231 overlay_full: addresses
232 .map(|a| a.overlay.clone())
233 .unwrap_or_else(|| "—".into()),
234 ethereum_full: addresses
235 .map(|a| a.ethereum.clone())
236 .unwrap_or_else(|| "—".into()),
237 underlays,
238 inbound,
239 outbound,
240 reachability: topo
241 .map(|t| ReachabilityStatus::from_api(&t.reachability))
242 .unwrap_or(ReachabilityStatus::NotLoaded),
243 network_availability: topo
244 .map(|t| AvailabilityStatus::from_api(&t.network_availability))
245 .unwrap_or(AvailabilityStatus::NotLoaded),
246 }
247 }
248}
249
250fn underlay_rows(a: &Addresses) -> Vec<UnderlayRow> {
251 a.underlay
252 .iter()
253 .map(|m| UnderlayRow {
254 multiaddr: m.clone(),
255 kind: classify_multiaddr(m),
256 })
257 .collect()
258}
259
260pub fn classify_multiaddr(m: &str) -> UnderlayKind {
265 let parts: Vec<&str> = m.split('/').collect();
266 let mut i = 0;
268 while i + 1 < parts.len() {
269 match parts[i] {
270 "ip4" => return classify_ipv4(parts[i + 1]),
271 "ip6" => return classify_ipv6(parts[i + 1]),
272 "dns" | "dns4" | "dns6" | "dnsaddr" => return UnderlayKind::Unknown,
273 _ => {}
274 }
275 i += 1;
276 }
277 UnderlayKind::Unknown
278}
279
280fn classify_ipv4(addr: &str) -> UnderlayKind {
281 let octets: Vec<u8> = match addr
282 .split('.')
283 .map(|s| s.parse::<u8>())
284 .collect::<Result<Vec<_>, _>>()
285 {
286 Ok(o) if o.len() == 4 => o,
287 _ => return UnderlayKind::Unknown,
288 };
289 let is_private = matches!(octets[0], 10)
291 || (octets[0] == 172 && (16..=31).contains(&octets[1]))
292 || (octets[0] == 192 && octets[1] == 168)
293 || octets[0] == 127
294 || (octets[0] == 169 && octets[1] == 254)
295 || (octets[0] == 100 && (64..=127).contains(&octets[1]))
296 || octets[0] == 0;
297 if is_private {
298 UnderlayKind::Private
299 } else {
300 UnderlayKind::Public
301 }
302}
303
304fn classify_ipv6(addr: &str) -> UnderlayKind {
305 let lower = addr.to_lowercase();
306 if lower == "::1" {
308 return UnderlayKind::Private;
309 }
310 if lower.starts_with("fc") || lower.starts_with("fd") {
312 return UnderlayKind::Private;
313 }
314 if lower.starts_with("fe8")
316 || lower.starts_with("fe9")
317 || lower.starts_with("fea")
318 || lower.starts_with("feb")
319 {
320 return UnderlayKind::Private;
321 }
322 UnderlayKind::Public
323}
324
325fn peer_direction_counts(t: &Topology) -> (u64, u64) {
326 let mut inbound = 0u64;
327 let mut outbound = 0u64;
328 for b in &t.bins {
329 for p in &b.connected_peers {
330 if let Some(m) = &p.metrics {
331 match m.session_connection_direction.as_str() {
332 "inbound" => inbound += 1,
333 "outbound" => outbound += 1,
334 _ => {}
335 }
336 }
337 }
338 }
339 (inbound, outbound)
340}
341
342fn short_hex(s: &str) -> String {
343 let trimmed = s.trim_start_matches("0x");
344 if trimmed.len() > 12 {
345 format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
346 } else {
347 trimmed.to_string()
348 }
349}
350
351pub fn format_stability(d: Duration) -> String {
355 let secs = d.as_secs();
356 if secs >= 3_600 {
357 let h = secs / 3_600;
358 let m = (secs % 3_600) / 60;
359 format!("{h}h {m:>2}m")
360 } else if secs >= 60 {
361 let m = secs / 60;
362 let s = secs % 60;
363 format!("{m}m {s:>2}s")
364 } else {
365 format!("{secs}s")
366 }
367}
368
369impl Component for Network {
370 fn update(&mut self, action: Action) -> Result<Option<Action>> {
371 if matches!(action, Action::Tick) {
372 self.pull_latest();
373 }
374 Ok(None)
375 }
376
377 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
378 let chunks = Layout::vertical([
379 Constraint::Length(3), Constraint::Length(4), Constraint::Length(4), Constraint::Min(0), Constraint::Length(1), ])
385 .split(area);
386
387 let header_l1 = Line::from(vec![Span::styled(
389 "NETWORK / NAT",
390 Style::default().add_modifier(Modifier::BOLD),
391 )]);
392 let mut header_l2 = Vec::new();
393 let t = theme::active();
394 if let Some(err) = &self.network.last_error {
395 let (color, msg) = theme::classify_header_error(err);
396 header_l2.push(Span::styled(msg, Style::default().fg(color)));
397 } else if !self.network.is_loaded() {
398 header_l2.push(Span::styled(
399 format!("{} loading…", theme::spinner_glyph()),
400 Style::default().fg(t.dim),
401 ));
402 }
403 frame.render_widget(
404 Paragraph::new(vec![header_l1, Line::from(header_l2)])
405 .block(Block::default().borders(Borders::BOTTOM)),
406 chunks[0],
407 );
408
409 let view = Self::view_for(&self.network, &self.topology);
410
411 let identity = vec![
415 Line::from(vec![
416 Span::styled(
417 " overlay ",
418 Style::default().add_modifier(Modifier::BOLD),
419 ),
420 Span::styled(view.overlay_full.clone(), Style::default().fg(t.info)),
421 ]),
422 Line::from(vec![
423 Span::styled(
424 " ethereum ",
425 Style::default().add_modifier(Modifier::BOLD),
426 ),
427 Span::styled(view.ethereum_full.clone(), Style::default().fg(t.info)),
428 ]),
429 ];
430 frame.render_widget(
431 Paragraph::new(identity).block(Block::default().borders(Borders::BOTTOM)),
432 chunks[1],
433 );
434
435 let stability = self
437 .reachability_changed_at
438 .map(|t| format_stability(Instant::now().saturating_duration_since(t)))
439 .unwrap_or_else(|| "—".into());
440 let conns = vec![
441 Line::from(vec![
442 Span::styled(
443 " inbound ",
444 Style::default().add_modifier(Modifier::BOLD),
445 ),
446 Span::raw(format!("{:<6}", view.inbound)),
447 Span::styled("outbound ", Style::default().add_modifier(Modifier::BOLD)),
448 Span::raw(format!("{}", view.outbound)),
449 ]),
450 Line::from(vec![
451 Span::styled(
452 " reachable ",
453 Style::default().add_modifier(Modifier::BOLD),
454 ),
455 Span::styled(
456 view.reachability.label(),
457 Style::default()
458 .fg(view.reachability.color())
459 .add_modifier(Modifier::BOLD),
460 ),
461 Span::styled(
462 format!(" (stable for {stability})"),
463 Style::default().fg(t.dim),
464 ),
465 ]),
466 Line::from(vec![
467 Span::styled(
468 " network ",
469 Style::default().add_modifier(Modifier::BOLD),
470 ),
471 Span::styled(
472 view.network_availability.label(),
473 Style::default()
474 .fg(view.network_availability.color())
475 .add_modifier(Modifier::BOLD),
476 ),
477 ]),
478 ];
479 frame.render_widget(
480 Paragraph::new(conns).block(Block::default().borders(Borders::BOTTOM)),
481 chunks[2],
482 );
483
484 let mut addr_lines: Vec<Line> = vec![Line::from(Span::styled(
486 " PUBLIC ADDRESSES",
487 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
488 ))];
489 if view.underlays.is_empty() {
490 addr_lines.push(Line::from(Span::styled(
491 " (no addresses reported)",
492 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
493 )));
494 } else {
495 for u in &view.underlays {
496 let (style, badge) = match u.kind {
497 UnderlayKind::Public => (Style::default().fg(t.pass), " PUB "),
498 UnderlayKind::Private => (Style::default().fg(t.dim), " PRIV"),
499 UnderlayKind::Unknown => (Style::default().fg(t.warn), " ??? "),
500 };
501 addr_lines.push(Line::from(vec![
502 Span::raw(" "),
503 Span::styled(format!("[{badge}] "), style),
504 Span::styled(u.multiaddr.clone(), style),
505 ]));
506 }
507 addr_lines.push(Line::from(""));
508 addr_lines.push(Line::from(Span::styled(
509 " External port-check + relay candidates require services Bee doesn't expose;",
510 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
511 )));
512 addr_lines.push(Line::from(Span::styled(
513 " use `nmap -p 1634 <ip>` from a separate machine to confirm public reachability.",
514 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
515 )));
516 }
517 frame.render_widget(Paragraph::new(addr_lines), chunks[3]);
518
519 frame.render_widget(
521 Paragraph::new(Line::from(vec![
522 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
523 Span::raw(" switch screen "),
524 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
525 Span::raw(" help "),
526 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
527 Span::raw(" quit "),
528 Span::styled(
529 "isReachable flickers under symmetric NAT — watch the stability window",
530 Style::default().fg(t.dim),
531 ),
532 ])),
533 chunks[4],
534 );
535
536 Ok(())
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn classify_ipv4_public_and_private() {
546 assert_eq!(classify_ipv4("8.8.8.8"), UnderlayKind::Public);
547 assert_eq!(classify_ipv4("77.123.45.6"), UnderlayKind::Public);
548 assert_eq!(classify_ipv4("10.0.0.1"), UnderlayKind::Private);
549 assert_eq!(classify_ipv4("172.16.0.1"), UnderlayKind::Private);
550 assert_eq!(classify_ipv4("172.31.0.1"), UnderlayKind::Private);
551 assert_eq!(classify_ipv4("192.168.1.1"), UnderlayKind::Private);
552 assert_eq!(classify_ipv4("127.0.0.1"), UnderlayKind::Private);
553 assert_eq!(classify_ipv4("169.254.1.1"), UnderlayKind::Private);
554 assert_eq!(classify_ipv4("100.64.0.1"), UnderlayKind::Private);
555 }
556
557 #[test]
558 fn classify_ipv4_invalid_is_unknown() {
559 assert_eq!(classify_ipv4("not-an-ip"), UnderlayKind::Unknown);
560 assert_eq!(classify_ipv4("1.2.3"), UnderlayKind::Unknown);
561 }
562
563 #[test]
564 fn classify_ipv6_public_and_private() {
565 assert_eq!(classify_ipv6("2a01:4f8:1:2::1"), UnderlayKind::Public);
566 assert_eq!(classify_ipv6("::1"), UnderlayKind::Private);
567 assert_eq!(classify_ipv6("fc00::1"), UnderlayKind::Private);
568 assert_eq!(classify_ipv6("fd12::1"), UnderlayKind::Private);
569 assert_eq!(classify_ipv6("fe80::1"), UnderlayKind::Private);
570 }
571
572 #[test]
573 fn classify_multiaddr_picks_protocol() {
574 assert_eq!(
575 classify_multiaddr("/ip4/77.123.45.6/tcp/1634/p2p/16Uiu..."),
576 UnderlayKind::Public
577 );
578 assert_eq!(
579 classify_multiaddr("/ip4/192.168.1.5/tcp/1634/p2p/16Uiu..."),
580 UnderlayKind::Private
581 );
582 assert_eq!(
583 classify_multiaddr("/dns4/bee.example.com/tcp/1634"),
584 UnderlayKind::Unknown
585 );
586 assert_eq!(classify_multiaddr("/p2p/16Uiu..."), UnderlayKind::Unknown);
588 }
589
590 #[test]
591 fn reachability_from_api_known_strings() {
592 assert_eq!(
593 ReachabilityStatus::from_api("Public"),
594 ReachabilityStatus::Public
595 );
596 assert_eq!(
597 ReachabilityStatus::from_api("Private"),
598 ReachabilityStatus::Private
599 );
600 assert_eq!(
601 ReachabilityStatus::from_api(""),
602 ReachabilityStatus::NotLoaded
603 );
604 assert_eq!(
605 ReachabilityStatus::from_api("Symmetric"),
606 ReachabilityStatus::Other("Symmetric".into())
607 );
608 }
609
610 #[test]
611 fn format_stability_unit_thresholds() {
612 assert_eq!(format_stability(Duration::from_secs(5)), "5s");
613 assert_eq!(format_stability(Duration::from_secs(125)), "2m 5s");
614 assert_eq!(format_stability(Duration::from_secs(3_725)), "1h 2m");
615 }
616}