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)
This commit is contained in:
jasonwitty 2025-11-17 11:24:32 -08:00 committed by GitHub
parent 5ddaed298b
commit 0d789fb97c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 385 additions and 180 deletions

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,7 +104,8 @@ 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 process_details_unsupported: bool, // Track if agent doesn't support process details 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
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,
@ -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,41 +786,39 @@ 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() { if let Some(display_pos) =
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect(); idxs.iter().position(|&idx| idx == selected_idx)
match self.procs_sort_by { {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { // Calculate viewport size
let aa = m.top_processes[a].cpu_usage; // Account for: borders (2) + header (1) + search box if active (3)
let bb = m.top_processes[b].cpu_usage; let extra_rows = if self.process_search_active
bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal) || !self.process_search_query.is_empty()
}),
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) =
idxs.iter().position(|&idx| idx == selected_idx)
{ {
// Adjust scroll offset to keep selection visible 3 // search box with border
if display_pos < self.procs_scroll_offset { } else {
// Selection is above viewport, scroll up 0
self.procs_scroll_offset = display_pos; };
} else if display_pos let viewport_rows =
>= self.procs_scroll_offset + viewport_rows p_area.height.saturating_sub(3 + extra_rows) as usize;
{
// Selection is below viewport, scroll down // Adjust scroll offset to keep selection visible
self.procs_scroll_offset = if display_pos < self.procs_scroll_offset {
display_pos.saturating_sub(viewport_rows - 1); // 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);
} }
} }
} }
@ -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,8 +240,19 @@ 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 avg (now: {:>5.1}% | avg: {:>5.1}%)",
mm.cpu_total, avg_cpu
)
} else { } else {
"CPU avg".into() "CPU avg".into()
}; };

View File

@ -271,7 +271,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
@ -477,12 +477,20 @@ 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
/ .............. Start/edit fuzzy search
c/C ............ Clear search filter
/ ............ Select/navigate processes / ............ Select/navigate processes
Enter .......... Open Process Details Enter .......... Open Process Details
x/X ............ Clear selection x/X ............ Clear selection
Click header ... Sort by column (CPU/Mem) Click header ... Sort by column (CPU/Mem)
Click row ...... Select process 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 CPU PER-CORE
/ ............ Scroll cores PgUp/PgDn ... Page up/down / ............ Scroll cores PgUp/PgDn ... Page up/down
Home/End ....... Jump to first/last core Home/End ....... Jump to first/last core
@ -493,8 +501,11 @@ PROCESS DETAILS MODAL
j/k ............ Scroll threads / (1 line) j/k ............ Scroll threads / (1 line)
d/u ............ Scroll threads / (10 lines) d/u ............ Scroll threads / (10 lines)
[ / ] .......... Scroll journal / [ / ] .......... Scroll journal /
Esc/Enter ...... Close modal"; 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 ")

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];
*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]; 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 {
// Find current position in sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx)
&& pos > 0
{
// Move up in 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);
}
} }
} }
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];
*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]; 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 {
// Find current position in sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx)
&& pos + 1 < idxs.len()
{
// Move down in 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);
}
} }
} }
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];