mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-01-04 04:49:53 -06:00
Feature/interface stats (#79)
* feat: adding interface stats * macOS specific improvements * fix windows interface stats
This commit is contained in:
@@ -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",
|
||||
|
||||
24
README.md
24
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
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Interface Statistics Monitoring</b></summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
## 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 |
|
||||
|
||||
122
USAGE.md
122
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.
|
||||
|
||||
88
src/app.rs
88
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<RwLock<String>>,
|
||||
|
||||
/// Interface statistics (cumulative totals)
|
||||
interface_stats: Arc<DashMap<String, InterfaceStats>>,
|
||||
|
||||
/// Interface rates (per-second rates)
|
||||
interface_rates: Arc<DashMap<String, InterfaceRates>>,
|
||||
}
|
||||
|
||||
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<String, InterfaceStats> = 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<DashMap<String, Connection>>) -> 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<InterfaceStats> {
|
||||
self.interface_stats
|
||||
.iter()
|
||||
.map(|entry| entry.value().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get interface rates (bytes/sec)
|
||||
pub fn get_interface_rates(&self) -> HashMap<String, InterfaceRates> {
|
||||
self.interface_rates
|
||||
.iter()
|
||||
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get application statistics
|
||||
pub fn get_stats(&self) -> AppStats {
|
||||
AppStats {
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@@ -364,7 +364,7 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
// 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<B: ratatui::prelude::Backend>(
|
||||
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;
|
||||
|
||||
166
src/network/interface_stats.rs
Normal file
166
src/network/interface_stats.rs
Normal file
@@ -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<InterfaceStats, io::Error>;
|
||||
|
||||
/// Get statistics for all available interfaces
|
||||
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, 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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod capture;
|
||||
pub mod dpi;
|
||||
pub mod interface_stats;
|
||||
pub mod link_layer;
|
||||
pub mod merge;
|
||||
pub mod parser;
|
||||
|
||||
115
src/network/platform/freebsd_interface_stats.rs
Normal file
115
src/network/platform/freebsd_interface_stats.rs
Normal file
@@ -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<InterfaceStats, io::Error> {
|
||||
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<Vec<InterfaceStats>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/network/platform/linux_interface_stats.rs
Normal file
152
src/network/platform/linux_interface_stats.rs
Normal file
@@ -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<InterfaceStats, io::Error> {
|
||||
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<Vec<InterfaceStats>, 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<u64, io::Error> {
|
||||
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::<u64>()
|
||||
.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<String> = 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);
|
||||
}
|
||||
}
|
||||
148
src/network/platform/macos_interface_stats.rs
Normal file
148
src/network/platform/macos_interface_stats.rs
Normal file
@@ -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<InterfaceStats, io::Error> {
|
||||
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<Vec<InterfaceStats>, 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<String> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
177
src/network/platform/windows_interface_stats.rs
Normal file
177
src/network/platform/windows_interface_stats.rs
Normal file
@@ -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<InterfaceStats, io::Error> {
|
||||
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<Vec<InterfaceStats>, 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<u64, InterfaceStats> = 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<InterfaceStats> = 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<Vec<InterfaceStats>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
271
src/ui.rs
271
src/ui.rs
@@ -13,8 +13,9 @@ use crate::network::types::{Connection, Protocol};
|
||||
pub type Terminal<B> = RatatuiTerminal<B>;
|
||||
|
||||
/// 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<Line> = 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<Line> = 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 {
|
||||
|
||||
Reference in New Issue
Block a user