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 {