feat(ui): add network health visualization to Graph tab (#93)

* feat(ui): add network health visualization to Graph tab

- Add RTT/latency tracking via TCP SYN-ACK timing
- Add packet loss percentage tracking from retransmit counts
- Add Network Health chart with dual-axis (RTT + loss)
- Add TCP States panel showing connection state distribution
- Add per-connection RTT display in Details tab
This commit is contained in:
Marco Cadetg
2025-12-14 17:59:54 +01:00
committed by GitHub
parent 2a1d58762b
commit 9f81385861
3 changed files with 688 additions and 16 deletions

View File

@@ -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<RwLock<TrafficHistory>>,
/// RTT tracker for latency measurement
rtt_tracker: Arc<Mutex<RttTracker>>,
/// Sandbox status (Linux Landlock)
#[cfg(target_os = "linux")]
sandbox_info: Arc<RwLock<SandboxInfo>>,
@@ -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<String>,
rtt_tracker: &Arc<Mutex<RttTracker>>,
) {
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<std::time::Duration> = 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 {

View File

@@ -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<f64>,
}
/// 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<f64>,
) {
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::<f64>()
/ window as f64;
let avg_loss: f64 =
w.iter().map(|s| s.packet_loss_pct as f64).sum::<f64>() / window as f64;
(-avg_age, avg_loss)
})
.collect();
let rtt: ChartData = samples
.windows(window)
.filter_map(|w| {
let rtts: Vec<f64> = 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::<f64>()
/ window as f64;
let avg_rtt: f64 = rtts.iter().sum::<f64>() / 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<ConnectionKey, Instant>,
/// 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<Duration> {
// 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<f64> {
let cutoff = Instant::now() - Duration::from_secs(window_secs);
let samples: Vec<Duration> = 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<TcpAnalytics>,
// Initial RTT measurement (from SYN-ACK timing)
pub initial_rtt: Option<std::time::Duration>,
}
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);
}
}

298
src/ui.rs
View File

@@ -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<B> = RatatuiTerminal<B>;
@@ -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::<f64>() / loss_data.len() as f64
} else {
0.0
};
let avg_rtt = if !rtt_data.is_empty() {
Some(rtt_data.iter().map(|(_, v)| v).sum::<f64>() / 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<Line> = 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()