From ebdbff6b7c512a9453c07d70d91dda92e3c4b2da Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Wed, 1 Oct 2025 17:08:28 +0200 Subject: [PATCH] feat: improve connection navigation and cleanup indication (#23) --- README.md | 87 ++++++++++++++++++++++++-- src/main.rs | 93 +++++++++------------------- src/network/types.rs | 130 ++++++++++++++++++++++++++++++++------ src/ui.rs | 144 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 357 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 54b4d0d..bf44733 100644 --- a/README.md +++ b/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 diff --git a/src/main.rs b/src/main.rs index 9530874..6d0c0f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,6 +176,8 @@ fn run_ui_loop( 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( }; // 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( } // 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( 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( // 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 diff --git a/src/network/types.rs b/src/network/types.rs index 7d45a50..1a9d0a9 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -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 diff --git a/src/ui.rs b/src/ui.rs index 218740d..d6dcf90 100644 --- a/src/ui.rs +++ b/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())); + } }