mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-01-04 04:49:53 -06:00
feat: improve connection navigation and cleanup indication (#23)
This commit is contained in:
87
README.md
87
README.md
@@ -27,9 +27,13 @@ A cross-platform network monitoring tool built with Rust. RustNet provides real-
|
||||
- **SSH connections** with version detection, software identification, and connection state tracking
|
||||
- **QUIC protocol with CONNECTION_CLOSE frame detection** and RFC 9000 compliance
|
||||
- **Connection Lifecycle Management**:
|
||||
- Configurable timeouts based on protocol, state, and activity (TCP closed: 5s, QUIC draining: 10s, SSH: 30min)
|
||||
- Protocol-specific cleanup (DNS: 30s, established TCP: 5min, QUIC with close frames: 1-10s)
|
||||
- Activity-based timeout adjustment for long-lived vs idle connections
|
||||
- **Smart protocol-aware timeouts** based on protocol, state, and activity level
|
||||
- **TCP connections**: 5-10 minutes for established (activity-based), with DPI-aware extensions
|
||||
- **HTTP/HTTPS keep-alive**: 10 minutes (both TCP and UDP/HTTP3)
|
||||
- **SSH sessions**: 30 minutes (both TCP and UDP)
|
||||
- **QUIC connections**: 5-10 minutes (activity-based)
|
||||
- **Fast cleanup**: DNS (30s), TCP closed (5s), QUIC draining (10s)
|
||||
- **Visual staleness indicators**: Connections turn yellow (75% timeout) then red (90% timeout) before cleanup
|
||||
- **Process Identification**: Associate network connections with running processes
|
||||
- **Note**: With experimental eBPF support, process names are limited to 16 characters from the kernel's `comm` field and may show thread names instead of full executable names
|
||||
- **Service Name Resolution**: Identify well-known services using port numbers
|
||||
@@ -370,6 +374,72 @@ Options:
|
||||
- `h`: Toggle help screen
|
||||
- `/`: Enter filter mode (vim-style search with real-time results)
|
||||
|
||||
## Connection Lifecycle & Visual Indicators
|
||||
|
||||
RustNet uses intelligent timeout management to automatically clean up inactive connections while providing visual warnings before removal.
|
||||
|
||||
### Visual Staleness Indicators
|
||||
|
||||
Connections change color based on how close they are to being cleaned up:
|
||||
|
||||
| Color | Meaning | Staleness |
|
||||
|-------|---------|-----------|
|
||||
| **White** (default) | Active connection | < 75% of timeout |
|
||||
| **Yellow** | Stale - approaching timeout | 75-90% of timeout |
|
||||
| **Red** | Critical - will be removed soon | > 90% of timeout |
|
||||
|
||||
**Example**: An HTTP connection with a 10-minute timeout will:
|
||||
- Stay **white** for the first 7.5 minutes
|
||||
- Turn **yellow** from 7.5 to 9 minutes (warning)
|
||||
- Turn **red** after 9 minutes (critical)
|
||||
- Be removed at 10 minutes
|
||||
|
||||
This gives you advance warning when a connection is about to disappear from the list.
|
||||
|
||||
### Smart Protocol-Aware Timeouts
|
||||
|
||||
RustNet adjusts connection timeouts based on the protocol and detected application:
|
||||
|
||||
#### TCP Connections
|
||||
- **HTTP/HTTPS** (detected via DPI): **10 minutes** - supports HTTP keep-alive
|
||||
- **SSH** (detected via DPI): **30 minutes** - accommodates long interactive sessions
|
||||
- **Active established** (< 1 min idle): **10 minutes**
|
||||
- **Idle established** (> 1 min idle): **5 minutes**
|
||||
- **TIME_WAIT**: 30 seconds - standard TCP timeout
|
||||
- **CLOSED**: 5 seconds - rapid cleanup
|
||||
- **SYN_SENT, FIN_WAIT, etc.**: 30-60 seconds
|
||||
|
||||
#### UDP Connections
|
||||
- **HTTP/3 (QUIC with HTTP)**: **10 minutes** - connection reuse
|
||||
- **HTTPS/3 (QUIC with HTTPS)**: **10 minutes** - connection reuse
|
||||
- **SSH over UDP**: **30 minutes** - long-lived sessions
|
||||
- **DNS**: **30 seconds** - short-lived queries
|
||||
- **Regular UDP**: **60 seconds** - standard timeout
|
||||
|
||||
#### QUIC Connections (Detected State)
|
||||
- **Connected (active)** (< 1 min idle): **10 minutes**
|
||||
- **Connected (idle)** (> 1 min idle): **5 minutes**
|
||||
- **With CONNECTION_CLOSE frame**: 1-10 seconds (based on close type)
|
||||
- **Initial/Handshaking**: 60 seconds - allow connection establishment
|
||||
- **Draining**: 10 seconds - RFC 9000 draining period
|
||||
|
||||
### Activity-Based Adjustment
|
||||
|
||||
Connections showing recent packet activity get longer timeouts:
|
||||
- **Last packet < 60 seconds ago**: Uses "active" timeout (longer)
|
||||
- **Last packet > 60 seconds ago**: Uses "idle" timeout (shorter)
|
||||
|
||||
This ensures active connections stay visible while idle connections are cleaned up more quickly.
|
||||
|
||||
### Why Connections Disappear
|
||||
|
||||
A connection is removed when:
|
||||
1. **No packets received** for the duration of its timeout period
|
||||
2. The connection enters a **closed state** (TCP CLOSED, QUIC CLOSED)
|
||||
3. **Explicit close frames** detected (QUIC CONNECTION_CLOSE)
|
||||
|
||||
**Note**: Rate indicators (bandwidth display) show *decaying* traffic based on recent activity. A connection may show declining bandwidth (yellow bars) but remain in the list until it exceeds its idle timeout. This is intentional - the visual decay gives you time to see the connection winding down before it's removed.
|
||||
|
||||
## Sorting
|
||||
|
||||
RustNet provides powerful table sorting to help you analyze network connections. Press `s` to cycle through sortable columns in left-to-right visual order, and press `S` (Shift+s) to toggle between ascending and descending order.
|
||||
@@ -622,8 +692,15 @@ RustNet uses a multi-threaded architecture for packet processing:
|
||||
2. **Packet Processors**: Multiple worker threads parse packets and perform DPI analysis
|
||||
3. **Process Enrichment**: Platform-specific APIs to associate connections with processes
|
||||
4. **Snapshot Provider**: Creates consistent snapshots for the UI at regular intervals
|
||||
5. **Cleanup Thread**: Removes connections using configurable timeouts based on protocol, state, and activity
|
||||
6. **DashMap**: Concurrent hashmap for storing connection state
|
||||
5. **Cleanup Thread**: Removes inactive connections using smart, protocol-aware timeouts:
|
||||
- **TCP Established**: 10 minutes (active) / 5 minutes (idle)
|
||||
- **HTTP/HTTPS**: 10 minutes (supports keep-alive)
|
||||
- **SSH**: 30 minutes (long-lived sessions)
|
||||
- **QUIC**: 10 minutes (active) / 5 minutes (idle)
|
||||
- **DNS**: 30 seconds (short-lived queries)
|
||||
- **TCP Closed**: 5 seconds (rapid cleanup)
|
||||
6. **Rate Refresh Thread**: Updates bandwidth calculations every second with gentle decay
|
||||
7. **DashMap**: Concurrent hashmap for storing connection state
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
93
src/main.rs
93
src/main.rs
@@ -176,6 +176,8 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
|
||||
loop {
|
||||
// Get current connections and stats
|
||||
// IMPORTANT: Fetch connections ONCE per iteration to ensure consistency
|
||||
// between display, navigation, and selection operations
|
||||
let mut connections = if ui_state.filter_query.is_empty() && !ui_state.filter_mode {
|
||||
app.get_connections()
|
||||
} else {
|
||||
@@ -183,6 +185,7 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
};
|
||||
|
||||
// Apply sorting (after filtering)
|
||||
// This sorted list MUST be used for all operations (display + navigation)
|
||||
sort_connections(&mut connections, ui_state.sort_column, ui_state.sort_ascending);
|
||||
|
||||
let stats = app.get_stats();
|
||||
@@ -258,30 +261,22 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
}
|
||||
// Allow navigation while in filter mode!
|
||||
KeyCode::Up => {
|
||||
// Navigate filtered connections while typing
|
||||
let nav_connections = if ui_state.filter_query.is_empty() {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
// to ensure index consistency with the displayed table
|
||||
debug!(
|
||||
"Filter mode navigation UP: {} connections available",
|
||||
nav_connections.len()
|
||||
connections.len()
|
||||
);
|
||||
ui_state.move_selection_up(&nav_connections);
|
||||
ui_state.move_selection_up(&connections);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Navigate filtered connections while typing
|
||||
let nav_connections = if ui_state.filter_query.is_empty() {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
// to ensure index consistency with the displayed table
|
||||
debug!(
|
||||
"Filter mode navigation DOWN: {} connections available",
|
||||
nav_connections.len()
|
||||
connections.len()
|
||||
);
|
||||
ui_state.move_selection_down(&nav_connections);
|
||||
ui_state.move_selection_down(&connections);
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Handle Ctrl+H as backspace for SecureCRT compatibility
|
||||
@@ -294,29 +289,21 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
match c {
|
||||
'k' => {
|
||||
// Vim-style up navigation while filtering
|
||||
let nav_connections = if ui_state.filter_query.is_empty() {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
debug!(
|
||||
"Filter mode navigation UP (k): {} connections available",
|
||||
nav_connections.len()
|
||||
connections.len()
|
||||
);
|
||||
ui_state.move_selection_up(&nav_connections);
|
||||
ui_state.move_selection_up(&connections);
|
||||
}
|
||||
'j' => {
|
||||
// Vim-style down navigation while filtering
|
||||
let nav_connections = if ui_state.filter_query.is_empty() {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
debug!(
|
||||
"Filter mode navigation DOWN (j): {} connections available",
|
||||
nav_connections.len()
|
||||
connections.len()
|
||||
);
|
||||
ui_state.move_selection_down(&nav_connections);
|
||||
ui_state.move_selection_down(&connections);
|
||||
}
|
||||
_ => {
|
||||
// Regular character input for filter
|
||||
@@ -374,61 +361,39 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
// Navigation in connection list
|
||||
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
// Refresh connections for navigation to ensure we have current filtered list
|
||||
let nav_connections =
|
||||
if ui_state.filter_query.is_empty() && !ui_state.filter_mode {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
// to ensure index consistency with the displayed table
|
||||
debug!(
|
||||
"Navigation UP: {} connections available",
|
||||
nav_connections.len()
|
||||
connections.len()
|
||||
);
|
||||
ui_state.move_selection_up(&nav_connections);
|
||||
ui_state.move_selection_up(&connections);
|
||||
}
|
||||
|
||||
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
// Refresh connections for navigation to ensure we have current filtered list
|
||||
let nav_connections =
|
||||
if ui_state.filter_query.is_empty() && !ui_state.filter_mode {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
// to ensure index consistency with the displayed table
|
||||
debug!(
|
||||
"Navigation DOWN: {} connections available",
|
||||
nav_connections.len()
|
||||
connections.len()
|
||||
);
|
||||
ui_state.move_selection_down(&nav_connections);
|
||||
ui_state.move_selection_down(&connections);
|
||||
}
|
||||
|
||||
// Page Up/Down navigation
|
||||
(KeyCode::PageUp, _) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
// Refresh connections for navigation
|
||||
let nav_connections =
|
||||
if ui_state.filter_query.is_empty() && !ui_state.filter_mode {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
// Move up by roughly 10 items (or adjust based on terminal height)
|
||||
ui_state.move_selection_page_up(&nav_connections, 10);
|
||||
ui_state.move_selection_page_up(&connections, 10);
|
||||
}
|
||||
|
||||
(KeyCode::PageDown, _) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
// Refresh connections for navigation
|
||||
let nav_connections =
|
||||
if ui_state.filter_query.is_empty() && !ui_state.filter_mode {
|
||||
app.get_connections()
|
||||
} else {
|
||||
app.get_filtered_connections(&ui_state.filter_query)
|
||||
};
|
||||
// Use the SAME sorted connections list from the main loop
|
||||
// Move down by roughly 10 items (or adjust based on terminal height)
|
||||
ui_state.move_selection_page_down(&nav_connections, 10);
|
||||
ui_state.move_selection_page_down(&connections, 10);
|
||||
}
|
||||
|
||||
// Enter to view details
|
||||
|
||||
@@ -987,9 +987,10 @@ impl Connection {
|
||||
match &dpi_info.application {
|
||||
ApplicationProtocol::Quic(quic) => self.get_quic_timeout(quic),
|
||||
ApplicationProtocol::Dns(_) => Duration::from_secs(30),
|
||||
ApplicationProtocol::Http(_) => Duration::from_secs(180),
|
||||
ApplicationProtocol::Https(_) => Duration::from_secs(180),
|
||||
ApplicationProtocol::Ssh(_) => Duration::from_secs(1800), // SSH can be very long-lived
|
||||
// HTTP/3 connections need longer timeouts for connection reuse
|
||||
ApplicationProtocol::Http(_) => Duration::from_secs(600), // 10 minutes (was 3 min)
|
||||
ApplicationProtocol::Https(_) => Duration::from_secs(600), // 10 minutes (was 3 min)
|
||||
ApplicationProtocol::Ssh(_) => Duration::from_secs(1800), // SSH can be very long-lived (30 min)
|
||||
}
|
||||
} else {
|
||||
// Regular UDP without DPI classification
|
||||
@@ -1001,15 +1002,29 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get TCP-specific timeout based on connection state
|
||||
/// Get TCP-specific timeout based on connection state and application protocol
|
||||
fn get_tcp_timeout(&self, tcp_state: &TcpState) -> Duration {
|
||||
match tcp_state {
|
||||
TcpState::Established => {
|
||||
// Give established connections longer timeout, especially if they're active
|
||||
// Check if we have DPI info for protocol-specific timeouts
|
||||
if let Some(dpi_info) = &self.dpi_info {
|
||||
match &dpi_info.application {
|
||||
// SSH connections need very long timeouts for interactive sessions
|
||||
ApplicationProtocol::Ssh(_) => return Duration::from_secs(1800), // 30 minutes
|
||||
// HTTP/HTTPS keep-alive connections
|
||||
ApplicationProtocol::Http(_) | ApplicationProtocol::Https(_) => {
|
||||
return Duration::from_secs(600); // 10 minutes
|
||||
}
|
||||
// Other protocols use default logic below
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Default established connection timeouts (increased from 300s/180s)
|
||||
if self.idle_time() < Duration::from_secs(60) {
|
||||
Duration::from_secs(300) // 5 minutes for active connections
|
||||
Duration::from_secs(600) // 10 minutes for active connections (was 5 min)
|
||||
} else {
|
||||
Duration::from_secs(180) // 3 minutes for idle established
|
||||
Duration::from_secs(300) // 5 minutes for idle established (was 3 min)
|
||||
}
|
||||
}
|
||||
TcpState::TimeWait => Duration::from_secs(30), // Standard TCP TIME_WAIT
|
||||
@@ -1043,11 +1058,11 @@ impl Connection {
|
||||
if let Some(idle_timeout) = quic.idle_timeout {
|
||||
idle_timeout
|
||||
} else {
|
||||
// Default timeout, but consider activity
|
||||
// Default timeout, but consider activity (increased for HTTP/3 connection reuse)
|
||||
if self.idle_time() < Duration::from_secs(60) {
|
||||
Duration::from_secs(300) // Active QUIC connections
|
||||
Duration::from_secs(600) // 10 minutes for active QUIC (was 5 min)
|
||||
} else {
|
||||
Duration::from_secs(180) // Idle QUIC connections
|
||||
Duration::from_secs(300) // 5 minutes for idle QUIC (was 3 min)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1062,6 +1077,19 @@ impl Connection {
|
||||
let timeout = self.get_timeout();
|
||||
now.duration_since(self.last_activity).unwrap_or_default() > timeout
|
||||
}
|
||||
|
||||
/// Get the staleness level as a percentage (0.0 to 1.0+)
|
||||
/// Returns how close the connection is to being cleaned up
|
||||
/// - 0.0 = just created
|
||||
/// - 0.75 = at warning threshold
|
||||
/// - 1.0 = will be cleaned up
|
||||
/// - >1.0 = should have been cleaned up already
|
||||
pub fn staleness_ratio(&self) -> f32 {
|
||||
let timeout = self.get_timeout();
|
||||
let idle = self.idle_time();
|
||||
|
||||
idle.as_secs_f32() / timeout.as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1582,13 +1610,13 @@ mod tests {
|
||||
fn test_dynamic_timeout_tcp() {
|
||||
let mut conn = create_test_connection();
|
||||
|
||||
// Test established connection timeout
|
||||
// Test established connection timeout (updated from 300s to 600s)
|
||||
conn.protocol_state = ProtocolState::Tcp(TcpState::Established);
|
||||
assert_eq!(conn.get_timeout(), Duration::from_secs(300)); // Active established
|
||||
assert_eq!(conn.get_timeout(), Duration::from_secs(600)); // Active established (was 300)
|
||||
|
||||
// Test idle established connection
|
||||
// Test idle established connection (updated from 180s to 300s)
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(120);
|
||||
assert_eq!(conn.get_timeout(), Duration::from_secs(180)); // Idle established
|
||||
assert_eq!(conn.get_timeout(), Duration::from_secs(300)); // Idle established (was 180)
|
||||
|
||||
// Test TIME_WAIT
|
||||
conn.protocol_state = ProtocolState::Tcp(TcpState::TimeWait);
|
||||
@@ -1681,16 +1709,82 @@ mod tests {
|
||||
conn.last_activity = now - Duration::from_secs(10); // Beyond 5s timeout for closed
|
||||
assert!(conn.should_cleanup(now));
|
||||
|
||||
// Test established connection within timeout
|
||||
// Test established connection within timeout (updated timeout from 300s to 600s)
|
||||
conn.protocol_state = ProtocolState::Tcp(TcpState::Established);
|
||||
conn.last_activity = now - Duration::from_secs(100); // Within 300s timeout
|
||||
conn.last_activity = now - Duration::from_secs(100); // Within 600s timeout
|
||||
assert!(!conn.should_cleanup(now));
|
||||
|
||||
// Test established connection beyond timeout
|
||||
conn.last_activity = now - Duration::from_secs(400); // Beyond timeout
|
||||
// Test established connection beyond timeout (updated timeout to 600s)
|
||||
conn.last_activity = now - Duration::from_secs(700); // Beyond 600s timeout
|
||||
assert!(conn.should_cleanup(now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_staleness_ratio() {
|
||||
let mut conn = create_test_connection();
|
||||
conn.protocol_state = ProtocolState::Tcp(TcpState::Established);
|
||||
|
||||
// Fresh connection - staleness ratio near 0
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(ratio < 0.05, "Fresh connection should have low staleness ratio");
|
||||
|
||||
// At 50% of timeout (300s total for idle, 150s elapsed)
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(150);
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(
|
||||
(ratio - 0.5).abs() < 0.1,
|
||||
"Staleness ratio should be around 0.5, got {}",
|
||||
ratio
|
||||
);
|
||||
|
||||
// At 75% of timeout (warning threshold) - 225s
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(225);
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(
|
||||
ratio >= 0.75,
|
||||
"Staleness ratio should be >= 0.75 at warning threshold, got {}",
|
||||
ratio
|
||||
);
|
||||
|
||||
// At 90% of timeout (critical threshold) - 270s
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(270);
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(
|
||||
ratio >= 0.90,
|
||||
"Staleness ratio should be >= 0.90 at critical threshold, got {}",
|
||||
ratio
|
||||
);
|
||||
|
||||
// Beyond timeout - 350s (beyond 300s timeout)
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(350);
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(
|
||||
ratio > 1.0,
|
||||
"Staleness ratio should exceed 1.0 beyond timeout, got {}",
|
||||
ratio
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_staleness_with_different_timeouts() {
|
||||
// Test TIME_WAIT (30s timeout)
|
||||
let mut conn = create_test_connection();
|
||||
conn.protocol_state = ProtocolState::Tcp(TcpState::TimeWait);
|
||||
|
||||
// At 75% of 30s = 22.5s
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(23);
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(ratio >= 0.75, "TIME_WAIT connection should be stale at 23s, ratio: {}", ratio);
|
||||
|
||||
// Test CLOSED (5s timeout)
|
||||
conn.protocol_state = ProtocolState::Tcp(TcpState::Closed);
|
||||
|
||||
// At 75% of 5s = 3.75s
|
||||
conn.last_activity = SystemTime::now() - Duration::from_secs(4);
|
||||
let ratio = conn.staleness_ratio();
|
||||
assert!(ratio >= 0.75, "CLOSED connection should be stale at 4s, ratio: {}", ratio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icmp_and_arp_states() {
|
||||
// Test ICMP states
|
||||
|
||||
144
src/ui.rs
144
src/ui.rs
@@ -174,21 +174,32 @@ impl UIState {
|
||||
}
|
||||
|
||||
let current_index = self.get_selected_index(connections).unwrap_or(0);
|
||||
let old_key = self.selected_connection_key.clone();
|
||||
log::debug!(
|
||||
"move_selection_up: current_index={}, total_connections={}",
|
||||
"move_selection_up: current_index={}, total_connections={}, current_key={:?}",
|
||||
current_index,
|
||||
connections.len()
|
||||
connections.len(),
|
||||
old_key
|
||||
);
|
||||
|
||||
if current_index > 0 {
|
||||
self.set_selected_by_index(connections, current_index - 1);
|
||||
log::debug!("move_selection_up: moved to index {}", current_index - 1);
|
||||
log::debug!(
|
||||
"move_selection_up: moved from index {} to {} (key: {:?} -> {:?})",
|
||||
current_index,
|
||||
current_index - 1,
|
||||
old_key,
|
||||
self.selected_connection_key
|
||||
);
|
||||
} else {
|
||||
// Wrap around to the bottom
|
||||
self.set_selected_by_index(connections, connections.len() - 1);
|
||||
log::debug!(
|
||||
"move_selection_up: wrapped to bottom index {}",
|
||||
connections.len() - 1
|
||||
"move_selection_up: wrapped from index {} to bottom index {} (key: {:?} -> {:?})",
|
||||
current_index,
|
||||
connections.len() - 1,
|
||||
old_key,
|
||||
self.selected_connection_key
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -201,19 +212,32 @@ impl UIState {
|
||||
}
|
||||
|
||||
let current_index = self.get_selected_index(connections).unwrap_or(0);
|
||||
let old_key = self.selected_connection_key.clone();
|
||||
log::debug!(
|
||||
"move_selection_down: current_index={}, total_connections={}",
|
||||
"move_selection_down: current_index={}, total_connections={}, current_key={:?}",
|
||||
current_index,
|
||||
connections.len()
|
||||
connections.len(),
|
||||
old_key
|
||||
);
|
||||
|
||||
if current_index < connections.len().saturating_sub(1) {
|
||||
self.set_selected_by_index(connections, current_index + 1);
|
||||
log::debug!("move_selection_down: moved to index {}", current_index + 1);
|
||||
log::debug!(
|
||||
"move_selection_down: moved from index {} to {} (key: {:?} -> {:?})",
|
||||
current_index,
|
||||
current_index + 1,
|
||||
old_key,
|
||||
self.selected_connection_key
|
||||
);
|
||||
} else {
|
||||
// Wrap around to the top
|
||||
self.set_selected_by_index(connections, 0);
|
||||
log::debug!("move_selection_down: wrapped to top index 0");
|
||||
log::debug!(
|
||||
"move_selection_down: wrapped from index {} to top index 0 (key: {:?} -> {:?})",
|
||||
current_index,
|
||||
old_key,
|
||||
self.selected_connection_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,6 +627,22 @@ fn draw_connections_list(
|
||||
let outgoing_rate = format_rate_compact(conn.current_outgoing_rate_bps);
|
||||
let bandwidth_display = format!("{}↓/{}↑", incoming_rate, outgoing_rate);
|
||||
|
||||
// Determine row color based on staleness
|
||||
// - Normal (white/default): fresh connections (< 75% of timeout)
|
||||
// - Yellow: approaching timeout (75-90% of timeout)
|
||||
// - Red: very close to timeout (> 90% of timeout)
|
||||
let staleness = conn.staleness_ratio();
|
||||
let row_style = if staleness >= 0.90 {
|
||||
// Critical: > 90% of timeout - will be cleaned up very soon
|
||||
Style::default().fg(Color::Red)
|
||||
} else if staleness >= 0.75 {
|
||||
// Warning: 75-90% of timeout - approaching cleanup
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
// Normal: < 75% of timeout
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let cells = [
|
||||
Cell::from(conn.protocol.to_string()),
|
||||
Cell::from(conn.local_addr.to_string()),
|
||||
@@ -613,7 +653,7 @@ fn draw_connections_list(
|
||||
Cell::from(bandwidth_display),
|
||||
Cell::from(process_display),
|
||||
];
|
||||
Row::new(cells)
|
||||
Row::new(cells).style(row_style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1076,6 +1116,25 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> {
|
||||
Span::raw("Enter filter mode (navigate while typing!)"),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Connection Colors:",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![
|
||||
Span::styled(" White ", Style::default()),
|
||||
Span::raw("Active connection (< 75% of timeout)"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Yellow ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("Stale connection (75-90% of timeout)"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Red ", Style::default().fg(Color::Red)),
|
||||
Span::raw("Critical - will be removed soon (> 90% of timeout)"),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Filter Examples:",
|
||||
Style::default()
|
||||
@@ -1465,4 +1524,69 @@ mod tests {
|
||||
assert_eq!(ui_state.sort_column, SortColumn::BandwidthUp);
|
||||
assert!(!ui_state.sort_ascending, "After second toggle, BandwidthUp should be descending again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigation_consistency_with_sorted_list() {
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use crate::network::types::{Protocol, ProtocolState};
|
||||
|
||||
// Create test connections with different process names for sorting
|
||||
let mut connections = vec![
|
||||
Connection::new(
|
||||
Protocol::TCP,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 443),
|
||||
ProtocolState::Tcp(crate::network::types::TcpState::Established),
|
||||
),
|
||||
Connection::new(
|
||||
Protocol::TCP,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8081),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2)), 443),
|
||||
ProtocolState::Tcp(crate::network::types::TcpState::Established),
|
||||
),
|
||||
Connection::new(
|
||||
Protocol::TCP,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8082),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 3)), 443),
|
||||
ProtocolState::Tcp(crate::network::types::TcpState::Established),
|
||||
),
|
||||
];
|
||||
|
||||
// Set different process names for sorting (alphabetically: alpha, beta, charlie)
|
||||
connections[0].process_name = Some("charlie".to_string());
|
||||
connections[1].process_name = Some("alpha".to_string());
|
||||
connections[2].process_name = Some("beta".to_string());
|
||||
|
||||
// Create UI state
|
||||
let mut ui_state = UIState::default();
|
||||
|
||||
// Initial state: select first connection (charlie)
|
||||
ui_state.set_selected_by_index(&connections, 0);
|
||||
assert_eq!(ui_state.selected_connection_key, Some(connections[0].key()));
|
||||
|
||||
// Sort by process name (ascending): alpha, beta, charlie
|
||||
connections.sort_by(|a, b| {
|
||||
a.process_name.as_deref().unwrap_or("").cmp(b.process_name.as_deref().unwrap_or(""))
|
||||
});
|
||||
|
||||
// After sorting, "charlie" is now at index 2
|
||||
// Selection should still point to "charlie" by key
|
||||
let current_index = ui_state.get_selected_index(&connections);
|
||||
assert_eq!(current_index, Some(2), "Selected connection should now be at index 2 after sorting");
|
||||
|
||||
// Navigate down: should move from charlie (2) to wrap to alpha (0)
|
||||
ui_state.move_selection_down(&connections);
|
||||
assert_eq!(ui_state.get_selected_index(&connections), Some(0), "Should wrap to index 0");
|
||||
assert_eq!(ui_state.selected_connection_key, Some(connections[0].key()));
|
||||
|
||||
// Navigate down: should move from alpha (0) to beta (1)
|
||||
ui_state.move_selection_down(&connections);
|
||||
assert_eq!(ui_state.get_selected_index(&connections), Some(1), "Should move to index 1");
|
||||
assert_eq!(ui_state.selected_connection_key, Some(connections[1].key()));
|
||||
|
||||
// Navigate up: should move from beta (1) to alpha (0)
|
||||
ui_state.move_selection_up(&connections);
|
||||
assert_eq!(ui_state.get_selected_index(&connections), Some(0), "Should move to index 0");
|
||||
assert_eq!(ui_state.selected_connection_key, Some(connections[0].key()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user