From 679a50b2e87f9985abab0555c9347a7c8cb1833e Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Wed, 19 Aug 2026 14:27:40 -0700 Subject: [PATCH] chore: dead-code sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete socktop_connector/src/connector.rs: orphaned since 08f248c removed 'pub mod connector;' during the modularization refactor. Never compiled (verified under default, wasm, and workspace feature combos) but shipped in the crates.io tarball and contained an outdated copy of the TLS verifier โ€” a trap for anyone patching the pinning bug in the dead copy. - Delete empty socktop/src/ws.rs, tracked editor backup ui/.modal.rs.backup, and stray test_thiserror.rs at the repo root. - Delete the two LEGACY #[allow(dead_code)] process input handlers; the header-click render test now exercises the live _with_selection handler instead (better coverage of the real path). - Drop unused sysinfo dependency from the socktop client. - Replace stale 'temporarily increased for testing' comment on COMPRESSION_THRESHOLD (it already held the production value). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 - socktop/Cargo.toml | 1 - socktop/src/ui/.modal.rs.backup | 1849 ---------------------------- socktop/src/ui/processes.rs | 111 +- socktop/src/ws.rs | 0 socktop_agent/src/ws.rs | 4 +- socktop_connector/src/connector.rs | 1152 ----------------- test_thiserror.rs | 0 8 files changed, 17 insertions(+), 3101 deletions(-) delete mode 100644 socktop/src/ui/.modal.rs.backup delete mode 100644 socktop/src/ws.rs delete mode 100644 socktop_connector/src/connector.rs delete mode 100644 test_thiserror.rs diff --git a/Cargo.lock b/Cargo.lock index b48e4a5..ce98f84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2423,7 +2423,6 @@ dependencies = [ "serde", "serde_json", "socktop_connector 1.50.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sysinfo", "tempfile", "tokio", "unicode-width", diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 0bf3419..1707a4e 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -23,7 +23,6 @@ crossterm = { workspace = true } unicode-width = { workspace = true } anyhow = { workspace = true } dirs-next = { workspace = true } -sysinfo = { workspace = true } [dev-dependencies] assert_cmd = "2.0" diff --git a/socktop/src/ui/.modal.rs.backup b/socktop/src/ui/.modal.rs.backup deleted file mode 100644 index a64db1b..0000000 --- a/socktop/src/ui/.modal.rs.backup +++ /dev/null @@ -1,1849 +0,0 @@ -//! Modal window system for socktop TUI application - -use std::time::Instant; - -use super::modal_format::{calculate_dynamic_y_max, format_duration, format_uptime, normalize_cpu_usage}; -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::{ - Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Padding, Paragraph, Row, - Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap, - }, -}; - -/// History data for process metrics rendering -pub struct ProcessHistoryData<'a> { - pub cpu: &'a std::collections::VecDeque, - pub mem: &'a std::collections::VecDeque, - pub io_read: &'a std::collections::VecDeque, - pub io_write: &'a std::collections::VecDeque, -} - -/// Process data for modal rendering -pub struct ProcessModalData<'a> { - pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, - pub journal: Option<&'a socktop_connector::JournalResponse>, - pub history: ProcessHistoryData<'a>, - pub unsupported: bool, -} - -/// Parameters for rendering scatter plot -struct ScatterPlotParams<'a> { - process: &'a socktop_connector::DetailedProcessInfo, - main_user_ms: f64, - main_system_ms: f64, - max_user: f64, - max_system: f64, -} - -#[derive(Debug, Clone)] -pub enum ModalType { - ConnectionError { - message: String, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - }, - ProcessDetails { - pid: u32, - }, - #[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, // Modal didn't handle the key, pass to main window - Handled, // Modal handled the key, don't pass to main window - RetryConnection, - ExitApp, - Confirm, - Cancel, - Dismiss, - SwitchToParentProcess(u32), // Switch to viewing parent process details -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ModalButton { - Retry, - Exit, - Confirm, - Cancel, - Ok, -} - -#[derive(Debug)] -pub struct ModalManager { - stack: Vec, - active_button: ModalButton, - pub thread_scroll_offset: usize, - pub journal_scroll_offset: usize, - thread_scroll_max: usize, - journal_scroll_max: usize, -} - -impl ModalManager { - pub fn new() -> Self { - Self { - stack: Vec::new(), - active_button: ModalButton::Retry, - thread_scroll_offset: 0, - journal_scroll_offset: 0, - thread_scroll_max: 0, - journal_scroll_max: 0, - } - } - pub fn is_active(&self) -> bool { - !self.stack.is_empty() - } - - pub fn current_modal(&self) -> Option<&ModalType> { - self.stack.last() - } - - 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::ProcessDetails { .. }) => { - // Reset scroll state for new process details - self.thread_scroll_offset = 0; - self.journal_scroll_offset = 0; - self.thread_scroll_max = 0; - self.journal_scroll_max = 0; - ModalButton::Ok - } - Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, - Some(ModalType::Info { .. }) => ModalButton::Ok, - None => ModalButton::Ok, - }; - } - pub fn pop_modal(&mut self) -> Option { - let m = self.stack.pop(); - if let Some(next) = self.stack.last() { - self.active_button = match next { - ModalType::ConnectionError { .. } => ModalButton::Retry, - ModalType::ProcessDetails { .. } => ModalButton::Ok, - ModalType::Confirmation { .. } => ModalButton::Confirm, - ModalType::Info { .. } => ModalButton::Ok, - }; - } - m - } - pub fn update_connection_error_countdown(&mut self, new_countdown: Option) { - if let Some(ModalType::ConnectionError { - auto_retry_countdown, - .. - }) = self.stack.last_mut() - { - *auto_retry_countdown = new_countdown; - } - } - pub fn handle_key(&mut self, key: KeyCode) -> ModalAction { - if !self.is_active() { - return ModalAction::None; - } - match key { - KeyCode::Esc => { - self.pop_modal(); - ModalAction::Cancel - } - KeyCode::Enter => self.handle_enter(), - KeyCode::Tab | KeyCode::Right => { - self.next_button(); - ModalAction::None - } - KeyCode::BackTab | KeyCode::Left => { - self.prev_button(); - ModalAction::None - } - KeyCode::Char('r') | KeyCode::Char('R') => { - if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { - ModalAction::RetryConnection - } else { - ModalAction::None - } - } - KeyCode::Char('q') | KeyCode::Char('Q') => { - if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { - ModalAction::ExitApp - } else { - ModalAction::None - } - } - KeyCode::Char('x') | KeyCode::Char('X') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - // Close all ProcessDetails modals at once (handles parent navigation chain) - while matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.pop_modal(); - } - ModalAction::Dismiss - } else { - ModalAction::None - } - } - KeyCode::Char('j') | KeyCode::Char('J') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self - .thread_scroll_offset - .saturating_add(1) - .min(self.thread_scroll_max); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('k') | KeyCode::Char('K') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(1); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('d') | KeyCode::Char('D') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self - .thread_scroll_offset - .saturating_add(10) - .min(self.thread_scroll_max); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('u') | KeyCode::Char('U') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(10); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('[') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.journal_scroll_offset = self.journal_scroll_offset.saturating_sub(1); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char(']') => { - if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { - self.journal_scroll_offset = self - .journal_scroll_offset - .saturating_add(1) - .min(self.journal_scroll_max); - ModalAction::Handled - } else { - ModalAction::None - } - } - KeyCode::Char('p') | KeyCode::Char('P') => { - // Switch to parent process if it exists - if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() { - // We need to get the parent PID from the process details - // For now, return a special action that the app can handle - // The app has access to the process details and can extract parent_pid - ModalAction::SwitchToParentProcess(*pid) - } 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::ProcessDetails { .. }), ModalButton::Ok) => { - self.pop_modal(); - ModalAction::Dismiss - } - (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(&mut self, f: &mut Frame, data: ProcessModalData) { - if let Some(m) = self.stack.last().cloned() { - self.render_background_dim(f); - self.render_modal_content(f, &m, data); - } - } - - 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(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) { - let area = f.area(); - // Different sizes for different modal types - let modal_area = match modal { - ModalType::ProcessDetails { .. } => { - // Process details modal uses almost full screen (95% width, 90% height) - self.centered_rect(95, 90, area) - } - _ => { - // Other modals use smaller size - 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::ProcessDetails { pid } => { - self.render_process_details(f, modal_area, *pid, data) - } - ModalType::Confirmation { - title, - message, - confirm_text, - cancel_text, - } => self.render_confirmation(f, modal_area, title, message, confirm_text, cancel_text), - ModalType::Info { title, message } => self.render_info(f, modal_area, title, message), - } - } - - fn render_connection_error( - &self, - f: &mut Frame, - area: Rect, - message: &str, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - ) { - let duration_text = format_duration(disconnected_at.elapsed()); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(4), - ]) - .split(area); - let block = Block::default() - .title(ICON_WARNING_TITLE) - .title_style( - Style::default() - .fg(MODAL_TITLE_FG) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(MODAL_BORDER_FG)) - .style(Style::default().bg(MODAL_BG).fg(MODAL_FG)); - f.render_widget(block, area); - - let content_area = chunks[1]; - let max_w = content_area.width.saturating_sub(15) as usize; - let clean_message = if message.to_lowercase().contains("hostname verification") - || message.contains("socktop_connector") - { - "Connection failed - hostname verification disabled".to_string() - } else if message.contains("Failed to fetch metrics:") { - if let Some(p) = message.find(':') { - let ess = message[p + 1..].trim(); - if ess.len() > max_w { - format!("{}...", &ess[..max_w.saturating_sub(3)]) - } else { - ess.to_string() - } - } else { - "Connection error".to_string() - } - } else if message.starts_with("Retry failed:") { - if let Some(p) = message.find(':') { - let ess = message[p + 1..].trim(); - if ess.len() > max_w { - format!("{}...", &ess[..max_w.saturating_sub(3)]) - } else { - ess.to_string() - } - } else { - "Retry failed".to_string() - } - } else if message.len() > max_w { - format!("{}...", &message[..max_w.saturating_sub(3)]) - } else { - message.to_string() - }; - let truncate = |s: &str| { - if s.len() > max_w { - format!("{}...", &s[..max_w.saturating_sub(3)]) - } else { - s.to_string() - } - }; - let agent_text = truncate("๐Ÿ“ก Cannot connect to socktop agent"); - let message_text = truncate(&clean_message); - let duration_display = truncate(&duration_text); - let retry_display = truncate(&retry_count.to_string()); - let countdown_text = auto_retry_countdown.map(|c| { - if c == 0 { - "Auto retry now...".to_string() - } else { - format!("{c}s") - } - }); - - // Determine if we have enough space (height + width) to show large centered icon - let icon_max_width = LARGE_ERROR_ICON - .iter() - .map(|l| l.trim().chars().count()) - .max() - .unwrap_or(0) as u16; - let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8) - && content_area.width >= icon_max_width + 6; // small margin for borders/padding - let mut icon_lines: Vec = Vec::new(); - if large_allowed { - for &raw in LARGE_ERROR_ICON.iter() { - let trimmed = raw.trim(); - icon_lines.push(Line::from( - trimmed - .chars() - .map(|ch| { - if ch == '!' { - Span::styled( - ch.to_string(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else if ch == '/' || ch == '\\' || ch == '_' { - // keep outline in pink - Span::styled( - ch.to_string(), - Style::default() - .fg(MODAL_ICON_PINK) - .add_modifier(Modifier::BOLD), - ) - } else if ch == ' ' { - Span::raw(" ") - } else { - Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK)) - } - }) - .collect::>(), - )); - } - icon_lines.push(Line::from("")); // blank spacer line below icon - } - - let mut info_lines: Vec = Vec::new(); - if !large_allowed { - info_lines.push(Line::from(vec![Span::styled( - ICON_CLUSTER, - Style::default().fg(MODAL_ICON_PINK), - )])); - info_lines.push(Line::from("")); - } - info_lines.push(Line::from(vec![Span::styled( - &agent_text, - Style::default().fg(MODAL_AGENT_FG), - )])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)), - Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)), - ])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled( - ICON_OFFLINE_LABEL, - Style::default().fg(MODAL_OFFLINE_LABEL_FG), - ), - Span::styled( - &duration_display, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - info_lines.push(Line::from(vec![ - Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)), - Span::styled( - &retry_display, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - if let Some(cd) = &countdown_text { - info_lines.push(Line::from(vec![ - Span::styled( - ICON_COUNTDOWN_LABEL, - Style::default().fg(MODAL_COUNTDOWN_LABEL_FG), - ), - Span::styled( - cd, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - } - - let constrained = Rect { - x: content_area.x + 2, - y: content_area.y, - width: content_area.width.saturating_sub(4), - height: content_area.height, - }; - if large_allowed { - let split = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(icon_lines.len() as u16), - Constraint::Min(0), - ]) - .split(constrained); - // Center the icon block; each line already trimmed so per-line centering keeps shape - f.render_widget( - Paragraph::new(Text::from(icon_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: false }), - split[0], - ); - f.render_widget( - Paragraph::new(Text::from(info_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - split[1], - ); - } else { - f.render_widget( - Paragraph::new(Text::from(info_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - constrained, - ); - } - - let button_area = Rect { - x: chunks[2].x, - y: chunks[2].y, - width: chunks[2].width, - height: chunks[2].height.saturating_sub(1), - }; - self.render_connection_error_buttons(f, button_area); - } - - fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) { - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(15), - Constraint::Percentage(10), - Constraint::Percentage(15), - Constraint::Percentage(30), - ]) - .split(area); - let retry_style = if self.active_button == ModalButton::Retry { - Style::default() - .bg(BTN_RETRY_BG_ACTIVE) - .fg(BTN_RETRY_FG_ACTIVE) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(BTN_RETRY_FG_INACTIVE) - .add_modifier(Modifier::DIM) - }; - let exit_style = if self.active_button == ModalButton::Exit { - Style::default() - .bg(BTN_EXIT_BG_ACTIVE) - .fg(BTN_EXIT_FG_ACTIVE) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(BTN_EXIT_FG_INACTIVE) - .add_modifier(Modifier::DIM) - }; - f.render_widget( - Paragraph::new(Text::from(Line::from(vec![Span::styled( - BTN_RETRY_TEXT, - retry_style, - )]))) - .alignment(Alignment::Center), - button_chunks[1], - ); - f.render_widget( - Paragraph::new(Text::from(Line::from(vec![Span::styled( - BTN_EXIT_TEXT, - exit_style, - )]))) - .alignment(Alignment::Center), - button_chunks[3], - ); - } - - fn render_confirmation( - &self, - f: &mut Frame, - area: Rect, - title: &str, - message: &str, - confirm_text: &str, - cancel_text: &str, - ) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3)]) - .split(area); - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .style(Style::default().bg(Color::Black)); - f.render_widget(block, area); - f.render_widget( - Paragraph::new(message) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - chunks[0], - ); - let buttons = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[1]); - let confirm_style = if self.active_button == ModalButton::Confirm { - Style::default() - .bg(Color::Green) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Green) - }; - let cancel_style = if self.active_button == ModalButton::Cancel { - Style::default() - .bg(Color::Red) - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Red) - }; - f.render_widget( - Paragraph::new(confirm_text) - .style(confirm_style) - .alignment(Alignment::Center), - buttons[0], - ); - f.render_widget( - Paragraph::new(cancel_text) - .style(cancel_style) - .alignment(Alignment::Center), - buttons[1], - ); - } - - fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3)]) - .split(area); - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .style(Style::default().bg(Color::Black)); - f.render_widget(block, area); - f.render_widget( - Paragraph::new(message) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - chunks[0], - ); - let ok_style = if self.active_button == ModalButton::Ok { - Style::default() - .bg(Color::Blue) - .fg(Color::White) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Blue) - }; - f.render_widget( - Paragraph::new("[ Enter ] OK") - .style(ok_style) - .alignment(Alignment::Center), - chunks[1], - ); - } - - fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let vert = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vert[1])[1] - } - - fn render_process_details( - &mut self, - f: &mut Frame, - area: Rect, - pid: u32, - data: ProcessModalData, - ) { - let title = format!("Process Details - PID {pid}"); - - // Use neutral colors to match main UI aesthetic - let block = Block::default().title(title).borders(Borders::ALL); - - // Split the modal into the 3-row layout as designed - let inner = block.inner(area); - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot - Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) - Constraint::Min(6), // Bottom row: Journal events (gets remaining space) - Constraint::Length(1), // Help line - ]) - .split(inner); - - // Render the border - f.render_widget(block, area); - - if let Some(details) = data.details { - // Top Row: CPU sparkline (left) | Thread scatter plot (right) - self.render_top_row_with_sparkline( - f, - main_chunks[0], - &details.process, - data.history.cpu, - ); - - // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) - self.render_middle_row_with_metadata( - f, - main_chunks[1], - &details.process, - data.history.mem, - data.history.io_read, - data.history.io_write, - ); - - // Bottom Row: Journal Events - if let Some(journal) = data.journal { - self.render_journal_events(f, main_chunks[2], journal); - } else { - self.render_loading_journal_events(f, main_chunks[2]); - } - } else if data.unsupported { - // Agent doesn't support this feature - self.render_unsupported_message(f, main_chunks[0]); - self.render_loading_middle_row(f, main_chunks[1]); - self.render_loading_journal_events(f, main_chunks[2]); - } else { - // Loading states for all sections - self.render_loading_top_row(f, main_chunks[0]); - self.render_loading_middle_row(f, main_chunks[1]); - self.render_loading_journal_events(f, main_chunks[2]); - } - - // Help line - let help_text = vec![Line::from(vec![ - Span::styled( - "X ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - "P ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "goto parent ", - Style::default().add_modifier(Modifier::DIM), - ), - Span::styled( - "j/k ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - "[ ] ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), - ])]; - let help = Paragraph::new(help_text).alignment(Alignment::Center); - f.render_widget(help, main_chunks[3]); - } - - fn render_thread_scatter_plot( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let plot_block = Block::default() - .title("Thread & Process CPU Time") - .borders(Borders::ALL); - - let inner = plot_block.inner(area); - - // Convert CPU times from microseconds to milliseconds for better readability - let main_user_ms = process.cpu_time_user as f64 / 1000.0; - let main_system_ms = process.cpu_time_system as f64 / 1000.0; - - // Calculate max values for scaling - let mut max_user = main_user_ms; - let mut max_system = main_system_ms; - - for child in &process.child_processes { - let child_user_ms = child.cpu_time_user as f64 / 1000.0; - let child_system_ms = child.cpu_time_system as f64 / 1000.0; - max_user = max_user.max(child_user_ms); - max_system = max_system.max(child_system_ms); - } - - // Add some padding to the scale - max_user = (max_user * 1.1).max(1.0); - max_system = (max_system * 1.1).max(1.0); - - // Render the existing scatter plot but in the smaller space - self.render_scatter_plot_content( - f, - inner, - ScatterPlotParams { - process, - main_user_ms, - main_system_ms, - max_user, - max_system, - }, - ); - - // Render the border - f.render_widget(plot_block, area); - } - - fn render_memory_io_graphs( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { - let graphs_block = Block::default() - .title("Memory & I/O") - .borders(Borders::ALL) - .padding(Padding::horizontal(1)); - - let mem_mb = process.mem_bytes as f64 / 1_048_576.0; - let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; - - let mut content_lines = vec![ - Line::from(vec![ - Span::styled("๐Ÿง  Memory", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(""), // Small padding - ]), - Line::from(vec![ - Span::styled(" RSS: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{mem_mb:.1} MB")), - ]), - ]; - - // Add memory sparkline if we have history - if mem_history.len() >= 2 { - let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB - let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); - - // Create mini sparkline using Unicode blocks - let blocks = ['โ–', 'โ–‚', 'โ–ƒ', 'โ–„', 'โ–…', 'โ–†', 'โ–‡', 'โ–ˆ']; - let sparkline_str: String = mem_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Blue)), - ])); - } else { - content_lines.push(Line::from(vec![Span::styled( - " Collecting...", - Style::default().add_modifier(Modifier::DIM), - )])); - } - - content_lines.push(Line::from(vec![ - Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{virtual_mb:.1} MB")), - ])); - - // Add shared memory if available - if let Some(shared_bytes) = process.shared_mem_bytes { - let shared_mb = shared_bytes as f64 / 1_048_576.0; - content_lines.push(Line::from(vec![ - Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{shared_mb:.1} MB")), - ])); - } - - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![ - Span::styled("๐Ÿ’พ Disk I/O", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(""), // Small padding - ])); - - // Add I/O stats if available - match (process.read_bytes, process.write_bytes) { - (Some(read), Some(write)) => { - let read_mb = read as f64 / 1_048_576.0; - let write_mb = write as f64 / 1_048_576.0; - content_lines.push(Line::from(vec![ - Span::styled(" Read: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{read_mb:.1} MB")), - ])); - - // Add read I/O sparkline if we have history - if io_read_history.len() >= 2 { - let read_data: Vec = io_read_history - .iter() - .map(|&bytes| bytes / 1_048_576) - .collect(); // Convert to MB - let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); - - let blocks = ['โ–', 'โ–‚', 'โ–ƒ', 'โ–„', 'โ–…', 'โ–†', 'โ–‡', 'โ–ˆ']; - let sparkline_str: String = read_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Green)), - ])); - } - - content_lines.push(Line::from(vec![ - Span::styled(" Write: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{write_mb:.1} MB")), - ])); - - // Add write I/O sparkline if we have history - if io_write_history.len() >= 2 { - let write_data: Vec = io_write_history - .iter() - .map(|&bytes| bytes / 1_048_576) - .collect(); // Convert to MB - let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); - - let blocks = ['โ–', 'โ–‚', 'โ–ƒ', 'โ–„', 'โ–…', 'โ–†', 'โ–‡', 'โ–ˆ']; - let sparkline_str: String = write_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), - ])); - } - } - _ => { - content_lines.push(Line::from(vec![Span::styled( - " Not available", - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - let content = Paragraph::new(content_lines).block(graphs_block); - - f.render_widget(content, area); - } - - fn render_thread_table( - &mut self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let total_items = process.threads.len() + process.child_processes.len(); - - // Manually calculate inner area (like processes.rs does) - let inner_area = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - - // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) - let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; - - // Calculate and store max scroll for key handler bounds checking - self.thread_scroll_max = if total_items > visible_rows { - total_items.saturating_sub(visible_rows) - } else { - 0 - }; - - // Clamp scroll offset to valid range - let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); - - // Combine threads and processes into rows - let mut rows = Vec::new(); - - // Add threads first - for thread in &process.threads { - rows.push(Row::new(vec![ - Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), - Line::from(format!("{}", thread.tid)), - Line::from(thread.name.clone()), - Line::from(thread.status.clone()), - ])); - } - - // Add child processes - for child in &process.child_processes { - rows.push(Row::new(vec![ - Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), - Line::from(format!("{}", child.pid)), - Line::from(child.name.clone()), - Line::from(format!("{:.1}%", child.cpu_usage)), - ])); - } - - // Create table header - let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) - .style(Style::default().add_modifier(Modifier::BOLD)) - .bottom_margin(1); - - let block = Block::default() - .title(format!( - "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", - process.threads.len(), - process.child_processes.len() - )) - .borders(Borders::ALL) - .padding(Padding::horizontal(1)); - - let table = Table::new( - rows.iter().skip(scroll_offset).take(visible_rows).cloned(), - [ - Constraint::Length(6), - Constraint::Length(10), - Constraint::Min(15), - Constraint::Length(12), - ], - ) - .header(header) - .block(block) - .highlight_style(Style::default()); - - f.render_widget(table, area); - - // Render scrollbar if there are more items than visible - if total_items > visible_rows { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("โ†‘")) - .end_symbol(Some("โ†“")); - - // Use the same max_scroll value we use for clamping - // This ensures the scrollbar position matches our actual scroll range - let mut scrollbar_state = - ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); - - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y + 1, - width: 1, - height: area.height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } - - fn render_journal_events( - &mut self, - f: &mut Frame, - area: Rect, - journal: &socktop_connector::JournalResponse, - ) { - let total_entries = journal.entries.len(); - let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders - - // Calculate and store max scroll for key handler bounds checking - self.journal_scroll_max = if total_entries > visible_lines { - total_entries.saturating_sub(visible_lines) - } else { - 0 - }; - - // Clamp scroll offset to valid range - let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); - - let journal_block = Block::default() - .title(format!( - "Journal Events ({total_entries} entries) - Use [ ] to scroll" - )) - .borders(Borders::ALL); - - let content_lines: Vec = if journal.entries.is_empty() { - vec![ - Line::from(""), - Line::from(Span::styled( - "No journal entries found for this process", - Style::default().add_modifier(Modifier::DIM), - )), - ] - } else { - journal - .entries - .iter() - .skip(scroll_offset) - .take(visible_lines) - .map(|entry| { - let priority_style = match entry.priority { - socktop_connector::LogLevel::Error - | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), - socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), - socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { - Style::default().fg(Color::Blue) - } - _ => Style::default(), - }; - - let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time - let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority - let message = &entry.message[..entry.message.len().min(message_max_len)]; - - Line::from(vec![ - Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), - Span::raw(" "), - Span::styled( - format!("{:>7}", format!("{:?}", entry.priority)), - priority_style, - ), - Span::raw(" "), - Span::raw(message), - if entry.message.len() > message_max_len { - Span::styled("...", Style::default().add_modifier(Modifier::DIM)) - } else { - Span::raw("") - }, - ]) - }) - .collect() - }; - - let content = Paragraph::new(content_lines).block(journal_block); - - f.render_widget(content, area); - - // Render scrollbar if there are more entries than visible - if total_entries > visible_lines { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("โ†‘")) - .end_symbol(Some("โ†“")); - - // Use the same max_scroll value we use for clamping - let mut scrollbar_state = - ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); - - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y + 1, - width: 1, - height: area.height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } - - fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { - if area.width < 20 || area.height < 10 { - // Area too small for meaningful plot - let content = Paragraph::new(vec![Line::from(Span::styled( - "Area too small for plot", - Style::default().fg(MODAL_HINT_FG), - ))]) - .alignment(Alignment::Center) - .style(Style::default().bg(MODAL_BG)); - f.render_widget(content, area); - return; - } - - // Calculate plot dimensions (leave space for axes labels + legend) - let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels - let plot_height = area.height.saturating_sub(6) as usize; // Leave space for legend (3 lines) + X-axis labels (2 lines) + title (1 line) - - if plot_width == 0 || plot_height == 0 { - return; - } - - // Create a 2D grid to represent the plot - let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; - - // Plot main process - let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let main_y = plot_height.saturating_sub(1).saturating_sub( - ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - if main_x < plot_width && main_y < plot_height { - plot_grid[main_y][main_x] = 'โ—'; // Main process marker - } - - // Plot threads (use different marker) - for thread in ¶ms.process.threads { - let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; - let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; - - let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let thread_y = plot_height.saturating_sub(1).saturating_sub( - ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - - if thread_x < plot_width && thread_y < plot_height { - if plot_grid[thread_y][thread_x] == ' ' { - plot_grid[thread_y][thread_x] = 'โ—‹'; // Thread marker (hollow circle) - } else if plot_grid[thread_y][thread_x] == 'โ—‹' { - plot_grid[thread_y][thread_x] = 'โ—Ž'; // Multiple threads at same point - } else { - plot_grid[thread_y][thread_x] = 'โ—‰'; // Mixed threads/processes at same point - } - } - } - - // Plot child processes - for child in ¶ms.process.child_processes { - let child_user_ms = child.cpu_time_user as f64 / 1000.0; - let child_system_ms = child.cpu_time_system as f64 / 1000.0; - - let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let child_y = plot_height.saturating_sub(1).saturating_sub( - ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - - if child_x < plot_width && child_y < plot_height { - if plot_grid[child_y][child_x] == ' ' { - plot_grid[child_y][child_x] = 'โ€ข'; // Child process marker - } else { - plot_grid[child_y][child_x] = 'โ—‰'; // Multiple items at same point - } - } - } - - // Render the plot - let mut lines = Vec::new(); - - // Add Y-axis labels and plot content - for (i, row) in plot_grid.iter().enumerate() { - let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); - // Always format with 4 characters width, right-aligned, to prevent axis shifting - let y_label = if y_value >= 100.0 { - format!("{y_value:>4.0}") - } else { - format!("{y_value:>4.1}") - }; - - let plot_content: String = row.iter().collect(); - - lines.push(Line::from(vec![ - Span::styled(y_label, Style::default()), - Span::styled(" โ”‚", Style::default()), - Span::styled(plot_content, Style::default()), - ])); - } - - // Add X-axis - let x_axis_padding = " ".to_string(); // Match Y-axis label width - let x_axis_line = "โ”€".repeat(plot_width + 1); - lines.push(Line::from(vec![ - Span::styled(x_axis_padding, Style::default()), - Span::styled(x_axis_line, Style::default()), - ])); - - // Add X-axis labels - let x_label_start = "0.0".to_string(); - let x_label_mid = format!("{:.1}", params.max_user / 2.0); - let x_label_end = format!("{:.1}", params.max_user); - - let spacing = plot_width / 3; - let x_labels = format!( - " {}{}{}{}{}", - x_label_start, - " ".repeat(spacing.saturating_sub(x_label_start.len())), - x_label_mid, - " ".repeat(spacing.saturating_sub(x_label_mid.len())), - x_label_end - ); - - lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); - - // Add axis titles with better visibility - lines.push(Line::from(vec![Span::styled( - " User CPU Time (ms) โ†’", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )])); - - // Add Y-axis label and legend at the top - lines.insert( - 0, - Line::from(vec![Span::styled( - "โ†‘ System CPU Time (ms)", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - ); - lines.insert( - 1, - Line::from(vec![Span::styled( - "โ— Main โ—‹ Thread โ€ข Child โ—‰ Multiple", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::DIM), - )]), - ); - lines.insert(2, Line::from("")); // Spacing after legend - - let content = Paragraph::new(lines) - .style(Style::default()) - .alignment(Alignment::Left); - - f.render_widget(content, area); - } - - fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) - .split(area); - - self.render_loading_metadata(f, top_chunks[0]); - self.render_loading_scatter(f, top_chunks[1]); - } - - fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(area); - - self.render_loading_graphs(f, middle_chunks[0]); - self.render_loading_table(f, middle_chunks[1]); - self.render_loading_command(f, middle_chunks[2]); - } - - fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Process Info & CPU History") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading process metadata...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Thread CPU Time Distribution") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading CPU time data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { - let block = Block::default().title("Memory & I/O").borders(Borders::ALL); - - let content = Paragraph::new("Loading memory & I/O data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_table(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Child Processes") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading child process data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_command(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Command & Details") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading command details...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Journal Events") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading journal entries...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Process Details") - .borders(Borders::ALL); - - let content = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - "โš  Agent Update Required", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "This agent version does not support per-process metrics.", - Style::default().add_modifier(Modifier::DIM), - )), - Line::from(Span::styled( - "Please update your socktop_agent to the latest version.", - Style::default().add_modifier(Modifier::DIM), - )), - ]) - .block(block) - .alignment(Alignment::Center); - - f.render_widget(content, area); - } - - fn render_top_row_with_sparkline( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - cpu_history: &std::collections::VecDeque, - ) { - // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(60), // CPU sparkline - Constraint::Percentage(40), // Thread scatter plot - ]) - .split(area); - - self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); - self.render_thread_scatter_plot(f, top_chunks[1], process); - } - - fn render_cpu_sparkline( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - cpu_history: &std::collections::VecDeque, - ) { - // Normalize CPU to 0-100% by dividing by thread count - // This shows per-core utilization rather than total utilization across all cores - let thread_count = process.thread_count; - - // Calculate actual average and current (normalized to 0-100%) - let current_cpu = normalize_cpu_usage( - cpu_history.back().copied().unwrap_or(0.0), - thread_count - ); - let avg_cpu = if cpu_history.is_empty() { - 0.0 - } else { - let total: f32 = cpu_history.iter().sum(); - normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) - }; - let title = format!("๐Ÿ“Š CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); - - // Similar to main CPU rendering but for process CPU - if cpu_history.len() < 2 { - let block = Block::default().title(title).borders(Borders::ALL); - let inner = block.inner(area); - f.render_widget(block, area); - - let content = Paragraph::new("Collecting CPU history data...") - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - f.render_widget(content, inner); - return; - } - - let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels - let start = cpu_history.len().saturating_sub(max_points); - - // Create data points for the chart (normalized to 0-100%) - let data: Vec<(f64, f64)> = cpu_history - .iter() - .skip(start) - .enumerate() - .map(|(i, &val)| { - let normalized = normalize_cpu_usage(val, thread_count); - (i as f64, normalized as f64) - }) - .collect(); - - let datasets = vec![ - Dataset::default() - .name("CPU %") - .marker(ratatui::symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(Color::Cyan)) - .data(&data), - ]; - - let x_max = data.len().max(1) as f64; - - // Dynamic Y-axis scaling in 10% increments - let max_cpu = data.iter().map(|(_, y)| *y).fold(0.0f64, f64::max); - let y_max = calculate_dynamic_y_max(max_cpu); - - let y_labels = vec![ - Line::from("0%"), - Line::from(format!("{:.0}%", y_max / 2.0)), - Line::from(format!("{y_max:.0}%")), - ]; - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::ALL).title(title)) - .x_axis( - Axis::default() - .style(Style::default().fg(Color::Gray)) - .bounds([0.0, x_max]), - ) - .y_axis( - Axis::default() - .style(Style::default().fg(Color::Gray)) - .labels(y_labels) - .bounds([0.0, y_max]), - ); - - f.render_widget(chart, area); - } - - fn render_middle_row_with_metadata( - &mut self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { - // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(area); - - self.render_memory_io_graphs( - f, - middle_chunks[0], - process, - mem_history, - io_read_history, - io_write_history, - ); - self.render_thread_table(f, middle_chunks[1], process); - self.render_command_and_metadata(f, middle_chunks[2], process); - } - - fn render_command_and_metadata( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let details_block = Block::default() - .title("Command & Details") - .borders(Borders::ALL) - .padding(Padding::horizontal(1)); - - // Calculate uptime - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let uptime_secs = now.saturating_sub(process.start_time); - let uptime_str = format_uptime(uptime_secs); - - // Format CPU times - let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; - let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; - - let mut content_lines = vec![ - Line::from(vec![ - Span::styled("โšก Status: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&process.status), - ]), - Line::from(vec![ - Span::styled("โฑ๏ธ Uptime: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(uptime_str), - ]), - Line::from(vec![ - Span::styled("๐Ÿงต Threads: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.thread_count)), - ]), - Line::from(vec![ - Span::styled("๐Ÿ‘ถ Children: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.child_processes.len())), - ]), - ]; - - // Add file descriptors if available - if let Some(fd_count) = process.fd_count { - content_lines.push(Line::from(vec![ - Span::styled("๐Ÿ“ FDs: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{fd_count}")), - ])); - } - - content_lines.push(Line::from("")); - - // Process hierarchy with clickable parent PID - if let Some(ppid) = process.parent_pid { - content_lines.push(Line::from(vec![ - Span::styled("๐Ÿ‘ช Parent: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled( - format!("{ppid}"), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED), - ), - Span::styled( - " [P]", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::DIM), - ), - ])); - } - - content_lines.push(Line::from(vec![ - Span::styled("๐Ÿ‘ค UID: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.user_id)), - Span::styled(" ๐Ÿ‘ฅ GID: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.group_id)), - ])); - - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![Span::styled( - "โฒ๏ธ CPU Time", - Style::default().add_modifier(Modifier::BOLD), - )])); - content_lines.push(Line::from(vec![ - Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{user_time_sec:.2}s")), - ])); - content_lines.push(Line::from(vec![ - Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{system_time_sec:.2}s")), - ])); - - content_lines.push(Line::from("")); - - // Executable path if available - if let Some(exe) = &process.executable_path { - content_lines.push(Line::from(vec![Span::styled( - "๐Ÿ“‚ Executable", - Style::default().add_modifier(Modifier::BOLD), - )])); - // Truncate if too long - let max_width = (area.width.saturating_sub(6)) as usize; - if exe.len() > max_width { - let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); - content_lines.push(Line::from(vec![Span::styled( - format!(" {truncated}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } else { - content_lines.push(Line::from(vec![Span::styled( - format!(" {exe}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - // Working directory if available - if let Some(cwd) = &process.working_directory { - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![Span::styled( - "๐Ÿ“ Working Dir", - Style::default().add_modifier(Modifier::BOLD), - )])); - // Truncate if too long - let max_width = (area.width.saturating_sub(6)) as usize; - if cwd.len() > max_width { - let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); - content_lines.push(Line::from(vec![Span::styled( - format!(" {truncated}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } else { - content_lines.push(Line::from(vec![Span::styled( - format!(" {cwd}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - content_lines.push(Line::from("")); - - // Add command line (wrap if needed) - content_lines.push(Line::from(vec![Span::styled( - "โš™๏ธ Command", - Style::default().add_modifier(Modifier::BOLD), - )])); - - - // Split command into multiple lines if too long - let cmd_text = &process.command; - let max_width = (area.width.saturating_sub(6)) as usize; // More conservative to avoid wrapping issues - if cmd_text.len() > max_width { - for chunk in cmd_text.as_bytes().chunks(max_width) { - if let Ok(s) = std::str::from_utf8(chunk) { - content_lines.push(Line::from(vec![Span::styled( - format!(" {s}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - } else { - content_lines.push(Line::from(vec![Span::styled( - format!(" {cmd_text}"), - Style::default().add_modifier(Modifier::DIM), - )])); - } - - let content = Paragraph::new(content_lines).block(details_block); - - f.render_widget(content, area); - } -} diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index 33fd194..5ebe517 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -499,7 +499,6 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces } } -/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) /// Parameters for process key event handling pub struct ProcessKeyParams<'a> { pub selected_process_pid: &'a mut Option, @@ -509,16 +508,6 @@ pub struct ProcessKeyParams<'a> { pub filtered_indices: &'a [usize], } -/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality -#[allow(dead_code)] -pub fn processes_handle_key( - scroll_offset: &mut usize, - key: crossterm::event::KeyEvent, - page_size: usize, -) { - crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); -} - pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { use crossterm::event::KeyCode; @@ -598,83 +587,6 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { } } -/// Handle mouse for content scrolling and scrollbar dragging. -/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked. -/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality -#[allow(dead_code)] -pub fn processes_handle_mouse( - scroll_offset: &mut usize, - drag: &mut Option, - mouse: MouseEvent, - area: Rect, - total_rows: usize, -) -> Option { - // Inner and content areas (match draw_top_processes) - let inner = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - if inner.height == 0 || inner.width <= 2 { - return None; - } - let content = Rect { - x: inner.x, - y: inner.y, - width: inner.width.saturating_sub(2), - height: inner.height, - }; - - // Scrollbar interactions (click arrows/page/drag) - per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows); - - // Wheel scrolling when inside the content - crate::ui::cpu::per_core_handle_mouse(scroll_offset, mouse, content, content.height as usize); - - // Header click to change sort - let header_area = Rect { - x: content.x, - y: content.y, - width: content.width, - height: 1, - }; - let inside_header = mouse.row == header_area.y - && mouse.column >= header_area.x - && mouse.column < header_area.x + header_area.width; - - if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - // Split the header the same way the draw path did, so a click lands on the - // column actually on screen even when PID has been dropped. - let columns = ProcColumns::for_width(header_area.width); - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints(columns.constraints()) - .spacing(COL_SPACING) // must match Table::column_spacing in the draw path - .split(header_area); - if let Some(cpu) = columns.cpu_index().map(|i| cols[i]) - && mouse.column >= cpu.x - && mouse.column < cpu.x + cpu.width - { - return Some(ProcSortBy::CpuDesc); - } - if let Some(mem) = columns.mem_index().map(|i| cols[i]) - && mouse.column >= mem.x - && mouse.column < mem.x + mem.width - { - return Some(ProcSortBy::MemDesc); - } - } - - // Clamp to valid range - per_core_clamp( - scroll_offset, - total_rows, - (content.height.saturating_sub(1)) as usize, - ); - None -} - /// Parameters for process mouse event handling pub struct ProcessMouseParams<'a> { pub scroll_offset: &'a mut usize, @@ -1003,20 +915,29 @@ mod click_tests { } fn click(width: u16, column: u16) -> Option { + let m = metrics(); let mut scroll = 0usize; let mut drag = None; - processes_handle_mouse( - &mut scroll, - &mut drag, - MouseEvent { + let mut sel_pid = None; + let mut sel_idx = None; + let idxs = [0usize]; + processes_handle_mouse_with_selection(ProcessMouseParams { + scroll_offset: &mut scroll, + selected_process_pid: &mut sel_pid, + selected_process_index: &mut sel_idx, + drag: &mut drag, + mouse: MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row: 1, modifiers: KeyModifiers::NONE, }, - Rect::new(0, 0, width, 8), - 1, - ) + area: Rect::new(0, 0, width, 8), + total_rows: 1, + metrics: Some(&m), + search_box_visible: false, + filtered_indices: &idxs, + }) } /// The hit-test rects are computed by a separate `Layout` call from the one `Table` diff --git a/socktop/src/ws.rs b/socktop/src/ws.rs deleted file mode 100644 index e69de29..0000000 diff --git a/socktop_agent/src/ws.rs b/socktop_agent/src/ws.rs index 7d70865..a02f15f 100644 --- a/socktop_agent/src/ws.rs +++ b/socktop_agent/src/ws.rs @@ -16,9 +16,7 @@ use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all} use crate::proto::pb; use crate::state::AppState; -// Compression threshold based on typical payload size -// Temporarily increased for testing - revert to 768 for production -//const COMPRESSION_THRESHOLD: usize = 50_000; +// Payloads at or below this many bytes are sent as-is; larger ones are gzipped. const COMPRESSION_THRESHOLD: usize = 768; // Reusable buffer for compression to avoid allocations diff --git a/socktop_connector/src/connector.rs b/socktop_connector/src/connector.rs deleted file mode 100644 index 3ed5b35..0000000 --- a/socktop_connector/src/connector.rs +++ /dev/null @@ -1,1152 +0,0 @@ -//! WebSocket connector for communicating with socktop agents. - -// WebSocket state constants -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_CONNECTING: u16 = 0; -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_OPEN: u16 = 1; -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_CLOSING: u16 = 2; -#[cfg(feature = "wasm")] -#[allow(dead_code)] -const WEBSOCKET_CLOSED: u16 = 3; - -// Gzip magic header constants -const GZIP_MAGIC_1: u8 = 0x1f; -const GZIP_MAGIC_2: u8 = 0x8b; - -// Shared imports for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -use flate2::read::GzDecoder; -#[cfg(any(feature = "networking", feature = "wasm"))] -use std::io::Read; -#[cfg(any(feature = "networking", feature = "wasm"))] -use prost::Message as ProstMessage; - -#[cfg(feature = "networking")] -use futures_util::{SinkExt, StreamExt}; -#[cfg(feature = "networking")] -use std::io::BufReader; -#[cfg(feature = "networking")] -use tokio::net::TcpStream; -#[cfg(feature = "networking")] -use tokio_tungstenite::{ - MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message, - tungstenite::client::IntoClientRequest, -}; -#[cfg(feature = "networking")] -use url::Url; - -#[cfg(feature = "wasm")] -use web_sys::WebSocket; - -#[cfg(all(feature = "wasm", not(feature = "networking")))] -use pb::Processes; -#[cfg(all(feature = "wasm", not(feature = "networking")))] -use wasm_bindgen::{JsCast, JsValue, closure::Closure}; - -#[cfg(feature = "tls")] -use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -#[cfg(feature = "tls")] -use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -#[cfg(feature = "tls")] -use rustls::{ClientConfig, RootCertStore}; -#[cfg(feature = "tls")] -use rustls::{DigitallySignedStruct, SignatureScheme}; -#[cfg(feature = "tls")] -use rustls_pemfile::Item; -#[cfg(feature = "tls")] -use std::{fs::File, sync::Arc}; -#[cfg(feature = "tls")] -use tokio_tungstenite::{Connector, connect_async_tls_with_config}; - -use crate::error::{ConnectorError, Result}; -use crate::types::{AgentRequest, AgentResponse}; -#[cfg(any(feature = "networking", feature = "wasm"))] -use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, ProcessMetricsResponse, JournalResponse}; -#[cfg(feature = "tls")] -fn ensure_crypto_provider() { - use std::sync::Once; - static INIT: Once = Once::new(); - INIT.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); -} - -#[cfg(any(feature = "networking", feature = "wasm"))] -mod pb { - // generated by build.rs - include!(concat!(env!("OUT_DIR"), "/socktop.rs")); -} - -#[cfg(feature = "networking")] -pub type WsStream = WebSocketStream>; - -/// Configuration for connecting to a socktop agent -#[derive(Debug, Clone)] -pub struct ConnectorConfig { - pub url: String, - pub tls_ca_path: Option, - pub verify_hostname: bool, - pub ws_protocols: Option>, - pub ws_version: Option, -} - -impl ConnectorConfig { - pub fn new(url: impl Into) -> Self { - Self { - url: url.into(), - tls_ca_path: None, - verify_hostname: false, - ws_protocols: None, - ws_version: None, - } - } - - pub fn with_tls_ca(mut self, ca_path: impl Into) -> Self { - self.tls_ca_path = Some(ca_path.into()); - self - } - - pub fn with_hostname_verification(mut self, verify: bool) -> Self { - self.verify_hostname = verify; - self - } - - /// Set WebSocket sub-protocols to negotiate - pub fn with_protocols(mut self, protocols: Vec) -> Self { - self.ws_protocols = Some(protocols); - self - } - - /// Set WebSocket protocol version (default is "13") - pub fn with_version(mut self, version: impl Into) -> Self { - self.ws_version = Some(version.into()); - self - } -} - -/// A WebSocket connector for communicating with socktop agents. -/// When the `networking` feature is disabled, the connector struct is available -/// for type compatibility but networking methods will return errors. -pub struct SocktopConnector { - config: ConnectorConfig, - #[cfg(feature = "networking")] - stream: Option, - #[cfg(feature = "wasm")] - #[allow(dead_code)] // Used in WASM builds - websocket: Option, -} - -impl SocktopConnector { - /// Create a new connector with the given configuration - pub fn new(config: ConnectorConfig) -> Self { - Self { - config, - #[cfg(feature = "networking")] - stream: None, - #[cfg(feature = "wasm")] - websocket: None, - } - } -} - -#[cfg(feature = "networking")] -impl SocktopConnector { - /// Connect to the agent - pub async fn connect(&mut self) -> Result<()> { - let stream = connect_to_agent(&self.config).await?; - self.stream = Some(stream); - Ok(()) - } - - /// Send a request to the agent and get the response - pub async fn request(&mut self, request: AgentRequest) -> Result { - let stream = self.stream.as_mut().ok_or(ConnectorError::NotConnected)?; - - match request { - AgentRequest::Metrics => { - let metrics = request_metrics(stream) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get metrics"))?; - Ok(AgentResponse::Metrics(metrics)) - } - AgentRequest::Disks => { - let disks = request_disks(stream) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get disks"))?; - Ok(AgentResponse::Disks(disks)) - } - AgentRequest::Processes => { - let processes = request_processes(stream) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; - Ok(AgentResponse::Processes(processes)) - } - AgentRequest::ProcessMetrics { pid } => { - let process_metrics = request_process_metrics(stream, pid) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get process metrics"))?; - Ok(AgentResponse::ProcessMetrics(process_metrics)) - } - AgentRequest::JournalEntries { pid } => { - let journal_entries = request_journal_entries(stream, pid) - .await - .ok_or_else(|| ConnectorError::invalid_response("Failed to get journal entries"))?; - Ok(AgentResponse::JournalEntries(journal_entries)) - } - } - } - - /// Check if the connector is connected - pub fn is_connected(&self) -> bool { - self.stream.is_some() - } - - /// Disconnect from the agent - pub async fn disconnect(&mut self) -> Result<()> { - if let Some(mut stream) = self.stream.take() { - let _ = stream.close(None).await; - } - Ok(()) - } -} - -// Connect to the agent and return the WS stream -#[cfg(feature = "networking")] -async fn connect_to_agent(config: &ConnectorConfig) -> Result { - #[cfg(feature = "tls")] - ensure_crypto_provider(); - - let mut u = Url::parse(&config.url)?; - if let Some(ca_path) = &config.tls_ca_path { - if u.scheme() == "ws" { - let _ = u.set_scheme("wss"); - } - return connect_with_ca_and_config(u.as_str(), ca_path, config).await; - } - // No TLS - hostname verification is not applicable - connect_without_ca_and_config(u.as_str(), config).await -} - -#[cfg(feature = "networking")] -async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> Result { - let mut req = url.into_client_request()?; - - // Apply WebSocket protocol configuration - if let Some(version) = &config.ws_version { - req.headers_mut().insert( - "Sec-WebSocket-Version", - version - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?, - ); - } - - if let Some(protocols) = &config.ws_protocols { - let protocols_str = protocols.join(", "); - req.headers_mut().insert( - "Sec-WebSocket-Protocol", - protocols_str - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?, - ); - } - - let (ws, _) = connect_async(req).await?; - Ok(ws) -} - -#[cfg(feature = "tls")] -#[cfg(feature = "networking")] -async fn connect_with_ca_and_config( - url: &str, - ca_path: &str, - config: &ConnectorConfig, -) -> Result { - // Initialize the crypto provider for rustls - let _ = rustls::crypto::ring::default_provider().install_default(); - - let mut root = RootCertStore::empty(); - let mut reader = BufReader::new(File::open(ca_path)?); - let mut der_certs = Vec::new(); - while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) { - if let Item::X509Certificate(der) = item { - der_certs.push(der); - } - } - root.add_parsable_certificates(der_certs); - - let mut cfg = ClientConfig::builder() - .with_root_certificates(root) - .with_no_client_auth(); - - let mut req = url.into_client_request()?; - - // Apply WebSocket protocol configuration - if let Some(version) = &config.ws_version { - req.headers_mut().insert( - "Sec-WebSocket-Version", - version - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?, - ); - } - - if let Some(protocols) = &config.ws_protocols { - let protocols_str = protocols.join(", "); - req.headers_mut().insert( - "Sec-WebSocket-Protocol", - protocols_str - .parse() - .map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?, - ); - } - - if !config.verify_hostname { - #[derive(Debug)] - struct NoVerify; - impl ServerCertVerifier for NoVerify { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> std::result::Result { - Ok(ServerCertVerified::assertion()) - } - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ED25519, - SignatureScheme::RSA_PSS_SHA256, - ] - } - } - cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify)); - // 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( - req, - None, - config.verify_hostname, - Some(Connector::Rustls(cfg)), - ) - .await?; - Ok(ws) -} - -#[cfg(not(feature = "tls"))] -#[cfg(feature = "networking")] -async fn connect_with_ca_and_config( - _url: &str, - _ca_path: &str, - _config: &ConnectorConfig, -) -> Result { - Err(ConnectorError::tls_error( - "TLS support not compiled in", - std::io::Error::new(std::io::ErrorKind::Unsupported, "TLS not available"), - )) -} - -// Send a "get_metrics" request and await a single JSON reply -#[cfg(feature = "networking")] -async fn request_metrics(ws: &mut WsStream) -> Option { - if ws.send(Message::Text("get_metrics".into())).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Send a "get_disks" request and await a JSON Vec -#[cfg(feature = "networking")] -async fn request_disks(ws: &mut WsStream) -> Option> { - if ws.send(Message::Text("get_disks".into())).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::>(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::>(&json).ok(), - _ => None, - } -} - -// Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped) -#[cfg(feature = "networking")] -async fn request_processes(ws: &mut WsStream) -> Option { - if ws - .send(Message::Text("get_processes".into())) - .await - .is_err() - { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - let gz = is_gzip(&b); - let data = if gz { gunzip_to_vec(&b).ok()? } else { b }; - match pb::Processes::decode(data.as_slice()) { - Ok(pb) => { - let rows: Vec = pb - .rows - .into_iter() - .map(|p: pb::Process| ProcessInfo { - pid: p.pid, - name: p.name, - cpu_usage: p.cpu_usage, - mem_bytes: p.mem_bytes, - }) - .collect(); - Some(ProcessesPayload { - process_count: pb.process_count as usize, - top_processes: rows, - }) - } - Err(e) => { - if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") { - eprintln!("protobuf decode failed: {e}"); - } - // Fallback: maybe it's JSON (bytes already decompressed if gz) - match String::from_utf8(data) { - Ok(s) => serde_json::from_str::(&s).ok(), - Err(_) => None, - } - } - } - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse -#[cfg(feature = "networking")] -async fn request_process_metrics(ws: &mut WsStream, pid: u32) -> Option { - let request = format!("get_process_metrics:{}", pid); - if ws.send(Message::Text(request)).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse -#[cfg(feature = "networking")] -async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { - let request = format!("get_journal_entries:{}", pid); - if ws.send(Message::Text(request)).await.is_err() { - return None; - } - match ws.next().await { - Some(Ok(Message::Binary(b))) => { - gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) - } - Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), - _ => None, - } -} - -// Decompress a gzip-compressed binary frame into a String. -/// Unified gzip decompression to string for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -fn gunzip_to_string(bytes: &[u8]) -> Result { - let mut decoder = GzDecoder::new(bytes); - let mut decompressed = String::new(); - decoder.read_to_string(&mut decompressed).map_err(|e| { - ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")) - })?; - Ok(decompressed) -} - -/// Unified gzip decompression to bytes for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -fn gunzip_to_vec(bytes: &[u8]) -> Result> { - let mut decoder = GzDecoder::new(bytes); - let mut decompressed = Vec::new(); - decoder.read_to_end(&mut decompressed).map_err(|e| { - ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")) - })?; - Ok(decompressed) -} - -/// Unified gzip detection for both networking and WASM -#[cfg(any(feature = "networking", feature = "wasm"))] -fn is_gzip(bytes: &[u8]) -> bool { - bytes.len() >= 2 && bytes[0] == GZIP_MAGIC_1 && bytes[1] == GZIP_MAGIC_2 -} - -/// Convenience function to create a connector and connect in one step. -/// -/// This function is for non-TLS WebSocket connections (`ws://`). Since there's no -/// certificate involved, hostname verification is not applicable. -/// -/// For TLS connections with certificate pinning, use `connect_to_socktop_agent_with_tls()`. -#[cfg(feature = "networking")] -pub async fn connect_to_socktop_agent(url: impl Into) -> Result { - let config = ConnectorConfig::new(url); - let mut connector = SocktopConnector::new(config); - connector.connect().await?; - Ok(connector) -} - -/// Convenience function to create a connector with TLS and connect in one step. -/// -/// This function enables TLS with certificate pinning using the provided CA certificate. -/// The `verify_hostname` parameter controls whether the server's hostname is verified -/// against the certificate (recommended for production, can be disabled for testing). -#[cfg(feature = "tls")] -#[cfg(feature = "networking")] -#[cfg_attr(docsrs, doc(cfg(feature = "tls")))] -pub async fn connect_to_socktop_agent_with_tls( - url: impl Into, - ca_path: impl Into, - verify_hostname: bool, -) -> Result { - let config = ConnectorConfig::new(url) - .with_tls_ca(ca_path) - .with_hostname_verification(verify_hostname); - let mut connector = SocktopConnector::new(config); - connector.connect().await?; - Ok(connector) -} - -/// Convenience function to create a connector with custom WebSocket protocol configuration. -/// -/// This function allows you to specify WebSocket protocol version and sub-protocols. -/// Most users should use the simpler `connect_to_socktop_agent()` function instead. -/// -/// # Example -/// ```no_run -/// use socktop_connector::connect_to_socktop_agent_with_config; -/// -/// # #[tokio::main] -/// # async fn main() -> Result<(), Box> { -/// let connector = connect_to_socktop_agent_with_config( -/// "ws://localhost:3000/ws", -/// Some(vec!["socktop".to_string()]), // WebSocket sub-protocols -/// Some("13".to_string()), // WebSocket version (13 is standard) -/// ).await?; -/// # Ok(()) -/// # } -/// ``` -#[cfg(feature = "networking")] -pub async fn connect_to_socktop_agent_with_config( - url: impl Into, - protocols: Option>, - version: Option, -) -> Result { - let mut config = ConnectorConfig::new(url); - - if let Some(protocols) = protocols { - config = config.with_protocols(protocols); - } - - if let Some(version) = version { - config = config.with_version(version); - } - - let mut connector = SocktopConnector::new(config); - connector.connect().await?; - Ok(connector) -} - -// WASM WebSocket implementation -#[cfg(all(feature = "wasm", not(feature = "networking")))] -impl SocktopConnector { - /// Connect to the agent using WASM WebSocket - pub async fn connect(&mut self) -> Result<()> { - let websocket = WebSocket::new(&self.config.url).map_err(|e| { - ConnectorError::protocol_error(format!("Failed to create WebSocket: {e:?}")) - })?; - - // Set binary type for proper message handling - websocket.set_binary_type(web_sys::BinaryType::Arraybuffer); - - // Wait for connection to be ready with proper async delays - let start_time = js_sys::Date::now(); - let timeout_ms = 10000.0; // 10 second timeout (increased from 5) - - // Poll connection status until ready or timeout - loop { - let ready_state = websocket.ready_state(); - - if ready_state == WEBSOCKET_OPEN { - // OPEN - connection is ready - break; - } else if ready_state == WEBSOCKET_CLOSED { - // CLOSED - return Err(ConnectorError::protocol_error( - "WebSocket connection closed", - )); - } else if ready_state == WEBSOCKET_CLOSING { - // CLOSING - return Err(ConnectorError::protocol_error("WebSocket is closing")); - } - - // Check timeout - let now = js_sys::Date::now(); - if now - start_time > timeout_ms { - return Err(ConnectorError::protocol_error( - "WebSocket connection timeout", - )); - } - - // Proper async delay using setTimeout Promise - let promise = js_sys::Promise::new(&mut |resolve, _| { - let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED)); - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 100, // 100ms delay between polls - ) - .unwrap(); - closure.forget(); - }); - - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; - } - - self.websocket = Some(websocket); - Ok(()) - } - - /// Send a request to the agent and get the response - pub async fn request(&mut self, request: AgentRequest) -> Result { - let ws = self - .websocket - .as_ref() - .ok_or(ConnectorError::NotConnected)?; - - // Use the legacy string format that the agent expects - let request_string = request.to_legacy_string(); - - // Send request - ws.send_with_str(&request_string).map_err(|e| { - ConnectorError::protocol_error(format!("Failed to send message: {e:?}")) - })?; - - // Wait for response using JavaScript Promise - let (response, binary_data) = self.wait_for_response_with_binary().await?; - - // Parse the response based on the request type - match request { - AgentRequest::Metrics => { - // Check if this is binary data (protobuf from agent) - if response.starts_with("BINARY_DATA:") { - // Extract the byte count - let byte_count: usize = response - .strip_prefix("BINARY_DATA:") - .unwrap_or("0") - .parse() - .unwrap_or(0); - - // For now, return a placeholder metrics response indicating binary data received - // TODO: Implement proper protobuf decoding for binary data - let placeholder_metrics = Metrics { - cpu_total: 0.0, - cpu_per_core: vec![0.0], - mem_total: 0, - mem_used: 0, - swap_total: 0, - swap_used: 0, - hostname: format!("Binary protobuf data ({byte_count} bytes)"), - cpu_temp_c: None, - disks: vec![], - networks: vec![], - top_processes: vec![], - gpus: None, - process_count: None, - }; - Ok(AgentResponse::Metrics(placeholder_metrics)) - } else { - // Try to parse as JSON (fallback) - let metrics: Metrics = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!( - "Failed to parse metrics: {e}" - )) - })?; - Ok(AgentResponse::Metrics(metrics)) - } - } - AgentRequest::Disks => { - let disks: Vec = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!("Failed to parse disks: {e}")) - })?; - Ok(AgentResponse::Disks(disks)) - } - AgentRequest::Processes => { - log_debug(&format!( - "๐Ÿ” Processing process request - response: {}", - if response.len() > 100 { - format!("{}...", &response[..100]) - } else { - response.clone() - } - )); - log_debug(&format!( - "๐Ÿ” Binary data available: {}", - binary_data.is_some() - )); - if let Some(ref data) = binary_data { - log_debug(&format!("๐Ÿ” Binary data size: {} bytes", data.len())); - // Check if it's gzipped data and decompress it first - if is_gzip(data) { - log_debug("๐Ÿ” Process data is gzipped, decompressing..."); - match gunzip_to_vec(data) { - Ok(decompressed_bytes) => { - log_debug(&format!( - "๐Ÿ” Successfully decompressed {} bytes, now decoding protobuf...", - decompressed_bytes.len() - )); - // Now decode the decompressed bytes as protobuf - match ::decode( - decompressed_bytes.as_slice(), - ) { - Ok(protobuf_processes) => { - log_debug(&format!( - "โœ… Successfully decoded {} processes from gzipped protobuf", - protobuf_processes.rows.len() - )); - - // Convert protobuf processes to ProcessInfo structs - let processes: Vec = protobuf_processes - .rows - .into_iter() - .map(|p| ProcessInfo { - pid: p.pid, - name: p.name, - cpu_usage: p.cpu_usage, - mem_bytes: p.mem_bytes, - }) - .collect(); - - let processes_payload = ProcessesPayload { - top_processes: processes, - process_count: protobuf_processes.process_count - as usize, - }; - return Ok(AgentResponse::Processes(processes_payload)); - } - Err(e) => { - log_debug(&format!( - "โŒ Failed to decode decompressed protobuf: {e}" - )); - } - } - } - Err(e) => { - log_debug(&format!( - "โŒ Failed to decompress gzipped process data: {e}" - )); - } - } - } - } - - // Check if this is binary data (protobuf from agent) - if response.starts_with("BINARY_DATA:") { - // Extract the binary data size and decode protobuf - let byte_count_str = response.strip_prefix("BINARY_DATA:").unwrap_or("0"); - let _byte_count: usize = byte_count_str.parse().unwrap_or(0); - - // Check if we have the actual binary data - if let Some(binary_bytes) = binary_data { - log_debug(&format!( - "๐Ÿ”ง Decoding {} bytes of protobuf process data", - binary_bytes.len() - )); - - // Try to decode the protobuf data using the prost Message trait - match ::decode(&binary_bytes[..]) { - Ok(protobuf_processes) => { - log_debug(&format!( - "โœ… Successfully decoded {} processes from protobuf", - protobuf_processes.rows.len() - )); - - // Convert protobuf processes to ProcessInfo structs - let processes: Vec = protobuf_processes - .rows - .into_iter() - .map(|p| ProcessInfo { - pid: p.pid, - name: p.name, - cpu_usage: p.cpu_usage, - mem_bytes: p.mem_bytes, - }) - .collect(); - - let processes_payload = ProcessesPayload { - top_processes: processes, - process_count: protobuf_processes.process_count as usize, - }; - Ok(AgentResponse::Processes(processes_payload)) - } - Err(e) => { - log_debug(&format!("โŒ Failed to decode protobuf: {e}")); - // Fallback to empty processes - let processes = ProcessesPayload { - top_processes: vec![], - process_count: 0, - }; - Ok(AgentResponse::Processes(processes)) - } - } - } else { - log_debug( - "โŒ Binary data indicator received but no actual binary data preserved", - ); - let processes = ProcessesPayload { - top_processes: vec![], - process_count: 0, - }; - Ok(AgentResponse::Processes(processes)) - } - } else { - // Try to parse as JSON (fallback) - let processes: ProcessesPayload = - serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!( - "Failed to parse processes: {e}" - )) - })?; - Ok(AgentResponse::Processes(processes)) - } - } - AgentRequest::ProcessMetrics { pid: _ } => { - // Parse JSON response for process metrics - let process_metrics: ProcessMetricsResponse = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!("Failed to parse process metrics: {e}")) - })?; - Ok(AgentResponse::ProcessMetrics(process_metrics)) - } - AgentRequest::JournalEntries { pid: _ } => { - // Parse JSON response for journal entries - let journal_entries: JournalResponse = serde_json::from_str(&response).map_err(|e| { - ConnectorError::serialization_error(format!("Failed to parse journal entries: {e}")) - })?; - Ok(AgentResponse::JournalEntries(journal_entries)) - } - } - } - - async fn wait_for_response_with_binary(&self) -> Result<(String, Option>)> { - let ws = self - .websocket - .as_ref() - .ok_or(ConnectorError::NotConnected)?; - - let start_time = js_sys::Date::now(); - let timeout_ms = 10000.0; // 10 second timeout - - // Store the response in a shared location - let response_cell = std::rc::Rc::new(std::cell::RefCell::new(None::)); - let binary_data_cell = std::rc::Rc::new(std::cell::RefCell::new(None::>)); - let error_cell = std::rc::Rc::new(std::cell::RefCell::new(None::)); - - // Use a unique request ID to avoid message collision - let _request_id = js_sys::Math::random(); - let response_received = std::rc::Rc::new(std::cell::RefCell::new(false)); - - // Set up the message handler that only processes if we haven't gotten a response yet - { - let response_cell = response_cell.clone(); - let binary_data_cell = binary_data_cell.clone(); - let response_received = response_received.clone(); - let onmessage_callback = Closure::wrap(Box::new(move |e: web_sys::MessageEvent| { - // Only process if we haven't already received a response for this request - if !*response_received.borrow() { - // Handle text messages (JSON responses for metrics/disks) - if let Ok(data) = e.data().dyn_into::() { - let message = data.as_string().unwrap_or_default(); - if !message.is_empty() { - // Debug: Log what we received (truncated) - let preview = if message.len() > 100 { - format!("{}...", &message[..100]) - } else { - message.clone() - }; - log_debug(&format!("๐Ÿ” Received text: {preview}")); - - *response_cell.borrow_mut() = Some(message); - *response_received.borrow_mut() = true; - } - } - // Handle binary messages (could be JSON as text bytes or actual protobuf) - else if let Ok(array_buffer) = e.data().dyn_into::() { - let uint8_array = js_sys::Uint8Array::new(&array_buffer); - let length = uint8_array.length() as usize; - let mut bytes = vec![0u8; length]; - uint8_array.copy_to(&mut bytes); - - log_debug(&format!("๐Ÿ” Received binary data: {length} bytes")); - - // Debug: Log the first few bytes to see what we're dealing with - let first_bytes = if bytes.len() >= 4 { - format!( - "0x{:02x} 0x{:02x} 0x{:02x} 0x{:02x}", - bytes[0], bytes[1], bytes[2], bytes[3] - ) - } else { - format!("Only {} bytes available", bytes.len()) - }; - log_debug(&format!("๐Ÿ” First bytes: {first_bytes}")); - - // Try to decode as UTF-8 text first (in case it's JSON sent as binary) - match String::from_utf8(bytes.clone()) { - Ok(text) => { - // If it decodes to valid UTF-8, check if it looks like JSON - let trimmed = text.trim(); - if (trimmed.starts_with('{') && trimmed.ends_with('}')) - || (trimmed.starts_with('[') && trimmed.ends_with(']')) - { - log_debug(&format!( - "๐Ÿ” Binary data is actually JSON text: {}", - if text.len() > 100 { - format!("{}...", &text[..100]) - } else { - text.clone() - } - )); - *response_cell.borrow_mut() = Some(text); - *response_received.borrow_mut() = true; - } else { - log_debug(&format!( - "๐Ÿ” Binary data is UTF-8 text but not JSON: {}", - if text.len() > 100 { - format!("{}...", &text[..100]) - } else { - text.clone() - } - )); - *response_cell.borrow_mut() = Some(text); - *response_received.borrow_mut() = true; - } - } - Err(_) => { - // If it's not valid UTF-8, check if it's gzipped data - if is_gzip(&bytes) { - log_debug(&format!( - "๐Ÿ” Binary data appears to be gzipped ({length} bytes)" - )); - // Try to decompress using unified gzip decompression - match gunzip_to_string(&bytes) { - Ok(decompressed_text) => { - log_debug(&format!( - "๐Ÿ” Gzipped data decompressed to text: {}", - if decompressed_text.len() > 100 { - format!("{}...", &decompressed_text[..100]) - } else { - decompressed_text.clone() - } - )); - *response_cell.borrow_mut() = Some(decompressed_text); - *response_received.borrow_mut() = true; - } - Err(e) => { - log_debug(&format!( - "๐Ÿ” Failed to decompress gzip: {e}" - )); - // Fallback: treat as actual binary protobuf data - *binary_data_cell.borrow_mut() = Some(bytes.clone()); - *response_cell.borrow_mut() = - Some(format!("BINARY_DATA:{length}")); - *response_received.borrow_mut() = true; - } - } - } else { - // If it's not valid UTF-8 and not gzipped, it's likely actual binary protobuf data - log_debug(&format!( - "๐Ÿ” Binary data is actual protobuf ({length} bytes)" - )); - *binary_data_cell.borrow_mut() = Some(bytes); - *response_cell.borrow_mut() = - Some(format!("BINARY_DATA:{length}")); - *response_received.borrow_mut() = true; - } - } - } - } else { - // Log what type of data we got - log_debug(&format!("๐Ÿ” Received unknown data type: {:?}", e.data())); - } - } - }) as Box); - ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref())); - onmessage_callback.forget(); - } - - // Set up the error handler - { - let error_cell = error_cell.clone(); - let response_received = response_received.clone(); - let onerror_callback = Closure::wrap(Box::new(move |_e: web_sys::ErrorEvent| { - if !*response_received.borrow() { - *error_cell.borrow_mut() = Some("WebSocket error occurred".to_string()); - *response_received.borrow_mut() = true; - } - }) as Box); - ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); - onerror_callback.forget(); - } - - // Poll for response with proper async delays - loop { - // Check for response - if *response_received.borrow() { - if let Some(response) = response_cell.borrow().as_ref() { - let binary_data = binary_data_cell.borrow().clone(); - return Ok((response.clone(), binary_data)); - } - if let Some(error) = error_cell.borrow().as_ref() { - return Err(ConnectorError::protocol_error(error)); - } - } - - // Check timeout - let now = js_sys::Date::now(); - if now - start_time > timeout_ms { - *response_received.borrow_mut() = true; // Mark as done to prevent future processing - return Err(ConnectorError::protocol_error("WebSocket response timeout")); - } - - // Wait 50ms before checking again - let promise = js_sys::Promise::new(&mut |resolve, _| { - let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED)); - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 50, - ) - .unwrap(); - closure.forget(); - }); - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; - } - } - - /// Check if the connector is connected - pub fn is_connected(&self) -> bool { - self.websocket - .as_ref() - .is_some_and(|ws| ws.ready_state() == WEBSOCKET_OPEN) - } - - /// Disconnect from the agent - pub async fn disconnect(&mut self) -> Result<()> { - if let Some(ws) = self.websocket.take() { - let _ = ws.close(); - } - Ok(()) - } - - /// Request metrics from the agent - pub async fn get_metrics(&mut self) -> Result { - match self.request(AgentRequest::Metrics).await? { - AgentResponse::Metrics(metrics) => Ok(metrics), - _ => Err(ConnectorError::protocol_error( - "Unexpected response type for metrics", - )), - } - } - - /// Request disk information from the agent - pub async fn get_disks(&mut self) -> Result> { - match self.request(AgentRequest::Disks).await? { - AgentResponse::Disks(disks) => Ok(disks), - _ => Err(ConnectorError::protocol_error( - "Unexpected response type for disks", - )), - } - } - - /// Request process information from the agent - pub async fn get_processes(&mut self) -> Result { - match self.request(AgentRequest::Processes).await? { - AgentResponse::Processes(processes) => Ok(processes), - _ => Err(ConnectorError::protocol_error( - "Unexpected response type for processes", - )), - } - } -} - -// Helper function for logging that works in WASI environments -/// Unified debug logging for both networking and WASM modes -#[cfg(any(feature = "networking", feature = "wasm"))] -#[allow(dead_code)] -fn log_debug(message: &str) { - #[cfg(feature = "networking")] - if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") { - eprintln!("{message}"); - } - - #[cfg(all(feature = "wasm", not(feature = "networking")))] - eprintln!("{message}"); -} - -// Stub implementations when neither networking nor wasm is enabled -#[cfg(not(any(feature = "networking", feature = "wasm")))] -impl SocktopConnector { - /// Connect to the socktop agent endpoint. - /// - /// Note: Networking functionality is disabled. Enable the "networking" feature to use this function. - pub async fn connect(&mut self) -> Result<()> { - Err(ConnectorError::protocol_error( - "Networking functionality disabled. Enable the 'networking' feature to connect to agents.", - )) - } - - /// Send a request to the agent and await a response. - /// - /// Note: Networking functionality is disabled. Enable the "networking" feature to use this function. - pub async fn request(&mut self, _request: AgentRequest) -> Result { - Err(ConnectorError::protocol_error( - "Networking functionality disabled. Enable the 'networking' feature to send requests.", - )) - } - - /// Close the connection to the agent. - /// - /// Note: Networking functionality is disabled. This is a no-op when networking is disabled. - pub async fn disconnect(&mut self) -> Result<()> { - Ok(()) // No-op when networking is disabled - } -} diff --git a/test_thiserror.rs b/test_thiserror.rs deleted file mode 100644 index e69de29..0000000