From 6e1426170bc77403b8575fc04fb82d2761413a5b Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Sat, 22 Nov 2025 17:34:53 +0100 Subject: [PATCH] Feature/interface stats (#79) * feat: adding interface stats * macOS specific improvements * fix windows interface stats --- Cargo.toml | 5 + README.md | 24 ++ USAGE.md | 122 ++++++++ src/app.rs | 88 ++++++ src/main.rs | 14 +- src/network/interface_stats.rs | 166 +++++++++++ src/network/mod.rs | 1 + .../platform/freebsd_interface_stats.rs | 115 ++++++++ src/network/platform/linux_interface_stats.rs | 152 ++++++++++ src/network/platform/macos_interface_stats.rs | 148 ++++++++++ src/network/platform/mod.rs | 20 ++ .../platform/windows_interface_stats.rs | 177 ++++++++++++ src/ui.rs | 271 +++++++++++++++++- 13 files changed, 1289 insertions(+), 14 deletions(-) create mode 100644 src/network/interface_stats.rs create mode 100644 src/network/platform/freebsd_interface_stats.rs create mode 100644 src/network/platform/linux_interface_stats.rs create mode 100644 src/network/platform/macos_interface_stats.rs create mode 100644 src/network/platform/windows_interface_stats.rs diff --git a/Cargo.toml b/Cargo.toml index 860ad2e..9bc650f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,10 +49,14 @@ 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", "Win32_NetworkManagement_IpHelper", + "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_System_LibraryLoader", "Win32_System_Threading", @@ -74,6 +78,7 @@ zip = "6.0" windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_NetworkManagement_IpHelper", + "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_System_LibraryLoader", "Win32_System_Threading", diff --git a/README.md b/README.md index 026963c..52c57fb 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,28 @@ 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: + +- **Overview Tab**: Shows active interfaces with current rates, errors, and drops +- **Interfaces Tab** (press `i`): Detailed table with comprehensive metrics for all interfaces +- **Cross-Platform**: Linux (sysfs), macOS/FreeBSD (getifaddrs), Windows (GetIfTable2 API) +- **Smart Filtering**: Windows automatically excludes virtual/filter adapters + +See [USAGE.md](USAGE.md#interface-statistics) for detailed documentation on interpreting interface statistics and platform-specific behavior. + +**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 +144,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/USAGE.md b/USAGE.md index 7cc74df..51bd07e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -10,6 +10,7 @@ This guide covers detailed usage of RustNet, including command-line options, key - [Filtering](#filtering) - [Sorting](#sorting) - [Network Statistics Panel](#network-statistics-panel) +- [Interface Statistics](#interface-statistics) - [Connection Lifecycle & Visual Indicators](#connection-lifecycle--visual-indicators) - [Logging](#logging) @@ -192,6 +193,7 @@ Log files are created in the `logs/` directory with timestamp: `rustnet_YYYY-MM- ### Views and Tabs - `Tab` - Switch between tabs (Overview, Details, Help) +- `i` - Toggle Interface Statistics view - `Enter` - View detailed information about selected connection - `Esc` - Go back to previous view or clear active filter - `h` - Toggle help screen @@ -463,6 +465,126 @@ Fast retransmit frequency indicates how well TCP is recovering from packet loss - SYN and FIN flags are properly accounted for in sequence number tracking (each consumes 1 sequence number) - Only TCP connections show analytics; UDP, ICMP, and other protocols do not have these metrics +## Interface Statistics + +RustNet provides real-time network interface statistics across all supported platforms (Linux, macOS, FreeBSD, Windows). Interface stats are displayed in two locations: + +### Accessing Interface Statistics + +**Overview Tab (Main Screen):** +- Interface stats appear in the right panel below Network Stats +- Shows up to 3 active interfaces with current rates +- Displays: `InterfaceName: X KB/s ↓ / Y KB/s ↑` +- Shows cumulative totals: `Errors (Total): N Drops (Total): M` + +**Interfaces Tab (Detailed View):** +- Press `i` to toggle the Interface Statistics view +- Shows a detailed table of all network interfaces +- Displays comprehensive metrics for each interface + +### Statistics Displayed + +| Metric | Description | Notes | +|--------|-------------|-------| +| **RX Rate** | Current receive rate (bytes/sec) | Calculated from recent activity | +| **TX Rate** | Current transmit rate (bytes/sec) | Calculated from recent activity | +| **RX Packets** | Total packets received | Cumulative since boot/interface up | +| **TX Packets** | Total packets transmitted | Cumulative since boot/interface up | +| **RX Err** | Receive errors | Cumulative total (not recent) | +| **TX Err** | Transmit errors | Cumulative total (not recent) | +| **RX Drop** | Dropped incoming packets | Cumulative total (not recent) | +| **TX Drop** | Dropped outgoing packets | Cumulative total (not recent) | +| **Collisions** | Network collisions | Platform-dependent availability | + +**Important**: Error and drop counters are **cumulative totals** since the system booted or the interface came up, not recent activity. These help identify long-term interface reliability but won't show immediate issues. + +### Platform-Specific Behavior + +**All Platforms:** +- All counters (bytes, packets, errors, drops) are cumulative from boot/interface up +- Rates (bytes/sec) are calculated from snapshots taken every 2 seconds +- Loopback interface is included for monitoring local traffic + +**Windows:** +- Filters out virtual/filter adapters to show only physical interfaces: + - Excludes: `-Npcap`, `-WFP`, `-QoS`, `-Native`, `-Virtual`, `-Packet` variants + - Excludes: `Lightweight Filter`, `MAC Layer` interfaces + - Excludes: Disconnected "Local Area Connection" adapters +- Uses LUID-based deduplication to prevent duplicate interface entries +- Collisions: Always 0 (not available on modern Windows interfaces) + +**macOS:** +- Includes data validation to detect corrupt counters on virtual interfaces +- TX Drops: Always 0 (limited availability on macOS) +- Sanitizes error/drop counters if values appear corrupted (>2^31 or errors>packets) + +**FreeBSD:** +- TX Drops: Always 0 (not typically available on FreeBSD) +- Uses BSD getifaddrs API with AF_LINK filtering + +**Linux:** +- Reads statistics from `/sys/class/net/{interface}/statistics` +- All counters typically available and reliable + +### Interpreting the Statistics + +**Healthy Interface:** +``` +Ethernet: 2.40 KB/s ↓ / 1.96 KB/s ↑ + Errors (Total): 0 Drops (Total): 0 +``` +Zero or very low error/drop counts indicate a reliable network connection. + +**Problematic Interface:** +``` +WiFi: 150 KB/s ↓ / 45 KB/s ↑ + Errors (Total): 1089 Drops (Total): 2178 +``` +High error/drop counts may indicate: +- Signal interference (WiFi) +- Cable issues (Ethernet) +- Network congestion +- Driver or hardware problems + +**Note**: Since error/drop counters are cumulative, evaluate them relative to total packets. A few errors out of millions of packets is normal; thousands of errors with low packet counts indicates problems. + +### Interface Filtering + +**Which Interfaces Are Shown:** +- Interfaces must be operationally "up" OR have traffic statistics +- Loopback interface is included (useful for monitoring local connections) +- Virtual/filter adapters are excluded on Windows (they mirror physical interfaces) + +**Overview Tab Filtering:** +- Windows: Shows all active interfaces (NPF device path detected automatically) +- macOS/Linux: Shows interfaces with recent traffic (`rx_bytes > 0 || tx_bytes > 0 || rx_packets > 0 || tx_packets > 0`) +- Special interfaces (`any`, `pktap`): Shows all interfaces with any activity + +**Interfaces Tab:** +- Shows all detected interfaces that pass the platform-specific filters +- Sorts to show the currently captured interface first (highlighted) +- Other interfaces appear in alphabetical order + +### Use Cases + +**Bandwidth Monitoring:** +Monitor real-time bandwidth usage across all network interfaces to identify: +- Which interface is carrying the most traffic +- Bandwidth distribution across WiFi vs Ethernet +- Local traffic volume (loopback interface) + +**Reliability Analysis:** +Check cumulative error and drop counters to: +- Identify unreliable network interfaces +- Detect hardware or driver issues +- Compare interface quality over time + +**Multi-Interface Systems:** +On systems with multiple network interfaces: +- Compare performance across interfaces +- Monitor VPN tunnel statistics +- Track interface failover behavior + ## Connection Lifecycle & Visual Indicators RustNet uses intelligent timeout management to automatically clean up inactive connections while providing visual warnings before removal. 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..94f4045 --- /dev/null +++ b/src/network/interface_stats.rs @@ -0,0 +1,166 @@ +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 + #[allow(dead_code)] + 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..f1143ab --- /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.get_all_stats(); + + match result { + Ok(stats) => { + assert!(!stats.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..2857d14 --- /dev/null +++ b/src/network/platform/macos_interface_stats.rs @@ -0,0 +1,148 @@ +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; + +/// Sanitize counter values that may be uninitialized or invalid on virtual interfaces. +/// On macOS, some virtual interfaces (like vmenet0) report garbage values for certain +/// statistics fields, particularly ifi_iqdrops. We detect these by checking if: +/// 1. The value is suspiciously large (> 2^31, suggesting signed overflow or garbage) +/// 2. The value is larger than total packets (logically impossible for drops/errors) +#[cfg(target_os = "macos")] +fn sanitize_counter(value: u32, total_packets: u32) -> u64 { + const MAX_REASONABLE_U32: u32 = 0x7FFF_FFFF; // 2^31 - 1 + + // If the value is very large (> 2^31), it's likely garbage or overflow + if value > MAX_REASONABLE_U32 { + return 0; + } + + // If drops/errors exceed total packets, the data is invalid + if total_packets > 0 && value > total_packets { + return 0; + } + + value as u64 +} + +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); + + // Calculate total packets for validation + let total_rx_packets = if_data.ifi_ipackets; + let total_tx_packets = if_data.ifi_opackets; + + stats.push(InterfaceStats { + interface_name: name, + rx_bytes: if_data.ifi_ibytes as u64, + tx_bytes: if_data.ifi_obytes as u64, + rx_packets: total_rx_packets as u64, + tx_packets: total_tx_packets as u64, + // Sanitize error and drop counters (may contain garbage on virtual interfaces) + rx_errors: sanitize_counter(if_data.ifi_ierrors, total_rx_packets), + tx_errors: sanitize_counter(if_data.ifi_oerrors, total_tx_packets), + rx_dropped: sanitize_counter(if_data.ifi_iqdrops, total_rx_packets), + tx_dropped: 0, // Limited on macOS + collisions: sanitize_counter(if_data.ifi_collisions, total_rx_packets + total_tx_packets), + 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.get_all_stats(); + + match result { + Ok(stats) => { + assert!(!stats.is_empty(), "Expected at least one interface"); + let interface_names: Vec = stats.iter().map(|s| s.interface_name.clone()).collect(); + // macOS should have at least loopback (lo0) + assert!( + interface_names.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..92337f8 --- /dev/null +++ b/src/network/platform/windows_interface_stats.rs @@ -0,0 +1,177 @@ +use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider}; +use std::collections::HashMap; +use std::io; +use std::time::SystemTime; + +#[cfg(target_os = "windows")] +use windows::Win32::NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2}; +#[cfg(target_os = "windows")] +use windows::Win32::NetworkManagement::Ndis::IfOperStatusUp; + +/// 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::other( + format!("GetIfTable2 failed with error code: {:?}", result), + )); + } + + if table.is_null() { + return Err(io::Error::other( + "Failed to get interface table", + )); + } + + let num_entries = (*table).NumEntries as usize; + // Use LUID as key for deduplication since it's unique per interface + let mut stats_map: HashMap = HashMap::new(); + + for i in 0..num_entries { + let row = &*(*table).Table.as_ptr().add(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; + } + + // Skip virtual/filter interfaces by name patterns + // These are NDIS filter drivers, WFP filters, and virtual adapters + // Always skip these as they just mirror the physical interface + let name_lower = name.to_lowercase(); + if name_lower.contains("-npcap") + || name_lower.contains("-wfp") + || name_lower.contains("-qos") + || name_lower.contains("-native") + || name_lower.contains("-virtual") + || name_lower.contains("-packet") + || name_lower.contains("lightweight filter") + || name_lower.contains("mac layer") + { + continue; + } + + // Skip "Local Area Con" with zero traffic (these are usually disconnected adapters) + let total_traffic = row.InOctets + row.OutOctets + row.InUcastPkts + row.OutUcastPkts; + if name_lower.starts_with("local area con") && total_traffic == 0 { + continue; + } + + // Skip interfaces that are not operationally up + // But allow them if they have any traffic statistics + let has_traffic = row.InOctets > 0 || row.OutOctets > 0; + if row.OperStatus != IfOperStatusUp && !has_traffic { + continue; + } + + let stat = InterfaceStats { + interface_name: name.clone(), + 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(), + }; + + // Use InterfaceLuid.Value as unique key to prevent duplicates + // This ensures each physical interface appears only once + let luid_value = row.InterfaceLuid.Value; + stats_map.insert(luid_value, stat); + } + + FreeMibTable(table.cast()); + + let stats_vec: Vec = stats_map.into_values().collect(); + log::debug!( + "Windows interface stats collected: {} interfaces", + stats_vec.len() + ); + for stat in &stats_vec { + log::debug!( + " {} - RX: {} bytes, TX: {} bytes", + stat.interface_name, + stat.rx_bytes, + stat.tx_bytes + ); + } + + Ok(stats_vec) + } + } + + #[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.get_all_stats(); + + match result { + Ok(stats) => { + assert!(!stats.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..160cf1a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,8 +13,9 @@ use crate::network::types::{Connection, Protocol}; pub type Terminal = RatatuiTerminal; /// Sort column options for the connections table -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SortColumn { + #[default] CreatedAt, // Default: creation time (oldest first) BandwidthTotal, // Combined up + down bandwidth Process, @@ -26,12 +27,6 @@ pub enum SortColumn { Protocol, } -impl Default for SortColumn { - fn default() -> Self { - Self::CreatedAt - } -} - impl SortColumn { /// Get the next sort column in the cycle (follows left-to-right visual order) pub fn next(self) -> Self { @@ -410,7 +405,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 +424,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 +705,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 +803,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 +817,124 @@ 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" or "pktap") + let captured_interface = app.get_current_interface(); + let filtered_interface_stats: Vec<_> = if let Some(ref iface) = captured_interface { + // Windows uses NPF device paths like \Device\NPF_{GUID} which don't match friendly names + // For these, show all active interfaces instead of trying exact match + let is_npf_device = iface.starts_with("\\Device\\NPF_"); + + if iface == "any" || iface == "pktap" || is_npf_device { + // Show interfaces with some data + // pktap is a macOS virtual interface that captures from all interfaces, + // so we show all active interfaces rather than trying to show stats for pktap itself + // On Windows, NPF device names don't match friendly names, so show active interfaces + all_interface_stats.into_iter() + .filter(|s| s.rx_bytes > 0 || s.tx_bytes > 0 || s.rx_packets > 0 || s.tx_packets > 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 || s.rx_packets > 0 || s.tx_packets > 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) - these are cumulative totals + lines.push(Line::from(vec![ + Span::raw(" Errors (Total): "), + Span::styled(format!("{}", total_errors), error_style), + Span::raw(" Drops (Total): "), + 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 +1306,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 +1378,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 {