From b635f5d7f4d19dcdfde86778735cfa6896ffa6ed Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 15 Sep 2025 10:16:47 -0700 Subject: [PATCH] feature - add error modal support and retry --- Cargo.lock | 2 +- socktop/src/app.rs | 509 ++++++++++++++- socktop/src/main.rs | 3 +- socktop/src/retry.rs | 114 ++++ socktop/src/ui/mod.rs | 1 + socktop/src/ui/modal.rs | 612 ++++++++++++++++++ socktop/src/ui/theme.rs | 45 ++ socktop/tests/modal_tests.rs | 46 ++ socktop_connector/src/connector.rs | 4 +- .../src/networking/connection.rs | 4 +- 10 files changed, 1301 insertions(+), 39 deletions(-) create mode 100644 socktop/src/retry.rs create mode 100644 socktop/src/ui/modal.rs create mode 100644 socktop/tests/modal_tests.rs diff --git a/Cargo.lock b/Cargo.lock index f90e60e..330e942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2182,7 +2182,7 @@ dependencies = [ [[package]] name = "socktop_agent" -version = "1.40.7" +version = "1.40.70" dependencies = [ "anyhow", "assert_cmd", diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 716a98a..5974111 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -20,12 +20,14 @@ use ratatui::{ use tokio::time::sleep; use crate::history::{PerCoreHistory, push_capped}; +use crate::retry::{RetryTiming, compute_retry_timing}; use crate::types::Metrics; use crate::ui::cpu::{ PerCoreScrollDrag, draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp, per_core_content_area, per_core_handle_key, per_core_handle_mouse, per_core_handle_scrollbar_mouse, }; +use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse}; use crate::ui::{ disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, @@ -40,6 +42,13 @@ use socktop_connector::{ const MIN_METRICS_INTERVAL_MS: u64 = 100; const MIN_PROCESSES_INTERVAL_MS: u64 = 200; +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionState { + Connected, + Disconnected, + Reconnecting, +} + pub struct App { // Latest metrics + histories last_metrics: Option, @@ -75,9 +84,22 @@ pub struct App { // For reconnects ws_url: String, + tls_ca: Option, + verify_hostname: bool, // Security / status flags pub is_tls: bool, pub has_token: bool, + + // Modal system + pub modal_manager: crate::ui::modal::ModalManager, + + // Connection state tracking + pub connection_state: ConnectionState, + last_connection_attempt: Instant, + original_disconnect_time: Option, // Track when we first disconnected + connection_retry_count: u32, + last_auto_retry: Option, // Track last automatic retry + replacement_connection: Option, } impl App { @@ -108,8 +130,17 @@ impl App { disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), ws_url: String::new(), + tls_ca: None, + verify_hostname: false, is_tls: false, has_token: false, + modal_manager: ModalManager::new(), + connection_state: ConnectionState::Disconnected, + last_connection_attempt: Instant::now(), + original_disconnect_time: None, + connection_retry_count: 0, + last_auto_retry: None, + replacement_connection: None, } } @@ -129,21 +160,163 @@ impl App { self } + /// Show a connection error modal + pub fn show_connection_error(&mut self, message: String) { + if !self.modal_manager.is_active() { + self.connection_state = ConnectionState::Disconnected; + // Set original disconnect time if this is the first disconnect + if self.original_disconnect_time.is_none() { + self.original_disconnect_time = Some(Instant::now()); + } + self.modal_manager.push_modal(ModalType::ConnectionError { + message, + disconnected_at: self.original_disconnect_time.unwrap(), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + } + } + + /// Attempt to retry the connection + pub async fn retry_connection(&mut self) { + // This method is called from the normal event loop when connection is lost during operation + self.connection_retry_count += 1; + self.last_connection_attempt = Instant::now(); + self.connection_state = ConnectionState::Reconnecting; + + // Show retrying message + if self.modal_manager.is_active() { + self.modal_manager.pop_modal(); // Remove old modal + } + self.modal_manager.push_modal(ModalType::ConnectionError { + message: "Retrying connection...".to_string(), + disconnected_at: self + .original_disconnect_time + .unwrap_or(self.last_connection_attempt), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + + // Actually attempt to reconnect using stored parameters + let tls_ca_ref = self.tls_ca.as_deref(); + match self + .try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname) + .await + { + Ok(new_ws) => { + // Connection successful! Store the new connection for the event loop to pick up + self.replacement_connection = Some(new_ws); + self.mark_connected(); + // The event loop will detect this and restart with the new connection + } + Err(e) => { + // Connection failed, update modal with error + self.modal_manager.pop_modal(); // Remove retrying modal + self.modal_manager.push_modal(ModalType::ConnectionError { + message: format!("Retry failed: {e}"), + disconnected_at: self + .original_disconnect_time + .unwrap_or(self.last_connection_attempt), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + self.connection_state = ConnectionState::Disconnected; + } + } + } + + /// Mark connection as successful and dismiss any error modals + pub fn mark_connected(&mut self) { + if self.connection_state != ConnectionState::Connected { + self.connection_state = ConnectionState::Connected; + self.connection_retry_count = 0; + self.original_disconnect_time = None; // Clear the original disconnect time + self.last_auto_retry = None; // Clear auto retry timer + // Remove connection error modal if it exists + if self.modal_manager.is_active() { + self.modal_manager.pop_modal(); + } + } + } + + /// Compute retry timing using pure policy function. + fn current_retry_timing(&self) -> RetryTiming { + compute_retry_timing( + self.connection_state == ConnectionState::Disconnected, + self.modal_manager.is_active(), + self.original_disconnect_time, + self.last_auto_retry, + Instant::now(), + Duration::from_secs(30), + ) + } + + /// Check if we should perform an automatic retry (every 30 seconds) + pub fn should_auto_retry(&self) -> bool { + self.current_retry_timing().should_retry_now + } + + /// Get seconds until next automatic retry (returns None if inactive) + pub fn seconds_until_next_auto_retry(&self) -> Option { + self.current_retry_timing().seconds_until_retry + } + + /// Perform automatic retry + pub async fn auto_retry_connection(&mut self) { + self.last_auto_retry = Some(Instant::now()); + let tls_ca_ref = self.tls_ca.as_deref(); + + // Increment retry count for auto retries too + self.connection_retry_count += 1; + + // Show retrying modal + self.modal_manager.pop_modal(); + self.modal_manager.push_modal(ModalType::ConnectionError { + message: "Auto-retrying connection...".to_string(), + disconnected_at: self.original_disconnect_time.unwrap_or(Instant::now()), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + self.connection_state = ConnectionState::Reconnecting; + + // Attempt connection + match self + .try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname) + .await + { + Ok(new_ws) => { + // Connection successful! Store the new connection for the event loop to pick up + self.replacement_connection = Some(new_ws); + self.mark_connected(); + // The event loop will detect this and restart with the new connection + } + Err(e) => { + // Connection failed, update modal with error + self.modal_manager.pop_modal(); // Remove retrying modal + self.modal_manager.push_modal(ModalType::ConnectionError { + message: format!("Auto-retry failed: {e}"), + disconnected_at: self + .original_disconnect_time + .unwrap_or(self.last_connection_attempt), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + self.connection_state = ConnectionState::Disconnected; + } + } + } + pub async fn run( &mut self, url: &str, tls_ca: Option<&str>, verify_hostname: bool, ) -> Result<(), Box> { - // Connect to agent self.ws_url = url.to_string(); - let mut ws = if let Some(ca_path) = tls_ca { - connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await? - } else { - connect_to_socktop_agent(url).await? - }; + self.tls_ca = tls_ca.map(|s| s.to_string()); + self.verify_hostname = verify_hostname; - // Terminal setup + // Terminal setup first - so we can show connection error modals enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; @@ -151,19 +324,219 @@ impl App { let mut terminal = Terminal::new(backend)?; terminal.clear()?; + // Try to connect to agent + let ws = match self.try_connect(url, tls_ca, verify_hostname).await { + Ok(connector) => connector, + Err(e) => { + // Show initial connection error and enter the error loop until user exits or we connect. + self.show_connection_error(format!("Initial connection failed: {e}")); + if let Err(err) = self + .run_with_connection_error(&mut terminal, url, tls_ca, verify_hostname) + .await + { + // Terminal teardown then propagate error + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + return Err(err); + } + + // If user chose to exit during error loop, mark quit and teardown. + if self.should_quit || self.connection_state != ConnectionState::Connected { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + return Ok(()); + } + + // We should have a replacement connection after successful retry. + match self.replacement_connection.take() { + Some(conn) => conn, + None => { + // Defensive: no connector despite Connected state; exit gracefully. + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + return Ok(()); + } + } + } + }; + + // Connection successful, mark as connected + self.mark_connected(); + // Main loop - let res = self.event_loop(&mut terminal, &mut ws).await; + let res = self.event_loop(&mut terminal, ws).await; // Teardown disable_raw_mode()?; - let backend = terminal.backend_mut(); - execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; terminal.show_cursor()?; - res } + /// Helper method to attempt connection + async fn try_connect( + &self, + url: &str, + tls_ca: Option<&str>, + verify_hostname: bool, + ) -> Result> { + if let Some(ca_path) = tls_ca { + Ok(connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await?) + } else { + Ok(connect_to_socktop_agent(url).await?) + } + } + + /// Run the app with a connection error modal from the start + async fn run_with_connection_error( + &mut self, + terminal: &mut Terminal, + _url: &str, + _tls_ca: Option<&str>, + _verify_hostname: bool, + ) -> Result<(), Box> { + loop { + // Handle input for modal + while event::poll(Duration::from_millis(10))? { + if let Event::Key(k) = event::read()? { + let action = self.modal_manager.handle_key(k.code); + match action { + ModalAction::ExitApp => { + return Ok(()); + } + ModalAction::RetryConnection => { + // Show "Retrying..." message + self.modal_manager.pop_modal(); // Remove old modal + self.modal_manager.push_modal(ModalType::ConnectionError { + message: "Retrying connection...".to_string(), + disconnected_at: self + .original_disconnect_time + .unwrap_or(self.last_connection_attempt), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + + // Force a redraw to show the retrying message + terminal.draw(|f| self.draw(f))?; + + // Update retry count + self.connection_retry_count += 1; + self.last_connection_attempt = Instant::now(); + + // Try to reconnect using stored parameters + let tls_ca_ref = self.tls_ca.as_deref(); + match self + .try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname) + .await + { + Ok(ws) => { + // Connection successful! + // Show success message briefly + self.modal_manager.pop_modal(); // Remove retrying modal + self.modal_manager.push_modal(ModalType::ConnectionError { + message: "Connection restored! Starting...".to_string(), + disconnected_at: self + .original_disconnect_time + .unwrap_or(self.last_connection_attempt), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + terminal.draw(|f| self.draw(f))?; + sleep(Duration::from_millis(500)).await; // Brief pause to show success + + // Explicitly clear all modals first + while self.modal_manager.is_active() { + self.modal_manager.pop_modal(); + } + // Mark as connected (this also clears modals but let's be explicit) + self.mark_connected(); + // Force a redraw to show the cleared state + terminal.draw(|f| self.draw(f))?; + // Start normal event loop + return self.event_loop(terminal, ws).await; + } + Err(e) => { + // Update modal with new error and retry count + self.modal_manager.pop_modal(); // Remove retrying modal + self.modal_manager.push_modal(ModalType::ConnectionError { + message: format!("Retry failed: {e}"), + disconnected_at: self + .original_disconnect_time + .unwrap_or(self.last_connection_attempt), + retry_count: self.connection_retry_count, + auto_retry_countdown: self.seconds_until_next_auto_retry(), + }); + } + } + } + _ => {} + } + } + } + + // Check for automatic retry (every 30 seconds) + if self.should_auto_retry() { + self.auto_retry_connection().await; + // If auto-retry succeeded, transition directly into the normal event loop + if let Some(ws) = self.replacement_connection.take() { + // Ensure we are marked connected (auto_retry_connection already does this) + // Start the normal event loop using the newly established connection + return self.event_loop(terminal, ws).await; + } + } + + // Update countdown for connection error modal if active + if self.modal_manager.is_active() { + self.modal_manager + .update_connection_error_countdown(self.seconds_until_next_auto_retry()); + } + + // Draw the modal + terminal.draw(|f| self.draw(f))?; + sleep(Duration::from_millis(50)).await; + } + } + async fn event_loop( + &mut self, + terminal: &mut Terminal, + mut ws: SocktopConnector, + ) -> Result<(), Box> { + loop { + // Main event loop + let result = self.run_event_loop_iteration(terminal, &mut ws).await; + + // Check if we need to restart with a new connection + if let Some(new_ws) = self.replacement_connection.take() { + ws = new_ws; + continue; // Restart the loop with new connection + } + + // If we get here and there's no replacement, return the result + return result; + } + } + + async fn run_event_loop_iteration( &mut self, terminal: &mut Terminal, ws: &mut SocktopConnector, @@ -173,6 +546,38 @@ impl App { while event::poll(Duration::from_millis(10))? { match event::read()? { Event::Key(k) => { + // Handle modal input first - if a modal consumes the input, don't process normal keys + if self.modal_manager.is_active() { + let action = self.modal_manager.handle_key(k.code); + match action { + ModalAction::ExitApp => { + self.should_quit = true; + continue; // Skip normal key processing + } + ModalAction::RetryConnection => { + self.retry_connection().await; + // Check if retry succeeded and we have a replacement connection + if self.replacement_connection.is_some() { + // Signal that we want to restart with new connection + // Return from this iteration so the outer loop can restart + return Ok(()); + } + continue; // Skip normal key processing + } + ModalAction::Cancel | ModalAction::Dismiss => { + // Modal was dismissed, continue to normal processing + } + ModalAction::Confirm => { + // Handle confirmation action here if needed in the future + } + ModalAction::None => { + // Modal is still active but didn't consume the key + continue; // Skip normal key processing + } + } + } + + // Normal key handling (only if no modal is active or modal didn't consume the key) if matches!( k.code, KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc @@ -284,37 +689,65 @@ impl App { _ => {} } } + + // Check for automatic retry (every 30 seconds) + if self.should_auto_retry() { + self.auto_retry_connection().await; + // Check if retry succeeded and we have a replacement connection + if self.replacement_connection.is_some() { + // Signal that we want to restart with new connection + return Ok(()); + } + } + if self.should_quit { break; } // Fetch and update - if let Ok(response) = ws.request(AgentRequest::Metrics).await { - if let AgentResponse::Metrics(m) = response { + match ws.request(AgentRequest::Metrics).await { + Ok(AgentResponse::Metrics(m)) => { + self.mark_connected(); // Mark as connected on successful request self.update_with_metrics(m); - } - // Only poll processes every 2s - if self.last_procs_poll.elapsed() >= self.procs_interval { - if let Ok(AgentResponse::Processes(procs)) = - ws.request(AgentRequest::Processes).await - && let Some(mm) = self.last_metrics.as_mut() - { - mm.top_processes = procs.top_processes; - mm.process_count = Some(procs.process_count); + // Only poll processes every 2s + if self.last_procs_poll.elapsed() >= self.procs_interval { + if let Ok(AgentResponse::Processes(procs)) = + ws.request(AgentRequest::Processes).await + && let Some(mm) = self.last_metrics.as_mut() + { + mm.top_processes = procs.top_processes; + mm.process_count = Some(procs.process_count); + } + self.last_procs_poll = Instant::now(); } - self.last_procs_poll = Instant::now(); - } - // Only poll disks every 5s - if self.last_disks_poll.elapsed() >= self.disks_interval { - if let Ok(AgentResponse::Disks(disks)) = ws.request(AgentRequest::Disks).await - && let Some(mm) = self.last_metrics.as_mut() - { - mm.disks = disks; + // Only poll disks every 5s + if self.last_disks_poll.elapsed() >= self.disks_interval { + if let Ok(AgentResponse::Disks(disks)) = + ws.request(AgentRequest::Disks).await + && let Some(mm) = self.last_metrics.as_mut() + { + mm.disks = disks; + } + self.last_disks_poll = Instant::now(); } - self.last_disks_poll = Instant::now(); } + Err(e) => { + // Connection error - show modal if not already shown + let error_message = format!("Failed to fetch metrics: {e}"); + self.show_connection_error(error_message); + } + _ => { + // Unexpected response type + self.show_connection_error("Unexpected response from agent".to_string()); + } + } + + // Update countdown for connection error modal if active + if self.modal_manager.is_active() { + self.modal_manager + .update_connection_error_countdown(self.seconds_until_next_auto_retry()); } // Draw @@ -487,6 +920,11 @@ impl App { self.procs_scroll_offset, self.procs_sort_by, ); + + // Render modals on top of everything else + if self.modal_manager.is_active() { + self.modal_manager.render(f); + } } } @@ -518,8 +956,17 @@ impl Default for App { disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), ws_url: String::new(), + tls_ca: None, + verify_hostname: false, is_tls: false, has_token: false, + modal_manager: ModalManager::new(), + connection_state: ConnectionState::Disconnected, + last_connection_attempt: Instant::now(), + original_disconnect_time: None, + connection_retry_count: 0, + last_auto_retry: None, + replacement_connection: None, } } } diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 428976c..281a1c9 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -3,8 +3,9 @@ mod app; mod history; mod profiles; +mod retry; mod types; -mod ui; +mod ui; // pure retry timing logic use app::App; use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles}; diff --git a/socktop/src/retry.rs b/socktop/src/retry.rs new file mode 100644 index 0000000..626bf58 --- /dev/null +++ b/socktop/src/retry.rs @@ -0,0 +1,114 @@ +//! Pure retry timing logic (decoupled from App state / UI) for testability. +use std::time::{Duration, Instant}; + +/// Result of computing retry timing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryTiming { + pub should_retry_now: bool, + /// Seconds until next retry (Some(0) means ready now); None means inactive/no countdown. + pub seconds_until_retry: Option, +} + +/// Compute retry timing given connection state inputs. +/// +/// Inputs: +/// - `disconnected`: true when connection_state == Disconnected. +/// - `modal_active`: requires the connection error modal be visible to show countdown / trigger auto retry. +/// - `original_disconnect_time`: time we first noticed disconnect. +/// - `last_auto_retry`: time we last performed an automatic retry. +/// - `now`: current time (injected for determinism / tests). +/// - `interval`: retry interval duration. +pub(crate) fn compute_retry_timing( + disconnected: bool, + modal_active: bool, + original_disconnect_time: Option, + last_auto_retry: Option, + now: Instant, + interval: Duration, +) -> RetryTiming { + if !disconnected || !modal_active { + return RetryTiming { + should_retry_now: false, + seconds_until_retry: None, + }; + } + + let baseline = match last_auto_retry.or(original_disconnect_time) { + Some(b) => b, + None => { + return RetryTiming { + should_retry_now: false, + seconds_until_retry: None, + }; + } + }; + + let elapsed = now.saturating_duration_since(baseline); + if elapsed >= interval { + RetryTiming { + should_retry_now: true, + seconds_until_retry: Some(0), + } + } else { + let remaining = interval - elapsed; + RetryTiming { + should_retry_now: false, + seconds_until_retry: Some(remaining.as_secs()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_when_not_disconnected() { + let now = Instant::now(); + let rt = compute_retry_timing(false, true, Some(now), None, now, Duration::from_secs(30)); + assert!(!rt.should_retry_now); + assert_eq!(rt.seconds_until_retry, None); + } + + #[test] + fn countdown_progress_and_ready() { + let base = Instant::now(); + let rt1 = compute_retry_timing( + true, + true, + Some(base), + None, + base + Duration::from_secs(10), + Duration::from_secs(30), + ); + assert!(!rt1.should_retry_now); + assert_eq!(rt1.seconds_until_retry, Some(20)); + let rt2 = compute_retry_timing( + true, + true, + Some(base), + None, + base + Duration::from_secs(30), + Duration::from_secs(30), + ); + assert!(rt2.should_retry_now); + assert_eq!(rt2.seconds_until_retry, Some(0)); + } + + #[test] + fn uses_last_auto_retry_as_baseline() { + let base: Instant = Instant::now(); + let last = base + Duration::from_secs(30); // one prior retry + // 10s after last retry => 20s remaining + let rt = compute_retry_timing( + true, + true, + Some(base), + Some(last), + last + Duration::from_secs(10), + Duration::from_secs(30), + ); + assert!(!rt.should_retry_now); + assert_eq!(rt.seconds_until_retry, Some(20)); + } +} diff --git a/socktop/src/ui/mod.rs b/socktop/src/ui/mod.rs index 75dbff8..7b5d160 100644 --- a/socktop/src/ui/mod.rs +++ b/socktop/src/ui/mod.rs @@ -5,6 +5,7 @@ pub mod disks; pub mod gpu; pub mod header; pub mod mem; +pub mod modal; pub mod net; pub mod processes; pub mod swap; diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs new file mode 100644 index 0000000..f020d62 --- /dev/null +++ b/socktop/src/ui/modal.rs @@ -0,0 +1,612 @@ +//! Modal window system for socktop TUI application + +use std::time::{Duration, Instant}; + +use super::theme::{ + BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT, + BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER, + ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE, + LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG, + MODAL_DIM_BG, MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, + MODAL_RETRY_LABEL_FG, MODAL_TITLE_FG, +}; +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +#[derive(Debug, Clone)] +pub enum ModalType { + ConnectionError { + message: String, + disconnected_at: Instant, + retry_count: u32, + auto_retry_countdown: Option, + }, + #[allow(dead_code)] + Confirmation { + title: String, + message: String, + confirm_text: String, + cancel_text: String, + }, + #[allow(dead_code)] + Info { title: String, message: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModalAction { + None, + RetryConnection, + ExitApp, + Confirm, + Cancel, + Dismiss, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModalButton { + Retry, + Exit, + Confirm, + Cancel, + Ok, +} + +#[derive(Debug)] +pub struct ModalManager { + stack: Vec, + active_button: ModalButton, +} + +impl ModalManager { + pub fn new() -> Self { + Self { + stack: Vec::new(), + active_button: ModalButton::Retry, + } + } + pub fn is_active(&self) -> bool { + !self.stack.is_empty() + } + + pub fn push_modal(&mut self, modal: ModalType) { + self.stack.push(modal); + self.active_button = match self.stack.last() { + Some(ModalType::ConnectionError { .. }) => ModalButton::Retry, + Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, + Some(ModalType::Info { .. }) => ModalButton::Ok, + None => ModalButton::Ok, + }; + } + pub fn pop_modal(&mut self) -> Option { + let m = self.stack.pop(); + if let Some(next) = self.stack.last() { + self.active_button = match next { + ModalType::ConnectionError { .. } => ModalButton::Retry, + ModalType::Confirmation { .. } => ModalButton::Confirm, + ModalType::Info { .. } => ModalButton::Ok, + }; + } + m + } + pub fn update_connection_error_countdown(&mut self, new_countdown: Option) { + if let Some(ModalType::ConnectionError { + auto_retry_countdown, + .. + }) = self.stack.last_mut() + { + *auto_retry_countdown = new_countdown; + } + } + pub fn handle_key(&mut self, key: KeyCode) -> ModalAction { + if !self.is_active() { + return ModalAction::None; + } + match key { + KeyCode::Esc => { + self.pop_modal(); + ModalAction::Cancel + } + KeyCode::Enter => self.handle_enter(), + KeyCode::Tab | KeyCode::Right => { + self.next_button(); + ModalAction::None + } + KeyCode::BackTab | KeyCode::Left => { + self.prev_button(); + ModalAction::None + } + KeyCode::Char('r') | KeyCode::Char('R') => { + if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { + ModalAction::RetryConnection + } else { + ModalAction::None + } + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { + ModalAction::ExitApp + } else { + ModalAction::None + } + } + _ => ModalAction::None, + } + } + fn handle_enter(&mut self) -> ModalAction { + match (&self.stack.last(), &self.active_button) { + (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => { + ModalAction::RetryConnection + } + (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp, + (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, + (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, + (Some(ModalType::Info { .. }), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } + _ => ModalAction::None, + } + } + fn next_button(&mut self) { + self.active_button = match (&self.stack.last(), &self.active_button) { + (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit, + (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry, + (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalButton::Cancel, + (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm, + _ => self.active_button.clone(), + }; + } + fn prev_button(&mut self) { + self.next_button(); + } + + pub fn render(&self, f: &mut Frame) { + if let Some(m) = self.stack.last() { + self.render_background_dim(f); + self.render_modal_content(f, m); + } + } + + fn render_background_dim(&self, f: &mut Frame) { + let area = f.area(); + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .style(Style::default().bg(MODAL_DIM_BG).fg(MODAL_DIM_BG)) + .borders(Borders::NONE), + area, + ); + } + + fn render_modal_content(&self, f: &mut Frame, modal: &ModalType) { + let area = f.area(); + let modal_area = self.centered_rect(70, 50, area); + f.render_widget(Clear, modal_area); + match modal { + ModalType::ConnectionError { + message, + disconnected_at, + retry_count, + auto_retry_countdown, + } => self.render_connection_error( + f, + modal_area, + message, + *disconnected_at, + *retry_count, + *auto_retry_countdown, + ), + ModalType::Confirmation { + title, + message, + confirm_text, + cancel_text, + } => self.render_confirmation(f, modal_area, title, message, confirm_text, cancel_text), + ModalType::Info { title, message } => self.render_info(f, modal_area, title, message), + } + } + + fn render_connection_error( + &self, + f: &mut Frame, + area: Rect, + message: &str, + disconnected_at: Instant, + retry_count: u32, + auto_retry_countdown: Option, + ) { + let duration_text = format_duration(disconnected_at.elapsed()); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(4), + ]) + .split(area); + let block = Block::default() + .title(ICON_WARNING_TITLE) + .title_style( + Style::default() + .fg(MODAL_TITLE_FG) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(MODAL_BORDER_FG)) + .style(Style::default().bg(MODAL_BG).fg(MODAL_FG)); + f.render_widget(block, area); + + let content_area = chunks[1]; + let max_w = content_area.width.saturating_sub(15) as usize; + let clean_message = if message.to_lowercase().contains("hostname verification") + || message.contains("socktop_connector") + { + "Connection failed - hostname verification disabled".to_string() + } else if message.contains("Failed to fetch metrics:") { + if let Some(p) = message.find(':') { + let ess = message[p + 1..].trim(); + if ess.len() > max_w { + format!("{}...", &ess[..max_w.saturating_sub(3)]) + } else { + ess.to_string() + } + } else { + "Connection error".to_string() + } + } else if message.starts_with("Retry failed:") { + if let Some(p) = message.find(':') { + let ess = message[p + 1..].trim(); + if ess.len() > max_w { + format!("{}...", &ess[..max_w.saturating_sub(3)]) + } else { + ess.to_string() + } + } else { + "Retry failed".to_string() + } + } else if message.len() > max_w { + format!("{}...", &message[..max_w.saturating_sub(3)]) + } else { + message.to_string() + }; + let truncate = |s: &str| { + if s.len() > max_w { + format!("{}...", &s[..max_w.saturating_sub(3)]) + } else { + s.to_string() + } + }; + let agent_text = truncate("📡 Cannot connect to socktop agent"); + let message_text = truncate(&clean_message); + let duration_display = truncate(&duration_text); + let retry_display = truncate(&retry_count.to_string()); + let countdown_text = auto_retry_countdown.map(|c| { + if c == 0 { + "Auto retry now...".to_string() + } else { + format!("{c}s") + } + }); + + // Determine if we have enough space (height + width) to show large centered icon + let icon_max_width = LARGE_ERROR_ICON + .iter() + .map(|l| l.trim().chars().count()) + .max() + .unwrap_or(0) as u16; + let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8) + && content_area.width >= icon_max_width + 6; // small margin for borders/padding + let mut icon_lines: Vec = Vec::new(); + if large_allowed { + for &raw in LARGE_ERROR_ICON.iter() { + let trimmed = raw.trim(); + icon_lines.push(Line::from( + trimmed + .chars() + .map(|ch| { + if ch == '!' { + Span::styled( + ch.to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else if ch == '/' || ch == '\\' || ch == '_' { + // keep outline in pink + Span::styled( + ch.to_string(), + Style::default() + .fg(MODAL_ICON_PINK) + .add_modifier(Modifier::BOLD), + ) + } else if ch == ' ' { + Span::raw(" ") + } else { + Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK)) + } + }) + .collect::>(), + )); + } + icon_lines.push(Line::from("")); // blank spacer line below icon + } + + let mut info_lines: Vec = Vec::new(); + if !large_allowed { + info_lines.push(Line::from(vec![Span::styled( + ICON_CLUSTER, + Style::default().fg(MODAL_ICON_PINK), + )])); + info_lines.push(Line::from("")); + } + info_lines.push(Line::from(vec![Span::styled( + &agent_text, + Style::default().fg(MODAL_AGENT_FG), + )])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)), + Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)), + ])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled( + ICON_OFFLINE_LABEL, + Style::default().fg(MODAL_OFFLINE_LABEL_FG), + ), + Span::styled( + &duration_display, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + info_lines.push(Line::from(vec![ + Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)), + Span::styled( + &retry_display, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + if let Some(cd) = &countdown_text { + info_lines.push(Line::from(vec![ + Span::styled( + ICON_COUNTDOWN_LABEL, + Style::default().fg(MODAL_COUNTDOWN_LABEL_FG), + ), + Span::styled( + cd, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + } + + let constrained = Rect { + x: content_area.x + 2, + y: content_area.y, + width: content_area.width.saturating_sub(4), + height: content_area.height, + }; + if large_allowed { + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(icon_lines.len() as u16), + Constraint::Min(0), + ]) + .split(constrained); + // Center the icon block; each line already trimmed so per-line centering keeps shape + f.render_widget( + Paragraph::new(Text::from(icon_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }), + split[0], + ); + f.render_widget( + Paragraph::new(Text::from(info_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + split[1], + ); + } else { + f.render_widget( + Paragraph::new(Text::from(info_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + constrained, + ); + } + + let button_area = Rect { + x: chunks[2].x, + y: chunks[2].y, + width: chunks[2].width, + height: chunks[2].height.saturating_sub(1), + }; + self.render_connection_error_buttons(f, button_area); + } + + fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) { + let button_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(15), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(30), + ]) + .split(area); + let retry_style = if self.active_button == ModalButton::Retry { + Style::default() + .bg(BTN_RETRY_BG_ACTIVE) + .fg(BTN_RETRY_FG_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(BTN_RETRY_FG_INACTIVE) + .add_modifier(Modifier::DIM) + }; + let exit_style = if self.active_button == ModalButton::Exit { + Style::default() + .bg(BTN_EXIT_BG_ACTIVE) + .fg(BTN_EXIT_FG_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(BTN_EXIT_FG_INACTIVE) + .add_modifier(Modifier::DIM) + }; + f.render_widget( + Paragraph::new(Text::from(Line::from(vec![Span::styled( + BTN_RETRY_TEXT, + retry_style, + )]))) + .alignment(Alignment::Center), + button_chunks[1], + ); + f.render_widget( + Paragraph::new(Text::from(Line::from(vec![Span::styled( + BTN_EXIT_TEXT, + exit_style, + )]))) + .alignment(Alignment::Center), + button_chunks[3], + ); + } + + fn render_confirmation( + &self, + f: &mut Frame, + area: Rect, + title: &str, + message: &str, + confirm_text: &str, + cancel_text: &str, + ) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); + let block = Block::default() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(message) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + chunks[0], + ); + let buttons = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + let confirm_style = if self.active_button == ModalButton::Confirm { + Style::default() + .bg(Color::Green) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green) + }; + let cancel_style = if self.active_button == ModalButton::Cancel { + Style::default() + .bg(Color::Red) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Red) + }; + f.render_widget( + Paragraph::new(confirm_text) + .style(confirm_style) + .alignment(Alignment::Center), + buttons[0], + ); + f.render_widget( + Paragraph::new(cancel_text) + .style(cancel_style) + .alignment(Alignment::Center), + buttons[1], + ); + } + + fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); + let block = Block::default() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(message) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + chunks[0], + ); + let ok_style = if self.active_button == ModalButton::Ok { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue) + }; + f.render_widget( + Paragraph::new("[ Enter ] OK") + .style(ok_style) + .alignment(Alignment::Center), + chunks[1], + ); + } + + fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vert[1])[1] + } +} + +fn format_duration(duration: Duration) -> String { + let total = duration.as_secs(); + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { + format!("{h}h {m}m {s}s") + } else if m > 0 { + format!("{m}m {s}s") + } else { + format!("{s}s") + } +} diff --git a/socktop/src/ui/theme.rs b/socktop/src/ui/theme.rs index 579cb89..5e79fef 100644 --- a/socktop/src/ui/theme.rs +++ b/socktop/src/ui/theme.rs @@ -6,3 +6,48 @@ use ratatui::style::Color; pub const SB_ARROW: Color = Color::Rgb(170, 170, 180); pub const SB_TRACK: Color = Color::Rgb(170, 170, 180); pub const SB_THUMB: Color = Color::Rgb(170, 170, 180); + +// Modal palette +pub const MODAL_DIM_BG: Color = Color::Rgb(15, 15, 25); +pub const MODAL_BG: Color = Color::Rgb(26, 26, 46); +pub const MODAL_FG: Color = Color::Rgb(230, 230, 230); +pub const MODAL_TITLE_FG: Color = Color::Rgb(255, 102, 102); // soft red for title text +pub const MODAL_BORDER_FG: Color = Color::Rgb(204, 51, 51); // darker red border + +pub const MODAL_ICON_PINK: Color = Color::Rgb(255, 182, 193); // light pink icons line +pub const MODAL_AGENT_FG: Color = Color::Rgb(220, 220, 255); // pale periwinkle +pub const MODAL_HINT_FG: Color = Color::Rgb(255, 215, 0); // gold for message icon +pub const MODAL_OFFLINE_LABEL_FG: Color = Color::Rgb(135, 206, 235); // sky blue label +pub const MODAL_RETRY_LABEL_FG: Color = Color::Rgb(255, 165, 0); // orange label +pub const MODAL_COUNTDOWN_LABEL_FG: Color = Color::Rgb(255, 192, 203); // pink label for countdown + +// Buttons +pub const BTN_RETRY_BG_ACTIVE: Color = Color::Rgb(46, 204, 113); // modern green +pub const BTN_RETRY_FG_ACTIVE: Color = Color::Rgb(26, 26, 46); +pub const BTN_RETRY_FG_INACTIVE: Color = Color::Rgb(46, 204, 113); + +pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red +pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46); +pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255); + +// Emoji / icon strings (centralized so they can be themed/swapped later) +pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR "; +pub const ICON_CLUSTER: &str = "⚠️"; +pub const ICON_MESSAGE: &str = "💭 "; +pub const ICON_OFFLINE_LABEL: &str = "⏱️ Offline for: "; +pub const ICON_RETRY_LABEL: &str = "🔄 Retry attempts: "; +pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: "; +pub const BTN_RETRY_TEXT: &str = " 🔄 Retry "; +pub const BTN_EXIT_TEXT: &str = " ❌ Exit "; + +// Large multi-line warning icon +pub const LARGE_ERROR_ICON: &[&str] = &[ + " /\\ ", + " / \\ ", + " / !! \\ ", + " / !!!! \\ ", + " / !! \\ ", + " / !!!! \\ ", + " / !! \\ ", + " /______________\\ ", +]; diff --git a/socktop/tests/modal_tests.rs b/socktop/tests/modal_tests.rs new file mode 100644 index 0000000..96ebcda --- /dev/null +++ b/socktop/tests/modal_tests.rs @@ -0,0 +1,46 @@ +//! Tests for modal formatting and duration helper. +use std::time::Duration; + +// Bring the format_duration function into scope by duplicating logic (private in module). If desired, +// this could be moved to a shared util module; for now we re-assert expected behavior. +fn format_duration_ref(duration: Duration) -> String { + let total_secs = duration.as_secs(); + let hours = total_secs / 3600; + let minutes = (total_secs % 3600) / 60; + let seconds = total_secs % 60; + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + +#[test] +fn test_format_duration_boundaries() { + assert_eq!(format_duration_ref(Duration::from_secs(0)), "0s"); + assert_eq!(format_duration_ref(Duration::from_secs(59)), "59s"); + assert_eq!(format_duration_ref(Duration::from_secs(60)), "1m 0s"); + assert_eq!(format_duration_ref(Duration::from_secs(61)), "1m 1s"); + assert_eq!(format_duration_ref(Duration::from_secs(3600)), "1h 0m 0s"); + assert_eq!(format_duration_ref(Duration::from_secs(3661)), "1h 1m 1s"); +} + +// Basic test to ensure auto-retry countdown semantics are consistent for initial state. +#[test] +fn test_auto_retry_initial_none() { + // We can't construct App directly without pulling in whole UI; just assert logic mimic. + // For a more thorough test, refactor countdown logic into a pure function. + // This placeholder asserts desired initial semantics: when no disconnect/original time, countdown should be None. + // (When integrated, consider exposing a pure helper returning Option.) + let modal_active = false; // requirement: must be active for countdown + let disconnected_state = true; // assume disconnected state + let countdown = if disconnected_state && modal_active { + // would compute target + Some(0) + } else { + None + }; + assert!(countdown.is_none()); +} diff --git a/socktop_connector/src/connector.rs b/socktop_connector/src/connector.rs index ed3d109..c9825c2 100644 --- a/socktop_connector/src/connector.rs +++ b/socktop_connector/src/connector.rs @@ -333,9 +333,7 @@ async fn connect_with_ca_and_config( } } cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); - eprintln!( - "socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking." - ); + // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking. } let cfg = Arc::new(cfg); let (ws, _) = connect_async_tls_with_config( diff --git a/socktop_connector/src/networking/connection.rs b/socktop_connector/src/networking/connection.rs index 6816444..8ba2ece 100644 --- a/socktop_connector/src/networking/connection.rs +++ b/socktop_connector/src/networking/connection.rs @@ -152,9 +152,7 @@ async fn connect_with_ca_and_config( } } cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); - eprintln!( - "socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking." - ); + // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking. } let cfg = Arc::new(cfg); let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(