diff --git a/src/app.rs b/src/app.rs index e20b8a1..52d9e32 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,7 +20,7 @@ use crate::network::{ parser::{PacketParser, ParsedPacket, ParserConfig}, platform::create_process_lookup, services::ServiceLookup, - types::{ApplicationProtocol, Connection, Protocol, TrafficHistory}, + types::{ApplicationProtocol, Connection, ConnectionKey, Protocol, RttTracker, TrafficHistory}, }; // Platform-specific interface stats provider @@ -227,6 +227,9 @@ pub struct App { /// Traffic history for graph visualization traffic_history: Arc>, + /// RTT tracker for latency measurement + rtt_tracker: Arc>, + /// Sandbox status (Linux Landlock) #[cfg(target_os = "linux")] sandbox_info: Arc>, @@ -255,6 +258,7 @@ impl App { interface_stats: Arc::new(DashMap::new()), interface_rates: Arc::new(DashMap::new()), traffic_history: Arc::new(RwLock::new(TrafficHistory::new(60))), // 60 seconds of history + rtt_tracker: Arc::new(Mutex::new(RttTracker::new())), #[cfg(target_os = "linux")] sandbox_info: Arc::new(RwLock::new(SandboxInfo::default())), }) @@ -458,6 +462,7 @@ impl App { let stats = Arc::clone(&self.stats); let linktype_storage = Arc::clone(&self.linktype); let json_log_path = self.config.json_log_file.clone(); + let rtt_tracker = Arc::clone(&self.rtt_tracker); let parser_config = ParserConfig { enable_dpi: self.config.enable_dpi, ..Default::default() @@ -498,7 +503,7 @@ impl App { let mut parsed_count = 0; for packet_data in &batch { if let Some(parsed) = parser.parse_packet(packet_data) { - update_connection(&connections, parsed, &stats, &json_log_path); + update_connection(&connections, parsed, &stats, &json_log_path, &rtt_tracker); parsed_count += 1; } } @@ -876,10 +881,16 @@ impl App { let traffic_history = Arc::clone(&self.traffic_history); let interface_rates = Arc::clone(&self.interface_rates); let connections_snapshot = Arc::clone(&self.connections_snapshot); + let stats = Arc::clone(&self.stats); + let rtt_tracker = Arc::clone(&self.rtt_tracker); thread::spawn(move || { info!("Traffic history thread started"); + // Track previous values for delta calculation + let mut prev_packets: u64 = 0; + let mut prev_retransmits: u64 = 0; + loop { if should_stop.load(Ordering::Relaxed) { info!("Traffic history thread stopping"); @@ -900,9 +911,32 @@ impl App { .map(|snap| snap.len()) .unwrap_or(0); + // Get packet and retransmit counts (calculate deltas) + let current_packets = stats.packets_processed.load(Ordering::Relaxed); + let current_retransmits = stats.total_tcp_retransmits.load(Ordering::Relaxed); + + let packets_delta = current_packets.saturating_sub(prev_packets); + let retransmits_delta = current_retransmits.saturating_sub(prev_retransmits); + + prev_packets = current_packets; + prev_retransmits = current_retransmits; + + // Get average RTT from tracker (last 1 second window) + let avg_rtt_ms = rtt_tracker + .lock() + .ok() + .and_then(|mut tracker| tracker.take_average_rtt(1)); + // Add sample to traffic history if let Ok(mut history) = traffic_history.write() { - history.add_sample(total_rx, total_tx, connection_count); + history.add_sample( + total_rx, + total_tx, + connection_count, + packets_delta, + retransmits_delta, + avg_rtt_ms, + ); } // Update every 1 second @@ -1133,10 +1167,31 @@ fn update_connection( parsed: ParsedPacket, _stats: &AppStats, json_log_path: &Option, + rtt_tracker: &Arc>, ) { let mut key = parsed.connection_key.clone(); let now = SystemTime::now(); + // Track RTT for TCP connections using SYN/SYN-ACK timing + let mut measured_rtt: Option = None; + if parsed.protocol == Protocol::TCP + && let Some(tcp_header) = &parsed.tcp_header + { + let conn_key = ConnectionKey::new(parsed.local_addr, parsed.remote_addr); + + if tcp_header.flags.syn && !tcp_header.flags.ack { + // This is a SYN packet (outgoing connection initiation) + if let Ok(mut tracker) = rtt_tracker.lock() { + tracker.record_syn(conn_key); + } + } else if tcp_header.flags.syn && tcp_header.flags.ack { + // This is a SYN-ACK packet (connection response) + if let Ok(mut tracker) = rtt_tracker.lock() { + measured_rtt = tracker.record_syn_ack(&conn_key); + } + } + } + // For QUIC packets, check if we have a connection ID mapping if parsed.protocol == Protocol::UDP && let Some(dpi_result) = &parsed.dpi_result @@ -1163,8 +1218,17 @@ fn update_connection( connections .entry(key.clone()) .and_modify(|conn| { - let (updated_conn, (new_retransmits, new_out_of_order, new_fast_retransmits)) = + let (mut updated_conn, (new_retransmits, new_out_of_order, new_fast_retransmits)) = merge_packet_into_connection(conn.clone(), &parsed, now); + + // Store RTT measurement if we got one from SYN-ACK + if let Some(rtt) = measured_rtt + && updated_conn.initial_rtt.is_none() + { + updated_conn.initial_rtt = Some(rtt); + debug!("RTT measured for {}: {:?}", key, rtt); + } + *conn = updated_conn; // Update global statistics @@ -1186,7 +1250,12 @@ fn update_connection( }) .or_insert_with(|| { debug!("New connection detected: {}", key); - let conn = create_connection_from_packet(&parsed, now); + let mut conn = create_connection_from_packet(&parsed, now); + + // Store RTT measurement if we got one (unlikely for new connection, but handle it) + if let Some(rtt) = measured_rtt { + conn.initial_rtt = Some(rtt); + } // Log new connection event if JSON logging is enabled if let Some(log_path) = json_log_path { diff --git a/src/network/types.rs b/src/network/types.rs index df926a6..ed18296 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::fmt; use std::net::SocketAddr; use std::time::{Duration, Instant, SystemTime}; @@ -566,6 +566,9 @@ pub struct TrafficSample { pub rx_bytes_per_sec: u64, pub tx_bytes_per_sec: u64, pub connection_count: usize, + // Network health metrics + pub packet_loss_pct: f32, + pub avg_rtt_ms: Option, } /// Ring buffer for aggregate traffic history (used for graphs) @@ -589,12 +592,23 @@ impl TrafficHistory { rx_bytes_per_sec: u64, tx_bytes_per_sec: u64, connection_count: usize, + total_packets: u64, + retransmit_count: u64, + avg_rtt_ms: Option, ) { + let packet_loss_pct = if total_packets > 0 { + (retransmit_count as f32 / total_packets as f32) * 100.0 + } else { + 0.0 + }; + let sample = TrafficSample { timestamp: Instant::now(), rx_bytes_per_sec, tx_bytes_per_sec, connection_count, + packet_loss_pct, + avg_rtt_ms, }; if self.samples.len() >= self.max_samples { @@ -714,6 +728,70 @@ impl TrafficHistory { (rx, tx) } + /// Get network health chart data: (packet_loss_pct, rtt_ms) as ChartData pairs + /// Time offset is negative seconds from now + pub fn get_health_chart_data(&self) -> (ChartData, ChartData) { + let now = Instant::now(); + let samples: Vec<_> = self.samples.iter().collect(); + + // Apply smoothing with window of 3 + let window = 3; + if samples.len() < window { + // Not enough data for smoothing, return raw + let loss: ChartData = samples + .iter() + .map(|s| { + let age = now.duration_since(s.timestamp).as_secs_f64(); + (-age, s.packet_loss_pct as f64) + }) + .collect(); + let rtt: ChartData = samples + .iter() + .filter_map(|s| { + s.avg_rtt_ms.map(|rtt| { + let age = now.duration_since(s.timestamp).as_secs_f64(); + (-age, rtt) + }) + }) + .collect(); + return (loss, rtt); + } + + let loss: ChartData = samples + .windows(window) + .map(|w| { + let avg_age: f64 = w + .iter() + .map(|s| now.duration_since(s.timestamp).as_secs_f64()) + .sum::() + / window as f64; + let avg_loss: f64 = + w.iter().map(|s| s.packet_loss_pct as f64).sum::() / window as f64; + (-avg_age, avg_loss) + }) + .collect(); + + let rtt: ChartData = samples + .windows(window) + .filter_map(|w| { + let rtts: Vec = w.iter().filter_map(|s| s.avg_rtt_ms).collect(); + if rtts.is_empty() { + None + } else { + let avg_age: f64 = w + .iter() + .map(|s| now.duration_since(s.timestamp).as_secs_f64()) + .sum::() + / window as f64; + let avg_rtt: f64 = rtts.iter().sum::() / rtts.len() as f64; + Some((-avg_age, avg_rtt)) + } + }) + .collect(); + + (loss, rtt) + } + /// Check if we have enough data to display pub fn has_enough_data(&self) -> bool { self.samples.len() >= 2 @@ -726,6 +804,108 @@ impl Default for TrafficHistory { } } +// ============================================================================ +// RTT Tracking Types (for latency measurement) +// ============================================================================ + +/// Key for tracking pending SYN packets +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ConnectionKey { + pub local_addr: SocketAddr, + pub remote_addr: SocketAddr, +} + +impl ConnectionKey { + pub fn new(local_addr: SocketAddr, remote_addr: SocketAddr) -> Self { + Self { + local_addr, + remote_addr, + } + } +} + +/// Tracks pending SYN packets and recent RTT measurements +#[derive(Debug)] +pub struct RttTracker { + /// Pending SYN packets awaiting SYN-ACK: (connection_key -> timestamp) + pending_syns: HashMap, + /// Recent RTT measurements for aggregation: (timestamp, rtt_duration) + recent_rtts: VecDeque<(Instant, Duration)>, + /// Maximum age for pending SYNs (cleanup stale entries) + max_pending_age: Duration, + /// Maximum number of recent RTTs to keep + max_recent_rtts: usize, +} + +impl RttTracker { + pub fn new() -> Self { + Self { + pending_syns: HashMap::new(), + recent_rtts: VecDeque::new(), + max_pending_age: Duration::from_secs(30), + max_recent_rtts: 100, + } + } + + /// Record a SYN packet being sent/received + pub fn record_syn(&mut self, key: ConnectionKey) { + self.pending_syns.insert(key, Instant::now()); + self.cleanup_stale(); + } + + /// Try to match a SYN-ACK to a pending SYN and calculate RTT + /// Returns the RTT if a match was found + pub fn record_syn_ack(&mut self, key: &ConnectionKey) -> Option { + // SYN and SYN-ACK have the same (local_addr, remote_addr) from parser's perspective + if let Some(syn_time) = self.pending_syns.remove(key) { + let rtt = syn_time.elapsed(); + self.add_rtt_sample(rtt); + Some(rtt) + } else { + None + } + } + + /// Add an RTT sample + fn add_rtt_sample(&mut self, rtt: Duration) { + let now = Instant::now(); + if self.recent_rtts.len() >= self.max_recent_rtts { + self.recent_rtts.pop_front(); + } + self.recent_rtts.push_back((now, rtt)); + } + + /// Get average RTT for the last N seconds, clearing consumed samples + pub fn take_average_rtt(&mut self, window_secs: u64) -> Option { + let cutoff = Instant::now() - Duration::from_secs(window_secs); + let samples: Vec = self + .recent_rtts + .iter() + .filter(|(ts, _)| *ts >= cutoff) + .map(|(_, rtt)| *rtt) + .collect(); + + if samples.is_empty() { + None + } else { + let total_ms: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum(); + Some(total_ms / samples.len() as f64) + } + } + + /// Clean up stale pending SYNs + fn cleanup_stale(&mut self) { + let cutoff = Instant::now() - self.max_pending_age; + self.pending_syns.retain(|_, ts| *ts > cutoff); + } +} + +impl Default for RttTracker { + fn default() -> Self { + Self::new() + } +} + /// Distribution of connections by application protocol (from DPI) #[derive(Debug, Clone, Default)] pub struct AppProtocolDistribution { @@ -1045,6 +1225,9 @@ pub struct Connection { // TCP analytics (only for TCP connections) pub tcp_analytics: Option, + + // Initial RTT measurement (from SYN-ACK timing) + pub initial_rtt: Option, } impl Connection { @@ -1082,6 +1265,7 @@ impl Connection { current_incoming_rate_bps: 0.0, current_outgoing_rate_bps: 0.0, tcp_analytics, + initial_rtt: None, } } @@ -2187,4 +2371,145 @@ mod tests { assert_eq!(conn.state(), "ARP_REQUEST"); assert_eq!(conn.get_timeout(), Duration::from_secs(30)); } + + // ======================================================================== + // RTT Tracker Tests + // ======================================================================== + + #[test] + fn test_rtt_tracker_new() { + let tracker = RttTracker::new(); + assert!(tracker.pending_syns.is_empty()); + assert!(tracker.recent_rtts.is_empty()); + } + + #[test] + fn test_rtt_tracker_record_syn() { + let mut tracker = RttTracker::new(); + let key = ConnectionKey::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 12345), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443), + ); + + tracker.record_syn(key.clone()); + assert_eq!(tracker.pending_syns.len(), 1); + assert!(tracker.pending_syns.contains_key(&key)); + } + + #[test] + fn test_rtt_tracker_record_syn_ack_no_match() { + let mut tracker = RttTracker::new(); + let key = ConnectionKey::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 12345), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443), + ); + + // Try to record SYN-ACK without prior SYN + let rtt = tracker.record_syn_ack(&key); + assert!(rtt.is_none()); + } + + #[test] + fn test_rtt_tracker_record_syn_ack_match() { + let mut tracker = RttTracker::new(); + let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 12345); + let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443); + + // Record SYN (outgoing: local -> remote) + let syn_key = ConnectionKey::new(local, remote); + tracker.record_syn(syn_key); + + // Simulate some delay + std::thread::sleep(Duration::from_millis(10)); + + // Record SYN-ACK (same key - parser normalizes to local,remote) + let syn_ack_key = ConnectionKey::new(local, remote); + let rtt = tracker.record_syn_ack(&syn_ack_key); + + assert!(rtt.is_some()); + let rtt = rtt.unwrap(); + assert!(rtt >= Duration::from_millis(10)); + assert!(tracker.pending_syns.is_empty()); + assert_eq!(tracker.recent_rtts.len(), 1); + } + + #[test] + fn test_rtt_tracker_take_average_rtt() { + let mut tracker = RttTracker::new(); + let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 12345); + let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443); + + // Record multiple RTT measurements + for port in 12345..12348 { + let local_with_port = SocketAddr::new(local.ip(), port); + let key = ConnectionKey::new(local_with_port, remote); + tracker.record_syn(key.clone()); + std::thread::sleep(Duration::from_millis(5)); + tracker.record_syn_ack(&key); + } + + // Get average RTT + let avg = tracker.take_average_rtt(60); + assert!(avg.is_some()); + let avg = avg.unwrap(); + assert!(avg >= 5.0); // At least 5ms + } + + // ======================================================================== + // Traffic History Health Data Tests + // ======================================================================== + + #[test] + fn test_traffic_sample_packet_loss_calculation() { + let mut history = TrafficHistory::new(60); + + // Add sample with 100 packets and 5 retransmits (5% loss) + history.add_sample(1000, 500, 10, 100, 5, Some(25.0)); + + let (loss_data, rtt_data) = history.get_health_chart_data(); + + // Should have at least one data point + assert!(!loss_data.is_empty()); + // Loss percentage should be 5% + assert!((loss_data[0].1 - 5.0).abs() < 0.01); + + // Should have RTT data + assert!(!rtt_data.is_empty()); + // RTT should be around 25ms + assert!((rtt_data[0].1 - 25.0).abs() < 0.01); + } + + #[test] + fn test_traffic_sample_zero_packets() { + let mut history = TrafficHistory::new(60); + + // Add sample with 0 packets (no loss calculation possible) + history.add_sample(1000, 500, 10, 0, 0, None); + + let (loss_data, rtt_data) = history.get_health_chart_data(); + + // Should have data with 0% loss + assert!(!loss_data.is_empty()); + assert!((loss_data[0].1).abs() < 0.01); + + // No RTT data + assert!(rtt_data.is_empty()); + } + + #[test] + fn test_traffic_history_health_smoothing() { + let mut history = TrafficHistory::new(60); + + // Add multiple samples + for i in 0..5 { + history.add_sample(1000, 500, 10, 100, i * 2, Some((i * 10) as f64)); + std::thread::sleep(Duration::from_millis(10)); + } + + let (loss_data, rtt_data) = history.get_health_chart_data(); + + // Should have smoothed data points + assert!(loss_data.len() >= 2); + assert!(rtt_data.len() >= 2); + } } diff --git a/src/ui.rs b/src/ui.rs index dfa36ef..251e41a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use crate::app::{App, AppStats}; -use crate::network::types::{AppProtocolDistribution, Connection, Protocol, TrafficHistory}; +use crate::network::types::{AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory}; pub type Terminal = RatatuiTerminal; @@ -1089,13 +1089,14 @@ fn draw_graph_tab( ) -> Result<()> { let traffic_history = app.get_traffic_history(); - // Main layout: top row (charts + legend), bottom row (info) + // Main layout: traffic chart, health chart, legend, bottom row let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(0), // Charts + Constraint::Percentage(35), // Traffic chart + Constraint::Percentage(20), // Network health + TCP states Constraint::Length(1), // Legend row - Constraint::Percentage(45), // App distribution + top processes + Constraint::Min(0), // App distribution + top processes ]) .split(area); @@ -1105,23 +1106,29 @@ fn draw_graph_tab( .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) .split(main_chunks[0]); - // Legend row: traffic legend (70%) + empty/connections count (30%) - let legend_chunks = Layout::default() + // Health row: health gauges (35%) + TCP counters (35%) + TCP states (30%) + let health_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .constraints([ + Constraint::Percentage(35), + Constraint::Percentage(35), + Constraint::Percentage(30), + ]) .split(main_chunks[1]); // Bottom row: app distribution (50%) + top processes (50%) let bottom_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(main_chunks[2]); + .split(main_chunks[3]); // Draw components draw_traffic_chart(f, &traffic_history, top_chunks[0]); draw_connections_sparkline(f, &traffic_history, top_chunks[1]); - draw_traffic_legend(f, legend_chunks[0]); - // legend_chunks[1] intentionally empty for alignment + draw_health_chart(f, &traffic_history, health_chunks[0]); + draw_tcp_counters(f, app, health_chunks[1]); + draw_tcp_states(f, connections, health_chunks[2]); + draw_traffic_legend(f, main_chunks[2]); draw_app_distribution(f, connections, bottom_chunks[0]); draw_top_processes(f, connections, bottom_chunks[1]); @@ -1363,6 +1370,261 @@ fn draw_traffic_legend(f: &mut Frame, area: Rect) { f.render_widget(legend, area); } +/// Draw the network health gauges with RTT and packet loss bars +fn draw_health_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title("Network Health"); + + let inner = block.inner(area); + f.render_widget(block, area); + + if !history.has_enough_data() { + let placeholder = Paragraph::new("Collecting data...") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(placeholder, inner); + return; + } + + // Get current values from history + let (loss_data, rtt_data) = history.get_health_chart_data(); + + // Get most recent values (last data point) + let current_loss = loss_data.last().map(|(_, v)| *v).unwrap_or(0.0); + let current_rtt = rtt_data.last().map(|(_, v)| *v); + + // Calculate averages + let avg_loss = if !loss_data.is_empty() { + loss_data.iter().map(|(_, v)| v).sum::() / loss_data.len() as f64 + } else { + 0.0 + }; + let avg_rtt = if !rtt_data.is_empty() { + Some(rtt_data.iter().map(|(_, v)| v).sum::() / rtt_data.len() as f64) + } else { + None + }; + + // Thresholds for gauges + const RTT_MAX: f64 = 200.0; // 200ms max scale + const LOSS_MAX: f64 = 10.0; // 10% max scale + + let bar_width = inner.width.saturating_sub(18) as usize; // Leave room for label + value + + // Build RTT gauge + let rtt_line = if let Some(rtt) = current_rtt { + let rtt_pct = (rtt / RTT_MAX).min(1.0); + let filled = (rtt_pct * bar_width as f64) as usize; + let empty = bar_width.saturating_sub(filled); + + let color = if rtt < 50.0 { + Color::Green + } else if rtt < 150.0 { + Color::Yellow + } else { + Color::Red + }; + + Line::from(vec![ + Span::styled(" RTT ", Style::default().fg(Color::White)), + Span::styled("█".repeat(filled), Style::default().fg(color)), + Span::styled("░".repeat(empty), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" {:>6.1}ms", rtt), Style::default().fg(color)), + ]) + } else { + Line::from(vec![ + Span::styled(" RTT ", Style::default().fg(Color::White)), + Span::styled("░".repeat(bar_width), Style::default().fg(Color::DarkGray)), + Span::styled(" -- ", Style::default().fg(Color::DarkGray)), + ]) + }; + + // Build Loss gauge + let loss_pct = (current_loss / LOSS_MAX).min(1.0); + let filled = (loss_pct * bar_width as f64) as usize; + let empty = bar_width.saturating_sub(filled); + + let loss_color = if current_loss < 1.0 { + Color::Green + } else if current_loss < 5.0 { + Color::Yellow + } else { + Color::Red + }; + + let loss_line = Line::from(vec![ + Span::styled(" Loss ", Style::default().fg(Color::White)), + Span::styled("█".repeat(filled.max(if current_loss > 0.0 { 1 } else { 0 })), Style::default().fg(loss_color)), + Span::styled("░".repeat(empty.min(bar_width)), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" {:>6.2}%", current_loss), Style::default().fg(loss_color)), + ]); + + // Build averages line + let avg_line = Line::from(vec![ + Span::styled(" avg: ", Style::default().fg(Color::DarkGray)), + Span::styled( + avg_rtt.map(|r| format!("{:.0}ms", r)).unwrap_or_else(|| "--".to_string()), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" / ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("{:.2}%", avg_loss), Style::default().fg(Color::DarkGray)), + ]); + + let paragraph = Paragraph::new(vec![rtt_line, loss_line, avg_line]); + f.render_widget(paragraph, inner); +} + +/// Draw TCP counters (retransmits, out of order, fast retransmits) +fn draw_tcp_counters(f: &mut Frame, app: &App, area: Rect) { + use std::sync::atomic::Ordering; + + let stats = app.get_stats(); + let retransmits = stats.total_tcp_retransmits.load(Ordering::Relaxed); + let out_of_order = stats.total_tcp_out_of_order.load(Ordering::Relaxed); + let fast_retransmits = stats.total_tcp_fast_retransmits.load(Ordering::Relaxed); + + let block = Block::default() + .borders(Borders::ALL) + .title("TCP Counters"); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Color based on counts (higher = more concerning) + let retrans_color = if retransmits == 0 { + Color::Green + } else if retransmits < 100 { + Color::Yellow + } else { + Color::Red + }; + + let ooo_color = if out_of_order == 0 { + Color::Green + } else if out_of_order < 50 { + Color::Yellow + } else { + Color::Red + }; + + let fast_color = if fast_retransmits == 0 { + Color::Green + } else if fast_retransmits < 50 { + Color::Yellow + } else { + Color::Red + }; + + let lines = vec![ + Line::from(vec![ + Span::styled(" Retransmits ", Style::default().fg(Color::White)), + Span::styled(format!("{:>8}", retransmits), Style::default().fg(retrans_color)), + ]), + Line::from(vec![ + Span::styled(" Out of Order ", Style::default().fg(Color::White)), + Span::styled(format!("{:>8}", out_of_order), Style::default().fg(ooo_color)), + ]), + Line::from(vec![ + Span::styled(" Fast Retrans ", Style::default().fg(Color::White)), + Span::styled(format!("{:>8}", fast_retransmits), Style::default().fg(fast_color)), + ]), + ]; + + let paragraph = Paragraph::new(lines); + f.render_widget(paragraph, inner); +} + +/// Draw TCP connection states breakdown +fn draw_tcp_states(f: &mut Frame, connections: &[Connection], area: Rect) { + use std::collections::HashMap; + + // Count TCP states + let mut state_counts: HashMap<&str, usize> = HashMap::new(); + for conn in connections { + if conn.protocol == Protocol::TCP + && let ProtocolState::Tcp(tcp_state) = &conn.protocol_state + { + let state_name = match tcp_state { + TcpState::Established => "ESTAB", + TcpState::SynSent => "SYN_SENT", + TcpState::SynReceived => "SYN_RECV", + TcpState::FinWait1 => "FIN_WAIT1", + TcpState::FinWait2 => "FIN_WAIT2", + TcpState::TimeWait => "TIME_WAIT", + TcpState::CloseWait => "CLOSE_WAIT", + TcpState::LastAck => "LAST_ACK", + TcpState::Closing => "CLOSING", + TcpState::Closed => "CLOSED", + TcpState::Listen => "LISTEN", + TcpState::Unknown => "UNKNOWN", + }; + *state_counts.entry(state_name).or_insert(0) += 1; + } + } + + // Fixed order based on connection lifecycle (most important first) + const STATE_ORDER: &[&str] = &[ + "ESTAB", "SYN_SENT", "SYN_RECV", "FIN_WAIT1", "FIN_WAIT2", + "TIME_WAIT", "CLOSE_WAIT", "LAST_ACK", "CLOSING", "CLOSED", + "LISTEN", "UNKNOWN", + ]; + + // Build ordered list with only non-zero counts + let states: Vec<_> = STATE_ORDER + .iter() + .filter_map(|&name| state_counts.get(name).map(|&count| (name, count))) + .collect(); + + let block = Block::default().borders(Borders::ALL).title("TCP States"); + let inner = block.inner(area); + f.render_widget(block, area); + + if states.is_empty() { + let text = Paragraph::new("No TCP connections") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(text, inner); + return; + } + + // Find max count for bar scaling + let max_count = states.iter().map(|(_, c)| *c).max().unwrap_or(1); + let bar_width = inner.width.saturating_sub(15) as usize; // Leave room for label + count + + // Build lines for each state (limit to available height) + let max_rows = inner.height as usize; + let lines: Vec = states + .iter() + .take(max_rows) + .map(|(name, count)| { + let bar_len = if max_count > 0 { + (*count * bar_width) / max_count + } else { + 0 + }; + let bar = "█".repeat(bar_len.max(1)); + + // Color based on state health + let color = match *name { + "ESTAB" => Color::Green, + "SYN_SENT" | "SYN_RECV" => Color::Yellow, + "TIME_WAIT" | "FIN_WAIT1" | "FIN_WAIT2" => Color::Cyan, + "CLOSE_WAIT" | "LAST_ACK" | "CLOSING" => Color::Magenta, + "CLOSED" => Color::DarkGray, + _ => Color::White, + }; + + Line::from(vec![ + Span::styled(format!("{:>10} ", name), Style::default().fg(color)), + Span::styled(bar, Style::default().fg(color)), + Span::raw(format!(" {}", count)), + ]) + }) + .collect(); + + let paragraph = Paragraph::new(lines); + f.render_widget(paragraph, inner); +} + /// Draw connection details view fn draw_connection_details( f: &mut Frame, @@ -1613,6 +1875,22 @@ fn draw_connection_details( ])); } + // Add initial RTT measurement if available + if let Some(rtt) = conn.initial_rtt { + let rtt_ms = rtt.as_secs_f64() * 1000.0; + let rtt_color = if rtt_ms < 50.0 { + Color::Green + } else if rtt_ms < 150.0 { + Color::Yellow + } else { + Color::Red + }; + details_text.push(Line::from(vec![ + Span::styled("Initial RTT: ", Style::default().fg(Color::Yellow)), + Span::styled(format!("{:.1}ms", rtt_ms), Style::default().fg(rtt_color)), + ])); + } + let details = Paragraph::new(details_text) .block( Block::default()