feature: show running time of processes (#801)

* feature: show running time of processes

* fix clippy

* add time searching

* update changelog

* use safer duration for linux in case of 0

* some cleanup

* quick hack to deal with some Windows processes returning unix epoch time as start time

---------

Co-authored-by: Clement Tsang <34804052+ClementTsang@users.noreply.github.com>
This commit is contained in:
Yuxuan Shui 2023-05-02 06:33:53 +01:00 committed by GitHub
parent 7edc2fc7e5
commit 80183b8b1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 291 additions and 57 deletions

View File

@ -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. - [#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. - [#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. - [#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 ## Changes

View File

@ -28,6 +28,8 @@ cfg_if::cfg_if! {
} }
} }
use std::{borrow::Cow, time::Duration};
use crate::Pid; use crate::Pid;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@ -35,7 +37,7 @@ pub struct ProcessHarvest {
/// The pid of the process. /// The pid of the process.
pub pid: Pid, 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<Pid>, pub parent_pid: Option<Pid>,
/// CPU usage as a percentage. /// CPU usage as a percentage.
@ -65,15 +67,18 @@ pub struct ProcessHarvest {
/// The total number of bytes written by the process. /// The total number of bytes written by the process.
pub total_write_bytes: u64, 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), 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. /// This is the *effective* user ID of the process. This is only used on Unix platforms.
#[cfg(target_family = "unix")] #[cfg(target_family = "unix")]
pub uid: Option<libc::uid_t>, pub uid: Option<libc::uid_t>,
/// This is the process' user. /// This is the process' user.
pub user: std::borrow::Cow<'static, str>, pub user: Cow<'static, str>,
// TODO: Additional fields // TODO: Additional fields
// pub rss_kb: u64, // pub rss_kb: u64,
// pub virt_kb: u64, // pub virt_kb: u64,
@ -88,5 +93,6 @@ impl ProcessHarvest {
self.write_bytes_per_sec += rhs.write_bytes_per_sec; self.write_bytes_per_sec += rhs.write_bytes_per_sec;
self.total_read_bytes += rhs.total_read_bytes; self.total_read_bytes += rhs.total_read_bytes;
self.total_write_bytes += rhs.total_write_bytes; self.total_write_bytes += rhs.total_write_bytes;
self.time += rhs.time;
} }
} }

View File

@ -2,6 +2,7 @@
use std::fs::File; use std::fs::File;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::time::Duration;
use hashbrown::{HashMap, HashSet}; use hashbrown::{HashMap, HashSet};
use procfs::process::{Process, Stat}; use procfs::process::{Process, Stat};
@ -196,6 +197,16 @@ fn read_proc(
let uid = process.uid()?; 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(( Ok((
ProcessHarvest { ProcessHarvest {
pid: process.pid, pid: process.pid,
@ -215,6 +226,7 @@ fn read_proc(
.get_uid_to_username_mapping(uid) .get_uid_to_username_mapping(uid)
.map(Into::into) .map(Into::into)
.unwrap_or_else(|_| "N/A".into()), .unwrap_or_else(|_| "N/A".into()),
time,
}, },
new_process_times, new_process_times,
)) ))

View File

@ -1,6 +1,7 @@
//! Shared process data harvesting code from macOS and FreeBSD via sysinfo. //! Shared process data harvesting code from macOS and FreeBSD via sysinfo.
use std::io; use std::io;
use std::time::Duration;
use hashbrown::HashMap; use hashbrown::HashMap;
use sysinfo::{CpuExt, PidExt, ProcessExt, ProcessStatus, System, SystemExt}; use sysinfo::{CpuExt, PidExt, ProcessExt, ProcessStatus, System, SystemExt};
@ -109,6 +110,7 @@ where
.ok() .ok()
}) })
.unwrap_or_else(|| "N/A".into()), .unwrap_or_else(|| "N/A".into()),
time: Duration::from_secs(process_val.run_time()),
}); });
} }

View File

@ -1,5 +1,7 @@
//! Process data collection for Windows. Uses sysinfo. //! Process data collection for Windows. Uses sysinfo.
use std::time::Duration;
use sysinfo::{CpuExt, PidExt, ProcessExt, System, SystemExt, UserExt}; use sysinfo::{CpuExt, PidExt, ProcessExt, System, SystemExt, UserExt};
use super::ProcessHarvest; use super::ProcessHarvest;
@ -79,6 +81,13 @@ pub fn get_process_data(
.user_id() .user_id()
.and_then(|uid| sys.get_user_by_id(uid)) .and_then(|uid| sys.get_user_by_id(uid))
.map_or_else(|| "N/A".into(), |user| user.name().to_owned().into()), .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())
},
}); });
} }

View File

@ -1,6 +1,9 @@
use std::fmt::Debug; use std::fmt::Debug;
use std::time::Duration;
use std::{borrow::Cow, collections::VecDeque}; use std::{borrow::Cow, collections::VecDeque};
use humantime::parse_duration;
use super::data_harvester::processes::ProcessHarvest; use super::data_harvester::processes::ProcessHarvest;
use crate::utils::error::{ use crate::utils::error::{
BottomError::{self, QueryError}, BottomError::{self, QueryError},
@ -279,12 +282,63 @@ pub fn parse_query(
}); });
} }
} }
PrefixType::Time => {
let mut condition: Option<QueryComparison> = None;
let mut duration_string: Option<String> = 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. // Now we gotta parse the content... yay.
let mut condition: Option<QueryComparison> = None; let mut condition: Option<QueryComparison> = None;
let mut value: Option<f64> = None; let mut value: Option<f64> = None;
// TODO: Jeez, what the heck did I write here... add some tests and clean this up, please.
if content == "=" { if content == "=" {
condition = Some(QueryComparison::Equal); condition = Some(QueryComparison::Equal);
if let Some(queue_next) = query.pop_front() { if let Some(queue_next) = query.pop_front() {
@ -321,11 +375,8 @@ pub fn parse_query(
if let Some(condition) = condition { if let Some(condition) = condition {
if let Some(read_value) = value { if let Some(read_value) = value {
// Now we want to check one last thing - is there a unit? // Note that the values *might* have a unit or need to be parsed differently
// If no unit, assume base. // based on the prefix type!
// Furthermore, base must be PEEKED at initially, and will
// require (likely) prefix_type specific checks
// Lastly, if it *is* a unit, remember to POP!
let mut value = read_value; let mut value = read_value;
@ -335,6 +386,11 @@ pub fn parse_query(
| PrefixType::Wps | PrefixType::Wps
| PrefixType::TRead | PrefixType::TRead
| PrefixType::TWrite => { | 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() { if let Some(potential_unit) = query.front() {
match potential_unit.to_lowercase().as_str() { match potential_unit.to_lowercase().as_str() {
"tb" => { "tb" => {
@ -385,7 +441,10 @@ pub fn parse_query(
regex_prefix: None, regex_prefix: None,
compare_prefix: Some(( compare_prefix: Some((
prefix_type, prefix_type,
NumericalQuery { condition, value }, ComparableQuery::Numerical(NumericalQuery {
condition,
value,
}),
)), )),
}); });
} }
@ -568,6 +627,7 @@ pub enum PrefixType {
Name, Name,
State, State,
User, User,
Time,
__Nonexhaustive, __Nonexhaustive,
} }
@ -591,16 +651,18 @@ impl std::str::FromStr for PrefixType {
"pid" => Ok(Pid), "pid" => Ok(Pid),
"state" => Ok(State), "state" => Ok(State),
"user" => Ok(User), "user" => Ok(User),
"time" => Ok(Time),
_ => Ok(Name), _ => Ok(Name),
} }
} }
} }
// TODO: This is also jank and could be better represented. Add tests, then clean up!
#[derive(Default)] #[derive(Default)]
pub struct Prefix { pub struct Prefix {
pub or: Option<Box<Or>>, pub or: Option<Box<Or>>,
pub regex_prefix: Option<(PrefixType, StringQuery)>, pub regex_prefix: Option<(PrefixType, StringQuery)>,
pub compare_prefix: Option<(PrefixType, NumericalQuery)>, pub compare_prefix: Option<(PrefixType, ComparableQuery)>,
} }
impl Prefix { 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 { if let Some(and) = &self.or {
and.check(process, is_using_command) and.check(process, is_using_command)
} else if let Some((prefix_type, query_content)) = &self.regex_prefix { } else if let Some((prefix_type, query_content)) = &self.regex_prefix {
@ -676,44 +748,52 @@ impl Prefix {
} else { } else {
true true
} }
} else if let Some((prefix_type, numerical_query)) = &self.compare_prefix { } else if let Some((prefix_type, comparable_query)) = &self.compare_prefix {
match prefix_type { match comparable_query {
PrefixType::PCpu => matches_condition( ComparableQuery::Numerical(numerical_query) => match prefix_type {
&numerical_query.condition, PrefixType::PCpu => matches_condition(
process.cpu_usage_percent, &numerical_query.condition,
numerical_query.value, process.cpu_usage_percent,
), numerical_query.value,
PrefixType::PMem => matches_condition( ),
&numerical_query.condition, PrefixType::PMem => matches_condition(
process.mem_usage_percent, &numerical_query.condition,
numerical_query.value, process.mem_usage_percent,
), numerical_query.value,
PrefixType::MemBytes => matches_condition( ),
&numerical_query.condition, PrefixType::MemBytes => matches_condition(
process.mem_usage_bytes as f64, &numerical_query.condition,
numerical_query.value, process.mem_usage_bytes as f64,
), numerical_query.value,
PrefixType::Rps => matches_condition( ),
&numerical_query.condition, PrefixType::Rps => matches_condition(
process.read_bytes_per_sec as f64, &numerical_query.condition,
numerical_query.value, process.read_bytes_per_sec as f64,
), numerical_query.value,
PrefixType::Wps => matches_condition( ),
&numerical_query.condition, PrefixType::Wps => matches_condition(
process.write_bytes_per_sec as f64, &numerical_query.condition,
numerical_query.value, process.write_bytes_per_sec as f64,
), numerical_query.value,
PrefixType::TRead => matches_condition( ),
&numerical_query.condition, PrefixType::TRead => matches_condition(
process.total_read_bytes as f64, &numerical_query.condition,
numerical_query.value, process.total_read_bytes as f64,
), numerical_query.value,
PrefixType::TWrite => matches_condition( ),
&numerical_query.condition, PrefixType::TWrite => matches_condition(
process.total_write_bytes as f64, &numerical_query.condition,
numerical_query.value, process.total_write_bytes as f64,
), numerical_query.value,
_ => true, ),
_ => true,
},
ComparableQuery::Time(time_query) => match prefix_type {
PrefixType::Time => {
matches_duration(&time_query.condition, process.time, time_query.duration)
}
_ => true,
},
} }
} else { } else {
// Somehow we have an empty condition... oh well. Return true. // Somehow we have an empty condition... oh well. Return true.
@ -751,8 +831,20 @@ pub enum StringQuery {
Regex(regex::Regex), Regex(regex::Regex),
} }
#[derive(Debug)]
pub enum ComparableQuery {
Numerical(NumericalQuery),
Time(TimeQuery),
}
#[derive(Debug)] #[derive(Debug)]
pub struct NumericalQuery { pub struct NumericalQuery {
pub condition: QueryComparison, pub condition: QueryComparison,
pub value: f64, pub value: f64,
} }
#[derive(Debug)]
pub struct TimeQuery {
pub condition: QueryComparison,
pub duration: Duration,
}

View File

@ -24,7 +24,7 @@ mod test {
#[test] #[test]
fn process_column_settings() { fn process_column_settings() {
let config = r#" 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(); let generated: ProcessConfig = toml_edit::de::from_str(config).unwrap();
@ -39,26 +39,23 @@ mod test {
ProcColumn::TotalWrite, ProcColumn::TotalWrite,
ProcColumn::ReadPerSecond, ProcColumn::ReadPerSecond,
ProcColumn::WritePerSecond, ProcColumn::WritePerSecond,
ProcColumn::Time,
ProcColumn::User,
ProcColumn::State,
]), ]),
); );
} }
#[test] #[test]
fn process_column_settings_2() { fn process_column_settings_2() {
let config = r#" let config = r#"columns = ["MEM%"]"#;
columns = ["MEM%"]
"#;
let generated: ProcessConfig = toml_edit::de::from_str(config).unwrap(); let generated: ProcessConfig = toml_edit::de::from_str(config).unwrap();
assert_eq!(generated.columns, Some(vec![ProcColumn::MemoryPercent])); assert_eq!(generated.columns, Some(vec![ProcColumn::MemoryPercent]));
} }
#[test] #[test]
fn process_column_settings_3() { fn process_column_settings_3() {
let config = r#" let config = r#"columns = ["MEM%", "TWrite", "Cpuz", "read", "wps"]"#;
columns = ["MEM%", "TWrite", "Cpuz", "read", "wps"]
"#;
toml_edit::de::from_str::<ProcessConfig>(config).expect_err("Should error out!"); toml_edit::de::from_str::<ProcessConfig>(config).expect_err("Should error out!");
} }

View File

@ -35,6 +35,8 @@ pub enum BottomError {
/// An error that just signifies something minor went wrong; no message. /// An error that just signifies something minor went wrong; no message.
#[error("Minor error.")] #[error("Minor error.")]
MinorError, MinorError,
#[error("Error casting integers {0}")]
TryFromIntError(#[from] std::num::TryFromIntError),
/// An error to represent errors with procfs /// An error to represent errors with procfs
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[error("Procfs error, {0}")] #[error("Procfs error, {0}")]

View File

@ -89,6 +89,7 @@ fn make_column(column: ProcColumn) -> SortColumn<ProcColumn> {
TotalWrite => SortColumn::hard(TotalWrite, 8).default_descending(), TotalWrite => SortColumn::hard(TotalWrite, 8).default_descending(),
User => SortColumn::soft(User, Some(0.05)), User => SortColumn::soft(User, Some(0.05)),
State => SortColumn::hard(State, 7), State => SortColumn::hard(State, 7),
Time => SortColumn::new(Time),
} }
} }
@ -114,6 +115,7 @@ pub enum ProcWidgetColumn {
TotalWrite, TotalWrite,
User, User,
State, State,
Time,
} }
pub struct ProcWidgetState { pub struct ProcWidgetState {
@ -251,6 +253,7 @@ impl ProcWidgetState {
TotalWrite => ProcWidgetColumn::TotalWrite, TotalWrite => ProcWidgetColumn::TotalWrite,
State => ProcWidgetColumn::State, State => ProcWidgetColumn::State,
User => ProcWidgetColumn::User, User => ProcWidgetColumn::User,
Time => ProcWidgetColumn::Time,
} }
}) })
.collect::<IndexSet<_>>(); .collect::<IndexSet<_>>();
@ -936,6 +939,8 @@ fn sort_skip_pid_asc(column: &ProcColumn, data: &mut [ProcWidgetData], order: So
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::time::Duration;
use super::*; use super::*;
use crate::widgets::MemUsage; use crate::widgets::MemUsage;
@ -959,6 +964,7 @@ mod test {
user: "N/A".to_string(), user: "N/A".to_string(),
num_similar: 0, num_similar: 0,
disabled: false, disabled: false,
time: Duration::from_secs(0),
}; };
let b = ProcWidgetData { let b = ProcWidgetData {

View File

@ -23,6 +23,7 @@ pub enum ProcColumn {
TotalWrite, TotalWrite,
State, State,
User, User,
Time,
} }
impl<'de> Deserialize<'de> for ProcColumn { impl<'de> Deserialize<'de> for ProcColumn {
@ -45,6 +46,7 @@ impl<'de> Deserialize<'de> for ProcColumn {
"twrite" | "t.write" => Ok(ProcColumn::TotalWrite), "twrite" | "t.write" => Ok(ProcColumn::TotalWrite),
"state" => Ok(ProcColumn::State), "state" => Ok(ProcColumn::State),
"user" => Ok(ProcColumn::User), "user" => Ok(ProcColumn::User),
"time" => Ok(ProcColumn::Time),
_ => Err(D::Error::custom("doesn't match any column type")), _ => Err(D::Error::custom("doesn't match any column type")),
} }
} }
@ -75,6 +77,7 @@ impl ColumnHeader for ProcColumn {
ProcColumn::TotalWrite => "T.Write", ProcColumn::TotalWrite => "T.Write",
ProcColumn::State => "State", ProcColumn::State => "State",
ProcColumn::User => "User", ProcColumn::User => "User",
ProcColumn::Time => "Time",
} }
.into() .into()
} }
@ -94,6 +97,7 @@ impl ColumnHeader for ProcColumn {
ProcColumn::TotalWrite => "T.Write", ProcColumn::TotalWrite => "T.Write",
ProcColumn::State => "State", ProcColumn::State => "State",
ProcColumn::User => "User", ProcColumn::User => "User",
ProcColumn::Time => "Time",
} }
.into() .into()
} }
@ -151,6 +155,9 @@ impl SortsRow for ProcColumn {
data.sort_by_cached_key(|pd| pd.user.to_lowercase()); 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));
}
} }
} }
} }

View File

@ -1,6 +1,7 @@
use std::{ use std::{
cmp::{max, Ordering}, cmp::{max, Ordering},
fmt::Display, fmt::Display,
time::Duration,
}; };
use concat_string::concat_string; 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)] #[derive(Clone, Debug)]
pub struct ProcWidgetData { pub struct ProcWidgetData {
pub pid: Pid, pub pid: Pid,
@ -123,6 +181,7 @@ pub struct ProcWidgetData {
pub user: String, pub user: String,
pub num_similar: u64, pub num_similar: u64,
pub disabled: bool, pub disabled: bool,
pub time: Duration,
} }
impl ProcWidgetData { impl ProcWidgetData {
@ -157,6 +216,7 @@ impl ProcWidgetData {
user: process.user.to_string(), user: process.user.to_string(),
num_similar: 1, num_similar: 1,
disabled: false, disabled: false,
time: process.time,
} }
} }
@ -204,6 +264,7 @@ impl ProcWidgetData {
ProcColumn::TotalWrite => dec_bytes_string(self.total_write), ProcColumn::TotalWrite => dec_bytes_string(self.total_write),
ProcColumn::State => self.process_char.to_string(), ProcColumn::State => self.process_char.to_string(),
ProcColumn::User => self.user.clone(), ProcColumn::User => self.user.clone(),
ProcColumn::Time => format_time(self.time),
} }
} }
} }
@ -237,6 +298,7 @@ impl DataToCell<ProcColumn> for ProcWidgetData {
} }
} }
ProcColumn::User => self.user.clone(), ProcColumn::User => self.user.clone(),
ProcColumn::Time => format_time(self.time),
}, },
calculated_width, calculated_width,
)) ))
@ -266,3 +328,41 @@ impl DataToCell<ProcColumn> for ProcWidgetData {
widths 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"
);
}
}