feature - add error modal support and retry
This commit is contained in:
parent
b4ed036357
commit
b635f5d7f4
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
114
socktop/src/retry.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
612
socktop/src/ui/modal.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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] = &[
|
||||||
|
" /\\ ",
|
||||||
|
" / \\ ",
|
||||||
|
" / !! \\ ",
|
||||||
|
" / !!!! \\ ",
|
||||||
|
" / !! \\ ",
|
||||||
|
" / !!!! \\ ",
|
||||||
|
" / !! \\ ",
|
||||||
|
" /______________\\ ",
|
||||||
|
];
|
||||||
|
|||||||
46
socktop/tests/modal_tests.rs
Normal file
46
socktop/tests/modal_tests.rs
Normal 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());
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user