From 4c02a302d873ef04e67655b71cd822c425c9c8c3 Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Sat, 13 Dec 2025 10:06:32 +0100 Subject: [PATCH] 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 --- Cargo.lock | 152 +++++++++++++ Cargo.toml | 2 +- src/app.rs | 60 ++++- src/main.rs | 18 +- src/network/types.rs | 235 ++++++++++++++++++++ src/ui.rs | 507 +++++++++++++++++++++++++++++++++++-------- 6 files changed, 881 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c819473..39a6a47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e07ec5e..31bab69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app.rs b/src/app.rs index 8e4abbe..e20b8a1 100644 --- a/src/app.rs +++ b/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>, + /// Traffic history for graph visualization + traffic_history: Arc>, + /// Sandbox status (Linux Landlock) #[cfg(target_os = "linux")] sandbox_info: Arc>, @@ -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>) -> 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 { diff --git a/src/main.rs b/src/main.rs index 308c4fd..47d573b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -439,10 +439,20 @@ fn run_ui_loop( 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( 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 } diff --git a/src/network/types.rs b/src/network/types.rs index 264d898..df926a6 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -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, + 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 { + let raw: Vec = self + .samples + .iter() + .rev() + .take(count) + .map(|s| s.rx_bytes_per_sec) + .collect::>() + .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 { + let raw: Vec = self + .samples + .iter() + .rev() + .take(count) + .map(|s| s.tx_bytes_per_sec) + .collect::>() + .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 { + self.samples + .iter() + .rev() + .take(count) + .map(|s| s.connection_count as u64) + .collect::>() + .into_iter() + .rev() + .collect() + } + + /// Apply simple moving average smoothing to data + fn smooth_data(data: &[u64], window: usize) -> Vec { + if data.len() < window || window == 0 { + return data.to_vec(); + } + data.windows(window) + .map(|w| w.iter().sum::() / 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::() + / window as f64; + let avg_rate: f64 = + w.iter().map(|s| s.rx_bytes_per_sec as f64).sum::() / 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::() + / window as f64; + let avg_rate: f64 = + w.iter().map(|s| s.tx_bytes_per_sec as f64).sum::() / 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, diff --git a/src/ui.rs b/src/ui.rs index ab1e8fd..dfa36ef 100644 --- a/src/ui.rs +++ b/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 = RatatuiTerminal; @@ -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 = 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 = if filtered_interface_stats.is_empty() { + let interface_text: Vec = 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 = 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 = 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 = 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 ) };