feat(logging): add connection direction to JSON log for TCP

This commit is contained in:
Marco Cadetg
2025-12-20 11:29:15 +01:00
parent 4ac144a75d
commit eacc57358e
13 changed files with 198 additions and 88 deletions

View File

@@ -818,6 +818,7 @@ sudo rustnet -i eth0 --json-log ~/network-events.json
| `pid` | number | Process ID (if available) |
| `process_name` | string | Process name (if available) |
| `service_name` | string | Service name from port lookup (if available) |
| `direction` | string | Connection direction (`outgoing` or `incoming`), TCP only when handshake observed |
| `dpi_protocol` | string | Detected application protocol (if DPI enabled) |
| `dpi_domain` | string | Extracted domain/hostname (if available) |
| `bytes_sent` | number | Total bytes sent (connection_closed only) |
@@ -827,8 +828,8 @@ sudo rustnet -i eth0 --json-log ~/network-events.json
**Example output:**
```json
{"timestamp":"2025-01-15T10:30:00Z","event":"new_connection","protocol":"TCP","source_ip":"192.168.1.100","source_port":54321,"destination_ip":"93.184.216.34","destination_port":443,"pid":1234,"process_name":"curl","service_name":"https","dpi_protocol":"HTTPS","dpi_domain":"example.com"}
{"timestamp":"2025-01-15T10:30:05Z","event":"connection_closed","protocol":"TCP","source_ip":"192.168.1.100","source_port":54321,"destination_ip":"93.184.216.34","destination_port":443,"pid":1234,"process_name":"curl","service_name":"https","bytes_sent":1024,"bytes_received":4096,"duration_secs":5}
{"timestamp":"2025-01-15T10:30:00Z","event":"new_connection","protocol":"TCP","source_ip":"192.168.1.100","source_port":54321,"destination_ip":"93.184.216.34","destination_port":443,"pid":1234,"process_name":"curl","service_name":"https","direction":"outgoing","dpi_protocol":"HTTPS","dpi_domain":"example.com"}
{"timestamp":"2025-01-15T10:30:05Z","event":"connection_closed","protocol":"TCP","source_ip":"192.168.1.100","source_port":54321,"destination_ip":"93.184.216.34","destination_port":443,"pid":1234,"process_name":"curl","service_name":"https","direction":"outgoing","bytes_sent":1024,"bytes_received":4096,"duration_secs":5}
```
**Processing JSON logs:**

View File

@@ -88,6 +88,11 @@ fn log_connection_event(
event["service_name"] = json!(service_name);
}
// Add connection direction (only for TCP when we observed the handshake)
if let Some(is_outgoing) = conn.connection_direction {
event["direction"] = json!(if is_outgoing { "outgoing" } else { "incoming" });
}
// Add DPI information if available
if let Some(dpi) = &conn.dpi_info {
event["dpi_protocol"] = json!(dpi.application.to_string());
@@ -516,7 +521,13 @@ impl App {
let mut parsed_count = 0;
for packet_data in &batch {
if let Some(parsed) = parser.parse_packet(packet_data) {
update_connection(&connections, parsed, &stats, &json_log_path, &rtt_tracker);
update_connection(
&connections,
parsed,
&stats,
&json_log_path,
&rtt_tracker,
);
parsed_count += 1;
}
}
@@ -911,12 +922,15 @@ impl App {
}
// 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,
)
});
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

View File

@@ -10,7 +10,8 @@ const INTERFACE_HELP: &str = "Network interface to monitor";
const BPF_HELP: &str = "BPF filter expression for packet capture (e.g., \"tcp port 443\"). Note: Using a BPF filter disables PKTAP (process info falls back to lsof)";
#[cfg(not(target_os = "macos"))]
const BPF_HELP: &str = "BPF filter expression for packet capture (e.g., \"tcp port 443\", \"dst port 80\")";
const BPF_HELP: &str =
"BPF filter expression for packet capture (e.g., \"tcp port 443\", \"dst port 80\")";
pub fn build_cli() -> Command {
let cmd = Command::new("rustnet")

View File

@@ -12,7 +12,9 @@ pub mod ui;
#[cfg(target_os = "windows")]
pub fn is_admin() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::Win32::Security::{
GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {

View File

@@ -92,7 +92,9 @@ fn main() -> Result<()> {
// - Log files need to be created first
#[cfg(all(target_os = "linux", feature = "landlock"))]
{
use network::platform::sandbox::{apply_sandbox, SandboxConfig, SandboxMode, SandboxStatus};
use network::platform::sandbox::{
SandboxConfig, SandboxMode, SandboxStatus, apply_sandbox,
};
use std::path::PathBuf;
let sandbox_mode = if matches.get_flag("no-sandbox") {
@@ -594,8 +596,8 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
// Try arboard first, fall back to wl-copy for Wayland (GNOME doesn't
// support the wlr-data-control protocol that arboard relies on)
let result = Clipboard::new()
.and_then(|mut cb| cb.set_text(&remote_addr));
let result =
Clipboard::new().and_then(|mut cb| cb.set_text(&remote_addr));
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let result = result.or_else(|_| {
@@ -636,7 +638,8 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
let msg = format!("Clipboard error: {}", e);
error!("{}", msg);
ui_state.clipboard_message = Some((msg, std::time::Instant::now()));
ui_state.clipboard_message =
Some((msg, std::time::Instant::now()));
}
}
}
@@ -779,7 +782,9 @@ fn check_dll_available(dll_name: &str) -> bool {
#[cfg(target_os = "windows")]
fn is_admin() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::Win32::Security::{
GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {

View File

@@ -231,7 +231,9 @@ pub fn setup_packet_capture(config: CaptureConfig) -> Result<(Capture<Active>, S
// Fallback to regular capture (original code)
#[cfg(target_os = "macos")]
if config.filter.is_some() {
log::warn!("BPF filter specified - using regular capture instead of PKTAP (BPF filters don't work with PKTAP)");
log::warn!(
"BPF filter specified - using regular capture instead of PKTAP (BPF filters don't work with PKTAP)"
);
}
log::info!("Setting up regular packet capture");
let device = find_capture_device(&config.interface)?;

View File

@@ -340,18 +340,38 @@ pub fn create_connection_from_packet(parsed: &ParsedPacket, now: SystemTime) ->
// Set initial TCP state based on flags if TCP
if let Some(tcp_header) = parsed.tcp_header {
conn.protocol_state = ProtocolState::Tcp(update_tcp_state(
TcpState::Unknown,
&tcp_header.flags,
parsed.is_outgoing,
));
let tcp_state = update_tcp_state(TcpState::Unknown, &tcp_header.flags, parsed.is_outgoing);
conn.protocol_state = ProtocolState::Tcp(tcp_state);
// Set connection direction only if we observed the TCP handshake
// SynSent = we initiated (outgoing), SynReceived = they initiated (incoming)
// Also detect from SYN+ACK: receiving SYN+ACK means we initiated (outgoing)
conn.connection_direction = match tcp_state {
TcpState::SynSent => Some(true), // outgoing - we sent SYN
TcpState::SynReceived => Some(false), // incoming - we received SYN
_ => {
// Check if first packet is SYN+ACK - can also determine direction
if tcp_header.flags.syn && tcp_header.flags.ack {
// SYN+ACK received = we initiated (outgoing)
// SYN+ACK sent = they initiated (incoming)
Some(!parsed.is_outgoing)
} else {
None // mid-stream capture, direction unknown
}
}
};
debug!(
"Created new {} connection: {:?} -> {:?}, state: {:?}",
parsed.protocol, parsed.local_addr, parsed.remote_addr, conn.protocol_state
"Created new {} connection: {:?} -> {:?}, state: {:?}, direction: {:?}",
parsed.protocol,
parsed.local_addr,
parsed.remote_addr,
conn.protocol_state,
conn.connection_direction
);
} else {
// For non-TCP protocols, use the provided state directly
// Connection direction is not determinable for stateless protocols
conn.protocol_state = parsed.protocol_state;
}

View File

@@ -54,8 +54,7 @@ impl LinuxProcessLookup {
}
/// Build connection -> process mapping and PID -> name mapping
fn build_process_map() -> Result<(ConnectionProcessMap, PidNameMap)>
{
fn build_process_map() -> Result<(ConnectionProcessMap, PidNameMap)> {
let mut process_map = HashMap::new();
// First, build inode -> process mapping and PID -> name mapping

View File

@@ -45,9 +45,7 @@ pub fn drop_cap_net_raw() -> Result<bool> {
// Also drop from permitted set to prevent re-acquiring
// This is optional but provides stronger security
if caps::has_cap(None, CapSet::Permitted, Capability::CAP_NET_RAW)
.unwrap_or(false)
{
if caps::has_cap(None, CapSet::Permitted, Capability::CAP_NET_RAW).unwrap_or(false) {
if let Err(e) = caps::drop(None, CapSet::Permitted, Capability::CAP_NET_RAW) {
// Not fatal - we already dropped from effective
log::warn!("Could not drop CAP_NET_RAW from permitted set: {}", e);

View File

@@ -19,8 +19,8 @@
use anyhow::{Context, Result};
use landlock::{
Access, AccessFs, AccessNet, BitFlags, LandlockStatus, PathBeneath, PathFd, Ruleset,
RulesetAttr, RulesetCreatedAttr, RulesetStatus, ABI,
ABI, Access, AccessFs, AccessNet, BitFlags, LandlockStatus, PathBeneath, PathFd, Ruleset,
RulesetAttr, RulesetCreatedAttr, RulesetStatus,
};
use std::path::Path;
@@ -95,7 +95,9 @@ pub fn apply_landlock(config: &SandboxConfig) -> Result<LandlockResult> {
};
// Create the ruleset
let mut ruleset_created = ruleset.create().context("Failed to create Landlock ruleset")?;
let mut ruleset_created = ruleset
.create()
.context("Failed to create Landlock ruleset")?;
// Add rule for /proc (read-only)
// This is required for process identification via procfs

View File

@@ -22,10 +22,10 @@ mod windows;
// Re-export factory functions and types from platform modules
#[cfg(target_os = "freebsd")]
pub use freebsd::{FreeBSDProcessLookup, FreeBSDStatsProvider, create_process_lookup};
#[cfg(target_os = "linux")]
pub use linux::{LinuxStatsProvider, create_process_lookup};
#[cfg(all(target_os = "linux", feature = "landlock"))]
pub use linux::sandbox;
#[cfg(target_os = "linux")]
pub use linux::{LinuxStatsProvider, create_process_lookup};
#[cfg(target_os = "macos")]
pub use macos::{MacOSStatsProvider, create_process_lookup};
#[cfg(target_os = "windows")]

View File

@@ -953,12 +953,28 @@ impl AppProtocolDistribution {
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),
(
"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),
(
"Other",
self.other_count,
self.other_count as f64 / total * 100.0,
),
]
}
}
@@ -1200,6 +1216,10 @@ pub struct Connection {
pub pid: Option<u32>,
pub process_name: Option<String>,
// Connection direction: true = outgoing (local initiated), false = incoming (remote initiated)
// Only set for TCP when we observe the handshake (SYN/SYN+ACK), None otherwise
pub connection_direction: Option<bool>,
// Traffic statistics
pub bytes_sent: u64,
pub bytes_received: u64,
@@ -1253,6 +1273,7 @@ impl Connection {
protocol_state: state,
pid: None,
process_name: None,
connection_direction: None,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
@@ -1786,16 +1807,24 @@ mod tests {
// Simulate receiving packets - use internal rate_tracker directly for deterministic timing
conn.bytes_sent = 1000;
conn.bytes_received = 500;
conn.rate_tracker.update_at_time(start, conn.bytes_sent, conn.bytes_received);
conn.rate_tracker
.update_at_time(start, conn.bytes_sent, conn.bytes_received);
conn.bytes_sent = 3000;
conn.bytes_received = 1500;
conn.rate_tracker
.update_at_time(start + Duration::from_secs(1), conn.bytes_sent, conn.bytes_received);
conn.rate_tracker.update_at_time(
start + Duration::from_secs(1),
conn.bytes_sent,
conn.bytes_received,
);
// Update cached rate values
conn.current_outgoing_rate_bps = conn.rate_tracker.get_outgoing_rate_at(start + Duration::from_secs(1));
conn.current_incoming_rate_bps = conn.rate_tracker.get_incoming_rate_at(start + Duration::from_secs(1));
conn.current_outgoing_rate_bps = conn
.rate_tracker
.get_outgoing_rate_at(start + Duration::from_secs(1));
conn.current_incoming_rate_bps = conn
.rate_tracker
.get_incoming_rate_at(start + Duration::from_secs(1));
// Verify backward compatibility fields are updated
assert!(conn.current_outgoing_rate_bps >= 0.0);
@@ -2007,13 +2036,17 @@ mod tests {
// Simulate first packet
conn.bytes_sent = 50_000;
conn.bytes_received = 25_000;
conn.rate_tracker.update_at_time(start, conn.bytes_sent, conn.bytes_received);
conn.rate_tracker
.update_at_time(start, conn.bytes_sent, conn.bytes_received);
// Simulate more traffic after 1 second
conn.bytes_sent = 100_000;
conn.bytes_received = 50_000;
conn.rate_tracker
.update_at_time(start + Duration::from_secs(1), conn.bytes_sent, conn.bytes_received);
conn.rate_tracker.update_at_time(
start + Duration::from_secs(1),
conn.bytes_sent,
conn.bytes_received,
);
// Update cached rates at the 1-second mark
let check_time = start + Duration::from_secs(1);

117
src/ui.rs
View File

@@ -5,11 +5,16 @@ use ratatui::{
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{Axis, Block, Borders, Cell, Chart, Dataset, GraphType, Paragraph, Row, Sparkline, 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::{AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory};
use crate::network::types::{
AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory,
};
pub type Terminal<B> = RatatuiTerminal<B>;
@@ -848,7 +853,10 @@ fn draw_stats_panel(
let available_indicator = if sandbox_info.landlock_available {
Span::styled(" [kernel supported]", Style::default().fg(Color::DarkGray))
} else {
Span::styled(" [kernel unsupported]", Style::default().fg(Color::DarkGray))
Span::styled(
" [kernel unsupported]",
Style::default().fg(Color::DarkGray),
)
};
vec![
@@ -1081,22 +1089,17 @@ fn draw_interface_stats_with_graph(f: &mut Frame, app: &App, area: Rect) -> Resu
}
/// Draw the Graph tab with traffic visualization
fn draw_graph_tab(
f: &mut Frame,
app: &App,
connections: &[Connection],
area: Rect,
) -> Result<()> {
fn draw_graph_tab(f: &mut Frame, app: &App, connections: &[Connection], area: Rect) -> Result<()> {
let traffic_history = app.get_traffic_history();
// Main layout: traffic chart, health chart, legend, bottom row
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35), // Traffic chart
Constraint::Percentage(20), // Network health + TCP states
Constraint::Length(1), // Legend row
Constraint::Min(0), // App distribution + top processes
Constraint::Percentage(35), // Traffic chart
Constraint::Percentage(20), // Network health + TCP states
Constraint::Length(1), // Legend row
Constraint::Min(0), // App distribution + top processes
])
.split(area);
@@ -1204,16 +1207,14 @@ fn draw_traffic_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
/// 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 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));
let placeholder =
Paragraph::new("Collecting...").style(Style::default().fg(Color::DarkGray));
f.render_widget(placeholder, inner);
return;
}
@@ -1339,8 +1340,8 @@ fn draw_top_processes(f: &mut Frame, connections: &[Connection], area: Rect) {
.collect();
if rows.is_empty() {
let placeholder = Paragraph::new("No active processes")
.style(Style::default().fg(Color::DarkGray));
let placeholder =
Paragraph::new("No active processes").style(Style::default().fg(Color::DarkGray));
f.render_widget(placeholder, inner);
return;
}
@@ -1350,8 +1351,11 @@ fn draw_top_processes(f: &mut Frame, connections: &[Connection], area: Rect) {
[Constraint::Percentage(60), Constraint::Percentage(40)],
)
.header(
Row::new(vec!["Process", "Rate"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Row::new(vec!["Process", "Rate"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(table, inner);
@@ -1380,8 +1384,8 @@ fn draw_health_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
f.render_widget(block, area);
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting data...")
.style(Style::default().fg(Color::DarkGray));
let placeholder =
Paragraph::new("Collecting data...").style(Style::default().fg(Color::DarkGray));
f.render_widget(placeholder, inner);
return;
}
@@ -1406,8 +1410,8 @@ fn draw_health_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
};
// Thresholds for gauges
const RTT_MAX: f64 = 200.0; // 200ms max scale
const LOSS_MAX: f64 = 10.0; // 10% max scale
const RTT_MAX: f64 = 200.0; // 200ms max scale
const LOSS_MAX: f64 = 10.0; // 10% max scale
let bar_width = inner.width.saturating_sub(18) as usize; // Leave room for label + value
@@ -1454,20 +1458,34 @@ fn draw_health_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
let loss_line = Line::from(vec![
Span::styled(" Loss ", Style::default().fg(Color::White)),
Span::styled("".repeat(filled.max(if current_loss > 0.0 { 1 } else { 0 })), Style::default().fg(loss_color)),
Span::styled("".repeat(empty.min(bar_width)), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {:>6.2}%", current_loss), Style::default().fg(loss_color)),
Span::styled(
"".repeat(filled.max(if current_loss > 0.0 { 1 } else { 0 })),
Style::default().fg(loss_color),
),
Span::styled(
"".repeat(empty.min(bar_width)),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!(" {:>6.2}%", current_loss),
Style::default().fg(loss_color),
),
]);
// Build averages line
let avg_line = Line::from(vec![
Span::styled(" avg: ", Style::default().fg(Color::DarkGray)),
Span::styled(
avg_rtt.map(|r| format!("{:.0}ms", r)).unwrap_or_else(|| "--".to_string()),
avg_rtt
.map(|r| format!("{:.0}ms", r))
.unwrap_or_else(|| "--".to_string()),
Style::default().fg(Color::DarkGray),
),
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:.2}%", avg_loss), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:.2}%", avg_loss),
Style::default().fg(Color::DarkGray),
),
]);
let paragraph = Paragraph::new(vec![rtt_line, loss_line, avg_line]);
@@ -1483,9 +1501,7 @@ fn draw_tcp_counters(f: &mut Frame, app: &App, area: Rect) {
let out_of_order = stats.total_tcp_out_of_order.load(Ordering::Relaxed);
let fast_retransmits = stats.total_tcp_fast_retransmits.load(Ordering::Relaxed);
let block = Block::default()
.borders(Borders::ALL)
.title("TCP Counters");
let block = Block::default().borders(Borders::ALL).title("TCP Counters");
let inner = block.inner(area);
f.render_widget(block, area);
@@ -1518,15 +1534,24 @@ fn draw_tcp_counters(f: &mut Frame, app: &App, area: Rect) {
let lines = vec![
Line::from(vec![
Span::styled(" Retransmits ", Style::default().fg(Color::White)),
Span::styled(format!("{:>8}", retransmits), Style::default().fg(retrans_color)),
Span::styled(
format!("{:>8}", retransmits),
Style::default().fg(retrans_color),
),
]),
Line::from(vec![
Span::styled(" Out of Order ", Style::default().fg(Color::White)),
Span::styled(format!("{:>8}", out_of_order), Style::default().fg(ooo_color)),
Span::styled(
format!("{:>8}", out_of_order),
Style::default().fg(ooo_color),
),
]),
Line::from(vec![
Span::styled(" Fast Retrans ", Style::default().fg(Color::White)),
Span::styled(format!("{:>8}", fast_retransmits), Style::default().fg(fast_color)),
Span::styled(
format!("{:>8}", fast_retransmits),
Style::default().fg(fast_color),
),
]),
];
@@ -1564,9 +1589,18 @@ fn draw_tcp_states(f: &mut Frame, connections: &[Connection], area: Rect) {
// Fixed order based on connection lifecycle (most important first)
const STATE_ORDER: &[&str] = &[
"ESTAB", "SYN_SENT", "SYN_RECV", "FIN_WAIT1", "FIN_WAIT2",
"TIME_WAIT", "CLOSE_WAIT", "LAST_ACK", "CLOSING", "CLOSED",
"LISTEN", "UNKNOWN",
"ESTAB",
"SYN_SENT",
"SYN_RECV",
"FIN_WAIT1",
"FIN_WAIT2",
"TIME_WAIT",
"CLOSE_WAIT",
"LAST_ACK",
"CLOSING",
"CLOSED",
"LISTEN",
"UNKNOWN",
];
// Build ordered list with only non-zero counts
@@ -1580,8 +1614,7 @@ fn draw_tcp_states(f: &mut Frame, connections: &[Connection], area: Rect) {
f.render_widget(block, area);
if states.is_empty() {
let text = Paragraph::new("No TCP connections")
.style(Style::default().fg(Color::DarkGray));
let text = Paragraph::new("No TCP connections").style(Style::default().fg(Color::DarkGray));
f.render_widget(text, inner);
return;
}