diff --git a/CHANGELOG.md b/CHANGELOG.md index 10555cc3..f140b740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#1063](https://github.com/ClementTsang/bottom/pull/1063): Add buffer and cache memory tracking. - [#1106](https://github.com/ClementTsang/bottom/pull/1106): Add current battery charging state. - [#1115](https://github.com/ClementTsang/bottom/pull/1115): Add customizable process columns to config file. +- [#801](https://github.com/ClementTsang/bottom/pull/801): Add optional process time column and querying. ## Changes diff --git a/src/app/data_harvester/processes.rs b/src/app/data_harvester/processes.rs index 62fc280c..0f082e00 100644 --- a/src/app/data_harvester/processes.rs +++ b/src/app/data_harvester/processes.rs @@ -28,6 +28,8 @@ cfg_if::cfg_if! { } } +use std::{borrow::Cow, time::Duration}; + use crate::Pid; #[derive(Debug, Clone, Default)] @@ -35,7 +37,7 @@ pub struct ProcessHarvest { /// The pid of the process. pub pid: Pid, - /// The parent PID of the process. Remember, parent_pid 0 is root. + /// The parent PID of the process. A `parent_pid` of 0 is usually the root. pub parent_pid: Option, /// CPU usage as a percentage. @@ -65,15 +67,18 @@ pub struct ProcessHarvest { /// The total number of bytes written by the process. pub total_write_bytes: u64, - /// The current state of the process (e.g. zombie, asleep) + /// The current state of the process (e.g. zombie, asleep). pub process_state: (String, char), + /// Cumulative total CPU time used. + pub time: Duration, + /// This is the *effective* user ID of the process. This is only used on Unix platforms. #[cfg(target_family = "unix")] pub uid: Option, /// This is the process' user. - pub user: std::borrow::Cow<'static, str>, + pub user: Cow<'static, str>, // TODO: Additional fields // pub rss_kb: u64, // pub virt_kb: u64, @@ -88,5 +93,6 @@ impl ProcessHarvest { self.write_bytes_per_sec += rhs.write_bytes_per_sec; self.total_read_bytes += rhs.total_read_bytes; self.total_write_bytes += rhs.total_write_bytes; + self.time += rhs.time; } } diff --git a/src/app/data_harvester/processes/linux.rs b/src/app/data_harvester/processes/linux.rs index 4918230f..78d022d5 100644 --- a/src/app/data_harvester/processes/linux.rs +++ b/src/app/data_harvester/processes/linux.rs @@ -2,6 +2,7 @@ use std::fs::File; use std::io::{BufRead, BufReader}; +use std::time::Duration; use hashbrown::{HashMap, HashSet}; use procfs::process::{Process, Stat}; @@ -196,6 +197,16 @@ fn read_proc( let uid = process.uid()?; + let time = if let Ok(ticks_per_sec) = u32::try_from(procfs::ticks_per_second()) { + if ticks_per_sec == 0 { + Duration::ZERO + } else { + Duration::from_secs(stat.utime + stat.stime) / ticks_per_sec + } + } else { + Duration::ZERO + }; + Ok(( ProcessHarvest { pid: process.pid, @@ -215,6 +226,7 @@ fn read_proc( .get_uid_to_username_mapping(uid) .map(Into::into) .unwrap_or_else(|_| "N/A".into()), + time, }, new_process_times, )) diff --git a/src/app/data_harvester/processes/macos_freebsd.rs b/src/app/data_harvester/processes/macos_freebsd.rs index 183722fc..48bbe717 100644 --- a/src/app/data_harvester/processes/macos_freebsd.rs +++ b/src/app/data_harvester/processes/macos_freebsd.rs @@ -1,6 +1,7 @@ //! Shared process data harvesting code from macOS and FreeBSD via sysinfo. use std::io; +use std::time::Duration; use hashbrown::HashMap; use sysinfo::{CpuExt, PidExt, ProcessExt, ProcessStatus, System, SystemExt}; @@ -109,6 +110,7 @@ where .ok() }) .unwrap_or_else(|| "N/A".into()), + time: Duration::from_secs(process_val.run_time()), }); } diff --git a/src/app/data_harvester/processes/windows.rs b/src/app/data_harvester/processes/windows.rs index 78f4d107..57af8b2c 100644 --- a/src/app/data_harvester/processes/windows.rs +++ b/src/app/data_harvester/processes/windows.rs @@ -1,5 +1,7 @@ //! Process data collection for Windows. Uses sysinfo. +use std::time::Duration; + use sysinfo::{CpuExt, PidExt, ProcessExt, System, SystemExt, UserExt}; use super::ProcessHarvest; @@ -79,6 +81,13 @@ pub fn get_process_data( .user_id() .and_then(|uid| sys.get_user_by_id(uid)) .map_or_else(|| "N/A".into(), |user| user.name().to_owned().into()), + time: if process_val.start_time() == 0 { + // Workaround for Windows occasionally returning a start time equal to UNIX epoch, giving a run time + // in the range of 50+ years. We just return a time of zero in this case for simplicity. + Duration::ZERO + } else { + Duration::from_secs(process_val.run_time()) + }, }); } diff --git a/src/app/query.rs b/src/app/query.rs index cd9d4f6c..eb2d281f 100644 --- a/src/app/query.rs +++ b/src/app/query.rs @@ -1,6 +1,9 @@ use std::fmt::Debug; +use std::time::Duration; use std::{borrow::Cow, collections::VecDeque}; +use humantime::parse_duration; + use super::data_harvester::processes::ProcessHarvest; use crate::utils::error::{ BottomError::{self, QueryError}, @@ -279,12 +282,63 @@ pub fn parse_query( }); } } + PrefixType::Time => { + let mut condition: Option = None; + let mut duration_string: Option = None; + + if content == "=" { + condition = Some(QueryComparison::Equal); + duration_string = query.pop_front(); + } else if content == ">" || content == "<" { + if let Some(queue_next) = query.pop_front() { + if queue_next == "=" { + condition = Some(if content == ">" { + QueryComparison::GreaterOrEqual + } else { + QueryComparison::LessOrEqual + }); + duration_string = query.pop_front(); + } else { + condition = Some(if content == ">" { + QueryComparison::Greater + } else { + QueryComparison::Less + }); + duration_string = Some(queue_next); + } + } else { + return Err(QueryError("Missing value".into())); + } + } + + if let Some(condition) = condition { + let duration = parse_duration( + &duration_string.ok_or(QueryError("Missing value".into()))?, + ) + .map_err(|err| QueryError(err.to_string().into()))?; + + return Ok(Prefix { + or: None, + regex_prefix: None, + compare_prefix: Some(( + prefix_type, + ComparableQuery::Time(TimeQuery { + condition, + duration, + }), + )), + }); + } else { + } + } _ => { + // Assume it's some numerical value. // Now we gotta parse the content... yay. let mut condition: Option = None; let mut value: Option = None; + // TODO: Jeez, what the heck did I write here... add some tests and clean this up, please. if content == "=" { condition = Some(QueryComparison::Equal); if let Some(queue_next) = query.pop_front() { @@ -321,11 +375,8 @@ pub fn parse_query( if let Some(condition) = condition { if let Some(read_value) = value { - // Now we want to check one last thing - is there a unit? - // If no unit, assume base. - // Furthermore, base must be PEEKED at initially, and will - // require (likely) prefix_type specific checks - // Lastly, if it *is* a unit, remember to POP! + // Note that the values *might* have a unit or need to be parsed differently + // based on the prefix type! let mut value = read_value; @@ -335,6 +386,11 @@ pub fn parse_query( | PrefixType::Wps | PrefixType::TRead | PrefixType::TWrite => { + // If no unit, assume base. + // Furthermore, base must be PEEKED at initially, and will + // require (likely) prefix_type specific checks + // Lastly, if it *is* a unit, remember to POP! + if let Some(potential_unit) = query.front() { match potential_unit.to_lowercase().as_str() { "tb" => { @@ -385,7 +441,10 @@ pub fn parse_query( regex_prefix: None, compare_prefix: Some(( prefix_type, - NumericalQuery { condition, value }, + ComparableQuery::Numerical(NumericalQuery { + condition, + value, + }), )), }); } @@ -568,6 +627,7 @@ pub enum PrefixType { Name, State, User, + Time, __Nonexhaustive, } @@ -591,16 +651,18 @@ impl std::str::FromStr for PrefixType { "pid" => Ok(Pid), "state" => Ok(State), "user" => Ok(User), + "time" => Ok(Time), _ => Ok(Name), } } } +// TODO: This is also jank and could be better represented. Add tests, then clean up! #[derive(Default)] pub struct Prefix { pub or: Option>, pub regex_prefix: Option<(PrefixType, StringQuery)>, - pub compare_prefix: Option<(PrefixType, NumericalQuery)>, + pub compare_prefix: Option<(PrefixType, ComparableQuery)>, } impl Prefix { @@ -658,6 +720,16 @@ impl Prefix { } } + fn matches_duration(condition: &QueryComparison, lhs: Duration, rhs: Duration) -> bool { + match condition { + QueryComparison::Equal => lhs == rhs, + QueryComparison::Less => lhs < rhs, + QueryComparison::Greater => lhs > rhs, + QueryComparison::LessOrEqual => lhs <= rhs, + QueryComparison::GreaterOrEqual => lhs >= rhs, + } + } + if let Some(and) = &self.or { and.check(process, is_using_command) } else if let Some((prefix_type, query_content)) = &self.regex_prefix { @@ -676,44 +748,52 @@ impl Prefix { } else { true } - } else if let Some((prefix_type, numerical_query)) = &self.compare_prefix { - match prefix_type { - PrefixType::PCpu => matches_condition( - &numerical_query.condition, - process.cpu_usage_percent, - numerical_query.value, - ), - PrefixType::PMem => matches_condition( - &numerical_query.condition, - process.mem_usage_percent, - numerical_query.value, - ), - PrefixType::MemBytes => matches_condition( - &numerical_query.condition, - process.mem_usage_bytes as f64, - numerical_query.value, - ), - PrefixType::Rps => matches_condition( - &numerical_query.condition, - process.read_bytes_per_sec as f64, - numerical_query.value, - ), - PrefixType::Wps => matches_condition( - &numerical_query.condition, - process.write_bytes_per_sec as f64, - numerical_query.value, - ), - PrefixType::TRead => matches_condition( - &numerical_query.condition, - process.total_read_bytes as f64, - numerical_query.value, - ), - PrefixType::TWrite => matches_condition( - &numerical_query.condition, - process.total_write_bytes as f64, - numerical_query.value, - ), - _ => true, + } else if let Some((prefix_type, comparable_query)) = &self.compare_prefix { + match comparable_query { + ComparableQuery::Numerical(numerical_query) => match prefix_type { + PrefixType::PCpu => matches_condition( + &numerical_query.condition, + process.cpu_usage_percent, + numerical_query.value, + ), + PrefixType::PMem => matches_condition( + &numerical_query.condition, + process.mem_usage_percent, + numerical_query.value, + ), + PrefixType::MemBytes => matches_condition( + &numerical_query.condition, + process.mem_usage_bytes as f64, + numerical_query.value, + ), + PrefixType::Rps => matches_condition( + &numerical_query.condition, + process.read_bytes_per_sec as f64, + numerical_query.value, + ), + PrefixType::Wps => matches_condition( + &numerical_query.condition, + process.write_bytes_per_sec as f64, + numerical_query.value, + ), + PrefixType::TRead => matches_condition( + &numerical_query.condition, + process.total_read_bytes as f64, + numerical_query.value, + ), + PrefixType::TWrite => matches_condition( + &numerical_query.condition, + process.total_write_bytes as f64, + numerical_query.value, + ), + _ => true, + }, + ComparableQuery::Time(time_query) => match prefix_type { + PrefixType::Time => { + matches_duration(&time_query.condition, process.time, time_query.duration) + } + _ => true, + }, } } else { // Somehow we have an empty condition... oh well. Return true. @@ -751,8 +831,20 @@ pub enum StringQuery { Regex(regex::Regex), } +#[derive(Debug)] +pub enum ComparableQuery { + Numerical(NumericalQuery), + Time(TimeQuery), +} + #[derive(Debug)] pub struct NumericalQuery { pub condition: QueryComparison, pub value: f64, } + +#[derive(Debug)] +pub struct TimeQuery { + pub condition: QueryComparison, + pub duration: Duration, +} diff --git a/src/options/process_columns.rs b/src/options/process_columns.rs index 74676d35..50f010d6 100644 --- a/src/options/process_columns.rs +++ b/src/options/process_columns.rs @@ -24,7 +24,7 @@ mod test { #[test] fn process_column_settings() { let config = r#" - columns = ["CPU%", "PiD", "user", "MEM", "Tread", "T.Write", "Rps", "W/s"] + columns = ["CPU%", "PiD", "user", "MEM", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state"] "#; let generated: ProcessConfig = toml_edit::de::from_str(config).unwrap(); @@ -39,26 +39,23 @@ mod test { ProcColumn::TotalWrite, ProcColumn::ReadPerSecond, ProcColumn::WritePerSecond, + ProcColumn::Time, + ProcColumn::User, + ProcColumn::State, ]), ); } #[test] fn process_column_settings_2() { - let config = r#" - columns = ["MEM%"] - "#; - + let config = r#"columns = ["MEM%"]"#; let generated: ProcessConfig = toml_edit::de::from_str(config).unwrap(); assert_eq!(generated.columns, Some(vec![ProcColumn::MemoryPercent])); } #[test] fn process_column_settings_3() { - let config = r#" - columns = ["MEM%", "TWrite", "Cpuz", "read", "wps"] - "#; - + let config = r#"columns = ["MEM%", "TWrite", "Cpuz", "read", "wps"]"#; toml_edit::de::from_str::(config).expect_err("Should error out!"); } diff --git a/src/utils/error.rs b/src/utils/error.rs index 3764469c..a0abff33 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -35,6 +35,8 @@ pub enum BottomError { /// An error that just signifies something minor went wrong; no message. #[error("Minor error.")] MinorError, + #[error("Error casting integers {0}")] + TryFromIntError(#[from] std::num::TryFromIntError), /// An error to represent errors with procfs #[cfg(target_os = "linux")] #[error("Procfs error, {0}")] diff --git a/src/widgets/process_table.rs b/src/widgets/process_table.rs index 0e8c6a9f..2566525f 100644 --- a/src/widgets/process_table.rs +++ b/src/widgets/process_table.rs @@ -89,6 +89,7 @@ fn make_column(column: ProcColumn) -> SortColumn { TotalWrite => SortColumn::hard(TotalWrite, 8).default_descending(), User => SortColumn::soft(User, Some(0.05)), State => SortColumn::hard(State, 7), + Time => SortColumn::new(Time), } } @@ -114,6 +115,7 @@ pub enum ProcWidgetColumn { TotalWrite, User, State, + Time, } pub struct ProcWidgetState { @@ -251,6 +253,7 @@ impl ProcWidgetState { TotalWrite => ProcWidgetColumn::TotalWrite, State => ProcWidgetColumn::State, User => ProcWidgetColumn::User, + Time => ProcWidgetColumn::Time, } }) .collect::>(); @@ -936,6 +939,8 @@ fn sort_skip_pid_asc(column: &ProcColumn, data: &mut [ProcWidgetData], order: So #[cfg(test)] mod test { + use std::time::Duration; + use super::*; use crate::widgets::MemUsage; @@ -959,6 +964,7 @@ mod test { user: "N/A".to_string(), num_similar: 0, disabled: false, + time: Duration::from_secs(0), }; let b = ProcWidgetData { diff --git a/src/widgets/process_table/proc_widget_column.rs b/src/widgets/process_table/proc_widget_column.rs index 262936e1..ff99b3f6 100644 --- a/src/widgets/process_table/proc_widget_column.rs +++ b/src/widgets/process_table/proc_widget_column.rs @@ -23,6 +23,7 @@ pub enum ProcColumn { TotalWrite, State, User, + Time, } impl<'de> Deserialize<'de> for ProcColumn { @@ -45,6 +46,7 @@ impl<'de> Deserialize<'de> for ProcColumn { "twrite" | "t.write" => Ok(ProcColumn::TotalWrite), "state" => Ok(ProcColumn::State), "user" => Ok(ProcColumn::User), + "time" => Ok(ProcColumn::Time), _ => Err(D::Error::custom("doesn't match any column type")), } } @@ -75,6 +77,7 @@ impl ColumnHeader for ProcColumn { ProcColumn::TotalWrite => "T.Write", ProcColumn::State => "State", ProcColumn::User => "User", + ProcColumn::Time => "Time", } .into() } @@ -94,6 +97,7 @@ impl ColumnHeader for ProcColumn { ProcColumn::TotalWrite => "T.Write", ProcColumn::State => "State", ProcColumn::User => "User", + ProcColumn::Time => "Time", } .into() } @@ -151,6 +155,9 @@ impl SortsRow for ProcColumn { data.sort_by_cached_key(|pd| pd.user.to_lowercase()); } } + ProcColumn::Time => { + data.sort_by(|a, b| sort_partial_fn(descending)(a.time, b.time)); + } } } } diff --git a/src/widgets/process_table/proc_widget_data.rs b/src/widgets/process_table/proc_widget_data.rs index 8cfa77c3..59d26ba3 100644 --- a/src/widgets/process_table/proc_widget_data.rs +++ b/src/widgets/process_table/proc_widget_data.rs @@ -1,6 +1,7 @@ use std::{ cmp::{max, Ordering}, fmt::Display, + time::Duration, }; use concat_string::concat_string; @@ -107,6 +108,63 @@ impl Display for MemUsage { } } +trait DurationExt { + fn num_days(&self) -> u64; + fn num_hours(&self) -> u64; + fn num_minutes(&self) -> u64; +} + +const SECS_PER_DAY: u64 = SECS_PER_HOUR * 24; +const SECS_PER_HOUR: u64 = SECS_PER_MINUTE * 60; +const SECS_PER_MINUTE: u64 = 60; + +impl DurationExt for Duration { + /// Number of full days in this duration. + #[inline] + fn num_days(&self) -> u64 { + self.as_secs() / SECS_PER_DAY + } + + /// Number of full hours in this duration. + #[inline] + fn num_hours(&self) -> u64 { + self.as_secs() / SECS_PER_HOUR + } + + /// Number of full minutes in this duration. + #[inline] + fn num_minutes(&self) -> u64 { + self.as_secs() / SECS_PER_MINUTE + } +} + +fn format_time(dur: Duration) -> String { + if dur.num_days() > 0 { + format!( + "{}d {}h {}m", + dur.num_days(), + dur.num_hours() % 24, + dur.num_minutes() % 60 + ) + } else if dur.num_hours() > 0 { + format!( + "{}h {}m {}s", + dur.num_hours(), + dur.num_minutes() % 60, + dur.as_secs() % 60 + ) + } else if dur.num_minutes() > 0 { + format!( + "{}m {}.{:02}s", + dur.num_minutes(), + dur.as_secs() % 60, + dur.as_millis() % 1000 / 10 + ) + } else { + format!("{}.{:03}s", dur.as_secs(), dur.as_millis() % 1000) + } +} + #[derive(Clone, Debug)] pub struct ProcWidgetData { pub pid: Pid, @@ -123,6 +181,7 @@ pub struct ProcWidgetData { pub user: String, pub num_similar: u64, pub disabled: bool, + pub time: Duration, } impl ProcWidgetData { @@ -157,6 +216,7 @@ impl ProcWidgetData { user: process.user.to_string(), num_similar: 1, disabled: false, + time: process.time, } } @@ -204,6 +264,7 @@ impl ProcWidgetData { ProcColumn::TotalWrite => dec_bytes_string(self.total_write), ProcColumn::State => self.process_char.to_string(), ProcColumn::User => self.user.clone(), + ProcColumn::Time => format_time(self.time), } } } @@ -237,6 +298,7 @@ impl DataToCell for ProcWidgetData { } } ProcColumn::User => self.user.clone(), + ProcColumn::Time => format_time(self.time), }, calculated_width, )) @@ -266,3 +328,41 @@ impl DataToCell for ProcWidgetData { widths } } + +#[cfg(test)] +mod test { + use std::time::Duration; + + use crate::widgets::proc_widget_data::format_time; + + #[test] + fn test_format_time() { + const ONE_DAY: u64 = 24 * 60 * 60; + + assert_eq!(format_time(Duration::from_millis(500)), "0.500s"); + assert_eq!(format_time(Duration::from_millis(900)), "0.900s"); + assert_eq!(format_time(Duration::from_secs(1)), "1.000s"); + assert_eq!(format_time(Duration::from_secs(10)), "10.000s"); + assert_eq!(format_time(Duration::from_secs(60)), "1m 0.00s"); + assert_eq!(format_time(Duration::from_secs(61)), "1m 1.00s"); + assert_eq!(format_time(Duration::from_secs(600)), "10m 0.00s"); + assert_eq!(format_time(Duration::from_secs(601)), "10m 1.00s"); + assert_eq!(format_time(Duration::from_secs(3600)), "1h 0m 0s"); + assert_eq!(format_time(Duration::from_secs(3601)), "1h 0m 1s"); + assert_eq!(format_time(Duration::from_secs(3660)), "1h 1m 0s"); + assert_eq!(format_time(Duration::from_secs(3661)), "1h 1m 1s"); + assert_eq!(format_time(Duration::from_secs(ONE_DAY - 1)), "23h 59m 59s"); + assert_eq!(format_time(Duration::from_secs(ONE_DAY)), "1d 0h 0m"); + assert_eq!(format_time(Duration::from_secs(ONE_DAY + 1)), "1d 0h 0m"); + assert_eq!(format_time(Duration::from_secs(ONE_DAY + 60)), "1d 0h 1m"); + assert_eq!( + format_time(Duration::from_secs(ONE_DAY + 3600 - 1)), + "1d 0h 59m" + ); + assert_eq!(format_time(Duration::from_secs(ONE_DAY + 3600)), "1d 1h 0m"); + assert_eq!( + format_time(Duration::from_secs(ONE_DAY * 365 - 1)), + "364d 23h 59m" + ); + } +}