From a4bb6f170aa46e495d8119fcda6cc759fed0aff1 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 16:18:41 -0700 Subject: [PATCH 1/5] feat(client): configurable metrics/process intervals with profile persistence; docs updated --- README.md | 16 ++++++++-- socktop/src/app.rs | 11 ++++++- socktop/src/main.rs | 66 +++++++++++++++++++++++++++++++++++------ socktop/src/profiles.rs | 4 +++ 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e34a2e4..7c78d4f 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,12 @@ First time you specify a new `--profile/-P` name together with a URL (and option socktop --profile prod ws://prod-host:3000/ws # With TLS pinning: socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws + +You can also set custom intervals (milliseconds): + +```bash +socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws +``` ``` If a profile already exists you will be prompted before overwriting: @@ -214,7 +220,7 @@ socktop --profile prod socktop -P prod-tls # short flag ``` -The stored URL (and TLS CA path, if any) will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL. +The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL. ### Interactive selection (no args) @@ -238,7 +244,12 @@ An example `profiles.json` (pretty‑printed): { "profiles": { "prod": { "url": "ws://prod-host:3000/ws" }, - "prod-tls": { "url": "wss://prod-host:8443/ws", "tls_ca": "/home/user/certs/prod-cert.pem" } + "prod-tls": { + "url": "wss://prod-host:8443/ws", + "tls_ca": "/home/user/certs/prod-cert.pem", + "metrics_interval_ms": 500, + "processes_interval_ms": 2000 + } }, "version": 0 } @@ -248,6 +259,7 @@ Notes: - The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`. - Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command). - Profiles are client-side convenience only; they do not affect the agent. +- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped. --- diff --git a/socktop/src/app.rs b/socktop/src/app.rs index be65c6b..46707e6 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -63,6 +63,7 @@ pub struct App { last_disks_poll: Instant, procs_interval: Duration, disks_interval: Duration, + metrics_interval: Duration, // For reconnects ws_url: String, @@ -94,10 +95,17 @@ impl App { .unwrap_or_else(Instant::now), procs_interval: Duration::from_secs(2), disks_interval: Duration::from_secs(5), + metrics_interval: Duration::from_millis(500), ws_url: String::new(), } } + pub fn with_intervals(mut self, metrics_ms: Option, procs_ms: Option) -> Self { + if let Some(m) = metrics_ms { self.metrics_interval = Duration::from_millis(m.max(100)); } + if let Some(p) = procs_ms { self.procs_interval = Duration::from_millis(p.max(200)); } + self + } + pub async fn run( &mut self, url: &str, @@ -284,7 +292,7 @@ impl App { terminal.draw(|f| self.draw(f))?; // Tick rate - sleep(Duration::from_millis(500)).await; + sleep(self.metrics_interval).await; } Ok(()) @@ -471,6 +479,7 @@ impl Default for App { .unwrap_or_else(Instant::now), procs_interval: Duration::from_secs(2), disks_interval: Duration::from_secs(5), + metrics_interval: Duration::from_millis(500), ws_url: String::new(), } } diff --git a/socktop/src/main.rs b/socktop/src/main.rs index c9204b2..62b07ca 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -19,6 +19,8 @@ pub(crate) struct ParsedArgs { save: bool, demo: bool, dry_run: bool, // hidden test helper: skip connecting + metrics_interval_ms: Option, + processes_interval_ms: Option, } pub(crate) fn parse_args>(args: I) -> Result { @@ -30,10 +32,12 @@ pub(crate) fn parse_args>(args: I) -> Result = None; + let mut processes_interval_ms: Option = None; while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { - return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]\n")); + return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n")); } "--tls-ca" | "-t" => { tls_ca = it.next(); @@ -51,6 +55,8 @@ pub(crate) fn parse_args>(args: I) -> Result { metrics_interval_ms = it.next().and_then(|v| v.parse().ok()); } + "--processes-interval-ms" => { processes_interval_ms = it.next().and_then(|v| v.parse().ok()); } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -65,6 +71,8 @@ pub(crate) fn parse_args>(args: I) -> Result Result<(), Box> { return run_demo_mode(parsed.tls_ca.as_deref()).await; } if let Some(entry) = profiles_mut.profiles.get(name) { - (entry.url.clone(), entry.tls_ca.clone()) + (entry.url.clone(), entry.tls_ca.clone(), entry.metrics_interval_ms, entry.processes_interval_ms) } else { return Ok(()); } @@ -191,22 +214,25 @@ async fn main() -> Result<(), Box> { } else { Some(ca.trim().to_string()) }; - profiles_mut.profiles.insert( + let (mi, pi) = gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?; + profiles_mut.profiles.insert( name.clone(), ProfileEntry { url: url.trim().to_string(), tls_ca: ca_opt.clone(), + metrics_interval_ms: mi, + processes_interval_ms: pi, }, ); let _ = save_profiles(&profiles_mut); - (url.trim().to_string(), ca_opt) + (url.trim().to_string(), ca_opt, mi, pi) } ResolveProfile::None => { eprintln!("No URL provided and no profiles to select."); return Ok(()); } }; - let mut app = App::new(); + let mut app = App::new().with_intervals(metrics_interval_ms, processes_interval_ms); if parsed.dry_run { return Ok(()); } @@ -231,6 +257,28 @@ fn prompt_string(prompt: &str) -> io::Result { Ok(line) } +fn gather_intervals(arg_metrics: Option, arg_procs: Option) -> Result<(Option, Option), Box> { + let default_metrics = 500u64; + let default_procs = 2000u64; + let metrics = match arg_metrics { + Some(v) => Some(v), + None => { + let inp = prompt_string(&format!("Metrics interval ms (default {default_metrics}, Enter for default): "))?; + let t = inp.trim(); + if t.is_empty() { Some(default_metrics) } else { Some(t.parse()?) } + } + }; + let procs = match arg_procs { + Some(v) => Some(v), + None => { + let inp = prompt_string(&format!("Processes interval ms (default {default_procs}, Enter for default): "))?; + let t = inp.trim(); + if t.is_empty() { Some(default_procs) } else { Some(t.parse()?) } + } + }; + Ok((metrics, procs)) +} + // Demo mode implementation async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box> { let port = 3231; diff --git a/socktop/src/profiles.rs b/socktop/src/profiles.rs index 8dcc682..4086f97 100644 --- a/socktop/src/profiles.rs +++ b/socktop/src/profiles.rs @@ -9,6 +9,10 @@ pub struct ProfileEntry { pub url: String, #[serde(skip_serializing_if = "Option::is_none")] pub tls_ca: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metrics_interval_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub processes_interval_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] From 9a35306340d6df051aa343238e97c8334aea077d Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 16:19:49 -0700 Subject: [PATCH 2/5] cargo fmt --- socktop/src/app.rs | 8 +++- socktop/src/main.rs | 101 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 46707e6..d2fe40d 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -101,8 +101,12 @@ impl App { } pub fn with_intervals(mut self, metrics_ms: Option, procs_ms: Option) -> Self { - if let Some(m) = metrics_ms { self.metrics_interval = Duration::from_millis(m.max(100)); } - if let Some(p) = procs_ms { self.procs_interval = Duration::from_millis(p.max(200)); } + if let Some(m) = metrics_ms { + self.metrics_interval = Duration::from_millis(m.max(100)); + } + if let Some(p) = procs_ms { + self.procs_interval = Duration::from_millis(p.max(200)); + } self } diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 62b07ca..1779626 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -55,8 +55,12 @@ pub(crate) fn parse_args>(args: I) -> Result { metrics_interval_ms = it.next().and_then(|v| v.parse().ok()); } - "--processes-interval-ms" => { processes_interval_ms = it.next().and_then(|v| v.parse().ok()); } + "--metrics-interval-ms" => { + metrics_interval_ms = it.next().and_then(|v| v.parse().ok()); + } + "--processes-interval-ms" => { + processes_interval_ms = it.next().and_then(|v| v.parse().ok()); + } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -71,8 +75,16 @@ pub(crate) fn parse_args>(args: I) -> Result io::Result { Ok(line) } -fn gather_intervals(arg_metrics: Option, arg_procs: Option) -> Result<(Option, Option), Box> { +fn gather_intervals( + arg_metrics: Option, + arg_procs: Option, +) -> Result<(Option, Option), Box> { let default_metrics = 500u64; let default_procs = 2000u64; let metrics = match arg_metrics { Some(v) => Some(v), None => { - let inp = prompt_string(&format!("Metrics interval ms (default {default_metrics}, Enter for default): "))?; + let inp = prompt_string(&format!( + "Metrics interval ms (default {default_metrics}, Enter for default): " + ))?; let t = inp.trim(); - if t.is_empty() { Some(default_metrics) } else { Some(t.parse()?) } + if t.is_empty() { + Some(default_metrics) + } else { + Some(t.parse()?) + } } }; let procs = match arg_procs { Some(v) => Some(v), None => { - let inp = prompt_string(&format!("Processes interval ms (default {default_procs}, Enter for default): "))?; + let inp = prompt_string(&format!( + "Processes interval ms (default {default_procs}, Enter for default): " + ))?; let t = inp.trim(); - if t.is_empty() { Some(default_procs) } else { Some(t.parse()?) } + if t.is_empty() { + Some(default_procs) + } else { + Some(t.parse()?) + } } }; Ok((metrics, procs)) From 67ecf36883b73c8e4ac47d28d57f4bc00d7f5504 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 17:24:41 -0700 Subject: [PATCH 3/5] feat(tui): header shows TLS/token status and polling intervals --- socktop/src/app.rs | 23 ++++++++++++++++++++++- socktop/src/main.rs | 6 +++++- socktop/src/ui/header.rs | 30 +++++++++++++++++++++++------- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index d2fe40d..654a7bc 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -67,6 +67,9 @@ pub struct App { // For reconnects ws_url: String, + // Security / status flags + pub is_tls: bool, + pub has_token: bool, } impl App { @@ -97,6 +100,8 @@ impl App { disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), ws_url: String::new(), + is_tls: false, + has_token: false, } } @@ -110,6 +115,12 @@ impl App { self } + pub fn with_status(mut self, is_tls: bool, has_token: bool) -> Self { + self.is_tls = is_tls; + self.has_token = has_token; + self + } + pub async fn run( &mut self, url: &str, @@ -363,7 +374,15 @@ impl App { .split(area); // Header - draw_header(f, rows[0], self.last_metrics.as_ref()); + draw_header( + f, + rows[0], + self.last_metrics.as_ref(), + self.is_tls, + self.has_token, + self.metrics_interval, + self.procs_interval, + ); // Top row: left CPU avg, right Per-core (full top-right) let top_lr = ratatui::layout::Layout::default() @@ -485,6 +504,8 @@ impl Default for App { disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), ws_url: String::new(), + is_tls: false, + has_token: false, } } } diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 1779626..c1f6c67 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -272,7 +272,11 @@ async fn main() -> Result<(), Box> { return Ok(()); } }; - let mut app = App::new().with_intervals(metrics_interval_ms, processes_interval_ms); + let is_tls = url.starts_with("wss://"); + let has_token = url.contains("token="); + let mut app = App::new() + .with_intervals(metrics_interval_ms, processes_interval_ms) + .with_status(is_tls, has_token); if parsed.dry_run { return Ok(()); } diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index c830bfe..0f9a027 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -1,13 +1,22 @@ //! Top header with hostname and CPU temperature indicator. use crate::types::Metrics; +use std::time::Duration; use ratatui::{ layout::Rect, widgets::{Block, Borders}, }; -pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { - let title = if let Some(mm) = m { +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 { let temp = mm .cpu_temp_c .map(|t| { @@ -21,12 +30,19 @@ pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) format!("CPU Temp: {t:.1}°C {icon}") }) .unwrap_or_else(|| "CPU Temp: N/A".into()); - format!( - "socktop — host: {} | {} (press 'q' to quit)", - mm.hostname, temp - ) + format!("socktop — host: {} | {}", mm.hostname, temp) } else { - "socktop — connecting... (press 'q' to quit)".into() + "socktop — connecting...".into() }; + let tls_txt = if is_tls { "🔒TLS" } else { "🔓WS" }; + let tok_txt = if has_token { "🔑token" } else { "" }; + let mi = metrics_interval.as_millis(); + let pi = procs_interval.as_millis(); + let intervals = format!("⏱{mi}ms metrics | {pi}ms procs"); + let mut parts = vec![base, tls_txt.into()]; + if !tok_txt.is_empty() { parts.push(tok_txt.into()); } + parts.push(intervals); + parts.push("(q to quit)".into()); + let title = parts.join(" | "); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); } From 2ca51adc61b114d1cfc448356ce62c98b7808e0e Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 17:28:21 -0700 Subject: [PATCH 4/5] tui: refine header icons (crossed TLS when disabled, spacing fix) --- socktop/src/ui/header.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index 0f9a027..03abeac 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -1,11 +1,11 @@ //! Top header with hostname and CPU temperature indicator. use crate::types::Metrics; -use std::time::Duration; use ratatui::{ layout::Rect, widgets::{Block, Borders}, }; +use std::time::Duration; pub fn draw_header( f: &mut ratatui::Frame<'_>, @@ -34,13 +34,17 @@ pub fn draw_header( } else { "socktop — connecting...".into() }; - let tls_txt = if is_tls { "🔒TLS" } else { "🔓WS" }; - let tok_txt = if has_token { "🔑token" } else { "" }; + // 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 mi = metrics_interval.as_millis(); let pi = procs_interval.as_millis(); - let intervals = format!("⏱{mi}ms metrics | {pi}ms procs"); + let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs"); let mut parts = vec![base, tls_txt.into()]; - if !tok_txt.is_empty() { parts.push(tok_txt.into()); } + if !tok_txt.is_empty() { + parts.push(tok_txt.into()); + } parts.push(intervals); parts.push("(q to quit)".into()); let title = parts.join(" | "); From e53d0ab98dca18acdb5dc7e55dbd3f5e8ee279bf Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 17:38:26 -0700 Subject: [PATCH 5/5] Add TLS / Token, polling interval indicators. --- socktop/src/ui/header.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index 03abeac..9e81674 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -35,12 +35,12 @@ pub fn draw_header( "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" }; + let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" }; // Token indicator let tok_txt = if has_token { "🔑 token" } else { "" }; let mi = metrics_interval.as_millis(); let pi = procs_interval.as_millis(); - let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs"); + let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs"); let mut parts = vec![base, tls_txt.into()]; if !tok_txt.is_empty() { parts.push(tok_txt.into());