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