mirror of
https://github.com/domcyrus/rustnet.git
synced 2025-12-30 18:39:52 -06:00
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:
79
src/app.rs
79
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<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 {
|
||||
|
||||
@@ -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
298
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<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()
|
||||
|
||||
Reference in New Issue
Block a user