Compare commits

..

No commits in common. "master" and "disk-section-cleanup" have entirely different histories.

15 changed files with 1160 additions and 1340 deletions

1490
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "socktop" name = "socktop"
version = "1.50.0" version = "1.40.0"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"] authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top" description = "Remote system monitor over WebSocket, TUI like top"
edition = "2024" edition = "2024"
@ -9,7 +9,7 @@ readme = "README.md"
[dependencies] [dependencies]
# socktop connector for agent communication # socktop connector for agent communication
socktop_connector = "1.50.0" socktop_connector = { path = "../socktop_connector" }
tokio = { workspace = true } tokio = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
@ -24,4 +24,4 @@ sysinfo = { workspace = true }
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.0" assert_cmd = "2.0"
tempfile = "3" tempfile = "3"

View File

@ -29,14 +29,12 @@ use crate::ui::cpu::{
}; };
use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::modal::{ModalAction, ModalManager, ModalType};
use crate::ui::processes::{ use crate::ui::processes::{
ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection, ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection,
processes_handle_mouse_with_selection,
}; };
use crate::ui::{ use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
swap::draw_swap, swap::draw_swap,
}; };
use socktop_connector::{ use socktop_connector::{
AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent, AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent,
connect_to_socktop_agent_with_tls, connect_to_socktop_agent_with_tls,
@ -85,10 +83,6 @@ pub struct App {
pub selected_process_index: Option<usize>, // Index in the visible/sorted list pub selected_process_index: Option<usize>, // Index in the visible/sorted list
prev_selected_process_pid: Option<u32>, // Track previous selection to detect changes prev_selected_process_pid: Option<u32>, // Track previous selection to detect changes
// Process search state
pub process_search_active: bool,
pub process_search_query: String,
last_procs_poll: Instant, last_procs_poll: Instant,
last_disks_poll: Instant, last_disks_poll: Instant,
procs_interval: Duration, procs_interval: Duration,
@ -104,8 +98,7 @@ pub struct App {
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples) pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation
last_io_write_bytes: Option<u64>, // Previous write bytes for delta calculation last_io_write_bytes: Option<u64>, // Previous write bytes for delta calculation
pub max_process_mem_bytes: u64, // Maximum memory usage observed for current process pub process_details_unsupported: bool, // Track if agent doesn't support process details
pub process_details_unsupported: bool, // Track if agent doesn't support process details
last_process_details_poll: Instant, last_process_details_poll: Instant,
last_journal_poll: Instant, last_journal_poll: Instant,
process_details_interval: Duration, process_details_interval: Duration,
@ -152,8 +145,6 @@ impl App {
selected_process_pid: None, selected_process_pid: None,
selected_process_index: None, selected_process_index: None,
prev_selected_process_pid: None, prev_selected_process_pid: None,
process_search_active: false,
process_search_query: String::new(),
last_procs_poll: Instant::now() last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2)) .checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop .unwrap_or_else(Instant::now), // trigger immediately on first loop
@ -171,7 +162,6 @@ impl App {
process_io_write_history: VecDeque::with_capacity(600), process_io_write_history: VecDeque::with_capacity(600),
last_io_read_bytes: None, last_io_read_bytes: None,
last_io_write_bytes: None, last_io_write_bytes: None,
max_process_mem_bytes: 0,
process_details_unsupported: false, process_details_unsupported: false,
last_process_details_poll: Instant::now() last_process_details_poll: Instant::now()
.checked_sub(Duration::from_secs(10)) .checked_sub(Duration::from_secs(10))
@ -624,8 +614,7 @@ impl App {
{ {
self.clear_process_details(); self.clear_process_details();
} }
// Modal was dismissed, skip normal key processing // Modal was dismissed, continue to normal processing
continue;
} }
ModalAction::Confirm => { ModalAction::Confirm => {
// Handle confirmation action here if needed in the future // Handle confirmation action here if needed in the future
@ -658,53 +647,6 @@ impl App {
} }
} }
// Handle search mode
if self.process_search_active {
match k.code {
KeyCode::Esc => {
// Exit search mode
self.process_search_active = false;
self.process_search_query.clear();
continue;
}
KeyCode::Enter => {
// Exit search mode, keep filter active, and auto-select first result
self.process_search_active = false;
// Auto-select first filtered result
if let Some(m) = self.last_metrics.as_ref() {
let idxs = get_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
);
if !idxs.is_empty() {
let first_idx = idxs[0];
self.selected_process_index = Some(first_idx);
self.selected_process_pid =
Some(m.top_processes[first_idx].pid);
}
}
continue;
}
KeyCode::Backspace => {
self.process_search_query.pop();
continue;
}
KeyCode::Char(c) => {
self.process_search_query.push(c);
continue;
}
KeyCode::Up | KeyCode::Down => {
// Allow arrow keys to navigate even while in search mode
// Fall through to normal navigation handling
}
_ => {
continue; // Block other keys in search mode
}
}
}
// Normal key handling (only if no modal is active or modal didn't consume the key) // Normal key handling (only if no modal is active or modal didn't consume the key)
if matches!( if matches!(
k.code, k.code,
@ -712,35 +654,6 @@ impl App {
) { ) {
self.should_quit = true; self.should_quit = true;
} }
// Activate search mode on '/' (clears query if starting new search, or edits existing)
if matches!(k.code, KeyCode::Char('/')) {
self.process_search_active = true;
// Don't clear query - allow editing existing search
continue;
}
// Clear search filter on 'c' or 'C' (when not in search mode)
if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
&& !self.process_search_query.is_empty()
&& !self.process_search_active
{
self.process_search_query.clear();
self.selected_process_pid = None;
self.selected_process_index = None;
continue;
}
// Show About modal on 'a' or 'A'
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
self.modal_manager.push_modal(ModalType::About);
}
// Show Help modal on 'h' or 'H'
if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) {
self.modal_manager.push_modal(ModalType::Help);
}
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
@ -761,15 +674,22 @@ impl App {
let content = per_core_content_area(top[1]); let content = per_core_content_area(top[1]);
// First try process selection (only handles arrows if a process is selected) // First try process selection (only handles arrows if a process is selected)
let process_handled = if self.last_procs_area.is_some() { let process_handled = if let Some(p_area) = self.last_procs_area {
processes_handle_key_with_selection(ProcessKeyParams { let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
selected_process_pid: &mut self.selected_process_pid, let total_rows = self
selected_process_index: &mut self.selected_process_index, .last_metrics
key: k, .as_ref()
metrics: self.last_metrics.as_ref(), .map(|m| m.top_processes.len())
sort_by: self.procs_sort_by, .unwrap_or(0);
search_query: &self.process_search_query, processes_handle_key_with_selection(
}) &mut self.procs_scroll_offset,
&mut self.selected_process_pid,
&mut self.selected_process_index,
k,
page,
total_rows,
self.last_metrics.as_ref(),
)
} else { } else {
false false
}; };
@ -783,46 +703,6 @@ impl App {
); );
} }
// Auto-scroll to keep selected process visible
if let (Some(selected_idx), Some(p_area)) =
(self.selected_process_index, self.last_procs_area)
&& let Some(m) = self.last_metrics.as_ref()
{
// Get filtered and sorted indices (same as display)
let idxs = get_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
);
// Find the display position of the selected process in filtered list
if let Some(display_pos) =
idxs.iter().position(|&idx| idx == selected_idx)
{
// Calculate viewport size
// Account for: borders (2) + header (1) + search box if active (3)
let extra_rows = if self.process_search_active
|| !self.process_search_query.is_empty()
{
3 // search box with border
} else {
0
};
let viewport_rows =
p_area.height.saturating_sub(3 + extra_rows) as usize;
// Adjust scroll offset to keep selection visible
if display_pos < self.procs_scroll_offset {
// Selection is above viewport, scroll up
self.procs_scroll_offset = display_pos;
} else if display_pos >= self.procs_scroll_offset + viewport_rows {
// Selection is below viewport, scroll down
self.procs_scroll_offset =
display_pos.saturating_sub(viewport_rows - 1);
}
}
}
// Check if process selection changed and clear details if so // Check if process selection changed and clear details if so
if self.selected_process_pid != self.prev_selected_process_pid { if self.selected_process_pid != self.prev_selected_process_pid {
self.clear_process_details(); self.clear_process_details();
@ -919,7 +799,6 @@ impl App {
total_rows: mm.top_processes.len(), total_rows: mm.top_processes.len(),
metrics: self.last_metrics.as_ref(), metrics: self.last_metrics.as_ref(),
sort_by: self.procs_sort_by, sort_by: self.procs_sort_by,
search_query: &self.process_search_query,
}) })
{ {
self.procs_sort_by = new_sort; self.procs_sort_by = new_sort;
@ -1005,11 +884,6 @@ impl App {
let mem_bytes = details.process.mem_bytes; let mem_bytes = details.process.mem_bytes;
push_capped(&mut self.process_mem_history, mem_bytes, 600); push_capped(&mut self.process_mem_history, mem_bytes, 600);
// Track maximum memory usage
if mem_bytes > self.max_process_mem_bytes {
self.max_process_mem_bytes = mem_bytes;
}
// I/O bytes from agent are cumulative, calculate deltas // I/O bytes from agent are cumulative, calculate deltas
if let Some(read) = details.process.read_bytes { if let Some(read) = details.process.read_bytes {
let delta = if let Some(last) = self.last_io_read_bytes let delta = if let Some(last) = self.last_io_read_bytes
@ -1115,7 +989,6 @@ impl App {
self.process_io_write_history.clear(); self.process_io_write_history.clear();
self.last_io_read_bytes = None; self.last_io_read_bytes = None;
self.last_io_write_bytes = None; self.last_io_write_bytes = None;
self.max_process_mem_bytes = 0;
self.process_details_unsupported = false; self.process_details_unsupported = false;
} }
@ -1275,15 +1148,11 @@ impl App {
crate::ui::processes::draw_top_processes( crate::ui::processes::draw_top_processes(
f, f,
procs_area, procs_area,
crate::ui::processes::ProcessDisplayParams { self.last_metrics.as_ref(),
metrics: self.last_metrics.as_ref(), self.procs_scroll_offset,
scroll_offset: self.procs_scroll_offset, self.procs_sort_by,
sort_by: self.procs_sort_by, self.selected_process_pid,
selected_process_pid: self.selected_process_pid, self.selected_process_index,
selected_process_index: self.selected_process_index,
search_query: &self.process_search_query,
search_active: self.process_search_active,
},
); );
// Render modals on top of everything else // Render modals on top of everything else
@ -1300,7 +1169,6 @@ impl App {
io_read: &self.process_io_read_history, io_read: &self.process_io_read_history,
io_write: &self.process_io_write_history, io_write: &self.process_io_write_history,
}, },
max_mem_bytes: self.max_process_mem_bytes,
unsupported: self.process_details_unsupported, unsupported: self.process_details_unsupported,
}, },
); );
@ -1329,8 +1197,6 @@ impl Default for App {
selected_process_pid: None, selected_process_pid: None,
selected_process_index: None, selected_process_index: None,
prev_selected_process_pid: None, prev_selected_process_pid: None,
process_search_active: false,
process_search_query: String::new(),
last_procs_poll: Instant::now() last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2)) .checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop .unwrap_or_else(Instant::now), // trigger immediately on first loop
@ -1348,7 +1214,6 @@ impl Default for App {
process_io_write_history: VecDeque::with_capacity(600), process_io_write_history: VecDeque::with_capacity(600),
last_io_read_bytes: None, last_io_read_bytes: None,
last_io_write_bytes: None, last_io_write_bytes: None,
max_process_mem_bytes: 0,
process_details_unsupported: false, process_details_unsupported: false,
last_process_details_poll: Instant::now() last_process_details_poll: Instant::now()
.checked_sub(Duration::from_secs(10)) .checked_sub(Duration::from_secs(10))

View File

@ -42,8 +42,8 @@ pub fn per_core_content_area(area: Rect) -> Rect {
/// Handles key events for per-core CPU bars. /// Handles key events for per-core CPU bars.
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) { pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
match key.code { match key.code {
KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1), KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1), KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
KeyCode::PageUp => { KeyCode::PageUp => {
let step = page_size.max(1); let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_sub(step); *scroll_offset = scroll_offset.saturating_sub(step);
@ -240,61 +240,20 @@ pub fn draw_cpu_avg_graph(
hist: &std::collections::VecDeque<u64>, hist: &std::collections::VecDeque<u64>,
m: Option<&Metrics>, m: Option<&Metrics>,
) { ) {
// Calculate average CPU over the monitoring period
let avg_cpu = if !hist.is_empty() {
let sum: u64 = hist.iter().sum();
sum as f64 / hist.len() as f64
} else {
0.0
};
let title = if let Some(mm) = m { let title = if let Some(mm) = m {
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu) format!("CPU avg (now: {:>5.1}%)", mm.cpu_total)
} else { } else {
"CPU avg".into() "CPU avg".into()
}; };
// Build the top-right info (CPU temp and polling intervals)
let top_right_info = if let Some(mm) = m {
mm.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into())
} else {
String::new()
};
let max_points = area.width.saturating_sub(2) as usize; let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points); let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect(); let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
// Render the sparkline with title on left
let spark = Sparkline::default() let spark = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(title)) .block(Block::default().borders(Borders::ALL).title(title))
.data(&data) .data(&data)
.max(100) .max(100)
.style(Style::default().fg(Color::Cyan)); .style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area); f.render_widget(spark, area);
// Render the top-right info as text overlay in the top-right corner
if !top_right_info.is_empty() {
let info_area = Rect {
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
y: area.y,
width: top_right_info.len() as u16 + 1,
height: 1,
};
let info_line = Line::from(Span::raw(top_right_info));
f.render_widget(Paragraph::new(info_line), info_area);
}
} }
/// Draws the per-core CPU bars with sparklines and trends. /// Draws the per-core CPU bars with sparklines and trends.

View File

@ -3,8 +3,7 @@
use crate::types::Metrics; use crate::types::Metrics;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
text::{Line, Span}, widgets::{Block, Borders},
widgets::{Block, Borders, Paragraph},
}; };
use std::time::Duration; use std::time::Duration;
@ -18,7 +17,20 @@ pub fn draw_header(
procs_interval: Duration, procs_interval: Duration,
) { ) {
let base = if let Some(mm) = m { let base = if let Some(mm) = m {
format!("socktop — host: {}", mm.hostname) let temp = mm
.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into());
format!("socktop — host: {} | {}", mm.hostname, temp)
} else { } else {
"socktop — connecting...".into() "socktop — connecting...".into()
}; };
@ -26,30 +38,15 @@ pub fn draw_header(
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" }; let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
// Token indicator // Token indicator
let tok_txt = if has_token { "🔑 token" } else { "" }; let tok_txt = if has_token { "🔑 token" } else { "" };
let mi = metrics_interval.as_millis();
let pi = procs_interval.as_millis();
let intervals = format!("{mi}ms metrics | {pi}ms procs");
let mut parts = vec![base, tls_txt.into()]; let mut parts = vec![base, tls_txt.into()];
if !tok_txt.is_empty() { if !tok_txt.is_empty() {
parts.push(tok_txt.into()); parts.push(tok_txt.into());
} }
parts.push("(a: about, h: help, q: quit)".into()); parts.push(intervals);
parts.push("(q to quit)".into());
let title = parts.join(" | "); let title = parts.join(" | ");
// Render the block with left-aligned title
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
// Render polling intervals on the right side
let mi = metrics_interval.as_millis();
let pi = procs_interval.as_millis();
let intervals = format!("{mi}ms metrics | {pi}ms procs");
let intervals_width = intervals.len() as u16;
if area.width > intervals_width + 2 {
let right_area = Rect {
x: area.x + area.width.saturating_sub(intervals_width + 1),
y: area.y,
width: intervals_width,
height: 1,
};
let intervals_line = Line::from(Span::raw(intervals));
f.render_widget(Paragraph::new(intervals_line), right_area);
}
} }

View File

@ -6,7 +6,6 @@ use ratatui::{
Frame, Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph, Wrap}, widgets::{Block, Borders, Clear, Paragraph, Wrap},
}; };
@ -23,7 +22,6 @@ pub struct ModalManager {
pub journal_scroll_offset: usize, pub journal_scroll_offset: usize,
pub thread_scroll_max: usize, pub thread_scroll_max: usize,
pub journal_scroll_max: usize, pub journal_scroll_max: usize,
pub help_scroll_offset: usize,
} }
impl ModalManager { impl ModalManager {
@ -35,7 +33,6 @@ impl ModalManager {
journal_scroll_offset: 0, journal_scroll_offset: 0,
thread_scroll_max: 0, thread_scroll_max: 0,
journal_scroll_max: 0, journal_scroll_max: 0,
help_scroll_offset: 0,
} }
} }
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
@ -58,12 +55,6 @@ impl ModalManager {
self.journal_scroll_max = 0; self.journal_scroll_max = 0;
ModalButton::Ok ModalButton::Ok
} }
Some(ModalType::About) => ModalButton::Ok,
Some(ModalType::Help) => {
// Reset scroll state for help modal
self.help_scroll_offset = 0;
ModalButton::Ok
}
Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Confirmation { .. }) => ModalButton::Confirm,
Some(ModalType::Info { .. }) => ModalButton::Ok, Some(ModalType::Info { .. }) => ModalButton::Ok,
None => ModalButton::Ok, None => ModalButton::Ok,
@ -75,8 +66,6 @@ impl ModalManager {
self.active_button = match next { self.active_button = match next {
ModalType::ConnectionError { .. } => ModalButton::Retry, ModalType::ConnectionError { .. } => ModalButton::Retry,
ModalType::ProcessDetails { .. } => ModalButton::Ok, ModalType::ProcessDetails { .. } => ModalButton::Ok,
ModalType::About => ModalButton::Ok,
ModalType::Help => ModalButton::Ok,
ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Confirmation { .. } => ModalButton::Confirm,
ModalType::Info { .. } => ModalButton::Ok, ModalType::Info { .. } => ModalButton::Ok,
}; };
@ -203,22 +192,6 @@ impl ModalManager {
ModalAction::None ModalAction::None
} }
} }
KeyCode::Up => {
if matches!(self.stack.last(), Some(ModalType::Help)) {
self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
ModalAction::Handled
} else {
ModalAction::None
}
}
KeyCode::Down => {
if matches!(self.stack.last(), Some(ModalType::Help)) {
self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
ModalAction::Handled
} else {
ModalAction::None
}
}
_ => ModalAction::None, _ => ModalAction::None,
} }
} }
@ -232,14 +205,6 @@ impl ModalManager {
self.pop_modal(); self.pop_modal();
ModalAction::Dismiss ModalAction::Dismiss
} }
(Some(ModalType::About), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
}
(Some(ModalType::Help), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
}
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
(Some(ModalType::Info { .. }), ModalButton::Ok) => { (Some(ModalType::Info { .. }), ModalButton::Ok) => {
@ -288,14 +253,6 @@ impl ModalManager {
// Process details modal uses almost full screen (95% width, 90% height) // Process details modal uses almost full screen (95% width, 90% height)
self.centered_rect(95, 90, area) self.centered_rect(95, 90, area)
} }
ModalType::About => {
// About modal uses medium size
self.centered_rect(90, 90, area)
}
ModalType::Help => {
// Help modal uses medium size
self.centered_rect(70, 80, area)
}
_ => { _ => {
// Other modals use smaller size // Other modals use smaller size
self.centered_rect(70, 50, area) self.centered_rect(70, 50, area)
@ -319,8 +276,6 @@ impl ModalManager {
ModalType::ProcessDetails { pid } => { ModalType::ProcessDetails { pid } => {
self.render_process_details(f, modal_area, *pid, data) self.render_process_details(f, modal_area, *pid, data)
} }
ModalType::About => self.render_about(f, modal_area),
ModalType::Help => self.render_help(f, modal_area),
ModalType::Confirmation { ModalType::Confirmation {
title, title,
message, message,
@ -423,196 +378,6 @@ impl ModalManager {
); );
} }
fn render_about(&self, f: &mut Frame, area: Rect) {
//get ASCII art from a constant stored in theme.rs
use super::theme::ASCII_ART;
let version = env!("CARGO_PKG_VERSION");
let about_text = format!(
"{}\n\
Version {}\n\
\n\
A terminal first remote monitoring tool\n\
\n\
Website: https://socktop.io\n\
GitHub: https://github.com/jasonwitty/socktop\n\
\n\
License: MIT License\n\
\n\
Created by Jason Witty\n\
jasonpwitty+socktop@proton.me",
ASCII_ART, version
);
// Render the border block
let block = Block::default()
.title(" About socktop ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
f.render_widget(block, area);
// Calculate inner area manually to avoid any parent styling
let inner_area = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2), // Leave room for button at bottom
};
// Render content area with explicit black background
f.render_widget(
Paragraph::new(about_text)
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
inner_area,
);
// Button area
let button_area = Rect {
x: area.x + 1,
y: area.y + area.height.saturating_sub(2),
width: area.width.saturating_sub(2),
height: 1,
};
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).bg(Color::Black)
};
f.render_widget(
Paragraph::new("[ Enter ] Close")
.style(ok_style)
.alignment(Alignment::Center),
button_area,
);
}
fn render_help(&self, f: &mut Frame, area: Rect) {
let help_lines = vec![
"GLOBAL",
" q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help",
"",
"PROCESS LIST",
" / .............. Start/edit fuzzy search",
" c/C ............ Clear search filter",
" ↑/↓ ............ Select/navigate processes",
" Enter .......... Open Process Details",
" x/X ............ Clear selection",
" Click header ... Sort by column (CPU/Mem)",
" Click row ...... Select process",
"",
"SEARCH MODE (after pressing /)",
" Type ........... Enter search query (fuzzy match)",
" ↑/↓ ............ Navigate results while typing",
" Esc ............ Cancel search and clear filter",
" Enter .......... Apply filter and select first result",
"",
"CPU PER-CORE",
" ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down",
" Home/End ....... Jump to first/last core",
"",
"PROCESS DETAILS MODAL",
" x/X ............ Close modal (all parent modals)",
" p/P ............ Navigate to parent process",
" j/k ............ Scroll threads ↓/↑ (1 line)",
" d/u ............ Scroll threads ↓/↑ (10 lines)",
" [ / ] .......... Scroll journal ↑/↓",
" Esc/Enter ...... Close modal",
"",
"MODAL NAVIGATION",
" Tab/→ .......... Next button │ Shift+Tab/← ... Previous button",
" Enter .......... Confirm/OK │ Esc ............ Cancel/Close",
];
// Render the border block
let block = Block::default()
.title(" Hotkey Help (use ↑/↓ to scroll) ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
f.render_widget(block, area);
// Split into content area and button area
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
});
let content_area = chunks[0];
let button_area = chunks[1];
// Calculate visible window
let visible_height = content_area.height as usize;
let total_lines = help_lines.len();
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll_offset = self.help_scroll_offset.min(max_scroll);
// Get visible lines
let visible_lines: Vec<Line> = help_lines
.iter()
.skip(scroll_offset)
.take(visible_height)
.map(|s| Line::from(*s))
.collect();
// Render scrollable content
f.render_widget(
Paragraph::new(visible_lines)
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
.alignment(Alignment::Left),
content_area,
);
// Render scrollbar if needed
if total_lines > visible_height {
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
let scrollbar_area = Rect {
x: area.x + area.width.saturating_sub(2),
y: area.y + 1,
width: 1,
height: area.height.saturating_sub(2),
};
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""))
.style(Style::default().fg(Color::DarkGray));
f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
}
// Button area
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).bg(Color::Black)
};
f.render_widget(
Paragraph::new("[ Enter ] Close")
.style(ok_style)
.alignment(Alignment::Center),
button_area,
);
}
fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let vert = Layout::default() let vert = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)

View File

@ -16,15 +16,6 @@ use super::modal_format::{calculate_dynamic_y_max, format_uptime, normalize_cpu_
use super::modal_types::{ProcessModalData, ScatterPlotParams}; use super::modal_types::{ProcessModalData, ScatterPlotParams};
use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT}; use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT};
/// Parameters for rendering memory and I/O graphs
struct MemoryIoParams<'a> {
process: &'a socktop_connector::DetailedProcessInfo,
mem_history: &'a std::collections::VecDeque<u64>,
io_read_history: &'a std::collections::VecDeque<u64>,
io_write_history: &'a std::collections::VecDeque<u64>,
max_mem_bytes: u64,
}
impl ModalManager { impl ModalManager {
pub(super) fn render_process_details( pub(super) fn render_process_details(
&mut self, &mut self,
@ -66,13 +57,10 @@ impl ModalManager {
self.render_middle_row_with_metadata( self.render_middle_row_with_metadata(
f, f,
main_chunks[1], main_chunks[1],
MemoryIoParams { &details.process,
process: &details.process, data.history.mem,
mem_history: data.history.mem, data.history.io_read,
io_read_history: data.history.io_read, data.history.io_write,
io_write_history: data.history.io_write,
max_mem_bytes: data.max_mem_bytes,
},
); );
// Bottom Row: Journal Events // Bottom Row: Journal Events
@ -181,14 +169,22 @@ impl ModalManager {
f.render_widget(plot_block, area); f.render_widget(plot_block, area);
} }
fn render_memory_io_graphs(&self, f: &mut Frame, area: Rect, params: MemoryIoParams) { fn render_memory_io_graphs(
&self,
f: &mut Frame,
area: Rect,
process: &socktop_connector::DetailedProcessInfo,
mem_history: &std::collections::VecDeque<u64>,
io_read_history: &std::collections::VecDeque<u64>,
io_write_history: &std::collections::VecDeque<u64>,
) {
let graphs_block = Block::default() let graphs_block = Block::default()
.title("Memory & I/O") .title("Memory & I/O")
.borders(Borders::ALL) .borders(Borders::ALL)
.padding(Padding::horizontal(1)); .padding(Padding::horizontal(1));
let mem_mb = params.process.mem_bytes as f64 / 1_048_576.0; let mem_mb = process.mem_bytes as f64 / 1_048_576.0;
let virtual_mb = params.process.virtual_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![ let mut content_lines = vec![
Line::from(vec![ Line::from(vec![
@ -202,12 +198,8 @@ impl ModalManager {
]; ];
// Add memory sparkline if we have history // Add memory sparkline if we have history
if params.mem_history.len() >= 2 { if mem_history.len() >= 2 {
let mem_data: Vec<u64> = params let mem_data: Vec<u64> = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB
.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); let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1);
// Create mini sparkline using Unicode blocks // Create mini sparkline using Unicode blocks
@ -236,23 +228,8 @@ impl ModalManager {
Span::raw(format!("{virtual_mb:.1} MB")), Span::raw(format!("{virtual_mb:.1} MB")),
])); ]));
// Add max memory if we have tracked it
if params.max_mem_bytes > 0 {
let max_mb = params.max_mem_bytes as f64 / 1_048_576.0;
content_lines.push(Line::from(vec![
Span::styled(
" Max Memory: ",
Style::default().add_modifier(Modifier::DIM),
),
Span::styled(
format!("{max_mb:.1} MB"),
Style::default().fg(Color::Yellow),
),
]));
}
// Add shared memory if available // Add shared memory if available
if let Some(shared_bytes) = params.process.shared_mem_bytes { if let Some(shared_bytes) = process.shared_mem_bytes {
let shared_mb = shared_bytes as f64 / 1_048_576.0; let shared_mb = shared_bytes as f64 / 1_048_576.0;
content_lines.push(Line::from(vec![ content_lines.push(Line::from(vec![
Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)),
@ -267,7 +244,7 @@ impl ModalManager {
])); ]));
// Add I/O stats if available // Add I/O stats if available
match (params.process.read_bytes, params.process.write_bytes) { match (process.read_bytes, process.write_bytes) {
(Some(read), Some(write)) => { (Some(read), Some(write)) => {
let read_mb = read as f64 / 1_048_576.0; let read_mb = read as f64 / 1_048_576.0;
let write_mb = write as f64 / 1_048_576.0; let write_mb = write as f64 / 1_048_576.0;
@ -277,9 +254,8 @@ impl ModalManager {
])); ]));
// Add read I/O sparkline if we have history // Add read I/O sparkline if we have history
if params.io_read_history.len() >= 2 { if io_read_history.len() >= 2 {
let read_data: Vec<u64> = params let read_data: Vec<u64> = io_read_history
.io_read_history
.iter() .iter()
.map(|&bytes| bytes / 1_048_576) .map(|&bytes| bytes / 1_048_576)
.collect(); // Convert to MB .collect(); // Convert to MB
@ -306,9 +282,8 @@ impl ModalManager {
])); ]));
// Add write I/O sparkline if we have history // Add write I/O sparkline if we have history
if params.io_write_history.len() >= 2 { if io_write_history.len() >= 2 {
let write_data: Vec<u64> = params let write_data: Vec<u64> = io_write_history
.io_write_history
.iter() .iter()
.map(|&bytes| bytes / 1_048_576) .map(|&bytes| bytes / 1_048_576)
.collect(); // Convert to MB .collect(); // Convert to MB
@ -867,7 +842,7 @@ impl ModalManager {
let total: f32 = cpu_history.iter().sum(); let total: f32 = cpu_history.iter().sum();
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
}; };
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)"); let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)");
// Similar to main CPU rendering but for process CPU // Similar to main CPU rendering but for process CPU
if cpu_history.len() < 2 { if cpu_history.len() < 2 {
@ -938,7 +913,10 @@ impl ModalManager {
&mut self, &mut self,
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
params: MemoryIoParams, process: &socktop_connector::DetailedProcessInfo,
mem_history: &std::collections::VecDeque<u64>,
io_read_history: &std::collections::VecDeque<u64>,
io_write_history: &std::collections::VecDeque<u64>,
) { ) {
// Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%)
let middle_chunks = Layout::default() let middle_chunks = Layout::default()
@ -953,16 +931,13 @@ impl ModalManager {
self.render_memory_io_graphs( self.render_memory_io_graphs(
f, f,
middle_chunks[0], middle_chunks[0],
MemoryIoParams { process,
process: params.process, mem_history,
mem_history: params.mem_history, io_read_history,
io_read_history: params.io_read_history, io_write_history,
io_write_history: params.io_write_history,
max_mem_bytes: params.max_mem_bytes,
},
); );
self.render_thread_table(f, middle_chunks[1], params.process); self.render_thread_table(f, middle_chunks[1], process);
self.render_command_and_metadata(f, middle_chunks[2], params.process); self.render_command_and_metadata(f, middle_chunks[2], process);
} }
fn render_command_and_metadata( fn render_command_and_metadata(

View File

@ -15,7 +15,6 @@ pub struct ProcessModalData<'a> {
pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, pub details: Option<&'a socktop_connector::ProcessMetricsResponse>,
pub journal: Option<&'a socktop_connector::JournalResponse>, pub journal: Option<&'a socktop_connector::JournalResponse>,
pub history: ProcessHistoryData<'a>, pub history: ProcessHistoryData<'a>,
pub max_mem_bytes: u64,
pub unsupported: bool, pub unsupported: bool,
} }
@ -39,8 +38,6 @@ pub enum ModalType {
ProcessDetails { ProcessDetails {
pid: u32, pid: u32,
}, },
About,
Help,
#[allow(dead_code)] #[allow(dead_code)]
Confirmation { Confirmation {
title: String, title: String,

View File

@ -18,66 +18,6 @@ use crate::ui::theme::{
}; };
use crate::ui::util::human; use crate::ui::util::human;
/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let haystack_lower = haystack.to_lowercase();
let needle_lower = needle.to_lowercase();
let mut haystack_chars = haystack_lower.chars();
for needle_char in needle_lower.chars() {
if !haystack_chars.any(|c| c == needle_char) {
return false;
}
}
true
}
/// Get filtered and sorted process indices based on search query and sort order
pub fn get_filtered_sorted_indices(
metrics: &Metrics,
search_query: &str,
sort_by: ProcSortBy,
) -> Vec<usize> {
// Filter processes by search query (fuzzy match)
let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
(0..metrics.top_processes.len()).collect()
} else {
(0..metrics.top_processes.len())
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
.collect()
};
// Sort filtered rows
match sort_by {
ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].cpu_usage;
let bb = metrics.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => filtered_idxs.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].mem_bytes;
let bb = metrics.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
filtered_idxs
}
/// Parameters for drawing the top processes table
pub struct ProcessDisplayParams<'a> {
pub metrics: Option<&'a Metrics>,
pub scroll_offset: usize,
pub sort_by: ProcSortBy,
pub selected_process_pid: Option<u32>,
pub selected_process_index: Option<usize>,
pub search_query: &'a str,
pub search_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProcSortBy { pub enum ProcSortBy {
#[default] #[default]
@ -94,61 +34,30 @@ const COLS: [Constraint; 5] = [
Constraint::Length(8), // Mem % Constraint::Length(8), // Mem %
]; ];
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) { pub fn draw_top_processes(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
scroll_offset: usize,
sort_by: ProcSortBy,
selected_process_pid: Option<u32>,
selected_process_index: Option<usize>,
) {
// Draw outer block and title // Draw outer block and title
let Some(mm) = params.metrics else { return }; let Some(mm) = m else { return };
let total = mm.process_count.unwrap_or(mm.top_processes.len()); let total = mm.process_count.unwrap_or(mm.top_processes.len());
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(format!("Top Processes ({total} total)")); .title(format!("Top Processes ({total} total)"));
f.render_widget(block, area); f.render_widget(block, area);
// Inner area (reserve space for search box if active) // Inner area and content area (reserve 2 columns for scrollbar)
let inner = Rect { let inner = Rect {
x: area.x + 1, x: area.x + 1,
y: area.y + 1, y: area.y + 1,
width: area.width.saturating_sub(2), width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2), height: area.height.saturating_sub(2),
}; };
// Draw search box if active
let content_start_y = if params.search_active || !params.search_query.is_empty() {
let search_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 3, // Height for border + content
};
let search_text = if params.search_active {
format!("Search: {}_", params.search_query)
} else {
format!(
"Filter: {} (press / to edit, c to clear)",
params.search_query
)
};
let search_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let search_paragraph = Paragraph::new(search_text)
.block(search_block)
.style(Style::default().fg(Color::Yellow));
f.render_widget(search_paragraph, search_area);
inner.y + 3
} else {
inner.y
};
// Content area (reserve 2 columns for scrollbar)
let inner = Rect {
x: inner.x,
y: content_start_y,
width: inner.width,
height: inner.height.saturating_sub(content_start_y - (area.y + 1)),
};
if inner.height < 1 || inner.width < 3 { if inner.height < 1 || inner.width < 3 {
return; return;
} }
@ -159,15 +68,27 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
height: inner.height, height: inner.height,
}; };
// Get filtered and sorted indices // Sort rows (by CPU% or Mem bytes), descending.
let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by); let mut idxs: Vec<usize> = (0..mm.top_processes.len()).collect();
match sort_by {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].cpu_usage;
let bb = mm.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].mem_bytes;
let bb = mm.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
// Scrolling // Scrolling
let total_rows = idxs.len(); let total_rows = idxs.len();
let header_rows = 1usize; let header_rows = 1usize;
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize; let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
let max_off = total_rows.saturating_sub(viewport_rows); let max_off = total_rows.saturating_sub(viewport_rows);
let offset = params.scroll_offset.min(max_off); let offset = scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows); let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Build visible rows // Build visible rows
@ -201,9 +122,9 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
}; };
// Check if this process is selected - prioritize PID matching // Check if this process is selected - prioritize PID matching
let is_selected = if let Some(selected_pid) = params.selected_process_pid { let is_selected = if let Some(selected_pid) = selected_process_pid {
selected_pid == p.pid selected_pid == p.pid
} else if let Some(selected_idx) = params.selected_process_index { } else if let Some(selected_idx) = selected_process_index {
selected_idx == ix // ix is the absolute index in the sorted list selected_idx == ix // ix is the absolute index in the sorted list
} else { } else {
false false
@ -232,11 +153,11 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
}); });
// Header with sort indicator // Header with sort indicator
let cpu_hdr = match params.sort_by { let cpu_hdr = match sort_by {
ProcSortBy::CpuDesc => "CPU % •", ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %", _ => "CPU %",
}; };
let mem_hdr = match params.sort_by { let mem_hdr = match sort_by {
ProcSortBy::MemDesc => "Mem •", ProcSortBy::MemDesc => "Mem •",
_ => "Mem", _ => "Mem",
}; };
@ -253,9 +174,9 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
f.render_widget(table, content); f.render_widget(table, content);
// Draw tooltip if a process is selected // Draw tooltip if a process is selected
if let Some(selected_pid) = params.selected_process_pid { if let Some(selected_pid) = selected_process_pid {
// Find the selected process to get its name // Find the selected process to get its name
let process_info = if let Some(metrics) = params.metrics { let process_info = if let Some(metrics) = m {
metrics metrics
.top_processes .top_processes
.iter() .iter()
@ -333,16 +254,6 @@ fn fmt_cpu_pct(v: f32) -> String {
} }
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) /// 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<u32>,
pub selected_process_index: &'a mut Option<usize>,
pub key: crossterm::event::KeyEvent,
pub metrics: Option<&'a Metrics>,
pub sort_by: ProcSortBy,
pub search_query: &'a str,
}
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
#[allow(dead_code)] #[allow(dead_code)]
pub fn processes_handle_key( pub fn processes_handle_key(
@ -353,85 +264,24 @@ pub fn processes_handle_key(
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
} }
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { /// Enhanced keyboard handler that also manages process selection
pub fn processes_handle_key_with_selection(
_scroll_offset: &mut usize,
selected_process_pid: &mut Option<u32>,
selected_process_index: &mut Option<usize>,
key: crossterm::event::KeyEvent,
_page_size: usize,
_total_rows: usize,
_metrics: Option<&Metrics>,
) -> bool {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
match params.key.code { match key.code {
KeyCode::Up => {
// Navigate through filtered and sorted results
if let Some(m) = params.metrics {
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
if idxs.is_empty() {
// No filtered results, clear selection
*params.selected_process_index = None;
*params.selected_process_pid = None;
} else if params.selected_process_index.is_none()
|| params.selected_process_pid.is_none()
{
// No selection - select the first process in filtered/sorted order
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
} else if let Some(current_idx) = *params.selected_process_index {
// Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
if pos > 0 {
// Move up in filtered/sorted list
let new_idx = idxs[pos - 1];
*params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
}
} else {
// Current selection not in filtered list, select first result
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
}
}
true // Handled
}
KeyCode::Down => {
// Navigate through filtered and sorted results
if let Some(m) = params.metrics {
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
if idxs.is_empty() {
// No filtered results, clear selection
*params.selected_process_index = None;
*params.selected_process_pid = None;
} else if params.selected_process_index.is_none()
|| params.selected_process_pid.is_none()
{
// No selection - select the first process in filtered/sorted order
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
} else if let Some(current_idx) = *params.selected_process_index {
// Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
if pos + 1 < idxs.len() {
// Move down in filtered/sorted list
let new_idx = idxs[pos + 1];
*params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
}
} else {
// Current selection not in filtered list, select first result
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
}
}
true // Handled
}
KeyCode::Char('x') | KeyCode::Char('X') => { KeyCode::Char('x') | KeyCode::Char('X') => {
// Unselect any selected process // Unselect any selected process
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() { if selected_process_pid.is_some() || selected_process_index.is_some() {
*params.selected_process_pid = None; *selected_process_pid = None;
*params.selected_process_index = None; *selected_process_index = None;
true // Handled true // Handled
} else { } else {
false // No selection to clear false // No selection to clear
@ -439,7 +289,7 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
} }
KeyCode::Enter => { KeyCode::Enter => {
// Signal that Enter was pressed with a selection // Signal that Enter was pressed with a selection
params.selected_process_pid.is_some() // Return true if we have a selection to handle selected_process_pid.is_some() // Return true if we have a selection to handle
} }
_ => { _ => {
// No other keys handled - let scrollbar handle all navigation // No other keys handled - let scrollbar handle all navigation
@ -527,7 +377,6 @@ pub struct ProcessMouseParams<'a> {
pub total_rows: usize, pub total_rows: usize,
pub metrics: Option<&'a Metrics>, pub metrics: Option<&'a Metrics>,
pub sort_by: ProcSortBy, pub sort_by: ProcSortBy,
pub search_query: &'a str,
} }
/// Enhanced mouse handler that also manages process selection /// Enhanced mouse handler that also manages process selection
@ -543,19 +392,11 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
if inner.height == 0 || inner.width <= 2 { if inner.height == 0 || inner.width <= 2 {
return None; return None;
} }
// Calculate content area - must match draw_top_processes exactly!
// If search is active or query exists, content starts after search box (3 lines)
let search_active = !params.search_query.is_empty();
let content_start_y = if search_active { inner.y + 3 } else { inner.y };
let content = Rect { let content = Rect {
x: inner.x, x: inner.x,
y: content_start_y, y: inner.y,
width: inner.width.saturating_sub(2), width: inner.width.saturating_sub(2),
height: inner height: inner.height,
.height
.saturating_sub(if search_active { 3 } else { 0 }),
}; };
// Scrollbar interactions (click arrows/page/drag) // Scrollbar interactions (click arrows/page/drag)
@ -612,12 +453,24 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
{ {
let clicked_row = (params.mouse.row - data_start_row) as usize; let clicked_row = (params.mouse.row - data_start_row) as usize;
// Find the actual process using the same filtering/sorting logic as the drawing code // Find the actual process using the same sorting logic as the drawing code
if let Some(m) = params.metrics { if let Some(m) = params.metrics {
// Use the same filtered and sorted indices as display // Create the same sorted index array as in draw_top_processes
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
match params.sort_by {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
let aa = m.top_processes[a].cpu_usage;
let bb = m.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal)
}),
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
let aa = m.top_processes[a].mem_bytes;
let bb = m.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
// Calculate which process was actually clicked based on filtered/sorted order // Calculate which process was actually clicked based on sorted order
let visible_process_position = *params.scroll_offset + clicked_row; let visible_process_position = *params.scroll_offset + clicked_row;
if visible_process_position < idxs.len() { if visible_process_position < idxs.len() {
let actual_process_index = idxs[visible_process_position]; let actual_process_index = idxs[visible_process_position];

View File

@ -49,7 +49,7 @@ pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry "; pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
pub const BTN_EXIT_TEXT: &str = " ❌ Exit "; pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
// warning icon // Large multi-line warning icon
pub const LARGE_ERROR_ICON: &[&str] = &[ pub const LARGE_ERROR_ICON: &[&str] = &[
" /\\ ", " /\\ ",
" / \\ ", " / \\ ",
@ -60,29 +60,3 @@ pub const LARGE_ERROR_ICON: &[&str] = &[
" / !! \\ ", " / !! \\ ",
" /______________\\ ", " /______________\\ ",
]; ];
//about logo
pub const ASCII_ART: &str = r#"
"#;

View File

@ -1,6 +1,6 @@
[package] [package]
name = "socktop_agent" name = "socktop_agent"
version = "1.50.2" version = "1.40.70"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"] authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket." description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024" edition = "2024"
@ -8,39 +8,33 @@ license = "MIT"
readme = "README.md" readme = "README.md"
[dependencies] [dependencies]
# Tokio: Use minimal features instead of "full" to reduce binary size tokio = { version = "1", features = ["full"] }
# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test])
# Excluded: io, fs, process, signal, time (not needed for this workload)
# Savings: ~200-300KB binary size, faster compile times
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] }
axum = { version = "0.7", features = ["ws", "macros"] } axum = { version = "0.7", features = ["ws", "macros"] }
sysinfo = { version = "0.37", features = ["network", "disk", "component"] } sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
futures-util = "0.3.31" futures-util = "0.3.31"
tracing = { version = "0.1", optional = true } tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now)
gfxinfo = "0.1.2" gfxinfo = "0.1.2"
once_cell = "1.19" once_cell = "1.19"
axum-server = { version = "0.7", features = ["tls-rustls"] } axum-server = { version = "0.6", features = ["tls-rustls"] }
rustls = { version = "0.23", features = ["aws-lc-rs"] } rustls = "0.23"
rustls-pemfile = "2.1" rustls-pemfile = "2.1"
rcgen = "0.13" rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build)
anyhow = "1" anyhow = "1"
hostname = "0.3" hostname = "0.3"
prost = { workspace = true } prost = { workspace = true }
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] } time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
# For executing journalctl commands
[features] tokio-process = "0.2"
default = []
logging = ["tracing", "tracing-subscriber"]
[build-dependencies] [build-dependencies]
prost-build = "0.13" prost-build = "0.13"
tonic-build = { version = "0.12", default-features = false, optional = true } tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3" protoc-bin-vendored = "3"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.0" assert_cmd = "2.0"
tempfile = "3.10" tempfile = "3.10"

View File

@ -29,53 +29,10 @@ fn arg_value(name: &str) -> Option<String> {
None None
} }
fn main() -> anyhow::Result<()> { #[tokio::main]
// Install rustls crypto provider before any TLS operations async fn main() -> anyhow::Result<()> {
// This is required when using axum-server's tls-rustls feature
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok(); // Ignore error if already installed
#[cfg(feature = "logging")]
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// Configure Tokio runtime with optimized thread pool for reduced overhead.
//
// The agent is primarily I/O-bound (WebSocket, /proc file reads, sysinfo)
// with no CPU-intensive or blocking operations, so a smaller thread pool
// is beneficial:
//
// Benefits:
// - Lower memory footprint (~1-2MB per thread saved)
// - Reduced context switching overhead
// - Fewer idle threads consuming resources
// - Better for resource-constrained systems
//
// Trade-offs:
// - Slightly reduced throughput under very high concurrent connections
// - Could introduce latency if blocking operations are added (don't do this!)
//
// Default: 2 threads (sufficient for typical workloads with 1-10 clients)
// Override: Set SOCKTOP_WORKER_THREADS=4 to use more threads if needed
//
// Note: Default Tokio uses num_cpus threads which is excessive for this workload.
let worker_threads = std::env::var("SOCKTOP_WORKER_THREADS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2)
.clamp(1, 16); // Ensure 1-16 threads
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(worker_threads)
.thread_name("socktop-agent")
.enable_all()
.build()?;
runtime.block_on(async_main())
}
async fn async_main() -> anyhow::Result<()> {
// Version flag (print and exit). Keep before heavy initialization. // Version flag (print and exit). Keep before heavy initialization.
if arg_flag("--version") || arg_flag("-V") { if arg_flag("--version") || arg_flag("-V") {
println!("socktop_agent {}", env!("CARGO_PKG_VERSION")); println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));

View File

@ -18,7 +18,6 @@ use std::sync::Mutex;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
#[cfg(feature = "logging")]
use tracing::warn; use tracing::warn;
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
@ -169,12 +168,11 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
} }
} }
let mut sys = state.sys.lock().await; let mut sys = state.sys.lock().await;
if let Err(_e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sys.refresh_cpu_usage(); sys.refresh_cpu_usage();
sys.refresh_memory(); sys.refresh_memory();
})) { })) {
#[cfg(feature = "logging")] warn!("sysinfo selective refresh panicked: {e:?}");
warn!("sysinfo selective refresh panicked: {_e:?}");
} }
// Get or initialize hostname once // Get or initialize hostname once
@ -268,9 +266,8 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let v = match collect_all_gpus() { let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v), Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None, Ok(_) => None,
Err(_e) => { Err(e) => {
#[cfg(feature = "logging")] warn!("gpu collection failed: {e}");
warn!("gpu collection failed: {_e}");
None None
} }
}; };
@ -351,7 +348,6 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
if label.contains("composite") if label.contains("composite")
&& let Some(temp) = c.temperature() && let Some(temp) = c.temperature()
{ {
#[cfg(feature = "logging")]
tracing::debug!("Found Composite temp: {}°C", temp); tracing::debug!("Found Composite temp: {}°C", temp);
composite_temps.push(temp); composite_temps.push(temp);
} }
@ -361,11 +357,9 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let mut temps = std::collections::HashMap::new(); let mut temps = std::collections::HashMap::new();
for (idx, temp) in composite_temps.iter().enumerate() { for (idx, temp) in composite_temps.iter().enumerate() {
let key = format!("nvme{}n1", idx); let key = format!("nvme{}n1", idx);
#[cfg(feature = "logging")]
tracing::debug!("Mapping {} -> {}°C", key, temp); tracing::debug!("Mapping {} -> {}°C", key, temp);
temps.insert(key, *temp); temps.insert(key, *temp);
} }
#[cfg(feature = "logging")]
tracing::debug!("Final disk_temps map: {:?}", temps); tracing::debug!("Final disk_temps map: {:?}", temps);
temps temps
}; };
@ -400,7 +394,6 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// Try to find temperature for this disk // Try to find temperature for this disk
let temperature = disk_temps.iter().find_map(|(key, &temp)| { let temperature = disk_temps.iter().find_map(|(key, &temp)| {
if name.starts_with(key) { if name.starts_with(key) {
#[cfg(feature = "logging")]
tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp); tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp);
Some(temp) Some(temp)
} else { } else {
@ -409,7 +402,6 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
}); });
if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") { if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") {
#[cfg(feature = "logging")]
tracing::debug!("No temperature found for disk: {}", name); tracing::debug!("No temperature found for disk: {}", name);
} }
@ -760,7 +752,6 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
proc_cache proc_cache
.names .names
.retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid))); .retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid)));
#[cfg(feature = "logging")]
tracing::debug!( tracing::debug!(
"Cleaned up {} stale process names in {}ms", "Cleaned up {} stale process names in {}ms",
proc_cache.names.capacity() - proc_cache.names.len(), proc_cache.names.capacity() - proc_cache.names.len(),

View File

@ -1,3 +1,4 @@
use assert_cmd::prelude::*;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@ -16,7 +17,7 @@ fn generates_self_signed_cert_and_key_in_xdg_path() {
let xdg = tmpdir.path().to_path_buf(); let xdg = tmpdir.path().to_path_buf();
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed // Run the agent once with --enableSSL, short timeout so it exits quickly when killed
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("socktop_agent")); let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes // Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
cmd.env("XDG_CONFIG_HOME", &xdg) cmd.env("XDG_CONFIG_HOME", &xdg)
.arg("--enableSSL") .arg("--enableSSL")

View File

@ -1,6 +1,6 @@
[package] [package]
name = "socktop_connector" name = "socktop_connector"
version = "1.50.0" version = "0.1.6"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
description = "WebSocket connector library for socktop agent communication" description = "WebSocket connector library for socktop agent communication"