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:
Marco Cadetg
2025-12-13 10:06:32 +01:00
committed by GitHub
parent 9ae9921d14
commit 4c02a302d8
6 changed files with 881 additions and 93 deletions

152
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
View File

@@ -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
)
};