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..654a7bc 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -63,9 +63,13 @@ pub struct App { last_disks_poll: Instant, procs_interval: Duration, disks_interval: Duration, + metrics_interval: Duration, // For reconnects ws_url: String, + // Security / status flags + pub is_tls: bool, + pub has_token: bool, } impl App { @@ -94,10 +98,29 @@ 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(), + is_tls: false, + has_token: false, } } + 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 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, @@ -284,7 +307,7 @@ impl App { terminal.draw(|f| self.draw(f))?; // Tick rate - sleep(Duration::from_millis(500)).await; + sleep(self.metrics_interval).await; } Ok(()) @@ -351,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() @@ -471,7 +502,10 @@ 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(), + is_tls: false, + has_token: false, } } } diff --git a/socktop/src/main.rs b/socktop/src/main.rs index c9204b2..c1f6c67 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,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()); + } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -65,6 +75,16 @@ 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 +253,30 @@ async fn main() -> Result<(), Box> { } else { Some(ca.trim().to_string()) }; + 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 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(()); } @@ -231,6 +301,43 @@ 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)] diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index c830bfe..9e81674 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -5,9 +5,18 @@ use ratatui::{ layout::Rect, widgets::{Block, Borders}, }; +use std::time::Duration; -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,23 @@ 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() }; + // 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 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); }