From c4b2df71d5e649eb58cb286bb40560afa995b11a Mon Sep 17 00:00:00 2001 From: Sudip Roy Date: Fri, 14 Aug 2026 01:03:28 +0530 Subject: [PATCH 1/2] Add process uptime metric --- readme.md | 4 +++- src/datasources/cpu_mem.rs | 3 +++ src/output/csv.rs | 5 +++-- src/output/tui.rs | 26 +++++++++++++++++++++++++- tests/cpu_mem_tests.rs | 10 +++++++++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index 45baa7b..fd1da5e 100644 --- a/readme.md +++ b/readme.md @@ -140,6 +140,7 @@ UniProc currently collects these fields for each sample: | `timestamp_ms` | milliseconds | Unix timestamp in milliseconds. | | `pid` | process ID | Target process ID. | | `name` | string | Process name reported by the operating system. | +| `uptime_seconds` | seconds | How long the target process has been running. | | `cpu_percent` | percent | CPU usage reported by `sysinfo`. | | `memory_bytes` | bytes | Resident memory for the process. | | `system_memory_bytes` | bytes | Total system memory at sample time. | @@ -156,7 +157,7 @@ The dashboard formats byte values for readability. Export files keep raw byte va CSV export writes a header row followed by one row per sample: ```text -timestamp_ms,pid,name,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes +timestamp_ms,pid,name,uptime_seconds,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes ``` Example: @@ -183,6 +184,7 @@ Example shape: "timestamp_ms": 1760000000000, "pid": 1234, "name": "my-service", + "uptime_seconds": 3600, "cpu_percent": 12.5, "memory_bytes": 104857600, "system_memory_bytes": 17179869184, diff --git a/src/datasources/cpu_mem.rs b/src/datasources/cpu_mem.rs index a4f1918..2d87c8f 100644 --- a/src/datasources/cpu_mem.rs +++ b/src/datasources/cpu_mem.rs @@ -13,6 +13,8 @@ pub struct ProcessInfo { pub timestamp_ms: u64, pub pid: u32, pub name: String, + /// Seconds the process has been running. + pub uptime_seconds: u64, pub cpu_percent: f32, pub memory_bytes: u64, pub system_memory_bytes: u64, @@ -70,6 +72,7 @@ impl ProcessSampler { .as_millis() as u64, pid: self.pid.as_u32(), name: process.name().to_string_lossy().into_owned(), + uptime_seconds: process.run_time(), cpu_percent: process.cpu_usage(), memory_bytes: process.memory(), system_memory_bytes: self.system.total_memory(), diff --git a/src/output/csv.rs b/src/output/csv.rs index 665155d..d7b8b6a 100644 --- a/src/output/csv.rs +++ b/src/output/csv.rs @@ -6,15 +6,16 @@ use std::path::Path; pub fn write(path: impl AsRef, samples: &[ProcessInfo]) -> Result<(), String> { let file = File::create(path.as_ref()).map_err(|e| format!("cannot create CSV output: {e}"))?; let mut writer = BufWriter::new(file); - writeln!(writer, "timestamp_ms,pid,name,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes").map_err(|e| e.to_string())?; + writeln!(writer, "timestamp_ms,pid,name,uptime_seconds,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes").map_err(|e| e.to_string())?; for sample in samples { let escaped_name = sample.name.replace('"', "\"\""); writeln!( writer, - "{},{},\"{}\",{:.2},{},{},{},{},{},{},{}", + "{},{},\"{}\",{},{:.2},{},{},{},{},{},{},{}", sample.timestamp_ms, sample.pid, escaped_name, + sample.uptime_seconds, sample.cpu_percent, sample.memory_bytes, sample.system_memory_bytes, diff --git a/src/output/tui.rs b/src/output/tui.rs index fefcf0c..f303d8d 100644 --- a/src/output/tui.rs +++ b/src/output/tui.rs @@ -253,7 +253,14 @@ fn render_header( status: &str, ) { let target = latest - .map(|s| format!("{} · PID {}", s.name, s.pid)) + .map(|s| { + format!( + "{} · PID {} · uptime {}", + s.name, + s.pid, + format_duration(s.uptime_seconds) + ) + }) .unwrap_or_else(|| "waiting for first sample".into()); let status_color = if paused { NETWORK } else { DISK }; let lines = vec![ @@ -411,3 +418,20 @@ pub fn format_bytes(bytes: u64) -> String { format!("{value:.1} {}", UNITS[unit]) } } + +pub fn format_duration(seconds: u64) -> String { + let days = seconds / 86_400; + let hours = (seconds % 86_400) / 3_600; + let minutes = (seconds % 3_600) / 60; + let seconds = seconds % 60; + + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} diff --git a/tests/cpu_mem_tests.rs b/tests/cpu_mem_tests.rs index 8f3caea..c308ef3 100644 --- a/tests/cpu_mem_tests.rs +++ b/tests/cpu_mem_tests.rs @@ -1,4 +1,7 @@ -use std::process; +use std::{ + process, + time::{SystemTime, UNIX_EPOCH}, +}; use sysinfo::System; use uniproc::datasources::cpu_mem; @@ -6,7 +9,12 @@ use uniproc::datasources::cpu_mem; fn test_sampler_collects_current_process() { let mut sampler = cpu_mem::ProcessSampler::new(process::id()).expect("current process exists"); let sample = sampler.sample().expect("current process can be sampled"); + let now_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after unix epoch") + .as_secs(); assert_eq!(sample.pid, process::id()); + assert!(sample.uptime_seconds <= now_seconds); assert!(sample.memory_bytes > 0); assert!(sample.system_memory_bytes >= sample.memory_bytes); } From 1eacb6569bb8a37a1a7e941ca6ca06e27a1bf95b Mon Sep 17 00:00:00 2001 From: Sudip Roy Date: Fri, 14 Aug 2026 01:17:50 +0530 Subject: [PATCH 2/2] add thread count, executable path --- readme.md | 16 ++++++++++++---- src/datasources/cpu_mem.rs | 8 ++++++++ src/output/csv.rs | 14 ++++++++++++-- src/output/tui.rs | 18 ++++++++++++++++-- tests/cpu_mem_tests.rs | 6 ++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index fd1da5e..1e3b2a0 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ Use UniProc when you want a lightweight, focused view of one process instead of ## Features -- Interactive Ratatui dashboard with CPU, resident memory, disk I/O, network, and history views +- Interactive Ratatui dashboard with CPU, resident memory, disk I/O, network, uptime, executable path, thread count, and history views - Safe target selection by PID or exact process name - Ambiguous process-name protection, with a prompt to select a PID when multiple processes match - Pause, clear-history, and quit controls in the dashboard @@ -32,7 +32,7 @@ Current support notes: - Linux: expected to work - Windows: not tested yet, but it should ideally work because the underlying libraries support Windows -Some metric values are platform dependent. In particular, disk I/O comes from process refresh data exposed by `sysinfo`, and network traffic is reported system-wide because portable per-process network I/O is not available through the current implementation. +Some metric values are platform dependent. In particular, disk I/O comes from process refresh data exposed by `sysinfo`, executable paths can be unavailable when the operating system cannot report them, thread counts are currently reported only where `sysinfo` exposes process tasks, and network traffic is reported system-wide because portable per-process network I/O is not available through the current implementation. ## Requirements @@ -123,6 +123,8 @@ Export modes require `--duration` so the command has a defined end. Without `--c ## Dashboard Controls +The dashboard header shows the target process name, PID, process uptime, thread count, and executable path when those details are available. Unsupported or unavailable thread counts are shown as `threads n/a`; unavailable executable paths are shown as `executable path unavailable`. + | Key | Action | | --- | --- | | `p` | Pause or resume sampling. | @@ -140,6 +142,8 @@ UniProc currently collects these fields for each sample: | `timestamp_ms` | milliseconds | Unix timestamp in milliseconds. | | `pid` | process ID | Target process ID. | | `name` | string | Process name reported by the operating system. | +| `executable_path` | path or null | Executable path for the process, when reported by the operating system. | +| `thread_count` | count or null | Number of process tasks/threads, when reported by the operating system. | | `uptime_seconds` | seconds | How long the target process has been running. | | `cpu_percent` | percent | CPU usage reported by `sysinfo`. | | `memory_bytes` | bytes | Resident memory for the process. | @@ -150,14 +154,14 @@ UniProc currently collects these fields for each sample: | `network_received_bytes` | bytes | System-wide network bytes received since the preceding refresh. | | `network_transmitted_bytes` | bytes | System-wide network bytes transmitted since the preceding refresh. | -The dashboard formats byte values for readability. Export files keep raw byte values. +The dashboard formats byte and duration values for readability. Export files keep raw byte values and write uptime as raw seconds. Optional fields use `null` in JSON and an empty CSV cell when the operating system does not report them. ## CSV Output CSV export writes a header row followed by one row per sample: ```text -timestamp_ms,pid,name,uptime_seconds,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes +timestamp_ms,pid,name,executable_path,thread_count,uptime_seconds,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes ``` Example: @@ -184,6 +188,8 @@ Example shape: "timestamp_ms": 1760000000000, "pid": 1234, "name": "my-service", + "executable_path": "/usr/local/bin/my-service", + "thread_count": 8, "uptime_seconds": 3600, "cpu_percent": 12.5, "memory_bytes": 104857600, @@ -205,6 +211,8 @@ Example shape: - Dashboard history is bounded to avoid unbounded memory growth. - Export collection sleeps for the configured interval between samples. - Disk I/O values are platform dependent. +- Executable paths may be unavailable because of operating-system permissions or platform limitations. +- Thread counts come from process task data and may be unavailable on platforms where `sysinfo` does not expose tasks. - Network values are system-wide deltas, not per-process network usage. - Export paths are overwritten if the target file already exists. diff --git a/src/datasources/cpu_mem.rs b/src/datasources/cpu_mem.rs index 2d87c8f..0410f16 100644 --- a/src/datasources/cpu_mem.rs +++ b/src/datasources/cpu_mem.rs @@ -13,6 +13,10 @@ pub struct ProcessInfo { pub timestamp_ms: u64, pub pid: u32, pub name: String, + /// Executable path for the process, when reported by the operating system. + pub executable_path: Option, + /// Number of process tasks/threads, when reported by the operating system. + pub thread_count: Option, /// Seconds the process has been running. pub uptime_seconds: u64, pub cpu_percent: f32, @@ -72,6 +76,10 @@ impl ProcessSampler { .as_millis() as u64, pid: self.pid.as_u32(), name: process.name().to_string_lossy().into_owned(), + executable_path: process + .exe() + .map(|path| path.to_string_lossy().into_owned()), + thread_count: process.tasks().map(|tasks| tasks.len()), uptime_seconds: process.run_time(), cpu_percent: process.cpu_usage(), memory_bytes: process.memory(), diff --git a/src/output/csv.rs b/src/output/csv.rs index d7b8b6a..3b3246d 100644 --- a/src/output/csv.rs +++ b/src/output/csv.rs @@ -6,15 +6,25 @@ use std::path::Path; pub fn write(path: impl AsRef, samples: &[ProcessInfo]) -> Result<(), String> { let file = File::create(path.as_ref()).map_err(|e| format!("cannot create CSV output: {e}"))?; let mut writer = BufWriter::new(file); - writeln!(writer, "timestamp_ms,pid,name,uptime_seconds,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes").map_err(|e| e.to_string())?; + writeln!(writer, "timestamp_ms,pid,name,executable_path,thread_count,uptime_seconds,cpu_percent,memory_bytes,system_memory_bytes,virtual_memory_bytes,disk_read_bytes,disk_written_bytes,system_network_received_bytes,system_network_transmitted_bytes").map_err(|e| e.to_string())?; for sample in samples { let escaped_name = sample.name.replace('"', "\"\""); + let escaped_executable_path = sample + .executable_path + .as_deref() + .unwrap_or_default() + .replace('"', "\"\""); writeln!( writer, - "{},{},\"{}\",{},{:.2},{},{},{},{},{},{},{}", + "{},{},\"{}\",\"{}\",{},{},{:.2},{},{},{},{},{},{},{}", sample.timestamp_ms, sample.pid, escaped_name, + escaped_executable_path, + sample + .thread_count + .map(|count| count.to_string()) + .unwrap_or_default(), sample.uptime_seconds, sample.cpu_percent, sample.memory_bytes, diff --git a/src/output/tui.rs b/src/output/tui.rs index f303d8d..b174401 100644 --- a/src/output/tui.rs +++ b/src/output/tui.rs @@ -255,13 +255,17 @@ fn render_header( let target = latest .map(|s| { format!( - "{} · PID {} · uptime {}", + "{} · PID {} · uptime {} · threads {}", s.name, s.pid, - format_duration(s.uptime_seconds) + format_duration(s.uptime_seconds), + format_thread_count(s.thread_count) ) }) .unwrap_or_else(|| "waiting for first sample".into()); + let executable_path = latest + .and_then(|s| s.executable_path.as_deref()) + .unwrap_or("executable path unavailable"); let status_color = if paused { NETWORK } else { DISK }; let lines = vec![ Line::from(vec![ @@ -288,6 +292,10 @@ fn render_header( .add_modifier(Modifier::BOLD), ), ]), + Line::from(Span::styled( + executable_path.to_owned(), + Style::default().fg(MUTED).bg(PANEL), + )), ]; frame.render_widget( Paragraph::new(lines) @@ -435,3 +443,9 @@ pub fn format_duration(seconds: u64) -> String { format!("{seconds}s") } } + +fn format_thread_count(thread_count: Option) -> String { + thread_count + .map(|count| count.to_string()) + .unwrap_or_else(|| "n/a".to_owned()) +} diff --git a/tests/cpu_mem_tests.rs b/tests/cpu_mem_tests.rs index c308ef3..5b21ef5 100644 --- a/tests/cpu_mem_tests.rs +++ b/tests/cpu_mem_tests.rs @@ -14,6 +14,12 @@ fn test_sampler_collects_current_process() { .expect("system clock is after unix epoch") .as_secs(); assert_eq!(sample.pid, process::id()); + if let Some(executable_path) = &sample.executable_path { + assert!(!executable_path.is_empty()); + } + if let Some(thread_count) = sample.thread_count { + assert!(thread_count > 0); + } assert!(sample.uptime_seconds <= now_seconds); assert!(sample.memory_bytes > 0); assert!(sample.system_memory_bytes >= sample.memory_bytes);