Merge pull request #15 from jasonwitty/feature/connection-error-modal
feature - add error modal support and retry
This commit is contained in:
commit
a238ce320b
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -2182,7 +2182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socktop_agent"
|
||||
version = "1.40.7"
|
||||
version = "1.40.70"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
||||
@ -20,12 +20,14 @@ use ratatui::{
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::history::{PerCoreHistory, push_capped};
|
||||
use crate::retry::{RetryTiming, compute_retry_timing};
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::cpu::{
|
||||
PerCoreScrollDrag, draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp,
|
||||
per_core_content_area, per_core_handle_key, per_core_handle_mouse,
|
||||
per_core_handle_scrollbar_mouse,
|
||||
};
|
||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||
use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse};
|
||||
use crate::ui::{
|
||||
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
|
||||
@ -40,6 +42,13 @@ use socktop_connector::{
|
||||
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
||||
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConnectionState {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Reconnecting,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
// Latest metrics + histories
|
||||
last_metrics: Option<Metrics>,
|
||||
@ -75,9 +84,22 @@ pub struct App {
|
||||
|
||||
// For reconnects
|
||||
ws_url: String,
|
||||
tls_ca: Option<String>,
|
||||
verify_hostname: bool,
|
||||
// Security / status flags
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
|
||||
// Modal system
|
||||
pub modal_manager: crate::ui::modal::ModalManager,
|
||||
|
||||
// Connection state tracking
|
||||
pub connection_state: ConnectionState,
|
||||
last_connection_attempt: Instant,
|
||||
original_disconnect_time: Option<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 {
|
||||
@ -108,8 +130,17 @@ impl App {
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
ws_url: String::new(),
|
||||
tls_ca: None,
|
||||
verify_hostname: false,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
modal_manager: ModalManager::new(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
last_connection_attempt: Instant::now(),
|
||||
original_disconnect_time: None,
|
||||
connection_retry_count: 0,
|
||||
last_auto_retry: None,
|
||||
replacement_connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,21 +160,163 @@ impl App {
|
||||
self
|
||||
}
|
||||
|
||||
/// Show a connection error modal
|
||||
pub fn show_connection_error(&mut self, message: String) {
|
||||
if !self.modal_manager.is_active() {
|
||||
self.connection_state = ConnectionState::Disconnected;
|
||||
// Set original disconnect time if this is the first disconnect
|
||||
if self.original_disconnect_time.is_none() {
|
||||
self.original_disconnect_time = Some(Instant::now());
|
||||
}
|
||||
self.modal_manager.push_modal(ModalType::ConnectionError {
|
||||
message,
|
||||
disconnected_at: self.original_disconnect_time.unwrap(),
|
||||
retry_count: self.connection_retry_count,
|
||||
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to retry the connection
|
||||
pub async fn retry_connection(&mut self) {
|
||||
// This method is called from the normal event loop when connection is lost during operation
|
||||
self.connection_retry_count += 1;
|
||||
self.last_connection_attempt = Instant::now();
|
||||
self.connection_state = ConnectionState::Reconnecting;
|
||||
|
||||
// Show retrying message
|
||||
if self.modal_manager.is_active() {
|
||||
self.modal_manager.pop_modal(); // Remove old modal
|
||||
}
|
||||
self.modal_manager.push_modal(ModalType::ConnectionError {
|
||||
message: "Retrying connection...".to_string(),
|
||||
disconnected_at: self
|
||||
.original_disconnect_time
|
||||
.unwrap_or(self.last_connection_attempt),
|
||||
retry_count: self.connection_retry_count,
|
||||
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
||||
});
|
||||
|
||||
// Actually attempt to reconnect using stored parameters
|
||||
let tls_ca_ref = self.tls_ca.as_deref();
|
||||
match self
|
||||
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
|
||||
.await
|
||||
{
|
||||
Ok(new_ws) => {
|
||||
// Connection successful! Store the new connection for the event loop to pick up
|
||||
self.replacement_connection = Some(new_ws);
|
||||
self.mark_connected();
|
||||
// The event loop will detect this and restart with the new connection
|
||||
}
|
||||
Err(e) => {
|
||||
// Connection failed, update modal with error
|
||||
self.modal_manager.pop_modal(); // Remove retrying modal
|
||||
self.modal_manager.push_modal(ModalType::ConnectionError {
|
||||
message: format!("Retry failed: {e}"),
|
||||
disconnected_at: self
|
||||
.original_disconnect_time
|
||||
.unwrap_or(self.last_connection_attempt),
|
||||
retry_count: self.connection_retry_count,
|
||||
auto_retry_countdown: self.seconds_until_next_auto_retry(),
|
||||
});
|
||||
self.connection_state = ConnectionState::Disconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark connection as successful and dismiss any error modals
|
||||
pub fn mark_connected(&mut self) {
|
||||
if self.connection_state != ConnectionState::Connected {
|
||||
self.connection_state = ConnectionState::Connected;
|
||||
self.connection_retry_count = 0;
|
||||
self.original_disconnect_time = None; // Clear the original disconnect time
|
||||
self.last_auto_retry = None; // Clear auto retry timer
|
||||
// Remove connection error modal if it exists
|
||||
if self.modal_manager.is_active() {
|
||||
self.modal_manager.pop_modal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute retry timing using pure policy function.
|
||||
fn current_retry_timing(&self) -> RetryTiming {
|
||||
compute_retry_timing(
|
||||
self.connection_state == ConnectionState::Disconnected,
|
||||
self.modal_manager.is_active(),
|
||||
self.original_disconnect_time,
|
||||
self.last_auto_retry,
|
||||
Instant::now(),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if we should perform an automatic retry (every 30 seconds)
|
||||
pub fn should_auto_retry(&self) -> bool {
|
||||
self.current_retry_timing().should_retry_now
|
||||
}
|
||||
|
||||
/// Get seconds until next automatic retry (returns None if inactive)
|
||||
pub fn seconds_until_next_auto_retry(&self) -> Option<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(
|
||||
&mut self,
|
||||
url: &str,
|
||||
tls_ca: Option<&str>,
|
||||
verify_hostname: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to agent
|
||||
self.ws_url = url.to_string();
|
||||
let mut ws = if let Some(ca_path) = tls_ca {
|
||||
connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await?
|
||||
} else {
|
||||
connect_to_socktop_agent(url).await?
|
||||
};
|
||||
self.tls_ca = tls_ca.map(|s| s.to_string());
|
||||
self.verify_hostname = verify_hostname;
|
||||
|
||||
// Terminal setup
|
||||
// Terminal setup first - so we can show connection error modals
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
@ -151,19 +324,219 @@ impl App {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
// Try to connect to agent
|
||||
let ws = match self.try_connect(url, tls_ca, verify_hostname).await {
|
||||
Ok(connector) => connector,
|
||||
Err(e) => {
|
||||
// Show initial connection error and enter the error loop until user exits or we connect.
|
||||
self.show_connection_error(format!("Initial connection failed: {e}"));
|
||||
if let Err(err) = self
|
||||
.run_with_connection_error(&mut terminal, url, tls_ca, verify_hostname)
|
||||
.await
|
||||
{
|
||||
// Terminal teardown then propagate error
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// If user chose to exit during error loop, mark quit and teardown.
|
||||
if self.should_quit || self.connection_state != ConnectionState::Connected {
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// We should have a replacement connection after successful retry.
|
||||
match self.replacement_connection.take() {
|
||||
Some(conn) => conn,
|
||||
None => {
|
||||
// Defensive: no connector despite Connected state; exit gracefully.
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Connection successful, mark as connected
|
||||
self.mark_connected();
|
||||
|
||||
// Main loop
|
||||
let res = self.event_loop(&mut terminal, &mut ws).await;
|
||||
let res = self.event_loop(&mut terminal, ws).await;
|
||||
|
||||
// Teardown
|
||||
disable_raw_mode()?;
|
||||
let backend = terminal.backend_mut();
|
||||
execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
/// Helper method to attempt connection
|
||||
async fn try_connect(
|
||||
&self,
|
||||
url: &str,
|
||||
tls_ca: Option<&str>,
|
||||
verify_hostname: bool,
|
||||
) -> Result<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>(
|
||||
&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,
|
||||
terminal: &mut Terminal<B>,
|
||||
ws: &mut SocktopConnector,
|
||||
@ -173,6 +546,38 @@ impl App {
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
match event::read()? {
|
||||
Event::Key(k) => {
|
||||
// Handle modal input first - if a modal consumes the input, don't process normal keys
|
||||
if self.modal_manager.is_active() {
|
||||
let action = self.modal_manager.handle_key(k.code);
|
||||
match action {
|
||||
ModalAction::ExitApp => {
|
||||
self.should_quit = true;
|
||||
continue; // Skip normal key processing
|
||||
}
|
||||
ModalAction::RetryConnection => {
|
||||
self.retry_connection().await;
|
||||
// Check if retry succeeded and we have a replacement connection
|
||||
if self.replacement_connection.is_some() {
|
||||
// Signal that we want to restart with new connection
|
||||
// Return from this iteration so the outer loop can restart
|
||||
return Ok(());
|
||||
}
|
||||
continue; // Skip normal key processing
|
||||
}
|
||||
ModalAction::Cancel | ModalAction::Dismiss => {
|
||||
// Modal was dismissed, continue to normal processing
|
||||
}
|
||||
ModalAction::Confirm => {
|
||||
// Handle confirmation action here if needed in the future
|
||||
}
|
||||
ModalAction::None => {
|
||||
// Modal is still active but didn't consume the key
|
||||
continue; // Skip normal key processing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal key handling (only if no modal is active or modal didn't consume the key)
|
||||
if matches!(
|
||||
k.code,
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
|
||||
@ -284,37 +689,65 @@ impl App {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for automatic retry (every 30 seconds)
|
||||
if self.should_auto_retry() {
|
||||
self.auto_retry_connection().await;
|
||||
// Check if retry succeeded and we have a replacement connection
|
||||
if self.replacement_connection.is_some() {
|
||||
// Signal that we want to restart with new connection
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if self.should_quit {
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch and update
|
||||
if let Ok(response) = ws.request(AgentRequest::Metrics).await {
|
||||
if let AgentResponse::Metrics(m) = response {
|
||||
match ws.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(m)) => {
|
||||
self.mark_connected(); // Mark as connected on successful request
|
||||
self.update_with_metrics(m);
|
||||
}
|
||||
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
if let Ok(AgentResponse::Processes(procs)) =
|
||||
ws.request(AgentRequest::Processes).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
if let Ok(AgentResponse::Processes(procs)) =
|
||||
ws.request(AgentRequest::Processes).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
}
|
||||
self.last_procs_poll = Instant::now();
|
||||
}
|
||||
self.last_procs_poll = Instant::now();
|
||||
}
|
||||
|
||||
// Only poll disks every 5s
|
||||
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
||||
if let Ok(AgentResponse::Disks(disks)) = ws.request(AgentRequest::Disks).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.disks = disks;
|
||||
// Only poll disks every 5s
|
||||
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
||||
if let Ok(AgentResponse::Disks(disks)) =
|
||||
ws.request(AgentRequest::Disks).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.disks = disks;
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
// Connection error - show modal if not already shown
|
||||
let error_message = format!("Failed to fetch metrics: {e}");
|
||||
self.show_connection_error(error_message);
|
||||
}
|
||||
_ => {
|
||||
// Unexpected response type
|
||||
self.show_connection_error("Unexpected response from agent".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Update countdown for connection error modal if active
|
||||
if self.modal_manager.is_active() {
|
||||
self.modal_manager
|
||||
.update_connection_error_countdown(self.seconds_until_next_auto_retry());
|
||||
}
|
||||
|
||||
// Draw
|
||||
@ -487,6 +920,11 @@ impl App {
|
||||
self.procs_scroll_offset,
|
||||
self.procs_sort_by,
|
||||
);
|
||||
|
||||
// Render modals on top of everything else
|
||||
if self.modal_manager.is_active() {
|
||||
self.modal_manager.render(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -518,8 +956,17 @@ impl Default for App {
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
ws_url: String::new(),
|
||||
tls_ca: None,
|
||||
verify_hostname: false,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
modal_manager: ModalManager::new(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
last_connection_attempt: Instant::now(),
|
||||
original_disconnect_time: None,
|
||||
connection_retry_count: 0,
|
||||
last_auto_retry: None,
|
||||
replacement_connection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
mod app;
|
||||
mod history;
|
||||
mod profiles;
|
||||
mod retry;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod ui; // pure retry timing logic
|
||||
|
||||
use app::App;
|
||||
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};
|
||||
|
||||
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 header;
|
||||
pub mod mem;
|
||||
pub mod modal;
|
||||
pub mod net;
|
||||
pub mod processes;
|
||||
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_TRACK: Color = Color::Rgb(170, 170, 180);
|
||||
pub const SB_THUMB: Color = Color::Rgb(170, 170, 180);
|
||||
|
||||
// Modal palette
|
||||
pub const MODAL_DIM_BG: Color = Color::Rgb(15, 15, 25);
|
||||
pub const MODAL_BG: Color = Color::Rgb(26, 26, 46);
|
||||
pub const MODAL_FG: Color = Color::Rgb(230, 230, 230);
|
||||
pub const MODAL_TITLE_FG: Color = Color::Rgb(255, 102, 102); // soft red for title text
|
||||
pub const MODAL_BORDER_FG: Color = Color::Rgb(204, 51, 51); // darker red border
|
||||
|
||||
pub const MODAL_ICON_PINK: Color = Color::Rgb(255, 182, 193); // light pink icons line
|
||||
pub const MODAL_AGENT_FG: Color = Color::Rgb(220, 220, 255); // pale periwinkle
|
||||
pub const MODAL_HINT_FG: Color = Color::Rgb(255, 215, 0); // gold for message icon
|
||||
pub const MODAL_OFFLINE_LABEL_FG: Color = Color::Rgb(135, 206, 235); // sky blue label
|
||||
pub const MODAL_RETRY_LABEL_FG: Color = Color::Rgb(255, 165, 0); // orange label
|
||||
pub const MODAL_COUNTDOWN_LABEL_FG: Color = Color::Rgb(255, 192, 203); // pink label for countdown
|
||||
|
||||
// Buttons
|
||||
pub const BTN_RETRY_BG_ACTIVE: Color = Color::Rgb(46, 204, 113); // modern green
|
||||
pub const BTN_RETRY_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
|
||||
pub const BTN_RETRY_FG_INACTIVE: Color = Color::Rgb(46, 204, 113);
|
||||
|
||||
pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red
|
||||
pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
|
||||
pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255);
|
||||
|
||||
// Emoji / icon strings (centralized so they can be themed/swapped later)
|
||||
pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR ";
|
||||
pub const ICON_CLUSTER: &str = "⚠️";
|
||||
pub const ICON_MESSAGE: &str = "💭 ";
|
||||
pub const ICON_OFFLINE_LABEL: &str = "⏱️ Offline for: ";
|
||||
pub const ICON_RETRY_LABEL: &str = "🔄 Retry attempts: ";
|
||||
pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
|
||||
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
|
||||
pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
|
||||
|
||||
// Large multi-line warning icon
|
||||
pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" /\\ ",
|
||||
" / \\ ",
|
||||
" / !! \\ ",
|
||||
" / !!!! \\ ",
|
||||
" / !! \\ ",
|
||||
" / !!!! \\ ",
|
||||
" / !! \\ ",
|
||||
" /______________\\ ",
|
||||
];
|
||||
|
||||
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));
|
||||
eprintln!(
|
||||
"socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking."
|
||||
);
|
||||
// Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
|
||||
}
|
||||
let cfg = Arc::new(cfg);
|
||||
let (ws, _) = connect_async_tls_with_config(
|
||||
|
||||
@ -152,9 +152,7 @@ async fn connect_with_ca_and_config(
|
||||
}
|
||||
}
|
||||
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
|
||||
eprintln!(
|
||||
"socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking."
|
||||
);
|
||||
// Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
|
||||
}
|
||||
let cfg = Arc::new(cfg);
|
||||
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user