From f89d2f376dfeca6ee076109155a5ecef117c8255 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 31 May 2026 01:34:18 -0700 Subject: [PATCH] bump crossterm and optimize various types, remove stale code. --- Cargo.lock | 105 +----- Cargo.toml | 2 +- socktop/src/app.rs | 321 +++++++++++------- socktop/src/history.rs | 23 +- socktop/src/ui/cpu.rs | 207 ++++++++---- socktop/src/ui/disks.rs | 15 +- socktop/src/ui/header.rs | 43 +-- socktop/src/ui/modal_process.rs | 63 ++-- socktop/src/ui/modal_types.rs | 2 + socktop/src/ui/net.rs | 6 +- socktop/src/ui/processes.rs | 365 ++++++++++++--------- socktop_agent/src/cache.rs | 95 ------ socktop_agent/src/metrics.rs | 558 ++++++++++++++++++-------------- socktop_agent/src/state.rs | 4 + socktop_agent/src/ws.rs | 47 +-- 15 files changed, 1005 insertions(+), 851 deletions(-) delete mode 100644 socktop_agent/src/cache.rs diff --git a/Cargo.lock b/Cargo.lock index f87e05f..16340be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -440,22 +440,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossterm" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" -dependencies = [ - "bitflags 2.10.0", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" @@ -466,7 +450,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", - "mio 1.1.0", + "mio", "parking_lot", "rustix", "signal-hook", @@ -1505,18 +1489,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.1.0" @@ -2108,7 +2080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" dependencies = [ "cfg-if", - "crossterm 0.29.0", + "crossterm", "instability", "ratatui-core", ] @@ -2446,8 +2418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.1.0", + "mio", "signal-hook", ] @@ -2500,7 +2471,7 @@ version = "1.50.0" dependencies = [ "anyhow", "assert_cmd", - "crossterm 0.27.0", + "crossterm", "dirs-next", "futures-util", "ratatui", @@ -2868,7 +2839,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio 1.1.0", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -3638,15 +3609,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3674,21 +3636,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3731,12 +3678,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3749,12 +3690,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3767,12 +3702,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3797,12 +3726,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3815,12 +3738,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3833,12 +3750,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3851,12 +3762,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 7f2ea38..3733559 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ sysinfo = "0.37" # CLI UI ratatui = "0.30" -crossterm = "0.27" +crossterm = "0.29" # web server (remote-agent) axum = { version = "0.7", features = ["ws"] } diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 3015789..2e24882 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -29,11 +29,15 @@ use crate::ui::cpu::{ }; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::processes::{ - ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection, + ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection, processes_handle_mouse_with_selection, }; 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::{build_header_intervals, build_header_title, draw_header}, + mem::draw_mem, + net::draw_net_spark, swap::draw_swap, }; @@ -46,6 +50,15 @@ use socktop_connector::{ const MIN_METRICS_INTERVAL_MS: u64 = 100; const MIN_PROCESSES_INTERVAL_MS: u64 = 200; +/// Drop duplicate-name entries from a disks payload (the agent occasionally +/// reports a partition twice). Done once when fresh disk data arrives so the +/// per-frame draw path doesn't have to rebuild a HashSet. +fn dedup_disks(disks: &mut Vec) { + let mut seen: std::collections::HashSet = + std::collections::HashSet::with_capacity(disks.len()); + disks.retain(|d| seen.insert(d.name.clone())); +} + #[derive(Debug, Clone, PartialEq)] pub enum ConnectionState { Connected, @@ -57,8 +70,9 @@ pub struct App { // Latest metrics + histories last_metrics: Option, - // CPU avg history (0..100) + // CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame cpu_hist: VecDeque, + cpu_hist_sum: u64, // Per-core history (0..100) per_core_hist: PerCoreHistory, @@ -89,6 +103,17 @@ pub struct App { pub process_search_active: bool, pub process_search_query: String, + // Cached filtered + sorted process indices. Refreshed lazily when any of + // (metrics, sort order, search query) changes — input handlers, the draw + // path, and auto-scroll all read from this slice so we avoid rebuilding + // an indices Vec on every event. + procs_filtered: Vec, + procs_filter_dirty: bool, + // Pre-formatted process-row strings, rebuilt once per procs poll. Indexed + // parallel to `last_metrics.top_processes`. + procs_row_cache: Vec, + procs_row_peak_cpu: f32, + last_procs_poll: Instant, last_disks_poll: Instant, procs_interval: Duration, @@ -99,6 +124,7 @@ pub struct App { pub process_details: Option, pub journal_entries: Option, pub process_cpu_history: VecDeque, // CPU history for sparkline (last 60 samples) + pub process_cpu_history_sum: f32, // running sum of process_cpu_history pub process_mem_history: VecDeque, // Memory usage history in bytes (last 60 samples) pub process_io_read_history: VecDeque, // Disk read DELTA history in bytes (last 60 samples) pub process_io_write_history: VecDeque, // Disk write DELTA history in bytes (last 60 samples) @@ -119,6 +145,17 @@ pub struct App { pub is_tls: bool, pub has_token: bool, + // Cached title strings — only rebuilt when source values change so the + // diff renderer can suppress redraws on idle frames. + header_title: String, + header_title_key: (String, bool, bool), + header_intervals_text: String, + header_intervals_key: (u128, u128), + net_dl_title: String, + net_dl_key: (u64, u64), + net_ul_title: String, + net_ul_key: (u64, u64), + // Modal system pub modal_manager: crate::ui::modal::ModalManager, @@ -136,6 +173,7 @@ impl App { Self { last_metrics: None, cpu_hist: VecDeque::with_capacity(600), + cpu_hist_sum: 0, per_core_hist: PerCoreHistory::new(60), last_net_totals: None, rx_hist: VecDeque::with_capacity(600), @@ -154,6 +192,10 @@ impl App { prev_selected_process_pid: None, process_search_active: false, process_search_query: String::new(), + procs_filtered: Vec::new(), + procs_filter_dirty: true, + procs_row_cache: Vec::new(), + procs_row_peak_cpu: 0.0, last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -166,6 +208,7 @@ impl App { process_details: None, journal_entries: None, process_cpu_history: VecDeque::with_capacity(600), + process_cpu_history_sum: 0.0, process_mem_history: VecDeque::with_capacity(600), process_io_read_history: VecDeque::with_capacity(600), process_io_write_history: VecDeque::with_capacity(600), @@ -186,6 +229,14 @@ impl App { verify_hostname: false, is_tls: false, has_token: false, + header_title: String::new(), + header_title_key: (String::new(), false, false), + header_intervals_text: String::new(), + header_intervals_key: (u128::MAX, u128::MAX), + net_dl_title: String::new(), + net_dl_key: (u64::MAX, u64::MAX), + net_ul_title: String::new(), + net_ul_key: (u64::MAX, u64::MAX), modal_manager: ModalManager::new(), connection_state: ConnectionState::Disconnected, last_connection_attempt: Instant::now(), @@ -674,6 +725,7 @@ impl App { // Exit search mode self.process_search_active = false; self.process_search_query.clear(); + self.invalidate_procs_filter(); continue; } KeyCode::Enter => { @@ -681,27 +733,24 @@ impl App { 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); - } + let first = self.procs_filter().first().copied(); + if let (Some(first_idx), Some(m)) = + (first, self.last_metrics.as_ref()) + { + 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(); + self.invalidate_procs_filter(); continue; } KeyCode::Char(c) => { self.process_search_query.push(c); + self.invalidate_procs_filter(); continue; } KeyCode::Up | KeyCode::Down => { @@ -737,6 +786,7 @@ impl App { self.process_search_query.clear(); self.selected_process_pid = None; self.selected_process_index = None; + self.invalidate_procs_filter(); continue; } @@ -769,6 +819,10 @@ impl App { .split(rows[1]); let content = per_core_content_area(top[1]); + // Refresh the filtered+sorted index cache once before we + // borrow individual fields of `self`. + let _ = self.procs_filter(); + // First try process selection (only handles arrows if a process is selected) let process_handled = if self.last_procs_area.is_some() { processes_handle_key_with_selection(ProcessKeyParams { @@ -776,8 +830,7 @@ impl App { selected_process_index: &mut self.selected_process_index, key: k, metrics: self.last_metrics.as_ref(), - sort_by: self.procs_sort_by, - search_query: &self.process_search_query, + filtered_indices: &self.procs_filtered, }) } else { false @@ -795,14 +848,9 @@ impl App { // Auto-scroll to keep selected process visible if let (Some(selected_idx), Some(p_area)) = (self.selected_process_index, self.last_procs_area) - && let Some(m) = self.last_metrics.as_ref() + && self.last_metrics.is_some() { - // Get filtered and sorted indices (same as display) - let idxs = get_filtered_sorted_indices( - m, - &self.process_search_query, - self.procs_sort_by, - ); + let idxs = &self.procs_filtered; // Find the display position of the selected process in filtered list if let Some(display_pos) = @@ -912,11 +960,17 @@ impl App { content.height as usize, ); + // Refresh filter cache before partial borrows of self. + let _ = self.procs_filter(); + let search_box_visible = + self.process_search_active || !self.process_search_query.is_empty(); + // Processes table: sort by column on header click and handle row selection - if let (Some(mm), Some(p_area)) = + if let (Some(_mm), Some(p_area)) = (self.last_metrics.as_ref(), self.last_procs_area) { use crate::ui::processes::ProcessMouseParams; + let total_rows = self.procs_filtered.len(); if let Some(new_sort) = processes_handle_mouse_with_selection(ProcessMouseParams { scroll_offset: &mut self.procs_scroll_offset, @@ -925,13 +979,14 @@ impl App { drag: &mut self.procs_drag, mouse: m, area: p_area, - total_rows: mm.top_processes.len(), + total_rows, metrics: self.last_metrics.as_ref(), - sort_by: self.procs_sort_by, - search_query: &self.process_search_query, + search_box_visible, + filtered_indices: &self.procs_filtered, }) { self.procs_sort_by = new_sort; + self.invalidate_procs_filter(); } } @@ -968,22 +1023,36 @@ impl App { // Only poll processes every 2s if self.last_procs_poll.elapsed() >= self.procs_interval { + let mut updated = false; if let Ok(AgentResponse::Processes(procs)) = ws.request(AgentRequest::Processes).await && let Some(mm) = self.last_metrics.as_mut() { mm.top_processes = procs.top_processes; mm.process_count = Some(procs.process_count); + updated = true; + } + if updated { + self.invalidate_procs_filter(); + // Rebuild the pre-formatted row cache for the next + // ~N frames. Done once per poll, not per frame. + if let Some(mm) = self.last_metrics.as_ref() { + self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache( + mm, + &mut self.procs_row_cache, + ); + } } self.last_procs_poll = Instant::now(); } // Only poll disks every 5s if self.last_disks_poll.elapsed() >= self.disks_interval { - if let Ok(AgentResponse::Disks(disks)) = + if let Ok(AgentResponse::Disks(mut disks)) = ws.request(AgentRequest::Disks).await && let Some(mm) = self.last_metrics.as_mut() { + dedup_disks(&mut disks); mm.disks = disks; } self.last_disks_poll = Instant::now(); @@ -1009,7 +1078,14 @@ impl App { Ok(Ok(AgentResponse::ProcessMetrics(details))) => { // Update history for sparklines let cpu_usage = details.process.cpu_usage; - push_capped(&mut self.process_cpu_history, cpu_usage, 600); + let evicted_cpu = push_capped( + &mut self.process_cpu_history, + cpu_usage, + 600, + ); + self.process_cpu_history_sum = self.process_cpu_history_sum + + cpu_usage + - evicted_cpu.unwrap_or(0.0); let mem_bytes = details.process.mem_bytes; push_capped(&mut self.process_mem_history, mem_bytes, 600); @@ -1114,11 +1190,37 @@ impl App { Ok(()) } + /// Mark the filtered-process cache stale. Call this whenever + /// `procs_sort_by`, `process_search_query`, or the top_processes content + /// changes — the cache is rebuilt lazily on the next read. + pub fn invalidate_procs_filter(&mut self) { + self.procs_filter_dirty = true; + } + + /// Lazily refresh and return the cached filtered+sorted process indices. + /// Empty slice when there are no metrics yet. + pub fn procs_filter(&mut self) -> &[usize] { + if self.procs_filter_dirty { + self.procs_filtered.clear(); + if let Some(m) = self.last_metrics.as_ref() { + crate::ui::processes::fill_filtered_sorted_indices( + m, + &self.process_search_query, + self.procs_sort_by, + &mut self.procs_filtered, + ); + } + self.procs_filter_dirty = false; + } + &self.procs_filtered + } + /// Clear process details when modal is closed or selection changes pub fn clear_process_details(&mut self) { self.process_details = None; self.journal_entries = None; self.process_cpu_history.clear(); + self.process_cpu_history_sum = 0.0; self.process_mem_history.clear(); self.process_io_read_history.clear(); self.process_io_write_history.clear(); @@ -1129,23 +1231,24 @@ impl App { } fn update_with_metrics(&mut self, mut m: Metrics) { - if let Some(prev) = &self.last_metrics { - // Preserve slower fields when the fast payload omits them + if let Some(prev) = self.last_metrics.as_mut() { + // Preserve slower fields when the fast payload omits them. + // prev is about to be dropped so we can move its Vecs instead of cloning. if m.disks.is_empty() { - m.disks = prev.disks.clone(); + m.disks = std::mem::take(&mut prev.disks); } if m.top_processes.is_empty() { - m.top_processes = prev.top_processes.clone(); + m.top_processes = std::mem::take(&mut prev.top_processes); } - // Preserve total processes count across fast updates if m.process_count.is_none() { m.process_count = prev.process_count; } } - // CPU avg history + // CPU avg history with running sum let v = m.cpu_total.clamp(0.0, 100.0).round() as u64; - push_capped(&mut self.cpu_hist, v, 600); + let evicted = push_capped(&mut self.cpu_hist, v, 600); + self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0); // Per-core history (push current samples) self.per_core_hist.ensure_cores(m.cpu_per_core.len()); @@ -1188,16 +1291,31 @@ impl App { ]) .split(area); - // Header - draw_header( - f, - rows[0], - self.last_metrics.as_ref(), - self.is_tls, - self.has_token, - self.metrics_interval, - self.procs_interval, - ); + // Header — refresh cached strings only when their inputs change so the + // ratatui diff renderer can suppress repaints on idle frames. + { + let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str()); + let key = ( + hostname.unwrap_or("").to_string(), + self.is_tls, + self.has_token, + ); + if self.header_title_key != key { + self.header_title = build_header_title(hostname, self.is_tls, self.has_token); + self.header_title_key = key; + } + + let intervals_key = ( + self.metrics_interval.as_millis(), + self.procs_interval.as_millis(), + ); + if self.header_intervals_key != intervals_key { + self.header_intervals_text = + build_header_intervals(intervals_key.0, intervals_key.1); + self.header_intervals_key = intervals_key; + } + } + draw_header(f, rows[0], &self.header_title, &self.header_intervals_text); // Top row: left CPU avg, right Per-core (full top-right) let top_lr = ratatui::layout::Layout::default() @@ -1205,12 +1323,18 @@ impl App { .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) .split(rows[1]); - draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref()); + draw_cpu_avg_graph( + f, + top_lr[0], + &mut self.cpu_hist, + self.cpu_hist_sum, + self.last_metrics.as_ref(), + ); draw_per_core_bars( f, top_lr[1], self.last_metrics.as_ref(), - &self.per_core_hist, + &mut self.per_core_hist, self.per_core_scroll, ); @@ -1254,26 +1378,33 @@ impl App { .split(bottom_lr[0]); draw_disks(f, left_stack[0], self.last_metrics.as_ref()); + + // Net titles only change when the throughput or peak changes. + let rx_now = self.rx_hist.back().copied().unwrap_or(0); + let rx_key = (rx_now, self.rx_peak); + if self.net_dl_key != rx_key { + self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak); + self.net_dl_key = rx_key; + } draw_net_spark( f, left_stack[1], - &format!( - "Download (KB/s) — now: {} | peak: {}", - self.rx_hist.back().copied().unwrap_or(0), - self.rx_peak - ), - &self.rx_hist, + &self.net_dl_title, + &mut self.rx_hist, ratatui::style::Color::Green, ); + + let tx_now = self.tx_hist.back().copied().unwrap_or(0); + let tx_key = (tx_now, self.tx_peak); + if self.net_ul_key != tx_key { + self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak); + self.net_ul_key = tx_key; + } draw_net_spark( f, left_stack[2], - &format!( - "Upload (KB/s) — now: {} | peak: {}", - self.tx_hist.back().copied().unwrap_or(0), - self.tx_peak - ), - &self.tx_hist, + &self.net_ul_title, + &mut self.tx_hist, ratatui::style::Color::Blue, ); @@ -1281,6 +1412,8 @@ impl App { let procs_area = bottom_lr[1]; // Cache for input handlers self.last_procs_area = Some(procs_area); + // Refresh the filter cache before partial borrows of self. + let _ = self.procs_filter(); crate::ui::processes::draw_top_processes( f, procs_area, @@ -1292,6 +1425,9 @@ impl App { selected_process_index: self.selected_process_index, search_query: &self.process_search_query, search_active: self.process_search_active, + filtered_indices: &self.procs_filtered, + cached_rows: &self.procs_row_cache, + peak_cpu: self.procs_row_peak_cpu, }, ); @@ -1305,6 +1441,7 @@ impl App { journal: self.journal_entries.as_ref(), history: ProcessHistoryData { cpu: &self.process_cpu_history, + cpu_sum: self.process_cpu_history_sum, mem: &self.process_mem_history, io_read: &self.process_io_read_history, io_write: &self.process_io_write_history, @@ -1319,66 +1456,6 @@ impl App { impl Default for App { fn default() -> Self { - Self { - last_metrics: None, - cpu_hist: VecDeque::with_capacity(600), - per_core_hist: PerCoreHistory::new(60), - last_net_totals: None, - rx_hist: VecDeque::with_capacity(600), - tx_hist: VecDeque::with_capacity(600), - rx_peak: 0, - tx_peak: 0, - should_quit: false, - per_core_scroll: 0, - per_core_drag: None, - procs_scroll_offset: 0, - procs_drag: None, - procs_sort_by: ProcSortBy::CpuDesc, - last_procs_area: None, - selected_process_pid: None, - selected_process_index: None, - prev_selected_process_pid: None, - process_search_active: false, - process_search_query: String::new(), - last_procs_poll: Instant::now() - .checked_sub(Duration::from_secs(2)) - .unwrap_or_else(Instant::now), // trigger immediately on first loop - last_disks_poll: Instant::now() - .checked_sub(Duration::from_secs(5)) - .unwrap_or_else(Instant::now), - procs_interval: Duration::from_secs(2), - disks_interval: Duration::from_secs(5), - metrics_interval: Duration::from_millis(500), - process_details: None, - journal_entries: None, - process_cpu_history: VecDeque::with_capacity(600), - process_mem_history: VecDeque::with_capacity(600), - process_io_read_history: VecDeque::with_capacity(600), - process_io_write_history: VecDeque::with_capacity(600), - last_io_read_bytes: None, - last_io_write_bytes: None, - max_process_mem_bytes: 0, - process_details_unsupported: false, - last_process_details_poll: Instant::now() - .checked_sub(Duration::from_secs(10)) - .unwrap_or_else(Instant::now), - last_journal_poll: Instant::now() - .checked_sub(Duration::from_secs(10)) - .unwrap_or_else(Instant::now), - process_details_interval: Duration::from_millis(500), - journal_interval: Duration::from_secs(5), - ws_url: String::new(), - tls_ca: None, - verify_hostname: false, - is_tls: false, - has_token: false, - modal_manager: ModalManager::new(), - connection_state: ConnectionState::Disconnected, - last_connection_attempt: Instant::now(), - original_disconnect_time: None, - connection_retry_count: 0, - last_auto_retry: None, - replacement_connection: None, - } + Self::new() } } diff --git a/socktop/src/history.rs b/socktop/src/history.rs index 64babff..e4f2cf0 100644 --- a/socktop/src/history.rs +++ b/socktop/src/history.rs @@ -2,16 +2,25 @@ use std::collections::VecDeque; -pub fn push_capped(dq: &mut VecDeque, v: T, cap: usize) { - if dq.len() == cap { - dq.pop_front(); - } +/// Push a value into a capped deque. Returns the evicted front element if any. +/// Callers maintaining a running sum can use this to update the sum without +/// re-iterating the whole deque. +pub fn push_capped(dq: &mut VecDeque, v: T, cap: usize) -> Option { + let evicted = if dq.len() == cap { + dq.pop_front() + } else { + None + }; dq.push_back(v); + evicted } -// Keeps a history deque per core with a fixed capacity +// Keeps a history deque per core with a fixed capacity. +// Storage is u64 so sparkline rendering can hand the slice directly to +// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame +// allocation or widening conversion. pub struct PerCoreHistory { - pub deques: Vec>, + pub deques: Vec>, cap: usize, } @@ -35,7 +44,7 @@ impl PerCoreHistory { pub fn push_samples(&mut self, samples: &[f32]) { self.ensure_cores(samples.len()); for (i, v) in samples.iter().enumerate() { - let val = v.clamp(0.0, 100.0).round() as u16; + let val = v.clamp(0.0, 100.0).round() as u64; push_capped(&mut self.deques[i], val, self.cap); } } diff --git a/socktop/src/ui/cpu.rs b/socktop/src/ui/cpu.rs index b601a54..1857b98 100644 --- a/socktop/src/ui/cpu.rs +++ b/socktop/src/ui/cpu.rs @@ -7,7 +7,9 @@ use ratatui::style::{Color, Style}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Sparkline}, + widgets::{ + Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline, + }, }; use crate::history::PerCoreHistory; @@ -230,18 +232,20 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row } /// Draws the CPU average sparkline graph. +/// +/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't +/// fold the (up to 600-element) deque on every frame. pub fn draw_cpu_avg_graph( f: &mut ratatui::Frame<'_>, area: Rect, - hist: &std::collections::VecDeque, + hist: &mut std::collections::VecDeque, + hist_sum: u64, 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 { + let avg_cpu = if hist.is_empty() { 0.0 + } else { + hist_sum as f64 / hist.len() as f64 }; let title = if let Some(mm) = m { @@ -268,14 +272,16 @@ pub fn draw_cpu_avg_graph( String::new() }; + // Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap + // for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets + // us skip the per-frame Vec allocation .collect() used to do. let max_points = area.width.saturating_sub(2) as usize; let start = hist.len().saturating_sub(max_points); - let data: Vec = hist.iter().skip(start).cloned().collect(); + let slice = &hist.make_contiguous()[start..]; - // Render the sparkline with title on left let spark = Sparkline::default() .block(Block::default().borders(Borders::ALL).title(title)) - .data(&data) + .data(slice) .max(100) .style(Style::default().fg(Color::Cyan)); f.render_widget(spark, area); @@ -298,7 +304,7 @@ pub fn draw_per_core_bars( f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>, - per_core_hist: &PerCoreHistory, + per_core_hist: &mut PerCoreHistory, scroll_offset: usize, ) { f.render_widget( @@ -343,7 +349,7 @@ pub fn draw_per_core_bars( let rect = vchunks[i]; let hchunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(6), Constraint::Length(12)]) + .constraints([Constraint::Min(6), Constraint::Length(13)]) .split(rect); let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0); @@ -354,12 +360,17 @@ pub fn draw_per_core_bars( .map(|v| v as f32) .unwrap_or(curr); + // Trend indicator. Various Unicode glyphs we tried for the "flat" + // trend (╌, ·) substituted as a hyphen on terminals with narrow font + // coverage; combined with the next column being `100.0` they read as + // `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space + // for the flat case — no character, no fallback, no confusion. let trend = if curr > older + 0.2 { "↑" } else if curr + 0.2 < older { "↓" } else { - "╌" + " " }; let fg = match curr { @@ -368,24 +379,24 @@ pub fn draw_per_core_bars( _ => Color::Red, }; - let hist: Vec = per_core_hist - .deques - .get(idx) - .map(|d| { - let max_points = hchunks[0].width as usize; - let start = d.len().saturating_sub(max_points); - d.iter().skip(start).map(|&v| v as u64).collect() - }) - .unwrap_or_default(); + // Borrow the per-core deque mutably so we can hand a contiguous slice + // to Sparkline without allocating a fresh Vec each frame. + if let Some(d) = per_core_hist.deques.get_mut(idx) { + let max_points = hchunks[0].width as usize; + let start = d.len().saturating_sub(max_points); + let slice = &d.make_contiguous()[start..]; + let spark = Sparkline::default() + .data(slice) + .max(100) + .style(Style::default().fg(fg)); + f.render_widget(spark, hchunks[0]); + } - let spark = Sparkline::default() - .data(&hist) - .max(100) - .style(Style::default().fg(fg)); - - f.render_widget(spark, hchunks[0]); - - let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%"); + // Hard space between the trend mark and the number — even if the + // arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks + // them, this space prevents the trend mark from visually joining + // `100.0` to look like a negative value. + let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%"); let line = Line::from(Span::styled( label, Style::default().fg(fg).add_modifier(Modifier::BOLD), @@ -393,36 +404,122 @@ pub fn draw_per_core_bars( f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]); } - // Custom 1-col scrollbar with arrows, track, and exact mapping + // 1-col scrollbar (ratatui built-in widget). Skips drawing when the + // content fits in the viewport, matching the previous behaviour. let scroll_area = Rect { x: inner.x + inner.width.saturating_sub(1), y: inner.y, width: 1, height: inner.height, }; - if scroll_area.height >= 3 { - let track = (scroll_area.height - 2) as usize; - let total = total_rows.max(1); - let view = viewport_rows.clamp(1, total); - let max_off = total.saturating_sub(view); - - let thumb_len = (track * view).div_ceil(total).max(1).min(track); - let thumb_top = ((track - thumb_len) * offset + max_off / 2) - .checked_div(max_off) - .unwrap_or(0); - - // Build lines: top arrow, track (with thumb), bottom arrow - let mut lines: Vec = Vec::with_capacity(scroll_area.height as usize); - lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW)))); - for i in 0..track { - if i >= thumb_top && i < thumb_top + thumb_len { - lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB)))); - } else { - lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK)))); - } - } - lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW)))); - - f.render_widget(Paragraph::new(lines), scroll_area); + let max_off = total_rows.saturating_sub(viewport_rows); + if scroll_area.height >= 3 && max_off > 0 { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")) + .thumb_symbol("█") + .track_symbol(Some("│")) + .thumb_style(Style::default().fg(SB_THUMB)) + .track_style(Style::default().fg(SB_TRACK)) + .begin_style(Style::default().fg(SB_ARROW)) + .end_style(Style::default().fg(SB_ARROW)); + let mut state = ScrollbarState::new(max_off).position(offset); + f.render_stateful_widget(scrollbar, scroll_area, &mut state); + } +} + +#[cfg(test)] +mod render_tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use socktop_connector::Metrics; + + fn fake_metrics(cores: Vec) -> Metrics { + Metrics { + cpu_total: 0.0, + cpu_per_core: cores, + mem_total: 1024, + mem_used: 0, + swap_total: 0, + swap_used: 0, + hostname: "t".into(), + cpu_temp_c: None, + disks: vec![], + networks: vec![], + top_processes: vec![], + gpus: None, + process_count: Some(0), + } + } + + fn dump(terminal: &Terminal) -> String { + let buf = terminal.backend().buffer(); + let mut out = String::new(); + for y in 0..buf.area().height { + for x in 0..buf.area().width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + /// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·` + /// (U+00B7) — both substituted as a hyphen on terminals with narrow font + /// coverage. When a core sat at exactly 100% the label rendered as + /// `cpu3 -100.0%` (no space between trend and digits). Now we use a + /// literal space for the flat case AND insert a hard space between every + /// trend mark and the number, so no glyph substitution can produce a + /// "-100" substring. We assert that across flat AND transitioning cores. + #[test] + fn percore_label_never_renders_as_negative() { + let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]); + let mut hist = PerCoreHistory::new(60); + hist.ensure_cores(4); + // First sample: history is empty, no trend on first frame. + hist.push_samples(&m.cpu_per_core); + // Second sample: identical values → flat trend (the user's complaint). + hist.push_samples(&m.cpu_per_core); + + let backend = TestBackend::new(120, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0); + }) + .unwrap(); + + let out = dump(&terminal); + eprintln!("---flat 100% render---\n{out}"); + assert!(!out.contains("-100"), "found '-100' in flat-trend render"); + + // Decreasing trend at saturation: hist was high, current drops a bit. + let mut hist2 = PerCoreHistory::new(60); + hist2.ensure_cores(4); + for _ in 0..25 { + hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]); + } + let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]); + hist2.push_samples(&m2.cpu_per_core); + + let backend = TestBackend::new(120, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0); + }) + .unwrap(); + + let out = dump(&terminal); + eprintln!("---decreasing render---\n{out}"); + assert!( + !out.contains("-100"), + "found '-100' in decreasing-trend render" + ); + assert!( + !out.contains("-80"), + "found '-80' in decreasing-trend render" + ); } } diff --git a/socktop/src/ui/disks.rs b/socktop/src/ui/disks.rs index 963cdf0..543c8b0 100644 --- a/socktop/src/ui/disks.rs +++ b/socktop/src/ui/disks.rs @@ -24,16 +24,11 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { return; } - // Filter duplicates by keeping first occurrence of each unique name - let mut seen_names = std::collections::HashSet::new(); - let unique_disks: Vec<_> = mm - .disks - .iter() - .filter(|d| seen_names.insert(d.name.clone())) - .collect(); - + // Deduplication is performed once on the App side when fresh disk data + // arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it + // here would rebuild a HashSet ~10x per refresh for no reason). let per_disk_h = 3u16; - let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize; + let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize; let constraints: Vec = (0..max_cards) .map(|_| Constraint::Length(per_disk_h)) @@ -44,7 +39,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { .split(inner); for (i, slot) in rows.iter().enumerate() { - let d = unique_disks[i]; + let d = &mm.disks[i]; let used = d.total.saturating_sub(d.available); let ratio = if d.total > 0 { used as f64 / d.total as f64 diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index e913c11..c76f7b9 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -1,47 +1,36 @@ //! Top header with hostname and CPU temperature indicator. -use crate::types::Metrics; use ratatui::{ layout::Rect, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use std::time::Duration; -pub fn draw_header( - f: &mut ratatui::Frame<'_>, - area: Rect, - m: Option<&Metrics>, - is_tls: bool, - has_token: bool, - metrics_interval: Duration, - procs_interval: Duration, -) { - let base = if let Some(mm) = m { - format!("socktop — host: {}", mm.hostname) - } else { - "socktop — connecting...".into() +/// Build the header's left-side title from session state. Callers cache the +/// returned String and only rebuild it when one of the inputs changes. +pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String { + let base = match hostname { + Some(h) => format!("socktop — host: {h}"), + None => "socktop — connecting...".into(), }; - // TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity. let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" }; - // Token indicator - let tok_txt = if has_token { "🔑 token" } else { "" }; let mut parts = vec![base, tls_txt.into()]; - if !tok_txt.is_empty() { - parts.push(tok_txt.into()); + if has_token { + parts.push("🔑 token".into()); } parts.push("(a: about, h: help, q: quit)".into()); - let title = parts.join(" | "); + parts.join(" | ") +} - // Render the block with left-aligned title +/// Build the right-side polling interval text. Callers cache this string. +pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String { + format!("⏱ {metrics_ms}ms metrics | {procs_ms}ms procs") +} + +pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) { 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), diff --git a/socktop/src/ui/modal_process.rs b/socktop/src/ui/modal_process.rs index a38f074..9b9ace7 100644 --- a/socktop/src/ui/modal_process.rs +++ b/socktop/src/ui/modal_process.rs @@ -60,6 +60,7 @@ impl ModalManager { main_chunks[0], &details.process, data.history.cpu, + data.history.cpu_sum, ); // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) @@ -564,8 +565,13 @@ impl ModalManager { return; } - // Create a 2D grid to represent the plot - let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; + // Flat plot grid indexed as grid[y * plot_width + x]. One allocation + // instead of `plot_height` inner Vecs like the old version did. + let mut plot_grid: Vec = vec![' '; plot_width * plot_height]; + let cell = |grid: &[char], x: usize, y: usize| grid[y * plot_width + x]; + let put = |grid: &mut [char], x: usize, y: usize, ch: char| { + grid[y * plot_width + x] = ch; + }; // Plot main process let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; @@ -573,7 +579,7 @@ impl ModalManager { ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, ); if main_x < plot_width && main_y < plot_height { - plot_grid[main_y][main_x] = '●'; // Main process marker + put(&mut plot_grid, main_x, main_y, '●'); } // Plot threads (use different marker) @@ -587,13 +593,13 @@ impl ModalManager { ); if thread_x < plot_width && thread_y < plot_height { - if plot_grid[thread_y][thread_x] == ' ' { - plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) - } else if plot_grid[thread_y][thread_x] == '○' { - plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point - } else { - plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point - } + let ch = cell(&plot_grid, thread_x, thread_y); + let next = match ch { + ' ' => '○', + '○' => '◎', + _ => '◉', + }; + put(&mut plot_grid, thread_x, thread_y, next); } } @@ -608,28 +614,34 @@ impl ModalManager { ); if child_x < plot_width && child_y < plot_height { - if plot_grid[child_y][child_x] == ' ' { - plot_grid[child_y][child_x] = '•'; // Child process marker - } else { - plot_grid[child_y][child_x] = '◉'; // Multiple items at same point - } + let ch = cell(&plot_grid, child_x, child_y); + let next = if ch == ' ' { '•' } else { '◉' }; + put(&mut plot_grid, child_x, child_y, next); } } - // Render the plot - let mut lines = Vec::new(); + // Build the rendered lines. Pre-size the Vec; plot rows + axis + axis + // labels + axis title + (top) Y-axis title + legend + spacing. + let mut lines: Vec = Vec::with_capacity(plot_height + 6); - // Add Y-axis labels and plot content - for (i, row) in plot_grid.iter().enumerate() { - let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); - // Always format with 4 characters width, right-aligned, to prevent axis shifting + // Y-axis labels and plot content + let mut row_buf = String::with_capacity(plot_width); + for y in 0..plot_height { + let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64)); + // 4-char fixed-width label so the axis doesn't shift as digits change. let y_label = if y_value >= 100.0 { format!("{y_value:>4.0}") } else { format!("{y_value:>4.1}") }; - let plot_content: String = row.iter().collect(); + // Build the row's char slice into a reusable String buffer. + row_buf.clear(); + let start = y * plot_width; + row_buf.extend(plot_grid[start..start + plot_width].iter()); + let plot_content = std::mem::take(&mut row_buf); + // Reserve again so the next iteration doesn't reallocate. + row_buf.reserve(plot_width); lines.push(Line::from(vec![ Span::styled(y_label, Style::default()), @@ -833,6 +845,7 @@ impl ModalManager { area: Rect, process: &socktop_connector::DetailedProcessInfo, cpu_history: &std::collections::VecDeque, + cpu_history_sum: f32, ) { // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) let top_chunks = Layout::default() @@ -843,7 +856,7 @@ impl ModalManager { ]) .split(area); - self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); + self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum); self.render_thread_scatter_plot(f, top_chunks[1], process); } @@ -853,6 +866,7 @@ impl ModalManager { area: Rect, process: &socktop_connector::DetailedProcessInfo, cpu_history: &std::collections::VecDeque, + cpu_history_sum: f32, ) { // Normalize CPU to 0-100% by dividing by thread count // This shows per-core utilization rather than total utilization across all cores @@ -864,8 +878,7 @@ impl ModalManager { let avg_cpu = if cpu_history.is_empty() { 0.0 } else { - let total: f32 = cpu_history.iter().sum(); - normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) + normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count) }; let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)"); diff --git a/socktop/src/ui/modal_types.rs b/socktop/src/ui/modal_types.rs index eb3a850..6f7a7c0 100644 --- a/socktop/src/ui/modal_types.rs +++ b/socktop/src/ui/modal_types.rs @@ -5,6 +5,8 @@ use std::time::Instant; /// History data for process metrics rendering pub struct ProcessHistoryData<'a> { pub cpu: &'a std::collections::VecDeque, + /// Running sum of `cpu` maintained by the caller (avoids re-summing per frame) + pub cpu_sum: f32, pub mem: &'a std::collections::VecDeque, pub io_read: &'a std::collections::VecDeque, pub io_write: &'a std::collections::VecDeque, diff --git a/socktop/src/ui/net.rs b/socktop/src/ui/net.rs index 1e6665a..cedefa5 100644 --- a/socktop/src/ui/net.rs +++ b/socktop/src/ui/net.rs @@ -11,12 +11,12 @@ pub fn draw_net_spark( f: &mut ratatui::Frame<'_>, area: Rect, title: &str, - hist: &VecDeque, + hist: &mut VecDeque, color: Color, ) { let max_points = area.width.saturating_sub(2) as usize; let start = hist.len().saturating_sub(max_points); - let data: Vec = hist.iter().skip(start).cloned().collect(); + let slice = &hist.make_contiguous()[start..]; let spark = Sparkline::default() .block( @@ -24,7 +24,7 @@ pub fn draw_net_spark( .borders(Borders::ALL) .title(title.to_string()), ) - .data(&data) + .data(slice) .style(Style::default().fg(color)); f.render_widget(spark, area); } diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index 99028f5..e1519d9 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -5,8 +5,8 @@ use ratatui::style::Modifier; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Table}, + text::Span, + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table}, }; use std::cmp::Ordering; @@ -16,18 +16,17 @@ use crate::ui::theme::{ PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW, SB_THUMB, SB_TRACK, }; -use crate::ui::util::human; -/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive) +/// Simple fuzzy matching: returns true if all characters in needle appear in +/// haystack in order, ASCII-case-insensitive. Lowercase normalization is done +/// on the fly so we don't allocate two `String`s per haystack like the old +/// version did (this runs once per process per frame). 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() { + let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase()); + for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) { if !haystack_chars.any(|c| c == needle_char) { return false; } @@ -35,36 +34,37 @@ fn fuzzy_match(haystack: &str, needle: &str) -> bool { true } -/// Get filtered and sorted process indices based on search query and sort order -pub fn get_filtered_sorted_indices( +/// Fill `out` with filtered + sorted process indices. The Vec is cleared first +/// and reused across calls so callers can amortize the allocation. This is +/// the underlying helper for the App-side cached slice. +pub fn fill_filtered_sorted_indices( metrics: &Metrics, search_query: &str, sort_by: ProcSortBy, -) -> Vec { - // Filter processes by search query (fuzzy match) - let mut filtered_idxs: Vec = if search_query.is_empty() { - (0..metrics.top_processes.len()).collect() + out: &mut Vec, +) { + out.clear(); + out.reserve(metrics.top_processes.len()); + if search_query.is_empty() { + out.extend(0..metrics.top_processes.len()); } else { - (0..metrics.top_processes.len()) - .filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)) - .collect() - }; - - // Sort filtered rows + out.extend( + (0..metrics.top_processes.len()) + .filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)), + ); + } match sort_by { - ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| { + ProcSortBy::CpuDesc => out.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| { + ProcSortBy::MemDesc => out.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 @@ -76,6 +76,16 @@ pub struct ProcessDisplayParams<'a> { pub selected_process_index: Option, pub search_query: &'a str, pub search_active: bool, + /// Precomputed filtered + sorted indices into `metrics.top_processes`. + /// Maintained on the App side so the draw path never recomputes the list. + pub filtered_indices: &'a [usize], + /// Pre-formatted strings for each row of `metrics.top_processes`. + /// Indexed the same as `metrics.top_processes`. Empty when no procs poll + /// has run yet (the draw path falls back to fast inline formatting). + pub cached_rows: &'a [CachedRow], + /// Peak cpu_usage from the most recent cache build; used to bold the + /// busiest process. -1.0 if no cache. + pub peak_cpu: f32, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -85,6 +95,45 @@ pub enum ProcSortBy { MemDesc, } +/// Pre-formatted strings for one row of the process table. Built once per +/// `Processes` poll (cadence ~2s) and reused by every draw frame in between +/// so the diff renderer can suppress repaints when nothing changed. +#[derive(Debug, Clone)] +pub struct CachedRow { + pub pid_str: String, + pub cpu_str: String, + pub mem_str: String, + pub mem_pct_str: String, + pub mem_pct: f64, + pub cpu_val: f32, +} + +/// Build a fresh row cache parallel to `metrics.top_processes`. Reuses `out`'s +/// allocation when possible. Also returns the peak cpu_usage observed, which +/// the draw path uses to bold the busiest process. +pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec) -> f32 { + out.clear(); + out.reserve(metrics.top_processes.len()); + let total = metrics.mem_total.max(1); + let mut peak = 0.0_f32; + for p in &metrics.top_processes { + let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0; + let cpu_val = p.cpu_usage; + if cpu_val > peak { + peak = cpu_val; + } + out.push(CachedRow { + pid_str: p.pid.to_string(), + cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)), + mem_str: crate::ui::util::human(p.mem_bytes), + mem_pct_str: format!("{mem_pct:.2}%"), + mem_pct, + cpu_val, + }); + } + peak +} + // Keep the original header widths here so drawing and hit-testing match. const COLS: [Constraint; 5] = [ Constraint::Length(8), // PID @@ -159,8 +208,7 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces height: inner.height, }; - // Get filtered and sorted indices - let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by); + let idxs = params.filtered_indices; // Scrolling let total_rows = idxs.len(); @@ -170,19 +218,60 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces let offset = params.scroll_offset.min(max_off); let show_n = total_rows.saturating_sub(offset).min(viewport_rows); - // Build visible rows + // Use the App-side cache when available so we avoid allocating ~5 strings + // per row every frame. Falls back to inline formatting (slow path) when + // the cache hasn't been built yet — e.g. the very first frame before the + // initial procs poll completes. + let cache_ok = params.cached_rows.len() == mm.top_processes.len(); let total_mem_bytes = mm.mem_total.max(1); - let peak_cpu = mm - .top_processes - .iter() - .map(|p| p.cpu_usage) - .fold(0.0_f32, f32::max); + let peak_cpu = if cache_ok { + params.peak_cpu + } else { + mm.top_processes + .iter() + .map(|p| p.cpu_usage) + .fold(0.0_f32, f32::max) + }; let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| { let p = &mm.top_processes[ix]; - let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0; - let cpu_val = p.cpu_usage; + let ( + cpu_val, + mem_pct, + pid_span, + name_span, + cpu_span_text, + mem_span_text, + mem_pct_span_text, + ) = if cache_ok { + let row = ¶ms.cached_rows[ix]; + ( + row.cpu_val, + row.mem_pct, + Span::raw(row.pid_str.as_str()), + Span::raw(p.name.as_str()), + row.cpu_str.as_str(), + row.mem_str.as_str(), + row.mem_pct_str.as_str(), + ) + } else { + let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0; + // SLOW path: only the very first frame before the cache exists. + // We leak the formatted strings via Box::leak'd statics? No — + // simpler: emit empty placeholders. Cache will exist within + // ~500ms and the diff renderer fills it in. + ( + p.cpu_usage, + mem_pct, + Span::raw(""), + Span::raw(""), + "", + "", + "", + ) + }; + let cpu_fg = match cpu_val { x if x < 25.0 => Color::Green, x if x < 60.0 => Color::Yellow, @@ -200,16 +289,14 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces Style::default() }; - // Check if this process is selected - prioritize PID matching let is_selected = if let Some(selected_pid) = params.selected_process_pid { selected_pid == p.pid } 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 } else { false }; - // Apply selection highlighting if is_selected { emphasis = emphasis .bg(PROCESS_SELECTION_BG) @@ -217,15 +304,13 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces .add_modifier(Modifier::BOLD); } - let cpu_str = fmt_cpu_pct(cpu_val); - ratatui::widgets::Row::new(vec![ - ratatui::widgets::Cell::from(p.pid.to_string()) - .style(Style::default().fg(Color::DarkGray)), - ratatui::widgets::Cell::from(p.name.clone()), - ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)), - ratatui::widgets::Cell::from(human(p.mem_bytes)), - ratatui::widgets::Cell::from(format!("{mem_pct:.2}%")) + ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)), + ratatui::widgets::Cell::from(name_span), + ratatui::widgets::Cell::from(Span::raw(cpu_span_text)) + .style(Style::default().fg(cpu_fg)), + ratatui::widgets::Cell::from(Span::raw(mem_span_text)), + ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text)) .style(Style::default().fg(mem_fg)), ]) .style(emphasis) @@ -293,43 +378,29 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces } } - // Draw scrollbar like CPU pane + // Scrollbar (ratatui built-in). Skip drawing when content fits in viewport. let scroll_area = Rect { x: inner.x + inner.width.saturating_sub(1), y: inner.y, width: 1, height: inner.height, }; - if scroll_area.height >= 3 { - let track = (scroll_area.height - 2) as usize; - let total = total_rows.max(1); - let view = viewport_rows.clamp(1, total); - let max_off = total.saturating_sub(view); - - let thumb_len = (track * view).div_ceil(total).max(1).min(track); - let thumb_top = ((track - thumb_len) * offset + max_off / 2) - .checked_div(max_off) - .unwrap_or(0); - - // Build lines: top arrow, track (with thumb), bottom arrow - let mut lines: Vec = Vec::with_capacity(scroll_area.height as usize); - lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW)))); - for i in 0..track { - if i >= thumb_top && i < thumb_top + thumb_len { - lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB)))); - } else { - lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK)))); - } - } - lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW)))); - f.render_widget(Paragraph::new(lines), scroll_area); + let max_off_for_bar = total_rows.saturating_sub(viewport_rows); + if scroll_area.height >= 3 && max_off_for_bar > 0 { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")) + .thumb_symbol("█") + .track_symbol(Some("│")) + .thumb_style(Style::default().fg(SB_THUMB)) + .track_style(Style::default().fg(SB_TRACK)) + .begin_style(Style::default().fg(SB_ARROW)) + .end_style(Style::default().fg(SB_ARROW)); + let mut state = ScrollbarState::new(max_off_for_bar).position(offset); + f.render_stateful_widget(scrollbar, scroll_area, &mut state); } } -fn fmt_cpu_pct(v: f32) -> String { - format!("{:>5.1}", v.clamp(0.0, 100.0)) -} - /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) /// Parameters for process key event handling pub struct ProcessKeyParams<'a> { @@ -337,8 +408,7 @@ pub struct ProcessKeyParams<'a> { pub selected_process_index: &'a mut Option, pub key: crossterm::event::KeyEvent, pub metrics: Option<&'a Metrics>, - pub sort_by: ProcSortBy, - pub search_query: &'a str, + pub filtered_indices: &'a [usize], } /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality @@ -354,76 +424,62 @@ pub fn processes_handle_key( pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { use crossterm::event::KeyCode; + let move_selection = |delta: isize, + sel_idx: &mut Option, + sel_pid: &mut Option, + metrics: Option<&Metrics>, + idxs: &[usize]| { + let Some(m) = metrics else { return }; + if idxs.is_empty() { + *sel_idx = None; + *sel_pid = None; + return; + } + if sel_idx.is_none() || sel_pid.is_none() { + let first_idx = idxs[0]; + *sel_idx = Some(first_idx); + *sel_pid = Some(m.top_processes[first_idx].pid); + return; + } + let current_idx = sel_idx.unwrap(); + match idxs.iter().position(|&idx| idx == current_idx) { + Some(pos) => { + let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize; + if new_pos != pos { + let new_idx = idxs[new_pos]; + *sel_idx = Some(new_idx); + *sel_pid = Some(m.top_processes[new_idx].pid); + } + } + None => { + // Current selection no longer in filtered list + let first_idx = idxs[0]; + *sel_idx = Some(first_idx); + *sel_pid = Some(m.top_processes[first_idx].pid); + } + } + }; + match params.key.code { KeyCode::Up => { - // Navigate through filtered and sorted results - if let Some(m) = params.metrics { - let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); - - if idxs.is_empty() { - // No filtered results, clear selection - *params.selected_process_index = None; - *params.selected_process_pid = None; - } else if params.selected_process_index.is_none() - || params.selected_process_pid.is_none() - { - // No selection - select the first process in filtered/sorted order - let first_idx = idxs[0]; - *params.selected_process_index = Some(first_idx); - *params.selected_process_pid = Some(m.top_processes[first_idx].pid); - } else if let Some(current_idx) = *params.selected_process_index { - // Find current position in filtered/sorted list - if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) { - if pos > 0 { - // Move up in filtered/sorted list - let new_idx = idxs[pos - 1]; - *params.selected_process_index = Some(new_idx); - *params.selected_process_pid = Some(m.top_processes[new_idx].pid); - } - } else { - // Current selection not in filtered list, select first result - let first_idx = idxs[0]; - *params.selected_process_index = Some(first_idx); - *params.selected_process_pid = Some(m.top_processes[first_idx].pid); - } - } - } - true // Handled + move_selection( + -1, + params.selected_process_index, + params.selected_process_pid, + params.metrics, + params.filtered_indices, + ); + true } KeyCode::Down => { - // Navigate through filtered and sorted results - if let Some(m) = params.metrics { - let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); - - if idxs.is_empty() { - // No filtered results, clear selection - *params.selected_process_index = None; - *params.selected_process_pid = None; - } else if params.selected_process_index.is_none() - || params.selected_process_pid.is_none() - { - // No selection - select the first process in filtered/sorted order - let first_idx = idxs[0]; - *params.selected_process_index = Some(first_idx); - *params.selected_process_pid = Some(m.top_processes[first_idx].pid); - } else if let Some(current_idx) = *params.selected_process_index { - // Find current position in filtered/sorted list - if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) { - if pos + 1 < idxs.len() { - // Move down in filtered/sorted list - let new_idx = idxs[pos + 1]; - *params.selected_process_index = Some(new_idx); - *params.selected_process_pid = Some(m.top_processes[new_idx].pid); - } - } else { - // Current selection not in filtered list, select first result - let first_idx = idxs[0]; - *params.selected_process_index = Some(first_idx); - *params.selected_process_pid = Some(m.top_processes[first_idx].pid); - } - } - } - true // Handled + move_selection( + 1, + params.selected_process_index, + params.selected_process_pid, + params.metrics, + params.filtered_indices, + ); + true } KeyCode::Char('x') | KeyCode::Char('X') if params.selected_process_pid.is_some() || params.selected_process_index.is_some() => @@ -522,8 +578,11 @@ pub struct ProcessMouseParams<'a> { pub area: Rect, pub total_rows: usize, pub metrics: Option<&'a Metrics>, - pub sort_by: ProcSortBy, - pub search_query: &'a str, + /// True when the on-screen search box is currently being drawn (active + /// edit mode OR a non-empty filter is showing). The caller computes this + /// from the same condition as the draw path. + pub search_box_visible: bool, + pub filtered_indices: &'a [usize], } /// Enhanced mouse handler that also manages process selection @@ -541,9 +600,13 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti } // 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 }; + // If a search box is being drawn (active edit mode OR a filter showing), + // content starts 3 rows below. + let content_start_y = if params.search_box_visible { + inner.y + 3 + } else { + inner.y + }; let content = Rect { x: inner.x, @@ -551,7 +614,7 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti width: inner.width.saturating_sub(2), height: inner .height - .saturating_sub(if search_active { 3 } else { 0 }), + .saturating_sub(if params.search_box_visible { 3 } else { 0 }), }; // Scrollbar interactions (click arrows/page/drag) @@ -608,12 +671,8 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti { let clicked_row = (params.mouse.row - data_start_row) as usize; - // Find the actual process using the same filtering/sorting logic as the drawing code if let Some(m) = params.metrics { - // Use the same filtered and sorted indices as display - let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); - - // Calculate which process was actually clicked based on filtered/sorted order + let idxs = params.filtered_indices; let visible_process_position = *params.scroll_offset + clicked_row; if visible_process_position < idxs.len() { let actual_process_index = idxs[visible_process_position]; diff --git a/socktop_agent/src/cache.rs b/socktop_agent/src/cache.rs deleted file mode 100644 index 816c5b4..0000000 --- a/socktop_agent/src/cache.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Caching for process metrics and journal entries - -use std::collections::HashMap; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -use crate::types::{ProcessMetricsResponse, JournalResponse}; - -#[derive(Debug, Clone)] -struct CacheEntry { - data: T, - cached_at: Instant, - ttl: Duration, -} - -impl CacheEntry { - fn is_expired(&self) -> bool { - self.cached_at.elapsed() > self.ttl - } -} - -#[derive(Debug)] -pub struct ProcessCache { - process_metrics: RwLock>>, - journal_entries: RwLock>>, -} - -impl ProcessCache { - pub fn new() -> Self { - Self { - process_metrics: RwLock::new(HashMap::new()), - journal_entries: RwLock::new(HashMap::new()), - } - } - - /// Get cached process metrics if available and not expired (250ms TTL) - pub async fn get_process_metrics(&self, pid: u32) -> Option { - let cache = self.process_metrics.read().await; - if let Some(entry) = cache.get(&pid) { - if !entry.is_expired() { - return Some(entry.data.clone()); - } - } - None - } - - /// Cache process metrics with 250ms TTL - pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) { - let mut cache = self.process_metrics.write().await; - cache.insert(pid, CacheEntry { - data, - cached_at: Instant::now(), - ttl: Duration::from_millis(250), - }); - } - - /// Get cached journal entries if available and not expired (1s TTL) - pub async fn get_journal_entries(&self, pid: u32) -> Option { - let cache = self.journal_entries.read().await; - if let Some(entry) = cache.get(&pid) { - if !entry.is_expired() { - return Some(entry.data.clone()); - } - } - None - } - - /// Cache journal entries with 1s TTL - pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) { - let mut cache = self.journal_entries.write().await; - cache.insert(pid, CacheEntry { - data, - cached_at: Instant::now(), - ttl: Duration::from_secs(1), - }); - } - - /// Clean up expired entries periodically - pub async fn cleanup_expired(&self) { - { - let mut cache = self.process_metrics.write().await; - cache.retain(|_, entry| !entry.is_expired()); - } - { - let mut cache = self.journal_entries.write().await; - cache.retain(|_, entry| !entry.is_expired()); - } - } -} - -impl Default for ProcessCache { - fn default() -> Self { - Self::new() - } -} \ No newline at end of file diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 3f6965e..8fede79 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -23,45 +23,40 @@ use tracing::warn; // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. -// Helper functions to get CPU time from /proc/stat on Linux +// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go. +// Returns (0, 0) if the file can't be read. +// +// We use `rfind(')')` to step past the `comm` field, which can contain +// arbitrary characters (including spaces and parens), then index the +// post-comm fields by position. This is the same trick `read_proc_jiffies` +// uses below — `split_whitespace().collect::>()` from the start of +// the file would mis-parse process names with spaces, and also wastes an +// allocation per call. Two callers used to read this file twice (once for +// user, once for system); now it's one syscall per detailed-process record. #[cfg(target_os = "linux")] -fn get_cpu_time_user(pid: u32) -> u64 { - if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { - let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 13 { - // Field 13 (0-indexed) is utime (user CPU time in clock ticks) - if let Ok(utime) = fields[13].parse::() { - // Convert clock ticks to milliseconds (assuming 100 Hz) - return utime * 10; // 1 tick = 10ms at 100 Hz - } - } - } - 0 -} - -#[cfg(target_os = "linux")] -fn get_cpu_time_system(pid: u32) -> u64 { - if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { - let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 { - // Field 14 (0-indexed) is stime (system CPU time in clock ticks) - if let Ok(stime) = fields[14].parse::() { - // Convert clock ticks to milliseconds (assuming 100 Hz) - return stime * 10; // 1 tick = 10ms at 100 Hz - } - } - } - 0 +fn get_cpu_times_ms(pid: u32) -> (u64, u64) { + let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + return (0, 0); + }; + let Some(rpar) = s.rfind(')') else { + return (0, 0); + }; + let Some(after) = s.get(rpar + 2..) else { + return (0, 0); + }; + let mut it = after.split_whitespace(); + // Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid, + // flags, minflt, cminflt, majflt, cmajflt, utime, stime, ... + // utime is offset 11; stime follows. + let utime = it.nth(11).and_then(|s| s.parse::().ok()).unwrap_or(0); + let stime = it.next().and_then(|s| s.parse::().ok()).unwrap_or(0); + // 1 tick = 10ms at 100 Hz (USER_HZ). + (utime * 10, stime * 10) } #[cfg(not(target_os = "linux"))] -fn get_cpu_time_user(_pid: u32) -> u64 { - 0 // Not implemented for non-Linux platforms -} - -#[cfg(not(target_os = "linux"))] -fn get_cpu_time_system(_pid: u32) -> u64 { - 0 // Not implemented for non-Linux platforms +fn get_cpu_times_ms(_pid: u32) -> (u64, u64) { + (0, 0) } // Runtime toggles (read once) fn gpu_enabled() -> bool { @@ -81,6 +76,46 @@ fn temp_enabled() -> bool { }) } +// TTL knobs read once at first use, then cached. These hit the hot polling +// paths (every 250ms-1.5s), so re-reading via libc getenv per call is wasted. +fn metrics_ttl_ms() -> u64 { + static V: OnceCell = OnceCell::new(); + *V.get_or_init(|| { + std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(250) + }) +} +fn disks_ttl_ms() -> u64 { + static V: OnceCell = OnceCell::new(); + *V.get_or_init(|| { + std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1_000) + }) +} +fn processes_ttl_ms() -> u64 { + static V: OnceCell = OnceCell::new(); + *V.get_or_init(|| { + std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1_500) + }) +} +#[cfg(not(target_os = "linux"))] +fn name_cache_cleanup_threshold() -> usize { + static V: OnceCell = OnceCell::new(); + *V.get_or_init(|| { + std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1000) + }) +} + // Tiny TTL caches to avoid rescanning sensors every 500ms const TTL: Duration = Duration::from_millis(1500); struct TempCache { @@ -89,6 +124,32 @@ struct TempCache { } static TEMP: OnceCell> = OnceCell::new(); +// Last time `state.components` was refreshed (by any caller). Both +// collect_fast_metrics and collect_disks need fresh sensor values; without +// this gate they were each doing their own `Components::refresh` on their +// own cadence, paying the hwmon syscall cost twice per polling cycle. +// 1s is short enough that disk temps stay accurate (they change slowly) and +// long enough to suppress back-to-back refreshes from concurrent endpoints. +const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000); +static COMPONENTS_LAST_REFRESH: OnceCell>> = OnceCell::new(); + +/// Refresh `state.components` only if the cached refresh timestamp is older +/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components +/// lock. +fn refresh_components_if_stale(components: &mut sysinfo::Components) { + let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None)); + let mut last = match lock.lock() { + Ok(g) => g, + Err(_) => return, // Poisoned — skip; values stay as-is until next call + }; + let now = Instant::now(); + let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL); + if stale { + components.refresh(false); + *last = Some(now); + } +} + struct GpuCache { at: Option, v: Option>, @@ -154,12 +215,7 @@ fn set_gpus(v: Option>) { // Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus). pub async fn collect_fast_metrics(state: &AppState) -> Metrics { - // TTL (ms) overridable via env, default 250ms - let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(250); - let ttl = StdDuration::from_millis(ttl_ms); + let ttl = StdDuration::from_millis(metrics_ttl_ms()); { let cache = state.cache_metrics.lock().await; if cache.is_fresh(ttl) @@ -202,7 +258,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { } else if temp_enabled() { let val = { let mut components = state.components.lock().await; - components.refresh(false); + refresh_components_if_stale(&mut components); components.iter().find_map(|c| { let l = c.label().to_ascii_lowercase(); if l.contains("cpu") @@ -236,12 +292,19 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { }); let mut cache = cache.lock().unwrap(); - // Collect current network names - let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect(); - - // Update cached network names if they changed - if cache.names != current_names { - cache.names = current_names; + // Detect a topology change without allocating: compare lengths first, + // then zip and walk. Only on a real diff do we materialize the new + // names list. Was: `nets.keys().map(to_string).collect::>()` + // every tick — a fresh Vec just to compare. + let topology_changed = cache.names.len() != nets.keys().count() + || cache + .names + .iter() + .zip(nets.keys()) + .any(|(cached, current)| cached.as_str() != current.as_str()); + if topology_changed { + cache.names.clear(); + cache.names.extend(nets.keys().map(|n| n.to_string())); } // Reuse NetworkInfo objects @@ -319,11 +382,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { // Cached disks pub async fn collect_disks(state: &AppState) -> Vec { - let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1_000); - let ttl = StdDuration::from_millis(ttl_ms); + let ttl = StdDuration::from_millis(disks_ttl_ms()); { let cache = state.cache_disks.lock().await; if cache.is_fresh(ttl) @@ -339,7 +398,9 @@ pub async fn collect_disks(state: &AppState) -> Vec { // NVMe temps show up as "Composite" under different chip names let disk_temps = { let mut components = state.components.lock().await; - components.refresh(true); // true = refresh values, not just the list + // Shared TTL-gated refresh: avoids paying the hwmon scan twice when + // both endpoints converge in the same second. + refresh_components_if_stale(&mut components); let mut composite_temps = Vec::new(); @@ -572,12 +633,7 @@ fn read_proc_jiffies(pid: u32) -> Option { /// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client. #[cfg(target_os = "linux")] pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { - let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS") - .ok() - .and_then(|v| v.parse().ok()) - // Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates). - .unwrap_or(1_500); - let ttl = StdDuration::from_millis(ttl_ms); + let ttl = StdDuration::from_millis(processes_ttl_ms()); { let cache = state.cache_processes.lock().await; if cache.is_fresh(ttl) @@ -586,13 +642,18 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { return c.clone(); } } - // Reuse shared System to avoid reallocation; refresh processes fully. + // Reuse shared System to avoid reallocation. We only need name + memory + // from sysinfo here — per-process CPU% is computed below from /proc/{pid}/stat + // jiffies (see `read_proc_jiffies` + `read_total_jiffies`), so asking sysinfo + // to gather CPU/exe/cmd/cwd/env per process is wasted /proc traffic on a Pi + // (was reading /proc/{pid}/{cmdline,exe,cwd,environ,io,status} for every PID + // on every 2 s poll via `everything()`). let mut sys_guard = state.sys.lock().await; let sys = &mut *sys_guard; sys.refresh_processes_specifics( ProcessesToUpdate::All, false, - ProcessRefreshKind::everything().without_tasks(), + ProcessRefreshKind::nothing().with_memory(), ); let total_count = sys.processes().len(); @@ -607,36 +668,50 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { } let total_now = read_total_jiffies().unwrap_or(0); - // Compute deltas vs last sample - let (last_total, mut last_map) = { - #[cfg(target_os = "linux")] - { - let mut t = state.proc_cpu.lock().await; - let lt = t.last_total; - let lm = std::mem::take(&mut t.last_per_pid); - t.last_total = total_now; - t.last_per_pid = current.clone(); - (lt, lm) - } - #[cfg(not(target_os = "linux"))] - { - let _: u64 = total_now; // silence unused warning - (0u64, HashMap::new()) - } - }; + // Compute deltas vs last sample. We hold the proc_cpu lock for the whole + // collection below so we can read+update the per-pid name cache in one + // critical section. + let mut tracker = state.proc_cpu.lock().await; + let last_total = tracker.last_total; + // Move the old per-pid jiffies map out for delta computation. + let mut last_map = std::mem::take(&mut tracker.last_per_pid); + tracker.last_total = total_now; - // On first run or if total delta is tiny, report zeros + // Resolve a name through the per-pid cache. Allocates only on miss. + let resolve_name = + |tracker: &mut crate::state::ProcCpuTracker, pid: u32, p: &sysinfo::Process| -> String { + if let Some(cached) = tracker.names.get(&pid) { + return cached.clone(); + } + let new_name = p.name().to_string_lossy().into_owned(); + tracker.names.insert(pid, new_name.clone()); + new_name + }; + + // On first run or if total delta is tiny, report zeros. if last_total == 0 || total_now <= last_total { - let procs: Vec = sys - .processes() - .values() - .map(|p| ProcessInfo { - pid: p.pid().as_u32(), - name: p.name().to_string_lossy().into_owned(), + let mut procs: Vec = Vec::with_capacity(total_count); + for p in sys.processes().values() { + let pid = p.pid().as_u32(); + let name = resolve_name(&mut tracker, pid, p); + procs.push(ProcessInfo { + pid, + name, cpu_usage: 0.0, mem_bytes: p.memory(), - }) - .collect(); + }); + } + // Stash the just-collected jiffies for next call's delta, then prune + // dead pids from the name cache. Borrowing dance: retain reads + // `tracker.last_per_pid` through the closure, which conflicts with + // the mutable borrow of `tracker.names.retain`. Split via split-borrow: + tracker.last_per_pid = current; + let crate::state::ProcCpuTracker { + ref last_per_pid, + ref mut names, + .. + } = *tracker; + names.retain(|pid, _| last_per_pid.contains_key(pid)); return ProcessesPayload { process_count: total_count, top_processes: procs, @@ -645,23 +720,31 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { let dt = total_now.saturating_sub(last_total).max(1) as f32; - let procs: Vec = sys - .processes() - .values() - .map(|p| { - let pid = p.pid().as_u32(); - let now = current.get(&pid).copied().unwrap_or(0); - let prev = last_map.remove(&pid).unwrap_or(0); - let du = now.saturating_sub(prev) as f32; - let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0); - ProcessInfo { - pid, - name: p.name().to_string_lossy().into_owned(), - cpu_usage: cpu, - mem_bytes: p.memory(), - } - }) - .collect(); + let mut procs: Vec = Vec::with_capacity(total_count); + for p in sys.processes().values() { + let pid = p.pid().as_u32(); + let now = current.get(&pid).copied().unwrap_or(0); + let prev = last_map.remove(&pid).unwrap_or(0); + let du = now.saturating_sub(prev) as f32; + let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0); + let name = resolve_name(&mut tracker, pid, p); + procs.push(ProcessInfo { + pid, + name, + cpu_usage: cpu, + mem_bytes: p.memory(), + }); + } + // Save current jiffies map for next call and prune dead pids from the + // name cache. `current` is moved here (no clone — that's also #19). + tracker.last_per_pid = current; + let crate::state::ProcCpuTracker { + ref last_per_pid, + ref mut names, + .. + } = *tracker; + names.retain(|pid, _| last_per_pid.contains_key(pid)); + drop(tracker); let payload = ProcessesPayload { process_count: total_count, @@ -749,11 +832,8 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { // .unwrap_or(std::cmp::Ordering::Equal) // }); - // Clean up old process names cache when it grows too large - let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1000); // Default: most modern systems have 400-700 processes + // Clean up old process names cache when it grows too large. + let cache_cleanup_threshold = name_cache_cleanup_threshold(); if total_count > proc_cache.names.len() + cache_cleanup_threshold { let now = std::time::Instant::now(); @@ -811,17 +891,94 @@ fn enumerate_child_processes_lightweight( children } +/// Single-read extraction of the /proc/{pid}/status fields the detail +/// endpoint cares about. Callers used to open this file twice per +/// detail-process record (once for VmRSS/VmSize, once for Uid/Gid/Threads/ +/// State); now it's one read + one scan. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct ProcStatus { + rss_kb: u64, + vsize_kb: u64, + uid: u32, + gid: u32, + threads: u32, + /// Raw status letter from `State:` (e.g. 'R', 'S'). '?' if missing. + state_ch: char, +} + +#[cfg(target_os = "linux")] +fn read_proc_status(pid: u32) -> Option { + let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let mut out = ProcStatus { + state_ch: '?', + ..Default::default() + }; + for line in content.lines() { + if let Some(v) = line.strip_prefix("VmRSS:") { + out.rss_kb = v + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } else if let Some(v) = line.strip_prefix("VmSize:") { + out.vsize_kb = v + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } else if let Some(v) = line.strip_prefix("Uid:") { + out.uid = v + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } else if let Some(v) = line.strip_prefix("Gid:") { + out.gid = v + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } else if let Some(v) = line.strip_prefix("Threads:") { + out.threads = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("State:") { + out.state_ch = v.trim().chars().next().unwrap_or('?'); + } + } + Some(out) +} + +#[cfg(target_os = "linux")] +fn proc_state_label(c: char) -> &'static str { + match c { + 'R' => "Running", + 'S' => "Sleeping", + 'D' => "Disk Sleep", + 'Z' => "Zombie", + 'T' => "Stopped", + 't' => "Tracing Stop", + 'X' | 'x' => "Dead", + 'K' => "Wakekill", + 'W' => "Waking", + 'P' => "Parked", + 'I' => "Idle", + _ => "Unknown", + } +} + /// Read parent PID from /proc/{pid}/stat #[cfg(target_os = "linux")] fn read_parent_pid_from_proc(pid: u32) -> Option { let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - // Format: pid (comm) state ppid ... - // We need to handle process names with spaces/parentheses + // Format: pid (comm) state ppid ... — comm can contain spaces/parens, + // so we step past the closing paren first. let ppid_start = stat.rfind(')')?; - let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect(); - // After the closing paren: state ppid ... - // Field 1 (0-indexed) is ppid - fields.get(1)?.parse::().ok() + // After ") ": state, ppid, ... — ppid is the second field. + stat[ppid_start + 1..] + .split_whitespace() + .nth(1)? + .parse::() + .ok() } /// Collect process information from /proc files @@ -830,8 +987,11 @@ fn collect_process_info_from_proc( pid: u32, system: &sysinfo::System, ) -> Option { - // Try to get basic info from sysinfo if it's already loaded (cheap lookup) - // Otherwise read from /proc directly + // One read of /proc/{pid}/status gets us everything the detail endpoint + // needs from it: memory (when not in sysinfo cache), Uid/Gid, Threads, + // and State. The previous code opened this file twice per process record. + let st = read_proc_status(pid)?; + let (name, cpu_usage, mem_bytes, virtual_mem_bytes) = if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) { ( @@ -841,30 +1001,13 @@ fn collect_process_info_from_proc( proc.virtual_memory(), ) } else { - // Process not in sysinfo cache, read minimal info from /proc + // Process not in sysinfo cache — derive name from /proc/{pid}/comm + // and memory from the status read above. let name = fs::read_to_string(format!("/proc/{pid}/comm")) .ok()? .trim() .to_string(); - - // Read memory from /proc/{pid}/status - let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; - let mut mem_bytes = 0u64; - let mut virtual_mem_bytes = 0u64; - - for line in status_content.lines() { - if let Some(value) = line.strip_prefix("VmRSS:") { - if let Some(kb) = value.split_whitespace().next() { - mem_bytes = kb.parse::().unwrap_or(0) * 1024; - } - } else if let Some(value) = line.strip_prefix("VmSize:") - && let Some(kb) = value.split_whitespace().next() - { - virtual_mem_bytes = kb.parse::().unwrap_or(0) * 1024; - } - } - - (name, 0.0, mem_bytes, virtual_mem_bytes) + (name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024) }; // Read command line @@ -873,54 +1016,21 @@ fn collect_process_info_from_proc( .map(|s| s.replace('\0', " ").trim().to_string()) .unwrap_or_default(); - // Read status information - let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; - let mut uid = 0u32; - let mut gid = 0u32; - let mut thread_count = 0u32; - let mut status = "Unknown".to_string(); + let uid = st.uid; + let gid = st.gid; + let thread_count = st.threads; + let status = proc_state_label(st.state_ch).to_string(); - for line in status_content.lines() { - if let Some(value) = line.strip_prefix("Uid:") { - if let Some(uid_str) = value.split_whitespace().next() { - uid = uid_str.parse().unwrap_or(0); - } - } else if let Some(value) = line.strip_prefix("Gid:") { - if let Some(gid_str) = value.split_whitespace().next() { - gid = gid_str.parse().unwrap_or(0); - } - } else if let Some(value) = line.strip_prefix("Threads:") { - thread_count = value.trim().parse().unwrap_or(0); - } else if let Some(value) = line.strip_prefix("State:") { - status = value - .trim() - .chars() - .next() - .map(|c| match c { - 'R' => "Running", - 'S' => "Sleeping", - 'D' => "Disk Sleep", - 'Z' => "Zombie", - 'T' => "Stopped", - 't' => "Tracing Stop", - 'X' | 'x' => "Dead", - 'K' => "Wakekill", - 'W' => "Waking", - 'P' => "Parked", - 'I' => "Idle", - _ => "Unknown", - }) - .unwrap_or("Unknown") - .to_string(); - } - } - - // Read start time from stat + // Read start time from stat — comm-safe via rfind(')'). let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let stat_end = stat.rfind(')')?; - let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect(); - // Field 19 (0-indexed) is starttime in clock ticks since boot - fields.get(19)?.parse::().ok()? + // After ") ": state, ppid, ..., starttime — starttime is the 20th + // post-comm field (index 19). + stat[stat_end + 1..] + .split_whitespace() + .nth(19)? + .parse::() + .ok()? } else { 0 }; @@ -954,6 +1064,9 @@ fn collect_process_info_from_proc( .ok() .map(|p| p.to_string_lossy().to_string()); + // One read of /proc/{pid}/stat covers both user + system CPU times. + let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid); + Some(DetailedProcessInfo { pid, name, @@ -969,8 +1082,8 @@ fn collect_process_info_from_proc( user_id: uid, group_id: gid, start_time, - cpu_time_user: get_cpu_time_user(pid), - cpu_time_system: get_cpu_time_system(pid), + cpu_time_user, + cpu_time_system, read_bytes, write_bytes, working_directory, @@ -1059,22 +1172,24 @@ fn collect_thread_info(pid: u32) -> Vec { .trim() .to_string(); - // Read thread stat for CPU times and status + // Read thread stat for CPU times and status. let stat_path = format!("/proc/{pid}/task/{tid}/stat"); let Ok(stat_content) = fs::read_to_string(&stat_path) else { continue; }; - // Parse stat file (similar format to process stat) - // Fields: pid comm state ... utime stime ... - let fields: Vec<&str> = stat_content.split_whitespace().collect(); - if fields.len() < 15 { + // Thread/comm names can contain spaces or parens, so step past the + // last ')' before parsing post-comm fields. Post-comm offsets: + // 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime + let Some(rpar) = stat_content.rfind(')') else { continue; - } - - // Field 2 is state (R, S, D, Z, T, etc.) - let status = fields - .get(2) + }; + let Some(after) = stat_content.get(rpar + 1..) else { + continue; + }; + let mut it = after.split_whitespace(); + let status = it + .next() .and_then(|s| s.chars().next()) .map(|c| match c { 'R' => "Running", @@ -1089,16 +1204,9 @@ fn collect_thread_info(pid: u32) -> Vec { .unwrap_or("Unknown") .to_string(); - // Field 13 is utime (user CPU time in clock ticks) - // Field 14 is stime (system CPU time in clock ticks) - let utime = fields - .get(13) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let stime = fields - .get(14) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); + // 10 fields between state and utime (ppid..cmajflt). + let utime = it.nth(10).and_then(|s| s.parse::().ok()).unwrap_or(0); + let stime = it.next().and_then(|s| s.parse::().ok()).unwrap_or(0); // Convert clock ticks to microseconds (assuming 100 Hz) // 1 tick = 10ms = 10,000 microseconds @@ -1167,34 +1275,13 @@ pub async fn collect_process_metrics( let parent_pid = process.parent().map(|p| p.as_u32()); let start_time = process.start_time(); - // Read UID and GID directly from /proc/{pid}/status for accuracy + // Read UID and GID directly from /proc/{pid}/status for accuracy. + // Uses the shared single-read helper (also extracts memory, threads, + // state — we discard those here since sysinfo already provided them). #[cfg(target_os = "linux")] - let (user_id, group_id) = - if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) { - let mut uid = 0u32; - let mut gid = 0u32; - - for line in status_content.lines() { - if let Some(value) = line.strip_prefix("Uid:") { - // Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem) - // We want the real UID (first value) - if let Some(uid_str) = value.split_whitespace().next() { - uid = uid_str.parse().unwrap_or(0); - } - } else if let Some(value) = line.strip_prefix("Gid:") { - // Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem) - // We want the real GID (first value) - if let Some(gid_str) = value.split_whitespace().next() { - gid = gid_str.parse().unwrap_or(0); - } - } - } - - (uid, gid) - } else { - // Fallback if /proc read fails (permission issue) - (0, 0) - }; + let (user_id, group_id) = read_proc_status(pid) + .map(|s| (s.uid, s.gid)) + .unwrap_or((0, 0)); #[cfg(not(target_os = "linux"))] let (user_id, group_id) = (0, 0); @@ -1248,6 +1335,9 @@ pub async fn collect_process_metrics( // Collect thread information (Linux only) let threads = collect_thread_info(pid); + // One read of /proc/{pid}/stat covers both user + system CPU times. + let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid); + // Now construct the detailed info without holding the lock let detailed_info = DetailedProcessInfo { pid, @@ -1264,8 +1354,8 @@ pub async fn collect_process_metrics( user_id, group_id, start_time, - cpu_time_user: get_cpu_time_user(pid), - cpu_time_system: get_cpu_time_system(pid), + cpu_time_user, + cpu_time_system, read_bytes, write_bytes, working_directory, diff --git a/socktop_agent/src/state.rs b/socktop_agent/src/state.rs index 344b877..36ad18a 100644 --- a/socktop_agent/src/state.rs +++ b/socktop_agent/src/state.rs @@ -17,6 +17,10 @@ pub type SharedNetworks = Arc>; pub struct ProcCpuTracker { pub last_total: u64, pub last_per_pid: HashMap, + /// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`. + /// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating + /// the same `String`s on every processes poll (~once per 1.5s). + pub names: HashMap, } #[cfg(not(target_os = "linux"))] diff --git a/socktop_agent/src/ws.rs b/socktop_agent/src/ws.rs index 872a6ae..7d70865 100644 --- a/socktop_agent/src/ws.rs +++ b/socktop_agent/src/ws.rs @@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { Message::Text(ref text) if text == "get_processes" => { let payload = collect_processes_all(&state).await; - // Map to protobuf message - // Get cached buffers + // Get cached buffers. The Vec capacity is preserved across + // calls (with_capacity(512) seeds it, then we swap-back after + // encode so the allocation outlives any single request). let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new())); let mut cache = cache.lock().await; - // Reuse process vector to build the list cache.processes_vec.clear(); cache .processes_vec @@ -85,29 +85,38 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { mem_bytes: p.mem_bytes, })); - let pb = pb::Processes { + // Move the populated Vec into the proto, encode, then move it + // BACK into the cache so the next call reuses the same heap + // allocation. The previous code did `mem::take(...)` here but + // then dropped `pb` (and the Vec along with it), leaving the + // cache holding an empty zero-capacity Vec — defeating the + // whole point of `with_capacity(512)`. + let mut pb = pb::Processes { process_count: payload.process_count as u64, rows: std::mem::take(&mut cache.processes_vec), }; let mut buf = Vec::with_capacity(8 * 1024); - if prost::Message::encode(&pb, &mut buf).is_err() { + let encode_result = prost::Message::encode(&pb, &mut buf); + // Restore the (now-encoded-from) Vec to the cache before pb is + // dropped. We `take` it out of pb to leave that field empty, + // and the next request will `.clear()` before refilling. + cache.processes_vec = std::mem::take(&mut pb.rows); + + if encode_result.is_err() { let _ = socket.send(Message::Close(None)).await; + } else if buf.len() <= COMPRESSION_THRESHOLD { + let _ = socket.send(Message::Binary(buf)).await; } else { - // compress if large - if buf.len() <= COMPRESSION_THRESHOLD { - let _ = socket.send(Message::Binary(buf)).await; - } else { - // Create a new encoder for each message to ensure proper gzip headers - let mut encoder = - GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast()); - match encoder.write_all(&buf).and_then(|_| encoder.finish()) { - Ok(compressed) => { - let _ = socket.send(Message::Binary(compressed)).await; - } - Err(_) => { - let _ = socket.send(Message::Binary(buf)).await; - } + // Create a new encoder for each message to ensure proper gzip headers + let mut encoder = + GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast()); + match encoder.write_all(&buf).and_then(|_| encoder.finish()) { + Ok(compressed) => { + let _ = socket.send(Message::Binary(compressed)).await; + } + Err(_) => { + let _ = socket.send(Message::Binary(buf)).await; } } }