feat(client): configurable metrics/process intervals with profile persistence; docs updated

This commit is contained in:
jasonwitty 2025-08-21 16:18:41 -07:00
parent f9114426cc
commit a4bb6f170a
4 changed files with 85 additions and 12 deletions

View File

@ -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 socktop --profile prod ws://prod-host:3000/ws
# With TLS pinning: # With TLS pinning:
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws 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: If a profile already exists you will be prompted before overwriting:
@ -214,7 +220,7 @@ socktop --profile prod
socktop -P prod-tls # short flag 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) ### Interactive selection (no args)
@ -238,7 +244,12 @@ An example `profiles.json` (prettyprinted):
{ {
"profiles": { "profiles": {
"prod": { "url": "ws://prod-host:3000/ws" }, "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 "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`. - 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). - 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. - 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.
--- ---

View File

@ -63,6 +63,7 @@ pub struct App {
last_disks_poll: Instant, last_disks_poll: Instant,
procs_interval: Duration, procs_interval: Duration,
disks_interval: Duration, disks_interval: Duration,
metrics_interval: Duration,
// For reconnects // For reconnects
ws_url: String, ws_url: String,
@ -94,10 +95,17 @@ impl App {
.unwrap_or_else(Instant::now), .unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2), procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5), disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
ws_url: String::new(), ws_url: String::new(),
} }
} }
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> 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( pub async fn run(
&mut self, &mut self,
url: &str, url: &str,
@ -284,7 +292,7 @@ impl App {
terminal.draw(|f| self.draw(f))?; terminal.draw(|f| self.draw(f))?;
// Tick rate // Tick rate
sleep(Duration::from_millis(500)).await; sleep(self.metrics_interval).await;
} }
Ok(()) Ok(())
@ -471,6 +479,7 @@ impl Default for App {
.unwrap_or_else(Instant::now), .unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2), procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5), disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
ws_url: String::new(), ws_url: String::new(),
} }
} }

View File

@ -19,6 +19,8 @@ pub(crate) struct ParsedArgs {
save: bool, save: bool,
demo: bool, demo: bool,
dry_run: bool, // hidden test helper: skip connecting dry_run: bool, // hidden test helper: skip connecting
metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>,
} }
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> { pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
@ -30,10 +32,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut save = false; let mut save = false;
let mut demo = false; let mut demo = false;
let mut dry_run = false; let mut dry_run = false;
let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None;
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
match arg.as_str() { match arg.as_str() {
"-h" | "--help" => { "-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" | "-t" => {
tls_ca = it.next(); tls_ca = it.next();
@ -51,6 +55,8 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
// intentionally undocumented // intentionally undocumented
dry_run = true; dry_run = true;
} }
"--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 arg.starts_with("--tls-ca=") => {
if let Some((_, v)) = arg.split_once('=') { if let Some((_, v)) = arg.split_once('=') {
if !v.is_empty() { if !v.is_empty() {
@ -65,6 +71,8 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
} }
} }
} }
_ if arg.starts_with("--metrics-interval-ms=") => { if let Some((_,v))=arg.split_once('='){ metrics_interval_ms = v.parse().ok(); } }
_ if arg.starts_with("--processes-interval-ms=") => { if let Some((_,v))=arg.split_once('='){ processes_interval_ms = v.parse().ok(); } }
_ => { _ => {
if url.is_none() { if url.is_none() {
url = Some(arg); url = Some(arg);
@ -81,6 +89,8 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
save, save,
demo, demo,
dry_run, dry_run,
metrics_interval_ms,
processes_interval_ms,
}) })
} }
@ -104,20 +114,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
let resolved = req.resolve(&profiles_file); let resolved = req.resolve(&profiles_file);
let mut profiles_mut = profiles_file.clone(); let mut profiles_mut = profiles_file.clone();
let (url, tls_ca): (String, Option<String>) = match resolved { let (url, tls_ca, metrics_interval_ms, processes_interval_ms): (String, Option<String>, Option<u64>, Option<u64>) = match resolved {
ResolveProfile::Direct(u, t) => { ResolveProfile::Direct(u, t) => {
if let Some(name) = parsed.profile.as_ref() { if let Some(name) = parsed.profile.as_ref() {
let existing = profiles_mut.profiles.get(name); let existing = profiles_mut.profiles.get(name);
match existing { match existing {
None => { None => {
let (mi, pi) = gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?;
profiles_mut.profiles.insert( profiles_mut.profiles.insert(
name.clone(), name.clone(),
ProfileEntry { ProfileEntry {
url: u.clone(), url: u.clone(),
tls_ca: t.clone(), tls_ca: t.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
}, },
); );
let _ = save_profiles(&profiles_mut); let _ = save_profiles(&profiles_mut);
(u, t, mi, pi)
} }
Some(entry) => { Some(entry) => {
let changed = entry.url != u || entry.tls_ca != t; let changed = entry.url != u || entry.tls_ca != t;
@ -130,22 +144,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)) ))
}; };
if overwrite { if overwrite {
let (mi, pi) = gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?;
profiles_mut.profiles.insert( profiles_mut.profiles.insert(
name.clone(), name.clone(),
ProfileEntry { ProfileEntry {
url: u.clone(), url: u.clone(),
tls_ca: t.clone(), tls_ca: t.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
}, },
); );
let _ = save_profiles(&profiles_mut); let _ = save_profiles(&profiles_mut);
(u, t, mi, pi)
} else {
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
} }
} } else { (u, t, entry.metrics_interval_ms, entry.processes_interval_ms) }
} }
} }
} }
(u, t) else { (u, t, parsed.metrics_interval_ms, parsed.processes_interval_ms) }
}
ResolveProfile::Loaded(u, t) => {
let entry = profiles_mut.profiles.get(parsed.profile.as_ref().unwrap()).unwrap();
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
} }
ResolveProfile::Loaded(u, t) => (u, t),
ResolveProfile::PromptSelect(mut names) => { ResolveProfile::PromptSelect(mut names) => {
if !names.iter().any(|n| n == "demo") { if !names.iter().any(|n| n == "demo") {
names.push("demo".into()); names.push("demo".into());
@ -165,7 +188,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return run_demo_mode(parsed.tls_ca.as_deref()).await; return run_demo_mode(parsed.tls_ca.as_deref()).await;
} }
if let Some(entry) = profiles_mut.profiles.get(name) { 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 { } else {
return Ok(()); return Ok(());
} }
@ -191,22 +214,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
Some(ca.trim().to_string()) 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(), name.clone(),
ProfileEntry { ProfileEntry {
url: url.trim().to_string(), url: url.trim().to_string(),
tls_ca: ca_opt.clone(), tls_ca: ca_opt.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
}, },
); );
let _ = save_profiles(&profiles_mut); let _ = save_profiles(&profiles_mut);
(url.trim().to_string(), ca_opt) (url.trim().to_string(), ca_opt, mi, pi)
} }
ResolveProfile::None => { ResolveProfile::None => {
eprintln!("No URL provided and no profiles to select."); eprintln!("No URL provided and no profiles to select.");
return Ok(()); return Ok(());
} }
}; };
let mut app = App::new(); let mut app = App::new().with_intervals(metrics_interval_ms, processes_interval_ms);
if parsed.dry_run { if parsed.dry_run {
return Ok(()); return Ok(());
} }
@ -231,6 +257,28 @@ fn prompt_string(prompt: &str) -> io::Result<String> {
Ok(line) Ok(line)
} }
fn gather_intervals(arg_metrics: Option<u64>, arg_procs: Option<u64>) -> Result<(Option<u64>, Option<u64>), Box<dyn std::error::Error>> {
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 // Demo mode implementation
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231; let port = 3231;

View File

@ -9,6 +9,10 @@ pub struct ProfileEntry {
pub url: String, pub url: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tls_ca: Option<String>, pub tls_ca: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_interval_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub processes_interval_ms: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]