Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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. |
Expand All @@ -140,6 +142,9 @@ 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. |
| `system_memory_bytes` | bytes | Total system memory at sample time. |
Expand All @@ -149,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,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:
Expand All @@ -183,6 +188,9 @@ 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,
"system_memory_bytes": 17179869184,
Expand All @@ -203,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.

Expand Down
11 changes: 11 additions & 0 deletions src/datasources/cpu_mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ 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<String>,
/// Number of process tasks/threads, when reported by the operating system.
pub thread_count: Option<usize>,
/// Seconds the process has been running.
pub uptime_seconds: u64,
pub cpu_percent: f32,
pub memory_bytes: u64,
pub system_memory_bytes: u64,
Expand Down Expand Up @@ -70,6 +76,11 @@ 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(),
system_memory_bytes: self.system.total_memory(),
Expand Down
15 changes: 13 additions & 2 deletions src/output/csv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@ use std::path::Path;
pub fn write(path: impl AsRef<Path>, 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,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,
sample.system_memory_bytes,
Expand Down
40 changes: 39 additions & 1 deletion src/output/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,19 @@ fn render_header(
status: &str,
) {
let target = latest
.map(|s| format!("{} · PID {}", s.name, s.pid))
.map(|s| {
format!(
"{} · PID {} · uptime {} · threads {}",
s.name,
s.pid,
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![
Expand All @@ -281,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)
Expand Down Expand Up @@ -411,3 +426,26 @@ 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")
}
}

fn format_thread_count(thread_count: Option<usize>) -> String {
thread_count
.map(|count| count.to_string())
.unwrap_or_else(|| "n/a".to_owned())
}
16 changes: 15 additions & 1 deletion tests/cpu_mem_tests.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
use std::process;
use std::{
process,
time::{SystemTime, UNIX_EPOCH},
};
use sysinfo::System;
use uniproc::datasources::cpu_mem;

#[test]
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());
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);
}
Expand Down
Loading