From e0a247bc71fe07a412db5c3a6615383d78211664 Mon Sep 17 00:00:00 2001 From: Sudip Roy Date: Sat, 8 Aug 2026 22:48:44 +0530 Subject: [PATCH 1/2] add ratatui and fix implementation --- Cargo.lock | 78 +++++++++- Cargo.toml | 2 + readme.md | 48 +++--- src/core/mod.rs | 1 + src/core/monitor.rs | 76 ++++++++++ src/datasources/cpu_mem.rs | 232 +++++++++++++---------------- src/lib.rs | 2 + src/main.rs | 113 +++++++------- src/output/csv.rs | 30 ++++ src/output/json.rs | 11 ++ src/output/mod.rs | 3 + src/output/tui.rs | 293 +++++++++++++++++++++++++++++++++++++ tests/cpu_mem_tests.rs | 10 ++ 13 files changed, 687 insertions(+), 212 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0e6b5e..bfcfd4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -224,7 +224,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.106", ] [[package]] @@ -235,7 +235,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -256,7 +256,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -341,7 +341,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -587,6 +587,49 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -654,7 +697,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.106", ] [[package]] @@ -668,6 +711,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sysinfo" version = "0.37.0" @@ -724,6 +778,8 @@ dependencies = [ "clap", "crossterm 0.29.0", "ratatui", + "serde", + "serde_json", "sysinfo", ] @@ -815,7 +871,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -826,7 +882,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1018,3 +1074,9 @@ name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 203004c..066c574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,5 @@ sysinfo = "0.37.0" clap = { version = "4.5", features = ["derive"] } ratatui = "0.29.0" crossterm = "0.29.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/readme.md b/readme.md index e45ff58..b64c536 100644 --- a/readme.md +++ b/readme.md @@ -1,36 +1,30 @@ -# 🚧 UniProc — Under Construction 🚧 +# UniProc -> **Status:** Work in Progress -> Expect breaking changes, missing features, and occasional dragons 🐉 +UniProc is a terminal-first process monitor built with Rust and Ratatui. It monitors one process safely by PID or exact name, with a responsive dashboard and script-friendly exports. ---- +## Features -## 📌 About +- Live CPU, resident memory, disk I/O, and history charts +- System-wide network traffic shown alongside the selected process (per-process network I/O is not portable) +- Pause, clear-history, and quit controls +- Exact process-name lookup that refuses ambiguous matches +- Bounded in-memory history and reliable terminal cleanup +- CSV and pretty JSON export with byte-accurate fields -**UniProc** Monitors process by PID or name. -Right now, we’re actively building the foundation — so things may be unstable, incomplete, or change without notice. +## Use ---- - -## 🛠 Current Status - -- [x] Project scaffolding set up -- [x] Initial build works locally -- [ ] Core functionality implementation -- [ ] Tests and documentation -- [ ] First public release - ->⚠️ Windows support isn’t fully tested yet; it’s still experimental. -If it starts failing in CI, we’ll disable Windows runs immediately. ---- - -## Getting Started +```bash +cargo run -- --pid 1234 +cargo run -- --name my-service --interval 500 +cargo run -- --pid 1234 --duration 60 --csv metrics.csv +cargo run -- --pid 1234 --duration 60 --json metrics.json +``` -> ⚠️ This project is not production-ready yet. +Use `p` or `Space` to pause the dashboard, `c` to clear its history, and `q` or `Esc` to quit. Export modes require `--duration` so they always finish predictably. -Clone and build locally: +## Development ```bash -git clone https://github.com/imsudiproy/uniproc.git -cd uniproc -cargo build +cargo test +cargo fmt --check +``` diff --git a/src/core/mod.rs b/src/core/mod.rs index e69de29..cb4171b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -0,0 +1 @@ +pub mod monitor; diff --git a/src/core/monitor.rs b/src/core/monitor.rs index e69de29..5ded7d7 100644 --- a/src/core/monitor.rs +++ b/src/core/monitor.rs @@ -0,0 +1,76 @@ +use crate::datasources::cpu_mem::{ProcessInfo, ProcessSampler, find_processes_by_name}; +use std::thread; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub enum Target { + Pid(u32), + Name(String), +} + +pub fn resolve_target(target: &Target) -> Result { + match target { + Target::Pid(pid) => Ok(*pid), + Target::Name(name) => { + let matches = find_processes_by_name(name); + match matches.as_slice() { + [] => Err(format!("no running process exactly named {name:?}")), + [(pid, _)] => Ok(*pid), + _ => Err(format!( + "{name:?} matches multiple processes ({}); select one with --pid: {}", + matches.len(), + matches + .iter() + .map(|(pid, _)| pid.to_string()) + .collect::>() + .join(", ") + )), + } + } + } +} + +pub struct Monitor { + sampler: ProcessSampler, + interval: Duration, + started_at: Instant, + duration: Option, +} + +impl Monitor { + pub fn new(pid: u32, interval: Duration, duration: Option) -> Result { + if interval.is_zero() { + return Err("--interval must be at least 1 ms".to_owned()); + } + Ok(Self { + sampler: ProcessSampler::new(pid)?, + interval, + started_at: Instant::now(), + duration, + }) + } + + pub fn sample(&mut self) -> Option { + self.sampler.sample() + } + pub fn interval(&self) -> Duration { + self.interval + } + pub fn is_expired(&self) -> bool { + self.duration + .is_some_and(|duration| self.started_at.elapsed() >= duration) + } + + pub fn collect(mut self) -> Result, String> { + let mut samples = Vec::new(); + loop { + if self.is_expired() { + break; + } + let sample = self.sample().ok_or("the monitored process exited")?; + samples.push(sample); + thread::sleep(self.interval); + } + Ok(samples) + } +} diff --git a/src/datasources/cpu_mem.rs b/src/datasources/cpu_mem.rs index e84e123..a4f1918 100644 --- a/src/datasources/cpu_mem.rs +++ b/src/datasources/cpu_mem.rs @@ -1,141 +1,117 @@ -//This file calculates the CPU and Memory usage of a process -use core::time; -use std::io::{self, Write}; -use std::thread; -use sysinfo::{DiskUsage, Pid, System}; - -pub fn get_process_info(pid: u32, system: &mut System) -> Option<(f32, u64, DiskUsage)> { - system.refresh_all(); - - if let Some(process) = system.process(Pid::from_u32(pid)) { - let cpu_usage = process.cpu_usage(); - let memory = process.memory(); - let disk_usage = process.disk_usage(); - Some((cpu_usage, memory, disk_usage)) - } else { - None - } +//! Process sampling backed by `sysinfo`. +//! +//! Values are kept in bytes internally; presentation code is responsible for +//! formatting them. This avoids the unit mismatch that existed in the first +//! implementation. + +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; +use sysinfo::{Networks, Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + +#[derive(Debug, Clone, Serialize)] +pub struct ProcessInfo { + pub timestamp_ms: u64, + pub pid: u32, + pub name: String, + pub cpu_percent: f32, + pub memory_bytes: u64, + pub system_memory_bytes: u64, + pub virtual_memory_bytes: u64, + /// Bytes read since the preceding process refresh (platform dependent). + pub disk_read_bytes: u64, + /// Bytes written since the preceding process refresh (platform dependent). + pub disk_written_bytes: u64, + /// System-wide network bytes received since the preceding refresh. + pub network_received_bytes: u64, + /// System-wide network bytes transmitted since the preceding refresh. + pub network_transmitted_bytes: u64, } -//This function finds all processes matching the given name and returns their PIDs -fn find_process_by_name(name: Option) -> Vec { - let mut matches = Vec::new(); - let mut system = System::new_all(); - system.refresh_all(); - - if let Some(target_name) = name { - for (pid, process) in system.processes() { - if let Some(process_name) = process.name().to_str() { - if process_name == target_name { - matches.push(*pid); - } - } - } - } - matches +pub struct ProcessSampler { + pid: Pid, + system: System, + networks: Networks, } -//This function lists all processes matching the given name and allows user to select one to monitor -pub fn show_process_by_name(name: Option, interval: u64, duration: Option) { - let pids = find_process_by_name(name); - - let mut system = System::new_all(); - system.refresh_all(); - - if pids.is_empty() { - println!("No matching process found!!"); - return; - } - - println!("Matching Processes: "); - for (index, pid) in pids.iter().enumerate() { - if let Some(proc) = system.process(*pid) { - println!( - "[{}] PID: {} | Name: {:?} | CPU: {:.2}% | Memory: {} MB", - index, - pid, - proc.name(), - proc.cpu_usage(), - proc.memory() - ); +impl ProcessSampler { + pub fn new(pid: u32) -> Result { + let pid = Pid::from_u32(pid); + let mut system = System::new(); + system.refresh_memory(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::everything(), + ); + if system.process(pid).is_none() { + return Err(format!("process with PID {pid} was not found")); } + Ok(Self { + pid, + system, + networks: Networks::new_with_refreshed_list(), + }) } - print!("Select a process by number: "); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_ok() { - if let Ok(selected_index) = input.trim().parse::() { - if let Some(selected_pid) = pids.get(selected_index) { - show_process_by_pid(selected_pid.as_u32(), interval, duration); - } - } + pub fn sample(&mut self) -> Option { + self.system.refresh_memory(); + self.networks.refresh(true); + self.system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[self.pid]), + true, + ProcessRefreshKind::everything(), + ); + let process = self.system.process(self.pid)?; + let disk = process.disk_usage(); + Some(ProcessInfo { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_millis() as u64, + pid: self.pid.as_u32(), + name: process.name().to_string_lossy().into_owned(), + cpu_percent: process.cpu_usage(), + memory_bytes: process.memory(), + system_memory_bytes: self.system.total_memory(), + virtual_memory_bytes: process.virtual_memory(), + disk_read_bytes: disk.read_bytes, + disk_written_bytes: disk.written_bytes, + network_received_bytes: self + .networks + .values() + .map(|network| network.received()) + .sum(), + network_transmitted_bytes: self + .networks + .values() + .map(|network| network.transmitted()) + .sum(), + }) } } -pub fn show_process_by_pid(pid: u32, interval: u64, duration: Option) { - let start_time = std::time::Instant::now(); - let duration = duration.unwrap_or(u64::MAX); - let mut system = System::new_all(); - system.refresh_all(); - - loop { - //refresh system information - if let Some((cpu, mem, disk)) = get_process_info(pid, &mut system) { - println!("Monitoring PID: {}", pid); - println!( - "CPU usage: {:.2}% | Memory: {:.2} MB | Disk read {} KB | Disk Written {} KB", - cpu, - ((mem as f64) / 1024.0), - (disk.read_bytes / 1024), - disk.written_bytes / 1024 - ); - } else { - println!("The process PID {} not found", pid); - break; - } - - if start_time.elapsed().as_secs() >= duration { - break; - } - - thread::sleep(time::Duration::from_millis(interval)); - } +/// Returns all exact process-name matches. Callers intentionally decide how to +/// handle ambiguity instead of silently monitoring an arbitrary process. +pub fn find_processes_by_name(name: &str) -> Vec<(u32, String)> { + let system = System::new_all(); + system + .processes_by_exact_name(name.as_ref()) + .map(|process| { + ( + process.pid().as_u32(), + process.name().to_string_lossy().into_owned(), + ) + }) + .collect() } -#[cfg(test)] -mod tests { - use super::*; - use std::process; - - #[test] - fn test_get_process_info_current_pid() { - let pid = process::id(); - let mut system = System::new_all(); - system.refresh_all(); - let result = get_process_info(pid, &mut system); - assert!(result.is_some(), "Expected process info for current PID"); - - let (cpu, mem, disk) = result.unwrap(); - // Memory should be non-zero for a real process - assert!(mem > 0, "Memory usage should be greater than 0"); - // CPU usage might be 0 if idle, so no strict check - println!( - "CPU: {}, MEM: {} MB, Read Disk {} KB, Write Disk {} KB", - cpu, - (mem as f64) / 1024.0, - disk.read_bytes / 1024, - disk.written_bytes / 1024 - ); - } - - #[test] - fn test_get_process_info_invalid_pid() { - let invalid_pid = u32::MAX; // something that should not exist - let mut system = System::new_all(); - system.refresh_all(); - let result = get_process_info(invalid_pid, &mut system); - assert!(result.is_none(), "Expected None for invalid PID"); - } +/// Compatibility helper retained for consumers of the original public API. +pub fn get_process_info(pid: u32, system: &mut System) -> Option<(f32, u64, sysinfo::DiskUsage)> { + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), + true, + ProcessRefreshKind::everything(), + ); + let process = system.process(Pid::from_u32(pid))?; + Some((process.cpu_usage(), process.memory(), process.disk_usage())) } diff --git a/src/lib.rs b/src/lib.rs index 4130213..160bdae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,3 @@ +pub mod core; pub mod datasources; +pub mod output; diff --git a/src/main.rs b/src/main.rs index 9f55683..6cacaa9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,65 +1,80 @@ use clap::{ArgGroup, Parser}; -mod datasources; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; +use uniproc::{ + core::monitor::{Monitor, Target, resolve_target}, + output, +}; #[derive(Parser, Debug)] -#[command(name = "uniproc")] -#[command(about = "Monitors process by PID or name", long_about = None)] -#[command(group( - ArgGroup::new("target") - .required(true) - .args(&["pid", "name"]) -))] +#[command( + name = "uniproc", + version, + about = "An interactive process resource monitor" +)] +#[command(group(ArgGroup::new("target").required(true).args(["pid", "name"])))] struct Cli { - // PID of process to Monitor - #[arg(required_unless_present = "name")] + /// Process ID to monitor. + #[arg(long, group = "target")] pid: Option, - - //Process Name (alternate to PID) - #[arg(long)] + /// Exact process name to monitor. Fails safely if multiple processes match. + #[arg(long, group = "target")] name: Option, - - //Refresh intervals in miliseconds (default 1000ms) - #[arg(long, default_value_t = 1000)] + /// Sampling interval in milliseconds. + #[arg(long, default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))] interval: u64, - - //export in cvs instead of live view - #[arg(long)] - csv: Option, - - //export json instead of live view - #[arg(long)] - json: Option, - - //duration to run in second - #[arg(long)] + /// Stop after this many seconds. Required when exporting data. + #[arg(long, value_parser = clap::value_parser!(u64).range(1..))] duration: Option, + /// Write captured samples as CSV instead of starting the dashboard. + #[arg(long, value_name = "PATH")] + csv: Option, + /// Write captured samples as formatted JSON instead of starting the dashboard. + #[arg(long, value_name = "PATH")] + json: Option, } -fn main() { - let cli = Cli::parse(); - //println!("{cli:#?}"); - - if let Some(pid) = cli.pid { - println!("Monitoring PID: {pid}"); - datasources::cpu_mem::show_process_by_pid(pid, cli.interval, cli.duration); - } else if let Some(name) = cli.name { - println!("Monitoring Name: {name}"); - datasources::cpu_mem::show_process_by_name(Some(name), cli.interval, cli.duration); +fn main() -> ExitCode { + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("uniproc: {error}"); + ExitCode::FAILURE + } } +} - println!("Refresh interval: {}ms", cli.interval); - - if let Some(csv) = cli.csv { - println!("Exporting to CVS: {csv}"); - todo!() +fn run(cli: Cli) -> Result<(), String> { + let target = match (cli.pid, cli.name) { + (Some(pid), _) => Target::Pid(pid), + (_, Some(name)) => Target::Name(name), + _ => unreachable!("clap validates the target"), + }; + let pid = resolve_target(&target)?; + let exporting = cli.csv.is_some() || cli.json.is_some(); + if exporting && cli.duration.is_none() { + return Err( + "--duration is required with --csv or --json so collection has a defined end".into(), + ); } - - if let Some(json) = cli.json { - println!("Exporting to json: {json}"); - todo!() + let monitor = Monitor::new( + pid, + Duration::from_millis(cli.interval), + cli.duration.map(Duration::from_secs), + )?; + let samples = if exporting { + monitor.collect()? + } else { + output::tui::run(monitor)? + }; + if let Some(path) = cli.csv { + output::csv::write(&path, &samples)?; + println!("Wrote {} samples to {}", samples.len(), path.display()); } - - if let Some(duration) = cli.duration { - println!("Monitoring for {duration} seconds"); + if let Some(path) = cli.json { + output::json::write(&path, &samples)?; + println!("Wrote {} samples to {}", samples.len(), path.display()); } + Ok(()) } diff --git a/src/output/csv.rs b/src/output/csv.rs index e69de29..665155d 100644 --- a/src/output/csv.rs +++ b/src/output/csv.rs @@ -0,0 +1,30 @@ +use crate::datasources::cpu_mem::ProcessInfo; +use std::fs::File; +use std::io::{BufWriter, Write}; +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())?; + for sample in samples { + let escaped_name = sample.name.replace('"', "\"\""); + writeln!( + writer, + "{},{},\"{}\",{:.2},{},{},{},{},{},{},{}", + sample.timestamp_ms, + sample.pid, + escaped_name, + sample.cpu_percent, + sample.memory_bytes, + sample.system_memory_bytes, + sample.virtual_memory_bytes, + sample.disk_read_bytes, + sample.disk_written_bytes, + sample.network_received_bytes, + sample.network_transmitted_bytes + ) + .map_err(|e| e.to_string())?; + } + writer.flush().map_err(|e| e.to_string()) +} diff --git a/src/output/json.rs b/src/output/json.rs index e69de29..e5abeea 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -0,0 +1,11 @@ +use crate::datasources::cpu_mem::ProcessInfo; +use std::fs::File; +use std::io::BufWriter; +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 JSON output: {e}"))?; + serde_json::to_writer_pretty(BufWriter::new(file), samples) + .map_err(|e| format!("cannot write JSON output: {e}")) +} diff --git a/src/output/mod.rs b/src/output/mod.rs index e69de29..67b8d34 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -0,0 +1,3 @@ +pub mod csv; +pub mod json; +pub mod tui; diff --git a/src/output/tui.rs b/src/output/tui.rs index e69de29..1520515 100644 --- a/src/output/tui.rs +++ b/src/output/tui.rs @@ -0,0 +1,293 @@ +//! Interactive Ratatui dashboard. + +use crate::core::monitor::Monitor; +use crate::datasources::cpu_mem::ProcessInfo; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols, + text::{Line, Span}, + widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType, Paragraph, Sparkline}, +}; +use std::io::{self, Stdout}; +use std::time::{Duration, Instant}; + +const HISTORY_LIMIT: usize = 180; + +pub fn run(monitor: Monitor) -> Result, String> { + enable_raw_mode().map_err(|e| format!("cannot enable terminal raw mode: {e}"))?; + let mut stdout = io::stdout(); + if let Err(error) = execute!(stdout, EnterAlternateScreen) { + let _ = disable_raw_mode(); + return Err(format!("cannot enter alternate screen: {error}")); + } + let backend = CrosstermBackend::new(stdout); + let mut terminal = match Terminal::new(backend) { + Ok(terminal) => terminal, + Err(error) => { + let _ = disable_raw_mode(); + let mut cleanup_stdout = io::stdout(); + let _ = execute!(cleanup_stdout, LeaveAlternateScreen); + return Err(format!("cannot initialize terminal: {error}")); + } + }; + let result = run_dashboard(&mut terminal, monitor); + let cleanup = restore_terminal(&mut terminal); + match (result, cleanup) { + (_, Err(error)) => Err(error), + (result, Ok(())) => result, + } +} + +fn restore_terminal(terminal: &mut Terminal>) -> Result<(), String> { + disable_raw_mode().map_err(|e| format!("cannot restore terminal mode: {e}"))?; + execute!(terminal.backend_mut(), LeaveAlternateScreen) + .map_err(|e| format!("cannot leave alternate screen: {e}"))?; + terminal + .show_cursor() + .map_err(|e| format!("cannot restore cursor: {e}")) +} + +fn run_dashboard( + terminal: &mut Terminal>, + mut monitor: Monitor, +) -> Result, String> { + let mut samples = Vec::new(); + let mut paused = false; + let mut status = String::from("LIVE"); + samples.push(monitor.sample().ok_or("the monitored process exited")?); + let mut last_tick = Instant::now(); + + loop { + terminal + .draw(|frame| draw(frame.area(), frame, &samples, paused, &status)) + .map_err(|e| format!("cannot draw dashboard: {e}"))?; + if monitor.is_expired() { + status = String::from("DURATION COMPLETE"); + terminal + .draw(|frame| draw(frame.area(), frame, &samples, true, &status)) + .map_err(|e| e.to_string())?; + return Ok(samples); + } + + let elapsed = last_tick.elapsed(); + let timeout = monitor + .interval() + .saturating_sub(elapsed) + .min(Duration::from_millis(100)); + if event::poll(timeout).map_err(|e| format!("cannot read terminal events: {e}"))? { + if let Event::Key(key) = event::read().map_err(|e| e.to_string())? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(samples), + KeyCode::Char('p') | KeyCode::Char(' ') => { + paused = !paused; + status = if paused { "PAUSED" } else { "LIVE" }.to_owned(); + } + KeyCode::Char('c') => { + samples.clear(); + status = if paused { + "PAUSED · HISTORY CLEARED" + } else { + "LIVE · HISTORY CLEARED" + } + .to_owned(); + } + _ => {} + } + } + } + } + + if !paused && last_tick.elapsed() >= monitor.interval() { + match monitor.sample() { + Some(sample) => { + samples.push(sample); + if samples.len() > HISTORY_LIMIT { + samples.remove(0); + } + status = String::from("LIVE"); + last_tick = Instant::now(); + } + None => return Err("the monitored process exited".to_owned()), + } + } + } +} + +fn draw( + area: Rect, + frame: &mut ratatui::Frame, + samples: &[ProcessInfo], + paused: bool, + status: &str, +) { + let latest = samples.last(); + let title = latest + .map(|s| format!(" {} · PID {} ", s.name, s.pid)) + .unwrap_or_else(|| " UniProc · waiting for first sample ".into()); + let header = Paragraph::new(Line::from(vec![ + Span::styled( + title, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + status, + Style::default() + .fg(if paused { Color::Yellow } else { Color::Green }) + .add_modifier(Modifier::BOLD), + ), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .title("UNIPROC MONITOR"), + ); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(5), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(area); + frame.render_widget(header, rows[0]); + + let cards = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[1]); + let cpu = latest.map_or(0.0, |s| s.cpu_percent); + let memory = latest.map_or(0, |s| s.memory_bytes); + let memory_ratio = latest + .filter(|sample| sample.system_memory_bytes > 0) + .map_or(0.0, |sample| { + sample.memory_bytes as f32 / sample.system_memory_bytes as f32 + }); + frame.render_widget( + metric_gauge( + "CPU", + cpu.min(100.0) / 100.0, + format!("{cpu:.1}%"), + Color::Cyan, + ), + cards[0], + ); + frame.render_widget( + metric_gauge( + "RESIDENT MEMORY", + memory_ratio, + format_bytes(memory), + Color::Magenta, + ), + cards[1], + ); + + let charts = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) + .split(rows[2]); + render_cpu_chart(frame, charts[0], samples); + let spark: Vec = samples + .iter() + .map(|sample| sample.memory_bytes / (1024 * 1024)) + .collect(); + frame.render_widget( + Sparkline::default() + .block( + Block::default() + .borders(Borders::ALL) + .title("MEMORY HISTORY (MiB)"), + ) + .data(&spark) + .style(Style::default().fg(Color::Magenta)), + charts[1], + ); + + let footer = latest + .map(|s| { + format!( + "Disk: ↓ {} ↑ {} · System network: ↓ {} ↑ {}", + format_bytes(s.disk_read_bytes), + format_bytes(s.disk_written_bytes), + format_bytes(s.network_received_bytes), + format_bytes(s.network_transmitted_bytes), + ) + }) + .unwrap_or_else(|| "Collecting process metrics…".into()); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center).block( + Block::default() + .borders(Borders::ALL) + .title(" p / space pause · c clear · q / esc quit "), + ), + rows[3], + ); +} + +fn metric_gauge(title: &str, ratio: f32, label: String, color: Color) -> Gauge<'_> { + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(title)) + .gauge_style(Style::default().fg(color).add_modifier(Modifier::BOLD)) + .ratio(ratio.clamp(0.0, 1.0) as f64) + .label(label) +} + +fn render_cpu_chart(frame: &mut ratatui::Frame, area: Rect, samples: &[ProcessInfo]) { + let data: Vec<(f64, f64)> = samples + .iter() + .enumerate() + .map(|(i, sample)| (i as f64, sample.cpu_percent as f64)) + .collect(); + let upper = data + .iter() + .map(|(_, value)| *value) + .fold(100.0_f64, f64::max) + .ceil(); + let x_upper = data.len().max(2) as f64 - 1.0; + let dataset = Dataset::default() + .name("CPU %") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&data); + let chart = Chart::new(vec![dataset]) + .block(Block::default().borders(Borders::ALL).title("CPU HISTORY")) + .x_axis( + Axis::default() + .bounds([0.0, x_upper]) + .labels([Line::from("now")]), + ) + .y_axis( + Axis::default() + .bounds([0.0, upper]) + .labels([Line::from("0%"), Line::from(format!("{upper:.0}%"))]), + ); + frame.render_widget(chart, area); +} + +pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} diff --git a/tests/cpu_mem_tests.rs b/tests/cpu_mem_tests.rs index 820d664..8f3caea 100644 --- a/tests/cpu_mem_tests.rs +++ b/tests/cpu_mem_tests.rs @@ -1,5 +1,15 @@ +use std::process; 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"); + assert_eq!(sample.pid, process::id()); + assert!(sample.memory_bytes > 0); + assert!(sample.system_memory_bytes >= sample.memory_bytes); +} #[test] //Invalid PID test fn test_get_process_info_invalid_pid() { From 36f0aeee7a03c72985cb3568dddfb59101b63249 Mon Sep 17 00:00:00 2001 From: Sudip Roy Date: Sat, 8 Aug 2026 22:52:51 +0530 Subject: [PATCH 2/2] add release workflow --- .github/workflows/release.yml | 99 +++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1d52ecf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release + +on: + push: + branches: + - master + workflow_dispatch: + +# Required to create the version tag and GitHub release with the built assets. +permissions: + contents: write + +# Never publish two releases from the same branch at once. +concurrency: + group: release-master + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.target }} + # Manual dispatches are also constrained to master. + if: github.ref == 'refs/heads/master' + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - os: macos-15-intel + target: x86_64-apple-darwin + - os: macos-15 + target: aarch64-apple-darwin + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build release binary + run: cargo build --locked --release --target ${{ matrix.target }} + + - name: Package archive and checksum + shell: bash + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + mkdir -p dist + archive="dist/uniproc-${TARGET}.tar.gz" + tar -C "target/${TARGET}/release" -czf "${archive}" uniproc + shasum -a 256 "${archive}" > "${archive}.sha256" + + - name: Upload release asset + uses: actions/upload-artifact@v4 + with: + name: uniproc-${{ matrix.target }} + path: dist/ + if-no-files-found: error + + publish: + name: Publish GitHub release + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Download release assets + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Read release version + id: version + shell: bash + run: | + set -euo pipefail + version="$(sed -nE 's/^version = "([^"]+)"/\1/p' Cargo.toml | head -n 1)" + test -n "${version}" + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + + - name: Create release and tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.version.outputs.tag }} + TARGET: ${{ github.sha }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; bump package.version to publish a new release." + exit 0 + fi + gh release create "$TAG" dist/* \ + --target "$TARGET" \ + --title "UniProc $TAG" \ + --generate-notes