feature - add error modal support and retry
Some checks failed
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled

This commit is contained in:
jasonwitty 2025-09-15 10:16:47 -07:00
parent b4ed036357
commit b635f5d7f4
10 changed files with 1301 additions and 39 deletions

2
Cargo.lock generated
View File

@ -2182,7 +2182,7 @@ dependencies = [
[[package]] [[package]]
name = "socktop_agent" name = "socktop_agent"
version = "1.40.7" version = "1.40.70"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",

View File

@ -20,12 +20,14 @@ use ratatui::{
use tokio::time::sleep; use tokio::time::sleep;
use crate::history::{PerCoreHistory, push_capped}; use crate::history::{PerCoreHistory, push_capped};
use crate::retry::{RetryTiming, compute_retry_timing};
use crate::types::Metrics; use crate::types::Metrics;
use crate::ui::cpu::{ use crate::ui::cpu::{
PerCoreScrollDrag, draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp, 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_content_area, per_core_handle_key, per_core_handle_mouse,
per_core_handle_scrollbar_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::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse};
use crate::ui::{ use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, 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_METRICS_INTERVAL_MS: u64 = 100;
const MIN_PROCESSES_INTERVAL_MS: u64 = 200; const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
#[derive(Debug, Clone, PartialEq)]
pub enum ConnectionState {
Connected,
Disconnected,
Reconnecting,
}
pub struct App { pub struct App {
// Latest metrics + histories // Latest metrics + histories
last_metrics: Option<Metrics>, last_metrics: Option<Metrics>,
@ -75,9 +84,22 @@ pub struct App {
// For reconnects // For reconnects
ws_url: String, ws_url: String,
tls_ca: Option<String>,
verify_hostname: bool,
// Security / status flags // Security / status flags
pub is_tls: bool, pub is_tls: bool,
pub has_token: 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<Instant>, // Track when we first disconnected
connection_retry_count: u32,
last_auto_retry: Option<Instant>, // Track last automatic retry
replacement_connection: Option<socktop_connector::SocktopConnector>,
} }
impl App { impl App {
@ -108,8 +130,17 @@ impl App {
disks_interval: Duration::from_secs(5), disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500), metrics_interval: Duration::from_millis(500),
ws_url: String::new(), ws_url: String::new(),
tls_ca: None,
verify_hostname: false,
is_tls: false, is_tls: false,
has_token: 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 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<u64> {
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( pub async fn run(
&mut self, &mut self,
url: &str, url: &str,
tls_ca: Option<&str>, tls_ca: Option<&str>,
verify_hostname: bool, verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// Connect to agent
self.ws_url = url.to_string(); self.ws_url = url.to_string();
let mut ws = if let Some(ca_path) = tls_ca { self.tls_ca = tls_ca.map(|s| s.to_string());
connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await? self.verify_hostname = verify_hostname;
} else {
connect_to_socktop_agent(url).await?
};
// Terminal setup // Terminal setup first - so we can show connection error modals
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
@ -151,19 +324,219 @@ impl App {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; 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 // Main loop
let res = self.event_loop(&mut terminal, &mut ws).await; let res = self.event_loop(&mut terminal, ws).await;
// Teardown // Teardown
disable_raw_mode()?; disable_raw_mode()?;
let backend = terminal.backend_mut(); execute!(
execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?; terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?; terminal.show_cursor()?;
res res
} }
/// Helper method to attempt connection
async fn try_connect(
&self,
url: &str,
tls_ca: Option<&str>,
verify_hostname: bool,
) -> Result<SocktopConnector, Box<dyn std::error::Error>> {
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<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut Terminal<B>,
_url: &str,
_tls_ca: Option<&str>,
_verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> {
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<B: ratatui::backend::Backend>( async fn event_loop<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut Terminal<B>,
mut ws: SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> {
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<B: ratatui::backend::Backend>(
&mut self, &mut self,
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
ws: &mut SocktopConnector, ws: &mut SocktopConnector,
@ -173,6 +546,38 @@ impl App {
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
match event::read()? { match event::read()? {
Event::Key(k) => { 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!( if matches!(
k.code, k.code,
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc 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 { if self.should_quit {
break; break;
} }
// Fetch and update // Fetch and update
if let Ok(response) = ws.request(AgentRequest::Metrics).await { match ws.request(AgentRequest::Metrics).await {
if let AgentResponse::Metrics(m) = response { Ok(AgentResponse::Metrics(m)) => {
self.mark_connected(); // Mark as connected on successful request
self.update_with_metrics(m); self.update_with_metrics(m);
}
// Only poll processes every 2s // Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval { if self.last_procs_poll.elapsed() >= self.procs_interval {
if let Ok(AgentResponse::Processes(procs)) = if let Ok(AgentResponse::Processes(procs)) =
ws.request(AgentRequest::Processes).await ws.request(AgentRequest::Processes).await
&& let Some(mm) = self.last_metrics.as_mut() && let Some(mm) = self.last_metrics.as_mut()
{ {
mm.top_processes = procs.top_processes; mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count); mm.process_count = Some(procs.process_count);
}
self.last_procs_poll = Instant::now();
} }
self.last_procs_poll = Instant::now();
}
// Only poll disks every 5s // Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval { if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Ok(AgentResponse::Disks(disks)) = ws.request(AgentRequest::Disks).await if let Ok(AgentResponse::Disks(disks)) =
&& let Some(mm) = self.last_metrics.as_mut() ws.request(AgentRequest::Disks).await
{ && let Some(mm) = self.last_metrics.as_mut()
mm.disks = disks; {
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 // Draw
@ -487,6 +920,11 @@ impl App {
self.procs_scroll_offset, self.procs_scroll_offset,
self.procs_sort_by, 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), disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500), metrics_interval: Duration::from_millis(500),
ws_url: String::new(), ws_url: String::new(),
tls_ca: None,
verify_hostname: false,
is_tls: false, is_tls: false,
has_token: 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,
} }
} }
} }

View File

@ -3,8 +3,9 @@
mod app; mod app;
mod history; mod history;
mod profiles; mod profiles;
mod retry;
mod types; mod types;
mod ui; mod ui; // pure retry timing logic
use app::App; use app::App;
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles}; use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};

114
socktop/src/retry.rs Normal file
View File

@ -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<u64>,
}
/// 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<Instant>,
last_auto_retry: Option<Instant>,
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));
}
}

View File

@ -5,6 +5,7 @@ pub mod disks;
pub mod gpu; pub mod gpu;
pub mod header; pub mod header;
pub mod mem; pub mod mem;
pub mod modal;
pub mod net; pub mod net;
pub mod processes; pub mod processes;
pub mod swap; pub mod swap;

612
socktop/src/ui/modal.rs Normal file
View File

@ -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<u64>,
},
#[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<ModalType>,
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<ModalType> {
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<u64>) {
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<u64>,
) {
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<Line> = 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::<Vec<_>>(),
));
}
icon_lines.push(Line::from("")); // blank spacer line below icon
}
let mut info_lines: Vec<Line> = 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")
}
}

View File

@ -6,3 +6,48 @@ use ratatui::style::Color;
pub const SB_ARROW: Color = Color::Rgb(170, 170, 180); pub const SB_ARROW: Color = Color::Rgb(170, 170, 180);
pub const SB_TRACK: 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); 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] = &[
" /\\ ",
" / \\ ",
" / !! \\ ",
" / !!!! \\ ",
" / !! \\ ",
" / !!!! \\ ",
" / !! \\ ",
" /______________\\ ",
];

View File

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

View File

@ -333,9 +333,7 @@ async fn connect_with_ca_and_config(
} }
} }
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
eprintln!( // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
"socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking."
);
} }
let cfg = Arc::new(cfg); let cfg = Arc::new(cfg);
let (ws, _) = connect_async_tls_with_config( let (ws, _) = connect_async_tls_with_config(

View File

@ -152,9 +152,7 @@ async fn connect_with_ca_and_config(
} }
} }
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
eprintln!( // Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
"socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking."
);
} }
let cfg = Arc::new(cfg); let cfg = Arc::new(cfg);
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config( let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(