feat: improve connection navigation and cleanup indication (#23)

This commit is contained in:
Marco Cadetg
2025-10-01 17:08:28 +02:00
committed by GitHub
parent aac52a79d4
commit ebdbff6b7c
4 changed files with 357 additions and 97 deletions

View File

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

View File

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

View File

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

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