mirror of
https://github.com/domcyrus/rustnet.git
synced 2025-12-19 05:00:33 -06:00
feat(ui): add traffic visualization and Graph tab (#90)
- Add traffic history tracking with 60-second ring buffer - Add Graph tab with traffic and connection charts - Add sparklines to Interface Stats on Overview - Add Tab/Shift+Tab navigation between tabs
This commit is contained in:
152
Cargo.lock
generated
152
Cargo.lock
generated
@@ -125,6 +125,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -702,6 +703,12 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -812,6 +819,12 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.4"
|
||||
@@ -1322,6 +1335,15 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.1"
|
||||
@@ -1494,6 +1516,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.36.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1554,6 +1586,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54acf3a685220b533e437e264e4d932cfbdc4cc7ec0cd232ed73c08d03b8a7ca"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@@ -1671,6 +1714,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.41"
|
||||
@@ -2241,6 +2293,17 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -2405,6 +2468,76 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix 1.1.2",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"rustix 1.1.2",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.10"
|
||||
@@ -2766,6 +2899,25 @@ version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 0.38.44",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
|
||||
@@ -24,7 +24,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
arboard = "3.6"
|
||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
||||
crossterm = "0.29"
|
||||
crossbeam = "0.8"
|
||||
dashmap = "6.1"
|
||||
|
||||
60
src/app.rs
60
src/app.rs
@@ -20,7 +20,7 @@ use crate::network::{
|
||||
parser::{PacketParser, ParsedPacket, ParserConfig},
|
||||
platform::create_process_lookup,
|
||||
services::ServiceLookup,
|
||||
types::{ApplicationProtocol, Connection, Protocol},
|
||||
types::{ApplicationProtocol, Connection, Protocol, TrafficHistory},
|
||||
};
|
||||
|
||||
// Platform-specific interface stats provider
|
||||
@@ -224,6 +224,9 @@ pub struct App {
|
||||
/// Interface rates (per-second rates)
|
||||
interface_rates: Arc<DashMap<String, InterfaceRates>>,
|
||||
|
||||
/// Traffic history for graph visualization
|
||||
traffic_history: Arc<RwLock<TrafficHistory>>,
|
||||
|
||||
/// Sandbox status (Linux Landlock)
|
||||
#[cfg(target_os = "linux")]
|
||||
sandbox_info: Arc<RwLock<SandboxInfo>>,
|
||||
@@ -251,6 +254,7 @@ impl App {
|
||||
process_detection_method: Arc::new(RwLock::new(String::from("initializing..."))),
|
||||
interface_stats: Arc::new(DashMap::new()),
|
||||
interface_rates: Arc::new(DashMap::new()),
|
||||
traffic_history: Arc::new(RwLock::new(TrafficHistory::new(60))), // 60 seconds of history
|
||||
#[cfg(target_os = "linux")]
|
||||
sandbox_info: Arc::new(RwLock::new(SandboxInfo::default())),
|
||||
})
|
||||
@@ -281,6 +285,9 @@ impl App {
|
||||
// Start interface stats collection thread
|
||||
self.start_interface_stats_thread()?;
|
||||
|
||||
// Start traffic history thread for graph visualization
|
||||
self.start_traffic_history_thread()?;
|
||||
|
||||
// Mark loading as complete after a short delay
|
||||
let is_loading = Arc::clone(&self.is_loading);
|
||||
thread::spawn(move || {
|
||||
@@ -863,6 +870,49 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start traffic history thread for graph visualization
|
||||
fn start_traffic_history_thread(&self) -> Result<()> {
|
||||
let should_stop = Arc::clone(&self.should_stop);
|
||||
let traffic_history = Arc::clone(&self.traffic_history);
|
||||
let interface_rates = Arc::clone(&self.interface_rates);
|
||||
let connections_snapshot = Arc::clone(&self.connections_snapshot);
|
||||
|
||||
thread::spawn(move || {
|
||||
info!("Traffic history thread started");
|
||||
|
||||
loop {
|
||||
if should_stop.load(Ordering::Relaxed) {
|
||||
info!("Traffic history thread stopping");
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate rates from all interfaces
|
||||
let (total_rx, total_tx) = interface_rates.iter().fold((0u64, 0u64), |(rx, tx), entry| {
|
||||
(
|
||||
rx + entry.value().rx_bytes_per_sec,
|
||||
tx + entry.value().tx_bytes_per_sec,
|
||||
)
|
||||
});
|
||||
|
||||
// Get connection count from snapshot
|
||||
let connection_count = connections_snapshot
|
||||
.read()
|
||||
.map(|snap| snap.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Add sample to traffic history
|
||||
if let Ok(mut history) = traffic_history.write() {
|
||||
history.add_sample(total_rx, total_tx, connection_count);
|
||||
}
|
||||
|
||||
// Update every 1 second
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -980,6 +1030,14 @@ impl App {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get traffic history for graph visualization
|
||||
pub fn get_traffic_history(&self) -> TrafficHistory {
|
||||
self.traffic_history
|
||||
.read()
|
||||
.map(|h| h.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get application statistics
|
||||
pub fn get_stats(&self) -> AppStats {
|
||||
AppStats {
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@@ -439,10 +439,20 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
break;
|
||||
}
|
||||
|
||||
// Tab navigation
|
||||
(KeyCode::Tab, _) => {
|
||||
// Tab navigation (forward)
|
||||
(KeyCode::Tab, KeyModifiers::NONE) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
ui_state.selected_tab = (ui_state.selected_tab + 1) % 4;
|
||||
ui_state.selected_tab = (ui_state.selected_tab + 1) % 5;
|
||||
}
|
||||
|
||||
// Shift+Tab navigation (backward)
|
||||
(KeyCode::BackTab, _) | (KeyCode::Tab, KeyModifiers::SHIFT) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
ui_state.selected_tab = if ui_state.selected_tab == 0 {
|
||||
4 // Wrap to last tab
|
||||
} else {
|
||||
ui_state.selected_tab - 1
|
||||
};
|
||||
}
|
||||
|
||||
// Help toggle
|
||||
@@ -450,7 +460,7 @@ 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 = 3; // Switch to help tab
|
||||
ui_state.selected_tab = 4; // Switch to help tab
|
||||
} else {
|
||||
ui_state.selected_tab = 0; // Back to overview
|
||||
}
|
||||
|
||||
@@ -552,6 +552,241 @@ pub struct DpiInfo {
|
||||
pub last_update_time: Instant,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Traffic History Types (for graph visualization)
|
||||
// ============================================================================
|
||||
|
||||
/// Chart data points as (time_offset, value) pairs
|
||||
pub type ChartData = Vec<(f64, f64)>;
|
||||
|
||||
/// A single sample of aggregate traffic data for graphing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrafficSample {
|
||||
pub timestamp: Instant,
|
||||
pub rx_bytes_per_sec: u64,
|
||||
pub tx_bytes_per_sec: u64,
|
||||
pub connection_count: usize,
|
||||
}
|
||||
|
||||
/// Ring buffer for aggregate traffic history (used for graphs)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrafficHistory {
|
||||
samples: VecDeque<TrafficSample>,
|
||||
max_samples: usize,
|
||||
}
|
||||
|
||||
impl TrafficHistory {
|
||||
pub fn new(max_samples: usize) -> Self {
|
||||
Self {
|
||||
samples: VecDeque::with_capacity(max_samples),
|
||||
max_samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new sample
|
||||
pub fn add_sample(
|
||||
&mut self,
|
||||
rx_bytes_per_sec: u64,
|
||||
tx_bytes_per_sec: u64,
|
||||
connection_count: usize,
|
||||
) {
|
||||
let sample = TrafficSample {
|
||||
timestamp: Instant::now(),
|
||||
rx_bytes_per_sec,
|
||||
tx_bytes_per_sec,
|
||||
connection_count,
|
||||
};
|
||||
|
||||
if self.samples.len() >= self.max_samples {
|
||||
self.samples.pop_front();
|
||||
}
|
||||
self.samples.push_back(sample);
|
||||
}
|
||||
|
||||
/// Get RX bytes/sec values for sparkline (newest last), smoothed with moving average
|
||||
pub fn get_rx_sparkline_data(&self, count: usize) -> Vec<u64> {
|
||||
let raw: Vec<u64> = self
|
||||
.samples
|
||||
.iter()
|
||||
.rev()
|
||||
.take(count)
|
||||
.map(|s| s.rx_bytes_per_sec)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
Self::smooth_data(&raw, 3)
|
||||
}
|
||||
|
||||
/// Get TX bytes/sec values for sparkline (newest last), smoothed with moving average
|
||||
pub fn get_tx_sparkline_data(&self, count: usize) -> Vec<u64> {
|
||||
let raw: Vec<u64> = self
|
||||
.samples
|
||||
.iter()
|
||||
.rev()
|
||||
.take(count)
|
||||
.map(|s| s.tx_bytes_per_sec)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
Self::smooth_data(&raw, 3)
|
||||
}
|
||||
|
||||
/// Get connection count values for sparkline (newest last)
|
||||
pub fn get_connection_sparkline_data(&self, count: usize) -> Vec<u64> {
|
||||
self.samples
|
||||
.iter()
|
||||
.rev()
|
||||
.take(count)
|
||||
.map(|s| s.connection_count as u64)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Apply simple moving average smoothing to data
|
||||
fn smooth_data(data: &[u64], window: usize) -> Vec<u64> {
|
||||
if data.len() < window || window == 0 {
|
||||
return data.to_vec();
|
||||
}
|
||||
data.windows(window)
|
||||
.map(|w| w.iter().sum::<u64>() / window as u64)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get data for Chart widget: (time_offset, rate) pairs, smoothed with moving average
|
||||
/// Time offset is negative seconds from now
|
||||
pub fn get_chart_data(&self) -> (ChartData, ChartData) {
|
||||
let now = Instant::now();
|
||||
let samples: Vec<_> = self.samples.iter().collect();
|
||||
|
||||
// Apply smoothing with window of 3
|
||||
let window = 3;
|
||||
if samples.len() < window {
|
||||
// Not enough data for smoothing, return raw
|
||||
let rx: ChartData = samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let age = now.duration_since(s.timestamp).as_secs_f64();
|
||||
(-age, s.rx_bytes_per_sec as f64)
|
||||
})
|
||||
.collect();
|
||||
let tx: ChartData = samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let age = now.duration_since(s.timestamp).as_secs_f64();
|
||||
(-age, s.tx_bytes_per_sec as f64)
|
||||
})
|
||||
.collect();
|
||||
return (rx, tx);
|
||||
}
|
||||
|
||||
let rx: ChartData = samples
|
||||
.windows(window)
|
||||
.map(|w| {
|
||||
let avg_age: f64 = w
|
||||
.iter()
|
||||
.map(|s| now.duration_since(s.timestamp).as_secs_f64())
|
||||
.sum::<f64>()
|
||||
/ window as f64;
|
||||
let avg_rate: f64 =
|
||||
w.iter().map(|s| s.rx_bytes_per_sec as f64).sum::<f64>() / window as f64;
|
||||
(-avg_age, avg_rate)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tx: ChartData = samples
|
||||
.windows(window)
|
||||
.map(|w| {
|
||||
let avg_age: f64 = w
|
||||
.iter()
|
||||
.map(|s| now.duration_since(s.timestamp).as_secs_f64())
|
||||
.sum::<f64>()
|
||||
/ window as f64;
|
||||
let avg_rate: f64 =
|
||||
w.iter().map(|s| s.tx_bytes_per_sec as f64).sum::<f64>() / window as f64;
|
||||
(-avg_age, avg_rate)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(rx, tx)
|
||||
}
|
||||
|
||||
/// Check if we have enough data to display
|
||||
pub fn has_enough_data(&self) -> bool {
|
||||
self.samples.len() >= 2
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TrafficHistory {
|
||||
fn default() -> Self {
|
||||
Self::new(60) // 60 seconds of history
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribution of connections by application protocol (from DPI)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppProtocolDistribution {
|
||||
pub https_count: usize,
|
||||
pub http_count: usize,
|
||||
pub quic_count: usize,
|
||||
pub dns_count: usize,
|
||||
pub ssh_count: usize,
|
||||
pub other_count: usize,
|
||||
}
|
||||
|
||||
impl AppProtocolDistribution {
|
||||
/// Calculate distribution from a list of connections
|
||||
pub fn from_connections(connections: &[Connection]) -> Self {
|
||||
let mut dist = Self::default();
|
||||
|
||||
for conn in connections {
|
||||
if let Some(dpi_info) = &conn.dpi_info {
|
||||
match &dpi_info.application {
|
||||
ApplicationProtocol::Https(_) => dist.https_count += 1,
|
||||
ApplicationProtocol::Http(_) => dist.http_count += 1,
|
||||
ApplicationProtocol::Quic(_) => dist.quic_count += 1,
|
||||
ApplicationProtocol::Dns(_) => dist.dns_count += 1,
|
||||
ApplicationProtocol::Ssh(_) => dist.ssh_count += 1,
|
||||
}
|
||||
} else {
|
||||
dist.other_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
dist
|
||||
}
|
||||
|
||||
/// Get total connection count
|
||||
pub fn total(&self) -> usize {
|
||||
self.https_count
|
||||
+ self.http_count
|
||||
+ self.quic_count
|
||||
+ self.dns_count
|
||||
+ self.ssh_count
|
||||
+ self.other_count
|
||||
}
|
||||
|
||||
/// Get distribution as percentages (label, count, percentage)
|
||||
pub fn as_percentages(&self) -> Vec<(&'static str, usize, f64)> {
|
||||
let total = self.total().max(1) as f64;
|
||||
vec![
|
||||
("HTTPS", self.https_count, self.https_count as f64 / total * 100.0),
|
||||
("QUIC", self.quic_count, self.quic_count as f64 / total * 100.0),
|
||||
("HTTP", self.http_count, self.http_count as f64 / total * 100.0),
|
||||
("DNS", self.dns_count, self.dns_count as f64 / total * 100.0),
|
||||
("SSH", self.ssh_count, self.ssh_count as f64 / total * 100.0),
|
||||
("Other", self.other_count, self.other_count as f64 / total * 100.0),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rate Tracking Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RateSample {
|
||||
timestamp: Instant,
|
||||
|
||||
507
src/ui.rs
507
src/ui.rs
@@ -3,12 +3,13 @@ use ratatui::{
|
||||
Frame, Terminal as RatatuiTerminal,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
symbols,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap},
|
||||
widgets::{Axis, Block, Borders, Cell, Chart, Dataset, GraphType, Paragraph, Row, Sparkline, Table, Tabs, Wrap},
|
||||
};
|
||||
|
||||
use crate::app::{App, AppStats};
|
||||
use crate::network::types::{Connection, Protocol};
|
||||
use crate::network::types::{AppProtocolDistribution, Connection, Protocol, TrafficHistory};
|
||||
|
||||
pub type Terminal<B> = RatatuiTerminal<B>;
|
||||
|
||||
@@ -406,7 +407,8 @@ pub fn draw(
|
||||
0 => draw_overview(f, ui_state, connections, stats, app, content_area)?,
|
||||
1 => draw_connection_details(f, ui_state, connections, content_area)?,
|
||||
2 => draw_interface_stats(f, app, content_area)?,
|
||||
3 => draw_help(f, content_area)?,
|
||||
3 => draw_graph_tab(f, app, connections, content_area)?,
|
||||
4 => draw_help(f, content_area)?,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -425,6 +427,7 @@ fn draw_tabs(f: &mut Frame, ui_state: &UIState, area: Rect) {
|
||||
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("Graph", Style::default().fg(Color::Green)),
|
||||
Span::styled("Help", Style::default().fg(Color::Green)),
|
||||
];
|
||||
|
||||
@@ -708,10 +711,9 @@ fn draw_stats_panel(
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(10), // Connection stats (increased for interface line)
|
||||
Constraint::Length(5), // Traffic stats
|
||||
Constraint::Length(7), // Network stats (TCP analytics + header)
|
||||
Constraint::Length(4), // Security stats (sandbox)
|
||||
Constraint::Min(0), // Interface stats
|
||||
Constraint::Min(0), // Interface stats (with traffic graph)
|
||||
])
|
||||
.split(area);
|
||||
|
||||
@@ -764,31 +766,6 @@ fn draw_stats_panel(
|
||||
.style(Style::default());
|
||||
f.render_widget(conn_stats, chunks[0]);
|
||||
|
||||
// Traffic statistics
|
||||
let total_incoming: f64 = connections
|
||||
.iter()
|
||||
.map(|c| c.current_incoming_rate_bps)
|
||||
.sum();
|
||||
let total_outgoing: f64 = connections
|
||||
.iter()
|
||||
.map(|c| c.current_outgoing_rate_bps)
|
||||
.sum();
|
||||
|
||||
let traffic_stats_text: Vec<Line> = vec![
|
||||
Line::from(format!("Total Incoming: {}", format_rate(total_incoming))),
|
||||
Line::from(format!("Total Outgoing: {}", format_rate(total_outgoing))),
|
||||
Line::from(""),
|
||||
Line::from(format!(
|
||||
"Last Update: {:?} ago",
|
||||
stats.last_update.read().unwrap().elapsed()
|
||||
)),
|
||||
];
|
||||
|
||||
let traffic_stats = Paragraph::new(traffic_stats_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Traffic"))
|
||||
.style(Style::default());
|
||||
f.render_widget(traffic_stats, chunks[1]);
|
||||
|
||||
// Network statistics (TCP analytics)
|
||||
let mut tcp_retransmits: u64 = 0;
|
||||
let mut tcp_out_of_order: u64 = 0;
|
||||
@@ -844,7 +821,7 @@ fn draw_stats_panel(
|
||||
.title("Network Stats"),
|
||||
)
|
||||
.style(Style::default());
|
||||
f.render_widget(network_stats, chunks[2]);
|
||||
f.render_widget(network_stats, chunks[1]);
|
||||
|
||||
// Security statistics (sandbox) - Linux only shows Landlock info
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -928,24 +905,105 @@ fn draw_stats_panel(
|
||||
let security_stats = Paragraph::new(security_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Security"))
|
||||
.style(Style::default());
|
||||
f.render_widget(security_stats, chunks[3]);
|
||||
f.render_widget(security_stats, chunks[2]);
|
||||
|
||||
// Interface statistics
|
||||
// Interface statistics with traffic graph
|
||||
draw_interface_stats_with_graph(f, app, chunks[3])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw interface stats section with embedded traffic sparklines
|
||||
fn draw_interface_stats_with_graph(f: &mut Frame, app: &App, area: Rect) -> Result<()> {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Interface Stats (press 'i')");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Split into: sparklines (3 lines) + interface details (remaining)
|
||||
let sections = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Traffic sparklines
|
||||
Constraint::Min(0), // Interface details
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Draw traffic sparklines
|
||||
let traffic_history = app.get_traffic_history();
|
||||
let sparkline_width = sections[0].width.saturating_sub(8) as usize; // Leave room for labels
|
||||
|
||||
// Split sparkline area into rows
|
||||
let sparkline_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // RX sparkline
|
||||
Constraint::Length(1), // TX sparkline
|
||||
Constraint::Length(1), // Current rates
|
||||
])
|
||||
.split(sections[0]);
|
||||
|
||||
// RX row: label + sparkline
|
||||
let rx_cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(sparkline_rows[0]);
|
||||
|
||||
let rx_label = Paragraph::new("RX").style(Style::default().fg(Color::Green));
|
||||
f.render_widget(rx_label, rx_cols[0]);
|
||||
|
||||
let rx_data = traffic_history.get_rx_sparkline_data(sparkline_width);
|
||||
let rx_sparkline = Sparkline::default()
|
||||
.data(&rx_data)
|
||||
.style(Style::default().fg(Color::Green));
|
||||
f.render_widget(rx_sparkline, rx_cols[1]);
|
||||
|
||||
// TX row: label + sparkline
|
||||
let tx_cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(sparkline_rows[1]);
|
||||
|
||||
let tx_label = Paragraph::new("TX").style(Style::default().fg(Color::Blue));
|
||||
f.render_widget(tx_label, tx_cols[0]);
|
||||
|
||||
let tx_data = traffic_history.get_tx_sparkline_data(sparkline_width);
|
||||
let tx_sparkline = Sparkline::default()
|
||||
.data(&tx_data)
|
||||
.style(Style::default().fg(Color::Blue));
|
||||
f.render_widget(tx_sparkline, tx_cols[1]);
|
||||
|
||||
// Current rates row
|
||||
let (current_rx, current_tx) = rx_data
|
||||
.last()
|
||||
.zip(tx_data.last())
|
||||
.map(|(rx, tx)| (*rx, *tx))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
let rates_text = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("↓{}/s", format_bytes(current_rx)),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("↑{}/s", format_bytes(current_tx)),
|
||||
Style::default().fg(Color::Blue),
|
||||
),
|
||||
]);
|
||||
let rates_para = Paragraph::new(rates_text);
|
||||
f.render_widget(rates_para, sparkline_rows[2]);
|
||||
|
||||
// Interface details section (errors/drops only, rates shown in sparklines above)
|
||||
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| {
|
||||
@@ -953,35 +1011,23 @@ fn draw_stats_panel(
|
||||
})
|
||||
.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[4].height as usize;
|
||||
let lines_for_borders = 2;
|
||||
let lines_per_interface = 2;
|
||||
let lines_for_more_message = 1;
|
||||
// Calculate how many interfaces can fit (1 line per interface now)
|
||||
let available_height = sections[1].height as usize;
|
||||
let max_interfaces = available_height.saturating_sub(1); // Reserve 1 for "more" message
|
||||
|
||||
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() {
|
||||
let interface_text: Vec<Line> = if filtered_interface_stats.is_empty() {
|
||||
vec![Line::from(Span::styled(
|
||||
"No interface stats available",
|
||||
Style::default().fg(Color::Gray),
|
||||
@@ -1006,37 +1052,20 @@ fn draw_stats_panel(
|
||||
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
|
||||
// Show interface name with errors/drops on single 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::raw("Err: "),
|
||||
Span::styled(format!("{}", total_errors), error_style),
|
||||
Span::raw(" Drops (Total): "),
|
||||
Span::raw(" Drop: "),
|
||||
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)",
|
||||
"... {} more (press 'i')",
|
||||
filtered_interface_stats.len() - num_to_show
|
||||
),
|
||||
Style::default().fg(Color::Gray),
|
||||
@@ -1045,18 +1074,295 @@ fn draw_stats_panel(
|
||||
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[4]);
|
||||
let interface_para = Paragraph::new(interface_text);
|
||||
f.render_widget(interface_para, sections[1]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw the Graph tab with traffic visualization
|
||||
fn draw_graph_tab(
|
||||
f: &mut Frame,
|
||||
app: &App,
|
||||
connections: &[Connection],
|
||||
area: Rect,
|
||||
) -> Result<()> {
|
||||
let traffic_history = app.get_traffic_history();
|
||||
|
||||
// Main layout: top row (charts + legend), bottom row (info)
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(0), // Charts
|
||||
Constraint::Length(1), // Legend row
|
||||
Constraint::Percentage(45), // App distribution + top processes
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Top row: traffic chart (70%) + connections sparkline (30%)
|
||||
let top_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
||||
.split(main_chunks[0]);
|
||||
|
||||
// Legend row: traffic legend (70%) + empty/connections count (30%)
|
||||
let legend_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
||||
.split(main_chunks[1]);
|
||||
|
||||
// Bottom row: app distribution (50%) + top processes (50%)
|
||||
let bottom_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(main_chunks[2]);
|
||||
|
||||
// Draw components
|
||||
draw_traffic_chart(f, &traffic_history, top_chunks[0]);
|
||||
draw_connections_sparkline(f, &traffic_history, top_chunks[1]);
|
||||
draw_traffic_legend(f, legend_chunks[0]);
|
||||
// legend_chunks[1] intentionally empty for alignment
|
||||
draw_app_distribution(f, connections, bottom_chunks[0]);
|
||||
draw_top_processes(f, connections, bottom_chunks[1]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw the full traffic chart with RX/TX lines
|
||||
fn draw_traffic_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Traffic Over Time (60s)");
|
||||
|
||||
if !history.has_enough_data() {
|
||||
let placeholder = Paragraph::new("Collecting data...")
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
f.render_widget(placeholder, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let (rx_data, tx_data) = history.get_chart_data();
|
||||
|
||||
// Find max value for Y axis scaling
|
||||
let max_rate = rx_data
|
||||
.iter()
|
||||
.chain(tx_data.iter())
|
||||
.map(|(_, y)| *y)
|
||||
.fold(0.0f64, |a, b| a.max(b))
|
||||
.max(1024.0); // Minimum 1 KB/s scale
|
||||
|
||||
let datasets = vec![
|
||||
Dataset::default()
|
||||
.name("RX ↓")
|
||||
.marker(symbols::Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(Style::default().fg(Color::Green))
|
||||
.data(&rx_data),
|
||||
Dataset::default()
|
||||
.name("TX ↑")
|
||||
.marker(symbols::Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(Style::default().fg(Color::Blue))
|
||||
.data(&tx_data),
|
||||
];
|
||||
|
||||
let chart = Chart::new(datasets)
|
||||
.block(block)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.title("Time")
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.bounds([-60.0, 0.0])
|
||||
.labels(vec![
|
||||
Line::from("-60s"),
|
||||
Line::from("-30s"),
|
||||
Line::from("now"),
|
||||
]),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default()
|
||||
.title("Rate")
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.bounds([0.0, max_rate])
|
||||
.labels(vec![
|
||||
Line::from("0"),
|
||||
Line::from(format_rate_compact(max_rate / 2.0)),
|
||||
Line::from(format_rate_compact(max_rate)),
|
||||
]),
|
||||
);
|
||||
|
||||
f.render_widget(chart, area);
|
||||
}
|
||||
|
||||
/// Draw connections count sparkline
|
||||
fn draw_connections_sparkline(f: &mut Frame, history: &TrafficHistory, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Connections");
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
if !history.has_enough_data() {
|
||||
let placeholder = Paragraph::new("Collecting...")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
f.render_widget(placeholder, inner);
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: sparkline + current count label
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
||||
.split(inner);
|
||||
|
||||
let width = inner.width as usize;
|
||||
let conn_data = history.get_connection_sparkline_data(width);
|
||||
|
||||
let sparkline = Sparkline::default()
|
||||
.data(&conn_data)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(sparkline, chunks[0]);
|
||||
|
||||
// Current connection count label
|
||||
let current_count = conn_data.last().copied().unwrap_or(0);
|
||||
let label = Paragraph::new(format!("{} connections", current_count))
|
||||
.style(Style::default().fg(Color::White));
|
||||
f.render_widget(label, chunks[1]);
|
||||
}
|
||||
|
||||
/// Draw application protocol distribution
|
||||
fn draw_app_distribution(f: &mut Frame, connections: &[Connection], area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Application Distribution");
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let dist = AppProtocolDistribution::from_connections(connections);
|
||||
let percentages = dist.as_percentages();
|
||||
|
||||
// Filter out zero-count protocols and create bars
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
for (label, count, pct) in percentages {
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a bar visualization
|
||||
let bar_width = (inner.width as f64 * 0.6) as usize; // 60% for bar
|
||||
let filled = ((pct / 100.0) * bar_width as f64) as usize;
|
||||
let bar: String = "█".repeat(filled) + &"░".repeat(bar_width.saturating_sub(filled));
|
||||
|
||||
let color = match label {
|
||||
"HTTPS" => Color::Green,
|
||||
"QUIC" => Color::Cyan,
|
||||
"HTTP" => Color::Yellow,
|
||||
"DNS" => Color::Magenta,
|
||||
"SSH" => Color::Blue,
|
||||
_ => Color::Gray,
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{:6}", label), Style::default().fg(color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(bar, Style::default().fg(color)),
|
||||
Span::raw(format!(" {:5.1}%", pct)),
|
||||
]));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No connections",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
f.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
/// Draw top processes by bandwidth
|
||||
fn draw_top_processes(f: &mut Frame, connections: &[Connection], area: Rect) {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Top Processes");
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Aggregate traffic by process
|
||||
let mut process_traffic: HashMap<String, f64> = HashMap::new();
|
||||
for conn in connections {
|
||||
let name = conn
|
||||
.process_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let traffic = conn.current_incoming_rate_bps + conn.current_outgoing_rate_bps;
|
||||
*process_traffic.entry(name).or_insert(0.0) += traffic;
|
||||
}
|
||||
|
||||
// Sort by traffic descending, filter out processes with no traffic
|
||||
let mut sorted: Vec<_> = process_traffic
|
||||
.into_iter()
|
||||
.filter(|(_, rate)| *rate > 0.0)
|
||||
.collect();
|
||||
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Create rows for top 5 processes
|
||||
let rows: Vec<Row> = sorted
|
||||
.into_iter()
|
||||
.take(5)
|
||||
.map(|(name, rate)| {
|
||||
let display_name = if name.len() > 20 {
|
||||
format!("{}...", &name[..17])
|
||||
} else {
|
||||
name
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(display_name),
|
||||
Cell::from(format_rate(rate)).style(Style::default().fg(Color::Cyan)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
let placeholder = Paragraph::new("No active processes")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
f.render_widget(placeholder, inner);
|
||||
return;
|
||||
}
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||
)
|
||||
.header(
|
||||
Row::new(vec!["Process", "Rate"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
);
|
||||
|
||||
f.render_widget(table, inner);
|
||||
}
|
||||
|
||||
/// Draw chart legend
|
||||
fn draw_traffic_legend(f: &mut Frame, area: Rect) {
|
||||
let legend = Paragraph::new(Line::from(vec![
|
||||
Span::styled("▬", Style::default().fg(Color::Green)),
|
||||
Span::raw(" RX (incoming) "),
|
||||
Span::styled("▬", Style::default().fg(Color::Blue)),
|
||||
Span::raw(" TX (outgoing)"),
|
||||
]))
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
f.render_widget(legend, area);
|
||||
}
|
||||
|
||||
/// Draw connection details view
|
||||
fn draw_connection_details(
|
||||
f: &mut Frame,
|
||||
@@ -1434,6 +1740,33 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> {
|
||||
Span::raw("Enter filter mode (navigate while typing!)"),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Tabs:",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Overview ", Style::default().fg(Color::Green)),
|
||||
Span::raw("Connection list with mini traffic graph"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Details ", Style::default().fg(Color::Green)),
|
||||
Span::raw("Full details for selected connection"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Interfaces ", Style::default().fg(Color::Green)),
|
||||
Span::raw("Network interface statistics"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Graph ", Style::default().fg(Color::Green)),
|
||||
Span::raw("Traffic charts and protocol distribution"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Help ", Style::default().fg(Color::Green)),
|
||||
Span::raw("This help screen"),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Connection Colors:",
|
||||
Style::default()
|
||||
@@ -1670,12 +2003,12 @@ fn draw_status_bar(f: &mut Frame, ui_state: &UIState, connection_count: usize, a
|
||||
}
|
||||
} else if !ui_state.filter_query.is_empty() {
|
||||
format!(
|
||||
" Press 'h' for help | '/' to filter | Showing {} filtered connections (Esc to clear filter) ",
|
||||
" 'h' help | Tab/Shift+Tab switch tabs | Showing {} filtered connections (Esc to clear) ",
|
||||
connection_count
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
" Press 'h' for help | '/' to filter & navigate | 'c' to copy address | Connections: {} ",
|
||||
" 'h' help | Tab/Shift+Tab switch tabs | '/' filter | 'c' copy | Connections: {} ",
|
||||
connection_count
|
||||
)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user