- minimal Docker Engine API client over Unix/TCP sockets with version negotiation and chunked response decoding - drop-in stats schema: active/stopped containers, CPU/memory/IO/network rates, ports, uptime (pretty_date), image, engine field - Docker + Podman engines, config (all, podman_sock, max_name_size, disable_stats, show/hide, per-container thresholds) - export strips the computation sub-dicts (--stdout/--stdout-csv) - TUI block above the process count - CI-safe fake Docker socket test + live differential parity
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
//! Formatting helpers (units, Python-compatible `str()`/`repr()`).
|
|
|
|
use serde_json::Value;
|
|
|
|
/// Convert Celsius to Fahrenheit.
|
|
pub fn to_fahrenheit(celsius: f64) -> f64 {
|
|
celsius * 1.8 + 32.0
|
|
}
|
|
|
|
/// Make a nice human-readable string out of a number (port of `auto_unit`).
|
|
pub fn auto_unit(number: f64, low_precision: bool, min_symbol: &str, none_symbol: &str) -> String {
|
|
let symbols: &[char] = &['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
|
|
let symbols: Vec<char> = match symbols.iter().position(|&s| s.to_string() == min_symbol) {
|
|
Some(idx) => symbols[idx..].to_vec(),
|
|
None => symbols.to_vec(),
|
|
};
|
|
let prefix = |symbol: char| -> f64 {
|
|
match symbol {
|
|
'Y' => 1208925819614629174706176.0,
|
|
'Z' => 1180591620717411303424.0,
|
|
'E' => 1152921504606846976.0,
|
|
'P' => 1125899906842624.0,
|
|
'T' => 1099511627776.0,
|
|
'G' => 1073741824.0,
|
|
'M' => 1048576.0,
|
|
'K' => 1024.0,
|
|
_ => 1.0,
|
|
}
|
|
};
|
|
|
|
if number.is_nan() {
|
|
return none_symbol.to_string();
|
|
}
|
|
if number == 0.0 {
|
|
// Avoid 0.0
|
|
return "0".to_string();
|
|
}
|
|
if !number.is_finite() {
|
|
return format!("{number}");
|
|
}
|
|
|
|
// If the value was given as an integer, decimal_precision is 0.
|
|
let mut decimal_precision = if number.fract() != 0.0 { 2 } else { 0 };
|
|
|
|
for &symbol in symbols.iter().rev() {
|
|
let value = number / prefix(symbol);
|
|
if value > 1.0 {
|
|
decimal_precision = 0;
|
|
if value <= 9.995 {
|
|
decimal_precision = 2;
|
|
} else if value < 99.95 {
|
|
decimal_precision = 1;
|
|
}
|
|
if low_precision {
|
|
if symbol == 'M' || symbol == 'K' {
|
|
decimal_precision = 0;
|
|
} else {
|
|
decimal_precision = decimal_precision.min(1);
|
|
}
|
|
} else if symbol == 'K' {
|
|
decimal_precision = 0;
|
|
}
|
|
return format!("{value:.decimal_precision$}{symbol}");
|
|
}
|
|
}
|
|
|
|
format!("{number:.decimal_precision$}")
|
|
}
|
|
|
|
/// Render a progress bar the way the Python `Bar` class does.
|
|
pub fn bar_percent(percent: f64, size: usize, bar_char: &str, display_value: bool) -> String {
|
|
bar_custom(percent, size, bar_char, " ", display_value)
|
|
}
|
|
|
|
/// Render a progress bar with custom characters.
|
|
pub fn bar_custom(
|
|
percent: f64,
|
|
size: usize,
|
|
bar_char: &str,
|
|
empty_char: &str,
|
|
display_value: bool,
|
|
) -> String {
|
|
let size = if display_value {
|
|
size.saturating_sub(6)
|
|
} else {
|
|
size
|
|
};
|
|
let value = percent.clamp(0.0, 100.0);
|
|
let scaled = size as f64 * value / 100.0;
|
|
let whole = scaled.floor() as usize;
|
|
let frac = scaled - whole as f64;
|
|
let mut bar = bar_char.repeat(whole);
|
|
let mut whole = whole;
|
|
if frac > 0.0 {
|
|
// curses_bars = [empty; 5] + [bar; 5]: index 5..7 draws a partial block.
|
|
if (frac * 8.0) as usize >= 5 {
|
|
bar.push_str(bar_char);
|
|
} else {
|
|
bar.push_str(empty_char);
|
|
}
|
|
whole += 1;
|
|
}
|
|
bar.push_str(&empty_char.repeat(size.saturating_sub(whole)));
|
|
if display_value {
|
|
if percent >= 100.0 {
|
|
bar = format!(
|
|
"{bar} {}{:3.0}%",
|
|
if percent > 100.0 { ">" } else { " " },
|
|
100.0
|
|
);
|
|
} else {
|
|
bar = format!("{bar}{percent:5.1}%");
|
|
}
|
|
}
|
|
bar
|
|
}
|
|
|
|
/// Get a datetime or a timestamp and return a pretty string like
|
|
/// 'an hour ago', 'yesterday', '3 months ago', 'just now'...
|
|
/// (port of `glances.globals.pretty_date`).
|
|
pub fn pretty_date(
|
|
started: chrono::DateTime<chrono::Local>,
|
|
now: chrono::DateTime<chrono::Local>,
|
|
) -> String {
|
|
let diff = now.signed_duration_since(started);
|
|
let day_diff = diff.num_days();
|
|
if day_diff < 0 {
|
|
return String::new();
|
|
}
|
|
// Python's timedelta.seconds excludes the days.
|
|
let second_diff = diff.num_seconds() - day_diff * 86_400;
|
|
|
|
if day_diff == 0 {
|
|
let thresholds: [(i64, i64, Option<&str>, Option<&str>); 6] = [
|
|
(10, 1, Some("just now"), None),
|
|
(60, 1, None, Some(" secs")),
|
|
(120, 1, Some("a min"), None),
|
|
(3600, 60, None, Some(" mins")),
|
|
(7200, 1, Some("an hour"), None),
|
|
(86_400, 3600, None, Some(" hours")),
|
|
];
|
|
for (max_value, divisor, singular, plural) in thresholds {
|
|
if second_diff < max_value {
|
|
if let Some(singular) = singular
|
|
&& plural.is_none()
|
|
{
|
|
return singular.to_string();
|
|
}
|
|
let value = second_diff / divisor;
|
|
return format!("{value}{}", plural.unwrap_or(""));
|
|
}
|
|
}
|
|
}
|
|
|
|
let day_thresholds: [(i64, i64, Option<&str>, Option<&str>); 5] = [
|
|
(2, 1, Some("yesterday"), None),
|
|
(7, 1, Some("a day"), Some(" days")),
|
|
(31, 7, Some("a week"), Some(" weeks")),
|
|
(365, 30, Some("a month"), Some(" months")),
|
|
(i64::MAX, 365, Some("an year"), Some(" years")),
|
|
];
|
|
for (max_days, divisor, singular, plural) in day_thresholds {
|
|
if day_diff < max_days {
|
|
let value = day_diff / divisor;
|
|
if let Some(singular) = singular
|
|
&& (value <= 1 || plural.is_none())
|
|
{
|
|
return singular.to_string();
|
|
}
|
|
return format!("{value}{}", plural.unwrap_or(""));
|
|
}
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
/// Parse a value like `'12.5 MB'` into a float (port of `string_value_to_float`).
|
|
pub fn string_value_to_float(s: &str) -> Option<f64> {
|
|
let cleaned: String = s.chars().filter(|c| *c != ' ').collect();
|
|
let mut value: Option<f64> = None;
|
|
let mut unit: Option<String> = None;
|
|
let mut digits = String::new();
|
|
let mut rest = String::new();
|
|
for c in cleaned.chars() {
|
|
if (c.is_ascii_digit() || c == '.') && rest.is_empty() {
|
|
digits.push(c);
|
|
continue;
|
|
}
|
|
rest.push(c);
|
|
}
|
|
if !digits.is_empty() {
|
|
value = digits.parse::<f64>().ok();
|
|
}
|
|
if !rest.is_empty() {
|
|
unit = Some(rest.to_uppercase());
|
|
}
|
|
let value = value?;
|
|
let multiplier = match unit.as_deref() {
|
|
None => 1.0,
|
|
Some("B") => 1.0,
|
|
Some("KB") => 1000.0,
|
|
Some("MB") => 1_000_000.0,
|
|
Some("GB") => 1_000_000_000.0,
|
|
Some("TB") => 1_000_000_000_000.0,
|
|
Some("PB") => 1_000_000_000_000_000.0,
|
|
Some(_) => return None,
|
|
};
|
|
Some(value * multiplier)
|
|
}
|
|
|
|
/// Python `str()` for a JSON value (used by stdout/csv outputs).
|
|
pub fn py_str(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => "None".to_string(),
|
|
Value::Bool(true) => "True".to_string(),
|
|
Value::Bool(false) => "False".to_string(),
|
|
Value::Number(n) => py_number(n),
|
|
Value::String(s) => s.clone(),
|
|
other => py_repr(other),
|
|
}
|
|
}
|
|
|
|
fn py_number(n: &serde_json::Number) -> String {
|
|
if let Some(i) = n.as_i64() {
|
|
return i.to_string();
|
|
}
|
|
if let Some(u) = n.as_u64() {
|
|
return u.to_string();
|
|
}
|
|
let f = n.as_f64().unwrap_or(0.0);
|
|
py_float(f)
|
|
}
|
|
|
|
/// Python `repr()` for a float.
|
|
pub fn py_float(f: f64) -> String {
|
|
if f.is_nan() {
|
|
return "nan".to_string();
|
|
}
|
|
if f.is_infinite() {
|
|
return if f > 0.0 { "inf" } else { "-inf" }.to_string();
|
|
}
|
|
if f.fract() == 0.0 && f.abs() < 1e16 {
|
|
format!("{f:.1}")
|
|
} else {
|
|
format!("{f}")
|
|
}
|
|
}
|
|
|
|
/// Python `repr()` for a JSON value (single quotes, True/False/None).
|
|
pub fn py_repr(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => "None".to_string(),
|
|
Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
|
|
Value::Number(n) => py_number(n),
|
|
Value::String(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
|
|
Value::Array(items) => {
|
|
let inner: Vec<String> = items.iter().map(py_repr).collect();
|
|
format!("[{}]", inner.join(", "))
|
|
}
|
|
Value::Object(map) => {
|
|
let inner: Vec<String> = map
|
|
.iter()
|
|
.map(|(k, v)| format!("'{}': {}", k.replace('\'', "\\'"), py_repr(v)))
|
|
.collect();
|
|
format!("{{{}}}", inner.join(", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract a f64 from a JSON value the way Python would accept a number.
|
|
pub fn as_f64(value: &Value) -> Option<f64> {
|
|
match value {
|
|
Value::Number(n) => n.as_f64(),
|
|
Value::Bool(_) | Value::Null | Value::String(_) | Value::Array(_) | Value::Object(_) => {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Simple mean subsampling (port of `subsample`).
|
|
pub fn subsample(data: &[f64], sampling: usize) -> Vec<f64> {
|
|
if data.len() <= sampling || sampling == 0 {
|
|
return data.to_vec();
|
|
}
|
|
let sampling_length = (data.len() as f64 / sampling as f64).round() as usize;
|
|
(0..sampling)
|
|
.map(|s| {
|
|
let start = s * sampling_length;
|
|
let end = ((s + 1) * sampling_length).min(data.len());
|
|
let slice = &data[start..end];
|
|
slice.iter().sum::<f64>() / slice.len() as f64
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Return a list of the substrings in the input string, using `sep` as the
|
|
/// separator char and `esc` as the escape character (port of `split_esc`,
|
|
/// only the explicit separator flavour is required).
|
|
pub fn split_esc(input: &str, sep: char, esc: char) -> Vec<String> {
|
|
let mut ret: Vec<String> = vec![String::new()];
|
|
let mut esc_seen = false;
|
|
for ch in input.chars() {
|
|
if !esc_seen {
|
|
if ch == esc {
|
|
esc_seen = true;
|
|
} else if ch == sep {
|
|
ret.push(String::new());
|
|
} else {
|
|
ret.last_mut().unwrap().push(ch);
|
|
}
|
|
} else {
|
|
ret.last_mut().unwrap().push(ch);
|
|
esc_seen = false;
|
|
}
|
|
}
|
|
ret
|
|
}
|
|
|
|
/// Replace special chars by another (new lines, tabs...) or escape for a
|
|
/// shell by adding a `$` prefix in front of them. (Only the replacement
|
|
/// flavour is needed by the plugins.)
|
|
pub fn replace_special_chars(input: &str, by: char) -> String {
|
|
input
|
|
.replace("\r\n", &by.to_string())
|
|
.replace('\n', &by.to_string())
|
|
.replace('\t', &by.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_auto_unit() {
|
|
assert_eq!(auto_unit(613421788.0, false, "K", "-"), "585M");
|
|
assert_eq!(auto_unit(5307033647.0, false, "K", "-"), "4.94G");
|
|
assert_eq!(auto_unit(5307033647.0, true, "K", "-"), "4.9G");
|
|
assert_eq!(auto_unit(44968414685.0, false, "K", "-"), "41.9G");
|
|
assert_eq!(auto_unit(838471403472.0, false, "K", "-"), "781G");
|
|
assert_eq!(auto_unit(9683209690677.0, false, "K", "-"), "8.81T");
|
|
assert_eq!(auto_unit(1073741824.0, false, "K", "-"), "1024M");
|
|
assert_eq!(auto_unit(1181116006.0, false, "K", "-"), "1.10G");
|
|
assert_eq!(auto_unit(0.0, false, "K", "-"), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_py_repr() {
|
|
assert_eq!(py_repr(&json!({"a": 1, "b": "x"})), "{'a': 1, 'b': 'x'}");
|
|
assert_eq!(py_str(&json!(1234.0)), "1234.0");
|
|
assert_eq!(py_str(&json!(5)), "5");
|
|
assert_eq!(py_str(&json!(true)), "True");
|
|
assert_eq!(py_str(&json!(null)), "None");
|
|
}
|
|
}
|