From 5c32d15156a3a76cae8aa0c254b5b74fbe033598 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:01:17 -0700 Subject: [PATCH 1/9] first run messaging. display welcome message on first run when a profile file is not created and remote server not specified. --- socktop/src/main.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 259fad8..4127762 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -217,7 +217,7 @@ async fn main() -> Result<(), Box> { (u, t, entry.metrics_interval_ms, entry.processes_interval_ms) } ResolveProfile::PromptSelect(mut names) => { - if !names.iter().any(|n| n == "demo") { + if !names.iter().any(|n: &String| n == "demo") { names.push("demo".into()); } eprintln!("Select profile:"); @@ -281,10 +281,25 @@ async fn main() -> Result<(), Box> { (url.trim().to_string(), ca_opt, mi, pi) } ResolveProfile::None => { - eprintln!("No URL provided and no profiles to select."); - return Ok(()); + //eprintln!("No URL provided and no profiles to select."); + + //first run, no args, no profiles: show welcome message and offer demo mode + if profiles_mut.profiles.is_empty() && parsed.url.is_none() { + eprintln!("Welcome to socktop!"); + eprintln!("It looks like this is your first time running the application."); + eprintln!("You can connect to a socktop_agent instance to monitor system metrics and processes."); + eprintln!("If you don't have an agent running, you can try the demo mode."); + if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") { + return run_demo_mode(parsed.tls_ca.as_deref()).await; + } else { + eprintln!("Aborting. You can run 'socktop --help' for usage information."); + return Ok(()); + } + } + return Err("No URL provided and no profiles to select.".into()); } }; + let is_tls = url.starts_with("wss://"); let has_token = url.contains("token="); let mut app = App::new() From 8bd1af7a2765a889887c2d48492c0d33ff8b2956 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:08:52 -0700 Subject: [PATCH 2/9] chore: remove unused deps (thiserror, chrono, futures, nvml-wrapper, tungstenite, bytes, prost-types) --- Cargo.lock | 66 +++------------------------------------- Cargo.toml | 7 ----- socktop/Cargo.toml | 3 -- socktop_agent/Cargo.toml | 6 +--- 4 files changed, 6 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 577a202..a57bb22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,10 +357,8 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -1761,18 +1759,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_chacha", + "rand_core", ] [[package]] @@ -1782,17 +1770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", + "rand_core", ] [[package]] @@ -1804,15 +1782,6 @@ dependencies = [ "getrandom 0.2.16", ] -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - [[package]] name = "ratatui" version = "0.28.1" @@ -2197,12 +2166,9 @@ version = "0.1.25" dependencies = [ "anyhow", "assert_cmd", - "bytes", - "chrono", "crossterm 0.27.0", "dirs-next", "flate2", - "futures", "futures-util", "prost", "prost-build", @@ -2226,17 +2192,13 @@ dependencies = [ "assert_cmd", "axum", "axum-server", - "bytes", "flate2", - "futures", "futures-util", "gfxinfo", "hostname", - "nvml-wrapper", "once_cell", "prost", "prost-build", - "prost-types", "protoc-bin-vendored", "rcgen", "rustls 0.23.31", @@ -2250,7 +2212,6 @@ dependencies = [ "tonic-build", "tracing", "tracing-subscriber", - "tungstenite 0.27.0", ] [[package]] @@ -2513,7 +2474,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", - "tungstenite 0.24.0", + "tungstenite", ] [[package]] @@ -2658,7 +2619,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.5", + "rand", "rustls 0.23.31", "rustls-pki-types", "sha1", @@ -2666,23 +2627,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.12", - "utf-8", -] - [[package]] name = "typenum" version = "1.18.0" diff --git a/Cargo.toml b/Cargo.toml index c3aad7a..0da0f00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,19 +8,16 @@ members = [ [workspace.dependencies] # async + streams tokio = { version = "1", features = ["full"] } -futures = "0.3" futures-util = "0.3" anyhow = "1.0" # websocket tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] } -tungstenite = "0.24" url = "2.5" # JSON + error handling serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -thiserror = "1.0" # system stats sysinfo = "0.32" @@ -29,16 +26,12 @@ sysinfo = "0.32" ratatui = "0.28" crossterm = "0.27" -# date/time -chrono = { version = "0.4", features = ["serde"] } # web server (remote-agent) axum = { version = "0.7", features = ["ws"] } # protobuf prost = "0.13" -prost-types = "0.13" -bytes = "1" dirs-next = "2" [profile.release] diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index b17ba0e..7b512b5 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -9,21 +9,18 @@ license = "MIT" [dependencies] tokio = { workspace = true } tokio-tungstenite = { workspace = true } -futures = { workspace = true } futures-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } url = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } -chrono = { workspace = true } anyhow = { workspace = true } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } dirs-next = { workspace = true } rustls = "0.23" rustls-pemfile = "2.1" prost = { workspace = true } -bytes = { workspace = true } [dev-dependencies] assert_cmd = "2.0" diff --git a/socktop_agent/Cargo.toml b/socktop_agent/Cargo.toml index d450701..c4bc429 100644 --- a/socktop_agent/Cargo.toml +++ b/socktop_agent/Cargo.toml @@ -13,13 +13,11 @@ sysinfo = { version = "0.37", features = ["network", "disk", "component"] } serde = { version = "1", features = ["derive"] } serde_json = "1" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } -futures = "0.3" futures-util = "0.3.31" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -nvml-wrapper = "0.10" +# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now) gfxinfo = "0.1.2" -tungstenite = "0.27.0" once_cell = "1.19" axum-server = { version = "0.6", features = ["tls-rustls"] } rustls = "0.23" @@ -27,13 +25,11 @@ rustls-pemfile = "2.1" rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build) anyhow = "1" hostname = "0.3" -bytes = { workspace = true } prost = { workspace = true } time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] } [build-dependencies] prost-build = "0.13" -prost-types = { workspace = true } tonic-build = { version = "0.12", default-features = false, optional = true } protoc-bin-vendored = "3" [dev-dependencies] From e624751f56aea71a1f36e2937f5f9d62cc5f421a Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:11:38 -0700 Subject: [PATCH 3/9] chore: align sysinfo to 0.37 across workspace --- Cargo.lock | 1 + Cargo.toml | 4 ++-- socktop/Cargo.toml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a57bb22..acc9560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2178,6 +2178,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sysinfo", "tempfile", "tokio", "tokio-tungstenite", diff --git a/Cargo.toml b/Cargo.toml index 0da0f00..9cb3230 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,8 @@ url = "2.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# system stats -sysinfo = "0.32" +# system stats (align across crates) +sysinfo = "0.37" # CLI UI ratatui = "0.28" diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 7b512b5..1fafb86 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -18,6 +18,7 @@ crossterm = { workspace = true } anyhow = { workspace = true } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } dirs-next = { workspace = true } +sysinfo = { workspace = true } rustls = "0.23" rustls-pemfile = "2.1" prost = { workspace = true } From 8de5943f34e3dd47dc0f387b32406e9a0b92b9e2 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:15:32 -0700 Subject: [PATCH 4/9] test(agent): move inline port parsing test to tests/port_parse.rs --- socktop_agent/src/main.rs | 43 +------------------------------ socktop_agent/tests/port_parse.rs | 40 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 42 deletions(-) create mode 100644 socktop_agent/tests/port_parse.rs diff --git a/socktop_agent/src/main.rs b/socktop_agent/src/main.rs index 3cb8cb3..2f82b3f 100644 --- a/socktop_agent/src/main.rs +++ b/socktop_agent/src/main.rs @@ -98,45 +98,4 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -#[cfg(test)] -mod tests_cli_agent { - // Local helper for testing port parsing - fn parse_port>(args: I, default_port: u16) -> u16 { - let mut it = args.into_iter(); - let _ = it.next(); // prog - let mut long: Option = None; - let mut short: Option = None; - while let Some(a) = it.next() { - match a.as_str() { - "--port" => long = it.next(), - "-p" => short = it.next(), - _ if a.starts_with("--port=") => { - if let Some((_, v)) = a.split_once('=') { - long = Some(v.to_string()); - } - } - _ => {} - } - } - long.or(short) - .and_then(|s| s.parse::().ok()) - .unwrap_or(default_port) - } - - #[test] - fn port_long_short_and_assign() { - assert_eq!( - parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443), - 9001 - ); - assert_eq!( - parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443), - 9002 - ); - assert_eq!( - parse_port(vec!["agent".into(), "--port=9003".into()], 8443), - 9003 - ); - assert_eq!(parse_port(vec!["agent".into()], 8443), 8443); - } -} +// Unit tests for CLI parsing moved to `tests/port_parse.rs`. diff --git a/socktop_agent/tests/port_parse.rs b/socktop_agent/tests/port_parse.rs new file mode 100644 index 0000000..acc28e9 --- /dev/null +++ b/socktop_agent/tests/port_parse.rs @@ -0,0 +1,40 @@ +//! Unit test for port parsing logic moved out of `main.rs`. + +fn parse_port>(args: I, default_port: u16) -> u16 { + let mut it = args.into_iter(); + let _ = it.next(); // program name + let mut long: Option = None; + let mut short: Option = None; + while let Some(a) = it.next() { + match a.as_str() { + "--port" => long = it.next(), + "-p" => short = it.next(), + _ if a.starts_with("--port=") => { + if let Some((_, v)) = a.split_once('=') { + long = Some(v.to_string()); + } + } + _ => {} + } + } + long.or(short) + .and_then(|s| s.parse::().ok()) + .unwrap_or(default_port) +} + +#[test] +fn port_long_short_and_assign() { + assert_eq!( + parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443), + 9001 + ); + assert_eq!( + parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443), + 9002 + ); + assert_eq!( + parse_port(vec!["agent".into(), "--port=9003".into()], 8443), + 9003 + ); + assert_eq!(parse_port(vec!["agent".into()], 8443), 8443); +} From b2468a5936330d37835fe7730854a82d7c662357 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:29:23 -0700 Subject: [PATCH 5/9] refactor(agent): remove unused background sampler infrastructure (request-driven only) --- README.md | 2 +- socktop_agent/src/main.rs | 14 +++----------- socktop_agent/src/sampler.rs | 34 ---------------------------------- 3 files changed, 4 insertions(+), 46 deletions(-) delete mode 100644 socktop_agent/src/sampler.rs diff --git a/README.md b/README.md index 69cc800..a103d71 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ sudo apt-get install libdrm-dev libdrm-amdgpu1 Two components: -1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects on demand when the client asks (fast metrics ~500 ms, processes ~2 s, disks ~5 s). No background loop when nobody is connected. +1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop. 2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates. diff --git a/socktop_agent/src/main.rs b/socktop_agent/src/main.rs index 2f82b3f..e68146c 100644 --- a/socktop_agent/src/main.rs +++ b/socktop_agent/src/main.rs @@ -1,10 +1,9 @@ -//! socktop agent entrypoint: sets up sysinfo handles, launches a sampler, -//! and serves a WebSocket endpoint at /ws. +//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws. mod gpu; mod metrics; mod proto; -mod sampler; +// sampler module removed (metrics now purely request-driven) mod state; mod types; mod ws; @@ -15,7 +14,6 @@ use std::str::FromStr; mod tls; -use crate::sampler::{spawn_disks_sampler, spawn_process_sampler, spawn_sampler}; use state::AppState; fn arg_flag(name: &str) -> bool { @@ -45,13 +43,7 @@ async fn main() -> anyhow::Result<()> { let state = AppState::new(); - // Start background sampler (adjust cadence as needed) - // 500ms fast metrics - let _h_fast = spawn_sampler(state.clone(), std::time::Duration::from_millis(500)); - // 2s processes (top 50) - let _h_procs = spawn_process_sampler(state.clone(), std::time::Duration::from_secs(2), 50); - // 5s disks - let _h_disks = spawn_disks_sampler(state.clone(), std::time::Duration::from_secs(5)); + // No background samplers: metrics collected on-demand per websocket request. // Web app: route /ws to the websocket handler async fn healthz() -> StatusCode { diff --git a/socktop_agent/src/sampler.rs b/socktop_agent/src/sampler.rs deleted file mode 100644 index 4088ef4..0000000 --- a/socktop_agent/src/sampler.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Background sampler: periodically collects metrics and updates precompressed caches, -//! so WS replies just read and send cached bytes. - -use crate::state::AppState; -use tokio::task::JoinHandle; -use tokio::time::{sleep, Duration}; - -// 500ms: fast path (cpu/mem/net/temp/gpu) -pub fn spawn_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> { - tokio::spawn(async move { - // no-op background sampler (request-driven collection elsewhere) - loop { - sleep(Duration::from_secs(3600)).await; - } - }) -} - -// 2s: processes top-k -pub fn spawn_process_sampler(_state: AppState, _period: Duration, _top_k: usize) -> JoinHandle<()> { - tokio::spawn(async move { - loop { - sleep(Duration::from_secs(3600)).await; - } - }) -} - -// 5s: disks -pub fn spawn_disks_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> { - tokio::spawn(async move { - loop { - sleep(Duration::from_secs(3600)).await; - } - }) -} From 85f9a44e4694c0197426e9325f72e0805c0612d2 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:38:32 -0700 Subject: [PATCH 6/9] perf(agent): add hostname + TTL caches (metrics/disks/processes) and reuse sys for processes --- socktop_agent/src/metrics.rs | 156 +++++++++++++++++++++++++---------- socktop_agent/src/state.rs | 36 ++++++++ 2 files changed, 150 insertions(+), 42 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index e1e8c4e..85e0d00 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -12,7 +12,8 @@ use std::fs; use std::io; use std::sync::Mutex; use std::time::{Duration, Instant}; -use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; +use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; +use std::time::Duration as StdDuration; use tracing::warn; // Runtime toggles (read once) @@ -97,6 +98,20 @@ 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 cache = state.cache_metrics.lock().await; + if cache.is_fresh(ttl) { + if let Some(c) = cache.take_clone() { + return c; + } + } + } let mut sys = state.sys.lock().await; if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { sys.refresh_cpu_usage(); @@ -105,7 +120,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { warn!("sysinfo selective refresh panicked: {e:?}"); } - let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string()); + let hostname = state.hostname.clone(); let cpu_total = sys.global_cpu_usage(); let cpu_per_core: Vec = sys.cpus().iter().map(|c| c.cpu_usage()).collect(); let mem_total = sys.total_memory(); @@ -192,7 +207,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { None }; - Metrics { + let metrics = Metrics { cpu_total, cpu_per_core, mem_total, @@ -205,21 +220,44 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics { networks, top_processes: Vec::new(), gpus, + }; + { + let mut cache = state.cache_metrics.lock().await; + cache.set(metrics.clone()); } + 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 cache = state.cache_disks.lock().await; + if cache.is_fresh(ttl) { + if let Some(v) = cache.take_clone() { + return v; + } + } + } let mut disks_list = state.disks.lock().await; disks_list.refresh(false); // don't drop missing disks - disks_list + let disks: Vec = disks_list .iter() .map(|d| DiskInfo { name: d.name().to_string_lossy().into_owned(), total: d.total_space(), available: d.available_space(), }) - .collect() + .collect(); + { + let mut cache = state.cache_disks.lock().await; + cache.set(disks.clone()); + } + disks } // Linux-only helpers and implementation using /proc deltas for accurate CPU%. @@ -260,8 +298,22 @@ 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 { - // Fresh view to avoid lingering entries and select "no tasks" (no per-thread rows). - let mut sys = System::new(); + let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1_000); + let ttl = StdDuration::from_millis(ttl_ms); + { + let cache = state.cache_processes.lock().await; + if cache.is_fresh(ttl) { + if let Some(v) = cache.take_clone() { + return v; + } + } + } + // Reuse shared System to avoid reallocation; refresh processes fully. + let mut sys_guard = state.sys.lock().await; + let sys = &mut *sys_guard; sys.refresh_processes_specifics( ProcessesToUpdate::All, false, @@ -336,50 +388,70 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { }) .collect(); - ProcessesPayload { + let payload = ProcessesPayload { process_count: total_count, top_processes: procs, + }; + { + let mut cache = state.cache_processes.lock().await; + cache.set(payload.clone()); } + payload } /// Collect all processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh. #[cfg(not(target_os = "linux"))] pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { use tokio::time::sleep; - - let mut sys = state.sys.lock().await; - - // First refresh to set baseline - sys.refresh_processes_specifics( - ProcessesToUpdate::All, - false, - ProcessRefreshKind::everything().without_tasks(), - ); - // Small delay so sysinfo can compute CPU deltas on next refresh + let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1_000); + let ttl = StdDuration::from_millis(ttl_ms); + { + let cache = state.cache_processes.lock().await; + if cache.is_fresh(ttl) { + if let Some(v) = cache.take_clone() { + return v; + } + } + } + { + let mut sys = state.sys.lock().await; + sys.refresh_processes_specifics( + ProcessesToUpdate::All, + false, + ProcessRefreshKind::everything().without_tasks(), + ); + } + // Release lock during sleep interval sleep(Duration::from_millis(250)).await; - sys.refresh_processes_specifics( - ProcessesToUpdate::All, - false, - ProcessRefreshKind::everything().without_tasks(), - ); - - let total_count = sys.processes().len(); - - let procs: Vec = sys - .processes() - .values() - .map(|p| ProcessInfo { - pid: p.pid().as_u32(), - name: p.name().to_string_lossy().into_owned(), - cpu_usage: p.cpu_usage(), - mem_bytes: p.memory(), - }) - .collect(); - ProcessesPayload { - process_count: total_count, - top_processes: procs, + { + let mut sys = state.sys.lock().await; + sys.refresh_processes_specifics( + ProcessesToUpdate::All, + false, + ProcessRefreshKind::everything().without_tasks(), + ); + let total_count = sys.processes().len(); + let procs: Vec = sys + .processes() + .values() + .map(|p| ProcessInfo { + pid: p.pid().as_u32(), + name: p.name().to_string_lossy().into_owned(), + cpu_usage: p.cpu_usage(), + mem_bytes: p.memory(), + }) + .collect(); + let payload = ProcessesPayload { + process_count: total_count, + top_processes: procs, + }; + { + let mut cache = state.cache_processes.lock().await; + cache.set(payload.clone()); + } + return payload; } } - -// Small helper to select and sort top-k by cpu -// Client now handles sorting/pagination. diff --git a/socktop_agent/src/state.rs b/socktop_agent/src/state.rs index 9b8a6ce..099aa8e 100644 --- a/socktop_agent/src/state.rs +++ b/socktop_agent/src/state.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::Arc; use sysinfo::{Components, Disks, Networks, System}; use tokio::sync::Mutex; +use std::time::{Duration, Instant}; pub type SharedSystem = Arc>; pub type SharedComponents = Arc>; @@ -25,6 +26,7 @@ pub struct AppState { pub components: SharedComponents, pub disks: SharedDisks, pub networks: SharedNetworks, + pub hostname: String, // For correct per-process CPU% using /proc deltas (Linux only path uses this tracker) #[cfg(target_os = "linux")] @@ -37,6 +39,36 @@ pub struct AppState { // GPU negative cache (probe once). gpu_checked=true after first attempt; gpu_present reflects result. pub gpu_checked: Arc, pub gpu_present: Arc, + + // Lightweight on-demand caches (TTL based) to cap CPU under bursty polling. + pub cache_metrics: Arc>>, + pub cache_disks: Arc>>>, + pub cache_processes: Arc>>, +} + +#[derive(Clone, Debug)] +pub struct CacheEntry { + pub at: Option, + pub value: Option, +} + +impl CacheEntry { + pub fn new() -> Self { + Self { at: None, value: None } + } + pub fn is_fresh(&self, ttl: Duration) -> bool { + self.at.is_some_and(|t| t.elapsed() < ttl) && self.value.is_some() + } + pub fn set(&mut self, v: T) { + self.value = Some(v); + self.at = Some(Instant::now()); + } + pub fn take_clone(&self) -> Option + where + T: Clone, + { + self.value.clone() + } } impl AppState { @@ -51,6 +83,7 @@ impl AppState { components: Arc::new(Mutex::new(components)), disks: Arc::new(Mutex::new(disks)), networks: Arc::new(Mutex::new(networks)), + hostname: System::host_name().unwrap_or_else(|| "unknown".into()), #[cfg(target_os = "linux")] proc_cpu: Arc::new(Mutex::new(ProcCpuTracker::default())), client_count: Arc::new(AtomicUsize::new(0)), @@ -59,6 +92,9 @@ impl AppState { .filter(|s| !s.is_empty()), gpu_checked: Arc::new(AtomicBool::new(false)), gpu_present: Arc::new(AtomicBool::new(false)), + cache_metrics: Arc::new(Mutex::new(CacheEntry::new())), + cache_disks: Arc::new(Mutex::new(CacheEntry::new())), + cache_processes: Arc::new(Mutex::new(CacheEntry::new())), } } } From 51e702368e4fd65a494ff7ddc0faf27286fa221a Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:40:35 -0700 Subject: [PATCH 7/9] cargo fmt --- socktop_agent/src/metrics.rs | 2 +- socktop_agent/src/state.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 85e0d00..bff201b 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -11,9 +11,9 @@ use std::fs; #[cfg(target_os = "linux")] use std::io; use std::sync::Mutex; +use std::time::Duration as StdDuration; use std::time::{Duration, Instant}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; -use std::time::Duration as StdDuration; use tracing::warn; // Runtime toggles (read once) diff --git a/socktop_agent/src/state.rs b/socktop_agent/src/state.rs index 099aa8e..12bffcb 100644 --- a/socktop_agent/src/state.rs +++ b/socktop_agent/src/state.rs @@ -4,9 +4,9 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::Arc; +use std::time::{Duration, Instant}; use sysinfo::{Components, Disks, Networks, System}; use tokio::sync::Mutex; -use std::time::{Duration, Instant}; pub type SharedSystem = Arc>; pub type SharedComponents = Arc>; @@ -54,7 +54,10 @@ pub struct CacheEntry { impl CacheEntry { pub fn new() -> Self { - Self { at: None, value: None } + Self { + at: None, + value: None, + } } pub fn is_fresh(&self, ttl: Duration) -> bool { self.at.is_some_and(|t| t.elapsed() < ttl) && self.value.is_some() From 8d48fa4c3bdb68943398c1ebf071d7c0eb1c7621 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:47:49 -0700 Subject: [PATCH 8/9] increment version --- Cargo.lock | 4 ++-- socktop/Cargo.toml | 2 +- socktop_agent/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acc9560..944f726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2162,7 +2162,7 @@ dependencies = [ [[package]] name = "socktop" -version = "0.1.25" +version = "0.1.3" dependencies = [ "anyhow", "assert_cmd", @@ -2187,7 +2187,7 @@ dependencies = [ [[package]] name = "socktop_agent" -version = "0.1.25" +version = "0.1.3" dependencies = [ "anyhow", "assert_cmd", diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 1fafb86..8faa99d 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop" -version = "0.1.25" +version = "0.1.3" authors = ["Jason Witty "] description = "Remote system monitor over WebSocket, TUI like top" edition = "2021" diff --git a/socktop_agent/Cargo.toml b/socktop_agent/Cargo.toml index c4bc429..509b1b5 100644 --- a/socktop_agent/Cargo.toml +++ b/socktop_agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "socktop_agent" -version = "0.1.25" +version = "0.1.3" authors = ["Jason Witty "] description = "Remote system monitor over WebSocket, TUI like top" edition = "2021" From ce59dd9dfe6ea87f9cf38fe2d8cc6ae9dd203e6e Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 24 Aug 2025 12:52:56 -0700 Subject: [PATCH 9/9] chore(agent): fix clippy needless_return for non-linux process collection --- socktop_agent/src/metrics.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index bff201b..35600f9 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -452,6 +452,6 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { let mut cache = state.cache_processes.lock().await; cache.set(payload.clone()); } - return payload; + payload } }