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 "loading…",
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(" overlay ", Style::default().add_modifier(Modifier::BOLD)),
399 Span::styled(view.overlay_short.clone(), Style::default().fg(t.info)),
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(t.info),
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(t.dim),
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(t.dim)
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(t.dim)
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(t.pass), " PUB "),
475 UnderlayKind::Private => (Style::default().fg(t.dim), " PRIV"),
476 UnderlayKind::Unknown => (Style::default().fg(t.warn), " ??? "),
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(t.dim)
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(t.dim)
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(t.dim),
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}