diff --git a/Cargo.toml b/Cargo.toml index 860ad2e..9f79160 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,9 @@ libbpf-rs = { version = "0.25", optional = true } bytes = { version = "1.11", optional = true } libc = { version = "0.2", optional = true } +[target.'cfg(any(target_os = "macos", target_os = "freebsd"))'.dependencies] +libc = "0.2" + [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ "Win32_Foundation", diff --git a/README.md b/README.md index 026963c..246d646 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A cross-platform network monitoring tool built with Rust. RustNet provides real- - **Real-time Network Monitoring**: Monitor active TCP, UDP, ICMP, and ARP connections with detailed state information - **Connection States**: Track TCP states (`ESTABLISHED`, `SYN_SENT`, `TIME_WAIT`), QUIC states (`QUIC_INITIAL`, `QUIC_HANDSHAKE`, `QUIC_CONNECTED`), DNS states, SSH states, and activity-based UDP states +- **Interface Statistics**: Real-time monitoring of network interface metrics including bytes/packets transferred, errors, drops, and collisions - **Deep Packet Inspection (DPI)**: Detect application protocols including HTTP, HTTPS/TLS with SNI, DNS, SSH with version detection, and QUIC with CONNECTION_CLOSE frame detection - **TCP Network Analytics**: Real-time detection of TCP retransmissions, out-of-order packets, and fast retransmits with per-connection and aggregate statistics - **Smart Connection Lifecycle**: Protocol-aware timeouts with visual staleness indicators (white → yellow → red) before cleanup @@ -56,6 +57,37 @@ See [EBPF_BUILD.md](EBPF_BUILD.md) for more details and [ARCHITECTURE.md](ARCHIT +
+Interface Statistics Monitoring + +RustNet provides real-time network interface statistics across all supported platforms. Interface stats are displayed in two ways: + +**Quick View (Overview Tab):** +- Shows up to 3 interfaces in the stats panel on the right +- Displays RX/TX bytes with error and drop indicators +- Color-coded: Green (healthy), Yellow (drops), Red (errors) +- Located below Network Stats panel + +**Detailed View (Interfaces Tab):** +- Press `i` or use Tab to switch to Interfaces tab +- Shows all network interfaces with comprehensive metrics +- Columns: Interface name, RX/TX bytes, RX/TX packets, errors, drops, collisions + +**Platform Implementation:** +- **Linux**: Uses sysfs (`/sys/class/net/`) +- **macOS/FreeBSD**: Uses `getifaddrs()` with `if_data` structure +- **Windows**: Uses IP Helper API (`GetIfTable2`) + +**Metrics Available:** +- Total bytes and packets (RX/TX) +- Error counters (receive and transmit) +- Packet drops (queue overflows) +- Collisions (legacy, rarely used on modern networks) + +Stats are collected every 2 seconds in a background thread with minimal performance impact. + +
+ ## Quick Start ### Installation @@ -121,6 +153,7 @@ See [INSTALL.md](INSTALL.md) for detailed permission setup and [USAGE.md](USAGE. | `q` | Quit (press twice to confirm) | | `Ctrl+C` | Quit immediately | | `Tab` | Switch between tabs | +| `i` | Toggle interface statistics view | | `↑/k` `↓/j` | Navigate up/down | | `g` `G` | Jump to first/last connection | | `Enter` | View connection details | diff --git a/src/app.rs b/src/app.rs index 4ed0d65..948e333 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use crate::filter::ConnectionFilter; use crate::network::{ capture::{CaptureConfig, PacketReader, setup_packet_capture}, + interface_stats::{InterfaceStats, InterfaceStatsProvider, InterfaceRates}, merge::{create_connection_from_packet, merge_packet_into_connection}, parser::{PacketParser, ParsedPacket, ParserConfig}, platform::create_process_lookup, @@ -22,6 +23,16 @@ use crate::network::{ types::{ApplicationProtocol, Connection, Protocol}, }; +// Platform-specific interface stats provider +#[cfg(target_os = "linux")] +use crate::network::platform::LinuxStatsProvider as PlatformStatsProvider; +#[cfg(target_os = "freebsd")] +use crate::network::platform::FreeBSDStatsProvider as PlatformStatsProvider; +#[cfg(target_os = "macos")] +use crate::network::platform::MacOSStatsProvider as PlatformStatsProvider; +#[cfg(target_os = "windows")] +use crate::network::platform::WindowsStatsProvider as PlatformStatsProvider; + use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; @@ -190,6 +201,12 @@ pub struct App { /// Current process detection method (e.g., "eBPF + procfs", "pktap", "lsof", "N/A") process_detection_method: Arc>, + + /// Interface statistics (cumulative totals) + interface_stats: Arc>, + + /// Interface rates (per-second rates) + interface_rates: Arc>, } impl App { @@ -212,6 +229,8 @@ impl App { linktype: Arc::new(RwLock::new(None)), pktap_active: Arc::new(AtomicBool::new(false)), process_detection_method: Arc::new(RwLock::new(String::from("initializing..."))), + interface_stats: Arc::new(DashMap::new()), + interface_rates: Arc::new(DashMap::new()), }) } @@ -237,6 +256,9 @@ impl App { // Start rate refresh thread self.start_rate_refresh_thread(connections)?; + // Start interface stats collection thread + self.start_interface_stats_thread()?; + // Mark loading as complete after a short delay let is_loading = Arc::clone(&self.is_loading); thread::spawn(move || { @@ -769,6 +791,56 @@ impl App { Ok(()) } + /// Start interface statistics collection thread + fn start_interface_stats_thread(&self) -> Result<()> { + let should_stop = Arc::clone(&self.should_stop); + let interface_stats = Arc::clone(&self.interface_stats); + let interface_rates = Arc::clone(&self.interface_rates); + + thread::spawn(move || { + info!("Interface stats collection thread started"); + + let provider = PlatformStatsProvider; + let mut previous_stats: HashMap = HashMap::new(); + + loop { + if should_stop.load(Ordering::Relaxed) { + info!("Interface stats thread stopping"); + break; + } + + // Collect stats from all interfaces + match provider.get_all_stats() { + Ok(stats_vec) => { + // Clear old entries + interface_stats.clear(); + interface_rates.clear(); + + for stat in stats_vec { + // Calculate rates if we have previous data + if let Some(prev) = previous_stats.get(&stat.interface_name) { + let rates = stat.calculate_rates(prev); + interface_rates.insert(stat.interface_name.clone(), rates); + } + + // Store current stats + interface_stats.insert(stat.interface_name.clone(), stat.clone()); + previous_stats.insert(stat.interface_name.clone(), stat); + } + } + Err(e) => { + debug!("Failed to collect interface stats: {}", e); + } + } + + // Refresh every 2 seconds + thread::sleep(Duration::from_secs(2)); + } + }); + + Ok(()) + } + /// Start cleanup thread to remove old connections fn start_cleanup_thread(&self, connections: Arc>) -> Result<()> { let should_stop = Arc::clone(&self.should_stop); @@ -870,6 +942,22 @@ impl App { .collect() } + /// Get interface statistics + pub fn get_interface_stats(&self) -> Vec { + self.interface_stats + .iter() + .map(|entry| entry.value().clone()) + .collect() + } + + /// Get interface rates (bytes/sec) + pub fn get_interface_rates(&self) -> HashMap { + self.interface_rates + .iter() + .map(|entry| (entry.key().clone(), entry.value().clone())) + .collect() + } + /// Get application statistics pub fn get_stats(&self) -> AppStats { AppStats { diff --git a/src/main.rs b/src/main.rs index 1959134..14ce440 100644 --- a/src/main.rs +++ b/src/main.rs @@ -364,7 +364,7 @@ fn run_ui_loop( // Tab navigation (KeyCode::Tab, _) => { ui_state.quit_confirmation = false; - ui_state.selected_tab = (ui_state.selected_tab + 1) % 3; + ui_state.selected_tab = (ui_state.selected_tab + 1) % 4; } // Help toggle @@ -372,12 +372,22 @@ fn run_ui_loop( ui_state.quit_confirmation = false; ui_state.show_help = !ui_state.show_help; if ui_state.show_help { - ui_state.selected_tab = 2; // Switch to help tab + ui_state.selected_tab = 3; // Switch to help tab } else { ui_state.selected_tab = 0; // Back to overview } } + // Interface stats toggle (shortcut to Interface tab) + (KeyCode::Char('i'), _) | (KeyCode::Char('I'), _) => { + ui_state.quit_confirmation = false; + if ui_state.selected_tab == 2 { + ui_state.selected_tab = 0; // Back to overview + } else { + ui_state.selected_tab = 2; // Switch to interfaces tab + } + } + // Navigation in connection list (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { ui_state.quit_confirmation = false; diff --git a/src/network/interface_stats.rs b/src/network/interface_stats.rs new file mode 100644 index 0000000..d269985 --- /dev/null +++ b/src/network/interface_stats.rs @@ -0,0 +1,165 @@ +use std::io; +use std::time::SystemTime; + +/// Statistics for a network interface +#[derive(Debug, Clone)] +pub struct InterfaceStats { + pub interface_name: String, + pub rx_bytes: u64, + pub tx_bytes: u64, + pub rx_packets: u64, + pub tx_packets: u64, + pub rx_errors: u64, + pub tx_errors: u64, + pub rx_dropped: u64, + pub tx_dropped: u64, + pub collisions: u64, + pub timestamp: SystemTime, +} + +impl InterfaceStats { + /// Calculate rates from two snapshots + pub fn calculate_rates(&self, previous: &InterfaceStats) -> InterfaceRates { + let duration = self + .timestamp + .duration_since(previous.timestamp) + .unwrap_or_default() + .as_secs_f64(); + + if duration == 0.0 { + return InterfaceRates::default(); + } + + InterfaceRates { + rx_bytes_per_sec: ((self.rx_bytes.saturating_sub(previous.rx_bytes)) as f64 / duration) + as u64, + tx_bytes_per_sec: ((self.tx_bytes.saturating_sub(previous.tx_bytes)) as f64 / duration) + as u64, + } + } +} + +/// Rate calculations for interface statistics +#[derive(Debug, Clone, Default)] +pub struct InterfaceRates { + pub rx_bytes_per_sec: u64, + pub tx_bytes_per_sec: u64, +} + +/// Trait for platform-specific interface statistics providers +pub trait InterfaceStatsProvider: Send + Sync { + /// Get statistics for a specific interface + fn get_stats(&self, interface: &str) -> Result; + + /// Get statistics for all available interfaces + fn get_all_stats(&self) -> Result, io::Error>; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_rate_calculation() { + let t1 = SystemTime::now(); + let t2 = t1 + Duration::from_secs(1); + + let stats1 = InterfaceStats { + interface_name: "test".to_string(), + rx_bytes: 1000, + tx_bytes: 500, + rx_packets: 10, + tx_packets: 5, + rx_errors: 0, + tx_errors: 0, + rx_dropped: 0, + tx_dropped: 0, + collisions: 0, + timestamp: t1, + }; + + let stats2 = InterfaceStats { + interface_name: "test".to_string(), + rx_bytes: 2000, + tx_bytes: 1000, + rx_packets: 20, + tx_packets: 10, + rx_errors: 0, + tx_errors: 0, + rx_dropped: 0, + tx_dropped: 0, + collisions: 0, + timestamp: t2, + }; + + let rates = stats2.calculate_rates(&stats1); + assert_eq!(rates.rx_bytes_per_sec, 1000); + assert_eq!(rates.tx_bytes_per_sec, 500); + } + + #[test] + fn test_rate_calculation_zero_duration() { + let t = SystemTime::now(); + + let stats1 = InterfaceStats { + interface_name: "test".to_string(), + rx_bytes: 1000, + tx_bytes: 500, + rx_packets: 10, + tx_packets: 5, + rx_errors: 0, + tx_errors: 0, + rx_dropped: 0, + tx_dropped: 0, + collisions: 0, + timestamp: t, + }; + + let stats2 = stats1.clone(); + + let rates = stats2.calculate_rates(&stats1); + assert_eq!(rates.rx_bytes_per_sec, 0); + assert_eq!(rates.tx_bytes_per_sec, 0); + } + + #[test] + fn test_rate_calculation_with_counter_wrapping() { + let t1 = SystemTime::now(); + let t2 = t1 + Duration::from_secs(1); + + let stats1 = InterfaceStats { + interface_name: "test".to_string(), + rx_bytes: 1000, + tx_bytes: 500, + rx_packets: 10, + tx_packets: 5, + rx_errors: 0, + tx_errors: 0, + rx_dropped: 0, + tx_dropped: 0, + collisions: 0, + timestamp: t1, + }; + + // Simulate counter reset (should use saturating_sub to avoid panic) + let stats2 = InterfaceStats { + interface_name: "test".to_string(), + rx_bytes: 500, // Less than previous + tx_bytes: 250, + rx_packets: 5, + tx_packets: 2, + rx_errors: 0, + tx_errors: 0, + rx_dropped: 0, + tx_dropped: 0, + collisions: 0, + timestamp: t2, + }; + + let rates = stats2.calculate_rates(&stats1); + // Should result in 0 due to saturating_sub + assert_eq!(rates.rx_bytes_per_sec, 0); + assert_eq!(rates.tx_bytes_per_sec, 0); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index e56bebc..c273a74 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -1,5 +1,6 @@ pub mod capture; pub mod dpi; +pub mod interface_stats; pub mod link_layer; pub mod merge; pub mod parser; diff --git a/src/network/platform/freebsd_interface_stats.rs b/src/network/platform/freebsd_interface_stats.rs new file mode 100644 index 0000000..aabb655 --- /dev/null +++ b/src/network/platform/freebsd_interface_stats.rs @@ -0,0 +1,115 @@ +use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider}; +use std::ffi::CStr; +use std::io; +use std::ptr; +use std::time::SystemTime; + +/// FreeBSD-specific implementation using getifaddrs +pub struct FreeBSDStatsProvider; + +impl InterfaceStatsProvider for FreeBSDStatsProvider { + fn get_stats(&self, interface: &str) -> Result { + let all_stats = self.get_all_stats()?; + all_stats + .into_iter() + .find(|s| s.interface_name == interface) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Interface {} not found", interface), + ) + }) + } + + fn get_all_stats(&self) -> Result, io::Error> { + unsafe { + let mut ifap: *mut libc::ifaddrs = ptr::null_mut(); + + if libc::getifaddrs(&mut ifap) != 0 { + return Err(io::Error::last_os_error()); + } + + let mut stats = Vec::new(); + let mut current = ifap; + + while !current.is_null() { + let ifa = &*current; + + // Only process AF_LINK entries (data link layer) + if !ifa.ifa_addr.is_null() + && (*ifa.ifa_addr).sa_family as i32 == libc::AF_LINK + { + let name = CStr::from_ptr(ifa.ifa_name) + .to_string_lossy() + .to_string(); + + // Get if_data from ifa_data + if !ifa.ifa_data.is_null() { + #[cfg(target_os = "freebsd")] + { + let if_data = &*(ifa.ifa_data as *const libc::if_data); + + stats.push(InterfaceStats { + interface_name: name, + rx_bytes: if_data.ifi_ibytes, + tx_bytes: if_data.ifi_obytes, + rx_packets: if_data.ifi_ipackets, + tx_packets: if_data.ifi_opackets, + rx_errors: if_data.ifi_ierrors, + tx_errors: if_data.ifi_oerrors, + rx_dropped: if_data.ifi_iqdrops, + tx_dropped: 0, // Not typically available on FreeBSD + collisions: if_data.ifi_collisions, + timestamp: SystemTime::now(), + }); + } + } + } + + current = ifa.ifa_next; + } + + libc::freeifaddrs(ifap); + Ok(stats) + } + } +} + +#[cfg(test)] +#[cfg(target_os = "freebsd")] +mod tests { + use super::*; + + #[test] + fn test_freebsd_list_interfaces() { + let provider = FreeBSDStatsProvider; + let result = provider.list_interfaces(); + + match result { + Ok(interfaces) => { + assert!(!interfaces.is_empty(), "Expected at least one interface"); + } + Err(e) => { + panic!("Failed to list interfaces: {:?}", e); + } + } + } + + #[test] + fn test_freebsd_get_all_stats() { + let provider = FreeBSDStatsProvider; + let result = provider.get_all_stats(); + + match result { + Ok(stats) => { + assert!(!stats.is_empty(), "Expected at least one interface"); + for stat in stats { + assert!(!stat.interface_name.is_empty()); + } + } + Err(e) => { + panic!("Failed to get stats: {:?}", e); + } + } + } +} diff --git a/src/network/platform/linux_interface_stats.rs b/src/network/platform/linux_interface_stats.rs new file mode 100644 index 0000000..9feba9a --- /dev/null +++ b/src/network/platform/linux_interface_stats.rs @@ -0,0 +1,152 @@ +use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider}; +use std::fs; +use std::io; +use std::time::SystemTime; + +/// Linux-specific implementation using sysfs +pub struct LinuxStatsProvider; + +impl InterfaceStatsProvider for LinuxStatsProvider { + fn get_stats(&self, interface: &str) -> Result { + let base_path = format!("/sys/class/net/{}/statistics", interface); + + // Check if interface exists + if !std::path::Path::new(&base_path).exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Interface {} not found", interface), + )); + } + + Ok(InterfaceStats { + interface_name: interface.to_string(), + rx_bytes: read_stat(&base_path, "rx_bytes")?, + tx_bytes: read_stat(&base_path, "tx_bytes")?, + rx_packets: read_stat(&base_path, "rx_packets")?, + tx_packets: read_stat(&base_path, "tx_packets")?, + rx_errors: read_stat(&base_path, "rx_errors")?, + tx_errors: read_stat(&base_path, "tx_errors")?, + rx_dropped: read_stat(&base_path, "rx_dropped")?, + tx_dropped: read_stat(&base_path, "tx_dropped")?, + collisions: read_stat(&base_path, "collisions")?, + timestamp: SystemTime::now(), + }) + } + + fn get_all_stats(&self) -> Result, io::Error> { + let mut stats = Vec::new(); + + for entry in fs::read_dir("/sys/class/net")? { + let entry = entry?; + let interface = entry.file_name().to_string_lossy().to_string(); + + // Skip if we can't read stats (some virtual interfaces may not have all stats) + if let Ok(stat) = self.get_stats(&interface) { + stats.push(stat); + } + } + + Ok(stats) + } +} + +/// Read a single statistic from sysfs +fn read_stat(base_path: &str, stat_name: &str) -> Result { + let path = format!("{}/{}", base_path, stat_name); + let content = fs::read_to_string(&path).map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to read {}: {}", path, e), + ) + })?; + + content + .trim() + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +#[cfg(test)] +#[cfg(target_os = "linux")] +mod tests { + use super::*; + + #[test] + fn test_linux_stats_loopback() { + let provider = LinuxStatsProvider; + let result = provider.get_stats("lo"); + + match result { + Ok(stats) => { + assert_eq!(stats.interface_name, "lo"); + // Stats are u64, so they're always >= 0 by definition + // Just verify the struct is properly populated + } + Err(e) => { + // Acceptable errors: NotFound or PermissionDenied + assert!( + e.kind() == io::ErrorKind::NotFound + || e.kind() == io::ErrorKind::PermissionDenied, + "Unexpected error: {:?}", + e + ); + } + } + } + + #[test] + fn test_list_interfaces() { + let provider = LinuxStatsProvider; + let result = provider.get_all_stats(); + + match result { + Ok(stats) => { + // Should have at least loopback + assert!( + !stats.is_empty(), + "Expected at least one interface (lo)" + ); + let interface_names: Vec = stats.iter().map(|s| s.interface_name.clone()).collect(); + assert!( + interface_names.iter().any(|name| name == "lo"), + "Expected loopback interface" + ); + } + Err(e) => { + // PermissionDenied is acceptable + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + } + } + } + + #[test] + fn test_get_all_stats() { + let provider = LinuxStatsProvider; + let result = provider.get_all_stats(); + + match result { + Ok(stats) => { + // Should have at least one interface + assert!(!stats.is_empty(), "Expected at least one interface"); + + // Check that all interfaces have valid names + for stat in stats { + assert!(!stat.interface_name.is_empty()); + } + } + Err(e) => { + // PermissionDenied is acceptable + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + } + } + } + + #[test] + fn test_nonexistent_interface() { + let provider = LinuxStatsProvider; + let result = provider.get_stats("nonexistent_interface_12345"); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + } +} diff --git a/src/network/platform/macos_interface_stats.rs b/src/network/platform/macos_interface_stats.rs new file mode 100644 index 0000000..01c203b --- /dev/null +++ b/src/network/platform/macos_interface_stats.rs @@ -0,0 +1,120 @@ +use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider}; +use std::ffi::CStr; +use std::io; +use std::ptr; +use std::time::SystemTime; + +/// macOS-specific implementation using getifaddrs +pub struct MacOSStatsProvider; + +impl InterfaceStatsProvider for MacOSStatsProvider { + fn get_stats(&self, interface: &str) -> Result { + let all_stats = self.get_all_stats()?; + all_stats + .into_iter() + .find(|s| s.interface_name == interface) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Interface {} not found", interface), + ) + }) + } + + fn get_all_stats(&self) -> Result, io::Error> { + unsafe { + let mut ifap: *mut libc::ifaddrs = ptr::null_mut(); + + if libc::getifaddrs(&mut ifap) != 0 { + return Err(io::Error::last_os_error()); + } + + let mut stats = Vec::new(); + let mut current = ifap; + + while !current.is_null() { + let ifa = &*current; + + // Only process AF_LINK entries (data link layer) + if !ifa.ifa_addr.is_null() + && (*ifa.ifa_addr).sa_family as i32 == libc::AF_LINK + { + let name = CStr::from_ptr(ifa.ifa_name) + .to_string_lossy() + .to_string(); + + // Get if_data from ifa_data + if !ifa.ifa_data.is_null() { + #[cfg(target_os = "macos")] + { + let if_data = &*(ifa.ifa_data as *const libc::if_data); + + stats.push(InterfaceStats { + interface_name: name, + rx_bytes: if_data.ifi_ibytes, + tx_bytes: if_data.ifi_obytes, + rx_packets: if_data.ifi_ipackets, + tx_packets: if_data.ifi_opackets, + rx_errors: if_data.ifi_ierrors, + tx_errors: if_data.ifi_oerrors, + rx_dropped: if_data.ifi_iqdrops, + tx_dropped: 0, // Limited on macOS + collisions: if_data.ifi_collisions, + timestamp: SystemTime::now(), + }); + } + } + } + + current = ifa.ifa_next; + } + + libc::freeifaddrs(ifap); + Ok(stats) + } + } +} + +#[cfg(test)] +#[cfg(target_os = "macos")] +mod tests { + use super::*; + + #[test] + fn test_macos_list_interfaces() { + let provider = MacOSStatsProvider; + let result = provider.list_interfaces(); + + match result { + Ok(interfaces) => { + assert!(!interfaces.is_empty(), "Expected at least one interface"); + // macOS should have at least loopback (lo0) + assert!( + interfaces.iter().any(|i| i.starts_with("lo")), + "Expected loopback interface" + ); + } + Err(e) => { + panic!("Failed to list interfaces: {:?}", e); + } + } + } + + #[test] + fn test_macos_get_all_stats() { + let provider = MacOSStatsProvider; + let result = provider.get_all_stats(); + + match result { + Ok(stats) => { + assert!(!stats.is_empty(), "Expected at least one interface"); + for stat in stats { + assert!(!stat.interface_name.is_empty()); + } + } + Err(e) => { + panic!("Failed to get stats: {:?}", e); + } + } + } +} diff --git a/src/network/platform/mod.rs b/src/network/platform/mod.rs index c42170d..0e5c627 100644 --- a/src/network/platform/mod.rs +++ b/src/network/platform/mod.rs @@ -17,6 +17,16 @@ mod macos; #[cfg(target_os = "windows")] mod windows; +// Platform-specific interface stats modules +#[cfg(target_os = "linux")] +mod linux_interface_stats; +#[cfg(target_os = "freebsd")] +mod freebsd_interface_stats; +#[cfg(target_os = "macos")] +mod macos_interface_stats; +#[cfg(target_os = "windows")] +mod windows_interface_stats; + // Re-export the appropriate implementation #[cfg(target_os = "freebsd")] pub use freebsd::FreeBSDProcessLookup; @@ -29,6 +39,16 @@ pub use macos::MacOSProcessLookup; #[cfg(target_os = "windows")] pub use windows::WindowsProcessLookup; +// Re-export interface stats providers +#[cfg(target_os = "linux")] +pub use linux_interface_stats::LinuxStatsProvider; +#[cfg(target_os = "freebsd")] +pub use freebsd_interface_stats::FreeBSDStatsProvider; +#[cfg(target_os = "macos")] +pub use macos_interface_stats::MacOSStatsProvider; +#[cfg(target_os = "windows")] +pub use windows_interface_stats::WindowsStatsProvider; + /// Trait for platform-specific process lookup pub trait ProcessLookup: Send + Sync { /// Look up process information for a connection diff --git a/src/network/platform/windows_interface_stats.rs b/src/network/platform/windows_interface_stats.rs new file mode 100644 index 0000000..42c9edf --- /dev/null +++ b/src/network/platform/windows_interface_stats.rs @@ -0,0 +1,126 @@ +use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider}; +use std::io; +use std::time::SystemTime; + +#[cfg(target_os = "windows")] +use windows::Win32::NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2}; + +/// Windows-specific implementation using IP Helper API +pub struct WindowsStatsProvider; + +impl InterfaceStatsProvider for WindowsStatsProvider { + fn get_stats(&self, interface: &str) -> Result { + let all_stats = self.get_all_stats()?; + all_stats + .into_iter() + .find(|s| s.interface_name == interface || s.interface_name.contains(interface)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Interface {} not found", interface), + ) + }) + } + + #[cfg(target_os = "windows")] + fn get_all_stats(&self) -> Result, io::Error> { + unsafe { + let mut table: *mut MIB_IF_TABLE2 = std::ptr::null_mut(); + + let result = GetIfTable2(&mut table); + if result.is_err() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("GetIfTable2 failed with error code: {:?}", result), + )); + } + + if table.is_null() { + return Err(io::Error::new( + io::ErrorKind::Other, + "Failed to get interface table", + )); + } + + let num_entries = (*table).NumEntries as usize; + let mut stats = Vec::new(); + + for i in 0..num_entries { + let row = &(*table).Table[i]; + + // Convert interface alias (friendly name) to string + let name = String::from_utf16_lossy(&row.Alias) + .trim_end_matches('\0') + .to_string(); + + if name.is_empty() { + continue; + } + + stats.push(InterfaceStats { + interface_name: name, + rx_bytes: row.InOctets, + tx_bytes: row.OutOctets, + rx_packets: row.InUcastPkts + row.InNUcastPkts, + tx_packets: row.OutUcastPkts + row.OutNUcastPkts, + rx_errors: row.InErrors, + tx_errors: row.OutErrors, + rx_dropped: row.InDiscards, + tx_dropped: row.OutDiscards, + collisions: 0, // Not available on modern Windows interfaces + timestamp: SystemTime::now(), + }); + } + + FreeMibTable(table.cast()); + Ok(stats) + } + } + + #[cfg(not(target_os = "windows"))] + fn get_all_stats(&self) -> Result, io::Error> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows interface stats not available on this platform", + )) + } +} + +#[cfg(test)] +#[cfg(target_os = "windows")] +mod tests { + use super::*; + + #[test] + fn test_windows_list_interfaces() { + let provider = WindowsStatsProvider; + let result = provider.list_interfaces(); + + match result { + Ok(interfaces) => { + assert!(!interfaces.is_empty(), "Expected at least one interface"); + } + Err(e) => { + panic!("Failed to list interfaces: {:?}", e); + } + } + } + + #[test] + fn test_windows_get_all_stats() { + let provider = WindowsStatsProvider; + let result = provider.get_all_stats(); + + match result { + Ok(stats) => { + assert!(!stats.is_empty(), "Expected at least one interface"); + for stat in stats { + assert!(!stat.interface_name.is_empty()); + } + } + Err(e) => { + panic!("Failed to get stats: {:?}", e); + } + } + } +} diff --git a/src/ui.rs b/src/ui.rs index 12369b6..6945325 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -410,7 +410,8 @@ pub fn draw( match ui_state.selected_tab { 0 => draw_overview(f, ui_state, connections, stats, app, content_area)?, 1 => draw_connection_details(f, ui_state, connections, content_area)?, - 2 => draw_help(f, content_area)?, + 2 => draw_interface_stats(f, app, content_area)?, + 3 => draw_help(f, content_area)?, _ => {} } @@ -428,6 +429,7 @@ fn draw_tabs(f: &mut Frame, ui_state: &UIState, area: Rect) { let titles = vec![ Span::styled("Overview", Style::default().fg(Color::Green)), Span::styled("Details", Style::default().fg(Color::Green)), + Span::styled("Interfaces", Style::default().fg(Color::Green)), Span::styled("Help", Style::default().fg(Color::Green)), ]; @@ -708,7 +710,8 @@ fn draw_stats_panel( .constraints([ Constraint::Length(10), // Connection stats (increased for interface line) Constraint::Length(5), // Traffic stats - Constraint::Min(0), // Network stats (TCP analytics) + Constraint::Length(7), // Network stats (TCP analytics + header) + Constraint::Min(0), // Interface stats ]) .split(area); @@ -805,9 +808,12 @@ fn draw_stats_panel( let total_fast_retransmits = stats.total_tcp_fast_retransmits.load(std::sync::atomic::Ordering::Relaxed); let network_stats_text: Vec = vec![ - Line::from(format!("TCP Retransmits: {} / {} total", tcp_retransmits, total_retransmits)), - Line::from(format!("Out-of-Order: {} / {} total", tcp_out_of_order, total_out_of_order)), - Line::from(format!("Fast Retransmits: {} / {} total", tcp_fast_retransmits, total_fast_retransmits)), + Line::from(vec![ + Span::styled("(Active / Total)", Style::default().fg(Color::Gray)), + ]), + Line::from(format!("TCP Retransmits: {} / {}", tcp_retransmits, total_retransmits)), + Line::from(format!("Out-of-Order: {} / {}", tcp_out_of_order, total_out_of_order)), + Line::from(format!("Fast Retransmits: {} / {}", tcp_fast_retransmits, total_fast_retransmits)), Line::from(format!("Active TCP Flows: {}", tcp_connections_with_analytics)), ]; @@ -816,6 +822,117 @@ fn draw_stats_panel( .style(Style::default()); f.render_widget(network_stats, chunks[2]); + // Interface statistics + let all_interface_stats = app.get_interface_stats(); + let interface_rates = app.get_interface_rates(); + + // Filter to show only the captured interface (or active interfaces if "any") + let captured_interface = app.get_current_interface(); + let filtered_interface_stats: Vec<_> = if let Some(ref iface) = captured_interface { + if iface == "any" { + // Show interfaces with some data + all_interface_stats.into_iter() + .filter(|s| s.rx_bytes > 0 || s.tx_bytes > 0) + .collect() + } else { + // Show only the captured interface + all_interface_stats.into_iter() + .filter(|s| s.interface_name == *iface) + .collect() + } + } else { + // No interface specified yet - show active interfaces + all_interface_stats.into_iter() + .filter(|s| s.rx_bytes > 0 || s.tx_bytes > 0) + .collect() + }; + + // Calculate how many interfaces can fit in the available space + // Each interface takes 2 lines, and we need 2 lines for borders + // Reserve 1 line for the "... N more" message if needed + let available_height = chunks[3].height as usize; + let lines_for_borders = 2; + let lines_per_interface = 2; + let lines_for_more_message = 1; + + let max_interfaces = if available_height > lines_for_borders + lines_for_more_message { + (available_height - lines_for_borders - lines_for_more_message) / lines_per_interface + } else { + 0 + }; + + let interface_stats_text: Vec = if filtered_interface_stats.is_empty() { + vec![ + Line::from(Span::styled( + "No interface stats available", + Style::default().fg(Color::Gray), + )), + ] + } else { + let mut lines = Vec::new(); + let num_to_show = max_interfaces.min(filtered_interface_stats.len()); + + for stat in filtered_interface_stats.iter().take(num_to_show) { + let total_errors = stat.rx_errors + stat.tx_errors; + let total_drops = stat.rx_dropped + stat.tx_dropped; + + let error_style = if total_errors > 0 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + }; + + let drop_style = if total_drops > 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + }; + + // Get rates for this interface (if available) + let rate_display = if let Some(rates) = interface_rates.get(&stat.interface_name) { + format!( + "{}/s ↓ / {}/s ↑", + format_bytes(rates.rx_bytes_per_sec), + format_bytes(rates.tx_bytes_per_sec) + ) + } else { + "Calculating...".to_string() + }; + + // Interface name and rate on first line + lines.push(Line::from(vec![ + Span::raw(format!("{}: ", stat.interface_name)), + Span::raw(rate_display), + ])); + + // Errors and drops on second line (indented) + lines.push(Line::from(vec![ + Span::raw(" Errors: "), + Span::styled(format!("{}", total_errors), error_style), + Span::raw(" Drops: "), + Span::styled(format!("{}", total_drops), drop_style), + ])); + } + + // Only show "more" message if there are actually more interfaces that don't fit + if filtered_interface_stats.len() > num_to_show { + lines.push(Line::from(Span::styled( + format!("... and {} more (press 'i' for details)", filtered_interface_stats.len() - num_to_show), + Style::default().fg(Color::Gray), + ))); + } + lines + }; + + let interface_stats_widget = Paragraph::new(interface_stats_text) + .block( + Block::default() + .borders(Borders::ALL) + .title("Interface Stats (press 'i')"), + ) + .style(Style::default()); + f.render_widget(interface_stats_widget, chunks[3]); + Ok(()) } @@ -1187,6 +1304,10 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> { Span::styled("h ", Style::default().fg(Color::Yellow)), Span::raw("Toggle this help screen"), ]), + Line::from(vec![ + Span::styled("i ", Style::default().fg(Color::Yellow)), + Span::raw("Toggle interface statistics view"), + ]), Line::from(vec![ Span::styled("/ ", Style::default().fg(Color::Yellow)), Span::raw("Enter filter mode (navigate while typing!)"), @@ -1255,6 +1376,130 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> { Ok(()) } +/// Draw interface statistics table +fn draw_interface_stats(f: &mut Frame, app: &crate::app::App, area: Rect) -> Result<()> { + let mut stats = app.get_interface_stats(); + let rates = app.get_interface_rates(); + + // Sort interfaces to show the captured interface first + let captured_interface = app.get_current_interface(); + if let Some(ref captured) = captured_interface { + stats.sort_by(|a, b| { + let a_is_captured = &a.interface_name == captured; + let b_is_captured = &b.interface_name == captured; + match (a_is_captured, b_is_captured) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.interface_name.cmp(&b.interface_name), + } + }); + } + + if stats.is_empty() { + let empty_msg = Paragraph::new("No interface statistics available yet...") + .block( + Block::default() + .borders(Borders::ALL) + .title(" Interface Statistics "), + ) + .style(Style::default().fg(Color::Gray)) + .alignment(ratatui::layout::Alignment::Center); + f.render_widget(empty_msg, area); + return Ok(()); + } + + // Create table rows + let mut rows = Vec::new(); + + for stat in &stats { + // Determine error style + let error_style = if stat.rx_errors > 0 || stat.tx_errors > 0 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + }; + + // Determine drop style + let drop_style = if stat.rx_dropped > 0 || stat.tx_dropped > 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + }; + + // Get rate for this interface + let rx_rate_str = if let Some(rate) = rates.get(&stat.interface_name) { + format!("{}/s", format_bytes(rate.rx_bytes_per_sec)) + } else { + "---".to_string() + }; + + let tx_rate_str = if let Some(rate) = rates.get(&stat.interface_name) { + format!("{}/s", format_bytes(rate.tx_bytes_per_sec)) + } else { + "---".to_string() + }; + + rows.push(Row::new(vec![ + Cell::from(stat.interface_name.clone()), + Cell::from(rx_rate_str), + Cell::from(tx_rate_str), + Cell::from(format!("{}", stat.rx_packets)), + Cell::from(format!("{}", stat.tx_packets)), + Cell::from(format!("{}", stat.rx_errors)).style(error_style), + Cell::from(format!("{}", stat.tx_errors)).style(error_style), + Cell::from(format!("{}", stat.rx_dropped)).style(drop_style), + Cell::from(format!("{}", stat.tx_dropped)).style(drop_style), + Cell::from(format!("{}", stat.collisions)), + ])); + } + + // Create table + let table = Table::new( + rows, + [ + Constraint::Length(14), // Interface + Constraint::Length(12), // RX Bytes + Constraint::Length(12), // TX Bytes + Constraint::Length(10), // RX Packets + Constraint::Length(10), // TX Packets + Constraint::Length(9), // RX Err + Constraint::Length(9), // TX Err + Constraint::Length(10), // RX Drop + Constraint::Length(10), // TX Drop + Constraint::Length(10), // Collis + ], + ) + .header( + Row::new(vec![ + "Interface", + "RX Rate", + "TX Rate", + "RX Packets", + "TX Packets", + "RX Err", + "TX Err", + "RX Drop", + "TX Drop", + "Collisions", + ]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Interface Statistics (Press 'i' to toggle) "), + ) + .style(Style::default()); + + f.render_widget(table, area); + + Ok(()) +} + /// Draw filter input area fn draw_filter_input(f: &mut Frame, ui_state: &UIState, area: Rect) { let title = if ui_state.filter_mode {