Compare commits

..

10 Commits

Author SHA1 Message Date
3024816525 hotfix for issue with socktop agent not creating ssl certificate on first launch after upgrade of axum server version.
Some checks failed
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-21 00:21:05 -08:00
1d7bc42d59 fix unit test, move to macro cargo_bin! 2025-11-21 00:07:44 -08:00
518ae8c2bf update axum server version
Some checks failed
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-17 15:09:53 -08:00
6eb1809309 set connector back to crate version 2025-11-17 14:15:39 -08:00
1c01902a71 update cargo version number 2025-11-17 14:13:48 -08:00
9d302ad475 patch header for small monitors and increase cargo version in advance of publish. 2025-11-17 11:52:22 -08:00
7875f132f7 Make help modal scrollable for small resolutions
- Add Up/Down arrow key handling in help modal
- Display scrollbar when content exceeds viewport
- Update title to indicate scrollability
- Fixes content cutoff on small terminal windows
2025-11-17 11:29:23 -08:00
0d789fb97c
Add TUI improvements: CPU averaging, max memory tracking, and fuzzy process search (#23)
This commit implements several major improvements to the TUI experience:

1. CPU Average Display in Main Window
   - Show average CPU usage over monitoring period alongside current value
   - Format: "CPU avg (now: 45.2% | avg: 52.3%)"
   - Helps identify sustained vs momentary CPU spikes

2. Max Memory Tracking in Process Details Modal
   - Track and display peak memory usage since monitoring started
   - Shown as "Max Memory: 67.8 MB" in yellow for emphasis
   - Helps identify memory leaks and usage patterns
   - Resets when switching to different process

3. Fuzzy Process Search
   - Press / to activate search mode with bordered search box
   - Type to fuzzy-match process names (case-insensitive)
   - Press Enter to auto-select first result
   - Navigate results with arrow keys while typing
   - Press c to clear filter
   - Press / again to edit existing search

   Search box features:
   - Yellow bordered box for high visibility
   - Active mode: "Search: query_"
   - Filter mode: "Filter: query (press / to edit, c to clear)"

   Technical implementation:
   - Centralized filtering with get_filtered_sorted_indices()
   - Consistent filtering across display, navigation, mouse, and auto-scroll
   - Proper content area offset calculation for search box
   - Real-time filtering as user types

4. Code Quality Improvements
   - Created ProcessDisplayParams and ProcessKeyParams structs
   - Created MemoryIoParams struct for process modal rendering
   - Reduced function arguments to stay under clippy limits
   - Exported get_filtered_sorted_indices for reuse

Files Modified:
- socktop/src/app.rs: Search state, auto-scroll with filtering, max memory tracking
- socktop/src/ui/cpu.rs: CPU average calculation and display
- socktop/src/ui/processes.rs: Fuzzy search, filtering, parameter structs
- socktop/src/ui/modal.rs: Updated help modal with new shortcuts
- socktop/src/ui/modal_process.rs: Max memory display, MemoryIoParams struct
- socktop/src/ui/modal_types.rs: Added max_mem_bytes field

Testing:
- All tests pass
- No clippy warnings
- Cargo fmt applied
- Tested search, navigation, mouse clicks, and auto-scroll
- Verified on both filtered and unfiltered process lists

Breaking Changes:
- None (all changes are additive features)

Closes: (performance monitoring improvements)
2025-11-17 11:24:32 -08:00
5ddaed298b
Optimize socktop_agent for reduced binary size and memory footprint (#22)
This commit implements several optimizations to make socktop_agent
significantly more lightweight without sacrificing functionality.

Changes:

1. Reduced Tokio Runtime Thread Pool (main.rs)
   - Changed from default (num_cpus) to 2 worker threads
   - Configurable via SOCKTOP_WORKER_THREADS environment variable
   - Rationale: Agent is I/O-bound, not CPU-intensive
   - Memory savings: ~6-12 MB on typical 8-core systems

2. Minimal Tokio Features (Cargo.toml)
   - Changed from features = ["full"] to minimal set:
     ["rt-multi-thread", "net", "sync", "macros"]
   - Removed unused features: io, fs, process, signal, time
   - Binary size reduction: ~200-300 KB
   - Faster compile times

3. Optional Tracing (Cargo.toml, main.rs, metrics.rs)
   - Made tracing dependencies optional with "logging" feature flag
   - Disabled by default for production builds
   - Binary size reduction: 1.5 MB (27%!)
   - Enable with: cargo build --features logging

4. Cleanup (Cargo.toml)
   - Removed unused tokio-process dependency

Results:
- Binary size: 5.6 MB → 4.0 MB (28% reduction)
- Memory usage: 25-40 MB → 15-25 MB (30-40% reduction)
- Worker threads: 8+ → 2 (75% reduction on 8-core systems)

Testing:
- All tests pass with and without logging feature
- No clippy warnings
- Functionality unchanged
- Production-ready

Breaking Changes:
- None (all changes are backward compatible)
- Default behavior is now more lightweight
- Logging can be re-enabled with --features logging

To build with logging for debugging:
  cargo build --package socktop_agent --release --features logging
2025-11-17 09:51:41 -08:00
1528568c30
Merge pull request #21 from jasonwitty/feature/about-modal
Feature/about modal
2025-11-17 00:18:55 -08:00
14 changed files with 1113 additions and 1252 deletions

1486
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.40.0" version = "1.50.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 = { path = "../socktop_connector" } socktop_connector = "1.50.0"
tokio = { workspace = true } tokio = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }

View File

@ -29,13 +29,14 @@ 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, processes_handle_key_with_selection, ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, 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,
@ -84,6 +85,10 @@ 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,
@ -99,6 +104,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,
@ -146,6 +152,8 @@ 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
@ -163,6 +171,7 @@ 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))
@ -649,6 +658,53 @@ 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,
@ -657,6 +713,24 @@ 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' // Show About modal on 'a' or 'A'
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) { if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
self.modal_manager.push_modal(ModalType::About); self.modal_manager.push_modal(ModalType::About);
@ -694,6 +768,7 @@ impl App {
key: k, key: k,
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,
}) })
} else { } else {
false false
@ -711,44 +786,42 @@ impl App {
// Auto-scroll to keep selected process visible // Auto-scroll to keep selected process visible
if let (Some(selected_idx), Some(p_area)) = if let (Some(selected_idx), Some(p_area)) =
(self.selected_process_index, self.last_procs_area) (self.selected_process_index, self.last_procs_area)
&& let Some(m) = self.last_metrics.as_ref()
{ {
// Calculate viewport size (excluding borders and header) // Get filtered and sorted indices (same as display)
let viewport_rows = p_area.height.saturating_sub(3) as usize; // borders (2) + header (1) let idxs = get_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
);
// Build sorted index list to find display position // Find the display position of the selected process in filtered list
if let Some(m) = self.last_metrics.as_ref() {
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
match self.procs_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)
}),
}
// Find the display position of the selected process
if let Some(display_pos) = if let Some(display_pos) =
idxs.iter().position(|&idx| idx == selected_idx) 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 // Adjust scroll offset to keep selection visible
if display_pos < self.procs_scroll_offset { if display_pos < self.procs_scroll_offset {
// Selection is above viewport, scroll up // Selection is above viewport, scroll up
self.procs_scroll_offset = display_pos; self.procs_scroll_offset = display_pos;
} else if display_pos } else if display_pos >= self.procs_scroll_offset + viewport_rows {
>= self.procs_scroll_offset + viewport_rows
{
// Selection is below viewport, scroll down // Selection is below viewport, scroll down
self.procs_scroll_offset = self.procs_scroll_offset =
display_pos.saturating_sub(viewport_rows - 1); 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 {
@ -846,6 +919,7 @@ 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;
@ -931,6 +1005,11 @@ 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
@ -1036,6 +1115,7 @@ 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;
} }
@ -1195,11 +1275,15 @@ impl App {
crate::ui::processes::draw_top_processes( crate::ui::processes::draw_top_processes(
f, f,
procs_area, procs_area,
self.last_metrics.as_ref(), crate::ui::processes::ProcessDisplayParams {
self.procs_scroll_offset, metrics: self.last_metrics.as_ref(),
self.procs_sort_by, scroll_offset: self.procs_scroll_offset,
self.selected_process_pid, sort_by: self.procs_sort_by,
self.selected_process_index, selected_process_pid: self.selected_process_pid,
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
@ -1216,6 +1300,7 @@ 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,
}, },
); );
@ -1244,6 +1329,8 @@ 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
@ -1261,6 +1348,7 @@ 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

@ -240,20 +240,61 @@ 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 avg (now: {:>5.1}%)", mm.cpu_total) format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
} 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,7 +3,8 @@
use crate::types::Metrics; use crate::types::Metrics;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
widgets::{Block, Borders}, text::{Line, Span},
widgets::{Block, Borders, Paragraph},
}; };
use std::time::Duration; use std::time::Duration;
@ -17,20 +18,7 @@ pub fn draw_header(
procs_interval: Duration, procs_interval: Duration,
) { ) {
let base = if let Some(mm) = m { let base = if let Some(mm) = m {
let temp = mm format!("socktop — host: {}", mm.hostname)
.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()
}; };
@ -38,15 +26,30 @@ 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(intervals);
parts.push("(a: about, h: help, q: quit)".into()); parts.push("(a: about, h: help, q: 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,6 +6,7 @@ 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},
}; };
@ -22,6 +23,7 @@ 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 {
@ -33,6 +35,7 @@ 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 {
@ -56,7 +59,11 @@ impl ModalManager {
ModalButton::Ok ModalButton::Ok
} }
Some(ModalType::About) => ModalButton::Ok, Some(ModalType::About) => ModalButton::Ok,
Some(ModalType::Help) => 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,
@ -196,6 +203,22 @@ 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,
} }
} }
@ -271,7 +294,7 @@ impl ModalManager {
} }
ModalType::Help => { ModalType::Help => {
// Help modal uses medium size // Help modal uses medium size
self.centered_rect(80, 80, area) self.centered_rect(70, 80, area)
} }
_ => { _ => {
// Other modals use smaller size // Other modals use smaller size
@ -472,61 +495,107 @@ impl ModalManager {
} }
fn render_help(&self, f: &mut Frame, area: Rect) { fn render_help(&self, f: &mut Frame, area: Rect) {
let help_text = "\ let help_lines = vec![
GLOBAL "GLOBAL",
q/Q/Esc ........ Quit a/A ....... About h/H ....... Help " q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help",
"",
PROCESS LIST "PROCESS LIST",
/ ............ Select/navigate processes " / .............. Start/edit fuzzy search",
Enter .......... Open Process Details " c/C ............ Clear search filter",
x/X ............ Clear selection " ↑/↓ ............ Select/navigate processes",
Click header ... Sort by column (CPU/Mem) " Enter .......... Open Process Details",
Click row ...... Select process " x/X ............ Clear selection",
" Click header ... Sort by column (CPU/Mem)",
CPU PER-CORE " Click row ...... Select process",
/ ............ Scroll cores PgUp/PgDn ... Page up/down "",
Home/End ....... Jump to first/last core "SEARCH MODE (after pressing /)",
" Type ........... Enter search query (fuzzy match)",
PROCESS DETAILS MODAL " ↑/↓ ............ Navigate results while typing",
x/X ............ Close modal (all parent modals) " Esc ............ Cancel search and clear filter",
p/P ............ Navigate to parent process " Enter .......... Apply filter and select first result",
j/k ............ Scroll threads / (1 line) "",
d/u ............ Scroll threads / (10 lines) "CPU PER-CORE",
[ / ] .......... Scroll journal / " ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down",
Esc/Enter ...... Close modal"; " 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 // Render the border block
let block = Block::default() let block = Block::default()
.title(" Hotkey Help ") .title(" Hotkey Help (use ↑/↓ to scroll) ")
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::DarkGray)); .style(Style::default().bg(Color::Black).fg(Color::DarkGray));
f.render_widget(block, area); f.render_widget(block, area);
// Calculate inner area manually to avoid any parent styling // Split into content area and button area
let inner_area = Rect { let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(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), // Leave room for button at bottom height: area.height.saturating_sub(2),
}; });
// Render content area with explicit black background 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( f.render_widget(
Paragraph::new(help_text) Paragraph::new(visible_lines)
.style(Style::default().fg(Color::Cyan).bg(Color::Black)) .style(Style::default().fg(Color::Cyan).bg(Color::Black))
.alignment(Alignment::Left) .alignment(Alignment::Left),
.wrap(Wrap { trim: false }), content_area,
inner_area,
); );
// Button area // Render scrollbar if needed
let button_area = Rect { if total_lines > visible_height {
x: area.x + 1, use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
y: area.y + area.height.saturating_sub(2),
width: area.width.saturating_sub(2), let scrollbar_area = Rect {
height: 1, 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 { let ok_style = if self.active_button == ModalButton::Ok {
Style::default() Style::default()
.bg(Color::Blue) .bg(Color::Blue)

View File

@ -16,6 +16,15 @@ 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,
@ -57,10 +66,13 @@ impl ModalManager {
self.render_middle_row_with_metadata( self.render_middle_row_with_metadata(
f, f,
main_chunks[1], main_chunks[1],
&details.process, MemoryIoParams {
data.history.mem, process: &details.process,
data.history.io_read, mem_history: data.history.mem,
data.history.io_write, io_read_history: data.history.io_read,
io_write_history: data.history.io_write,
max_mem_bytes: data.max_mem_bytes,
},
); );
// Bottom Row: Journal Events // Bottom Row: Journal Events
@ -169,22 +181,14 @@ impl ModalManager {
f.render_widget(plot_block, area); f.render_widget(plot_block, area);
} }
fn render_memory_io_graphs( fn render_memory_io_graphs(&self, f: &mut Frame, area: Rect, params: MemoryIoParams) {
&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 = process.mem_bytes as f64 / 1_048_576.0; let mem_mb = params.process.mem_bytes as f64 / 1_048_576.0;
let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; let virtual_mb = params.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![
@ -198,8 +202,12 @@ impl ModalManager {
]; ];
// Add memory sparkline if we have history // Add memory sparkline if we have history
if mem_history.len() >= 2 { if params.mem_history.len() >= 2 {
let mem_data: Vec<u64> = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB let mem_data: Vec<u64> = params
.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
@ -228,8 +236,23 @@ 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) = process.shared_mem_bytes { if let Some(shared_bytes) = params.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)),
@ -244,7 +267,7 @@ impl ModalManager {
])); ]));
// Add I/O stats if available // Add I/O stats if available
match (process.read_bytes, process.write_bytes) { match (params.process.read_bytes, params.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;
@ -254,8 +277,9 @@ impl ModalManager {
])); ]));
// Add read I/O sparkline if we have history // Add read I/O sparkline if we have history
if io_read_history.len() >= 2 { if params.io_read_history.len() >= 2 {
let read_data: Vec<u64> = io_read_history let read_data: Vec<u64> = params
.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
@ -282,8 +306,9 @@ impl ModalManager {
])); ]));
// Add write I/O sparkline if we have history // Add write I/O sparkline if we have history
if io_write_history.len() >= 2 { if params.io_write_history.len() >= 2 {
let write_data: Vec<u64> = io_write_history let write_data: Vec<u64> = params
.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
@ -842,7 +867,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 avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); let title = format!("CPU (now: {current_cpu:.1}% | {avg_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 {
@ -913,10 +938,7 @@ impl ModalManager {
&mut self, &mut self,
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
process: &socktop_connector::DetailedProcessInfo, params: MemoryIoParams,
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()
@ -931,13 +953,16 @@ impl ModalManager {
self.render_memory_io_graphs( self.render_memory_io_graphs(
f, f,
middle_chunks[0], middle_chunks[0],
process, MemoryIoParams {
mem_history, process: params.process,
io_read_history, mem_history: params.mem_history,
io_write_history, io_read_history: params.io_read_history,
io_write_history: params.io_write_history,
max_mem_bytes: params.max_mem_bytes,
},
); );
self.render_thread_table(f, middle_chunks[1], process); self.render_thread_table(f, middle_chunks[1], params.process);
self.render_command_and_metadata(f, middle_chunks[2], process); self.render_command_and_metadata(f, middle_chunks[2], params.process);
} }
fn render_command_and_metadata( fn render_command_and_metadata(

View File

@ -15,6 +15,7 @@ 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,
} }

View File

@ -18,6 +18,66 @@ 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]
@ -34,30 +94,61 @@ const COLS: [Constraint; 5] = [
Constraint::Length(8), // Mem % Constraint::Length(8), // Mem %
]; ];
pub fn draw_top_processes( pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
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) = m else { return }; let Some(mm) = params.metrics 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 and content area (reserve 2 columns for scrollbar) // Inner area (reserve space for search box if active)
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;
} }
@ -68,27 +159,15 @@ pub fn draw_top_processes(
height: inner.height, height: inner.height,
}; };
// Sort rows (by CPU% or Mem bytes), descending. // Get filtered and sorted indices
let mut idxs: Vec<usize> = (0..mm.top_processes.len()).collect(); let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by);
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 = scroll_offset.min(max_off); let offset = params.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
@ -122,9 +201,9 @@ pub fn draw_top_processes(
}; };
// 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) = selected_process_pid { let is_selected = if let Some(selected_pid) = params.selected_process_pid {
selected_pid == p.pid selected_pid == p.pid
} else if let Some(selected_idx) = selected_process_index { } else if let Some(selected_idx) = params.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
@ -153,11 +232,11 @@ pub fn draw_top_processes(
}); });
// Header with sort indicator // Header with sort indicator
let cpu_hdr = match sort_by { let cpu_hdr = match params.sort_by {
ProcSortBy::CpuDesc => "CPU % •", ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %", _ => "CPU %",
}; };
let mem_hdr = match sort_by { let mem_hdr = match params.sort_by {
ProcSortBy::MemDesc => "Mem •", ProcSortBy::MemDesc => "Mem •",
_ => "Mem", _ => "Mem",
}; };
@ -174,9 +253,9 @@ pub fn draw_top_processes(
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) = selected_process_pid { if let Some(selected_pid) = params.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) = m { let process_info = if let Some(metrics) = params.metrics {
metrics metrics
.top_processes .top_processes
.iter() .iter()
@ -261,6 +340,7 @@ pub struct ProcessKeyParams<'a> {
pub key: crossterm::event::KeyEvent, pub key: crossterm::event::KeyEvent,
pub metrics: Option<&'a Metrics>, pub metrics: Option<&'a Metrics>,
pub sort_by: ProcSortBy, 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
@ -278,79 +358,71 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
match params.key.code { match params.key.code {
KeyCode::Up => { KeyCode::Up => {
// Build sorted index list to navigate through display order // Navigate through filtered and sorted results
if let Some(m) = params.metrics { if let Some(m) = params.metrics {
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect(); let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
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(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)
}),
}
if params.selected_process_index.is_none() || params.selected_process_pid.is_none() 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 sorted order // No selection - select the first process in filtered/sorted order
if !idxs.is_empty() {
let first_idx = idxs[0]; let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx); *params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid); *params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
} else if let Some(current_idx) = *params.selected_process_index { } else if let Some(current_idx) = *params.selected_process_index {
// Find current position in sorted list // Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
&& pos > 0 if pos > 0 {
{ // Move up in filtered/sorted list
// Move up in sorted list
let new_idx = idxs[pos - 1]; let new_idx = idxs[pos - 1];
*params.selected_process_index = Some(new_idx); *params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid); *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 true // Handled
} }
KeyCode::Down => { KeyCode::Down => {
// Build sorted index list to navigate through display order // Navigate through filtered and sorted results
if let Some(m) = params.metrics { if let Some(m) = params.metrics {
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect(); let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
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(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)
}),
}
if params.selected_process_index.is_none() || params.selected_process_pid.is_none() 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 sorted order // No selection - select the first process in filtered/sorted order
if !idxs.is_empty() {
let first_idx = idxs[0]; let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx); *params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid); *params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
} else if let Some(current_idx) = *params.selected_process_index { } else if let Some(current_idx) = *params.selected_process_index {
// Find current position in sorted list // Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
&& pos + 1 < idxs.len() if pos + 1 < idxs.len() {
{ // Move down in filtered/sorted list
// Move down in sorted list
let new_idx = idxs[pos + 1]; let new_idx = idxs[pos + 1];
*params.selected_process_index = Some(new_idx); *params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid); *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 true // Handled
@ -455,6 +527,7 @@ 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
@ -470,11 +543,19 @@ 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: inner.y, y: content_start_y,
width: inner.width.saturating_sub(2), width: inner.width.saturating_sub(2),
height: inner.height, height: inner
.height
.saturating_sub(if search_active { 3 } else { 0 }),
}; };
// Scrollbar interactions (click arrows/page/drag) // Scrollbar interactions (click arrows/page/drag)
@ -531,24 +612,12 @@ 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 sorting logic as the drawing code // Find the actual process using the same filtering/sorting logic as the drawing code
if let Some(m) = params.metrics { if let Some(m) = params.metrics {
// Create the same sorted index array as in draw_top_processes // Use the same filtered and sorted indices as display
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect(); let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
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 sorted order // Calculate which process was actually clicked based on filtered/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

@ -1,6 +1,6 @@
[package] [package]
name = "socktop_agent" name = "socktop_agent"
version = "1.40.70" version = "1.50.2"
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,33 +8,39 @@ license = "MIT"
readme = "README.md" readme = "README.md"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } # Tokio: Use minimal features instead of "full" to reduce binary size
# 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 = "0.1" tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# 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.6", features = ["tls-rustls"] } axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = "0.23" rustls = { version = "0.23", features = ["aws-lc-rs"] }
rustls-pemfile = "2.1" rustls-pemfile = "2.1"
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build) rcgen = "0.13"
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
tokio-process = "0.2" [features]
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,10 +29,53 @@ fn arg_value(name: &str) -> Option<String> {
None None
} }
#[tokio::main] fn main() -> anyhow::Result<()> {
async fn main() -> anyhow::Result<()> { // Install rustls crypto provider before any TLS operations
// 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,6 +18,7 @@ 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.
@ -168,11 +169,12 @@ 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();
})) { })) {
warn!("sysinfo selective refresh panicked: {e:?}"); #[cfg(feature = "logging")]
warn!("sysinfo selective refresh panicked: {_e:?}");
} }
// Get or initialize hostname once // Get or initialize hostname once
@ -266,8 +268,9 @@ 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) => {
warn!("gpu collection failed: {e}"); #[cfg(feature = "logging")]
warn!("gpu collection failed: {_e}");
None None
} }
}; };
@ -348,6 +351,7 @@ 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);
} }
@ -357,9 +361,11 @@ 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
}; };
@ -394,6 +400,7 @@ 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 {
@ -402,6 +409,7 @@ 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);
} }
@ -752,6 +760,7 @@ 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,4 +1,3 @@
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;
@ -17,7 +16,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::cargo_bin("socktop_agent").expect("binary exists"); let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("socktop_agent"));
// 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 = "0.1.6" version = "1.50.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
description = "WebSocket connector library for socktop agent communication" description = "WebSocket connector library for socktop agent communication"