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