containers: Docker (and best-effort Podman) plugin

- 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
This commit is contained in:
glances-rs
2026-10-10 00:24:39 +00:00
parent a522d21b0b
commit ea9debf71b
13 changed files with 2036 additions and 6 deletions
Generated
+1
View File
@@ -753,6 +753,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"glances-core",
"indexmap",
"libc",
+8 -2
View File
@@ -26,8 +26,8 @@ exporters.
| Terminal UI (ratatui) | done |
| stdout / CSV / JSON / fetch / issue modes | done |
| Core plugins | cpu, percpu, mem, memswap, load, fs, network, diskio, sensors, irq, system, uptime, now, core, version, psutilversion, quicklook, processcount, processlist, programlist |
| Extended plugins | folders, amps, ports, raid, wifi, ip |
| Not ported yet | containers, vms, gpu/npu/mpp, connections, smart, cloud |
| Extended plugins | folders, amps, ports, raid, wifi, ip, containers (Docker) |
| Not ported yet | vms, gpu/npu/mpp, connections, smart, cloud, LXD containers |
| Python-specific flags | `--trace-malloc`, `--api-doc`, `--api-restful-doc` rejected with a clear error |
## Build and run
@@ -53,6 +53,12 @@ $ python3 tools/parity.py # all implemented plugins
$ python3 tools/parity.py cpu mem # selected plugins
```
The containers plugin is compared against a live Docker daemon. Install the
Python extras (`pip install docker python-dateutil requests`) and have a
running container; the harness gives the plugin six refreshes because both
implementations need a warm-up sample before their rates are available. Use a
config with `[containers] all=True` to also compare stopped containers.
## Workspace layout
| Crate | Purpose |
+3 -1
View File
@@ -117,7 +117,9 @@ pub fn render_stdout_json(engine: &Engine, plugins: &[String]) -> String {
continue;
};
if plugin.is_enabled() {
result.insert(name.clone(), plugin.export());
// Python stdout-json serializes the raw stats (not the export
// flavour used by --stdout/--stdout-csv).
result.insert(name.clone(), plugin.stats().clone());
}
}
serde_json::to_string(&Value::Object(result)).unwrap_or_else(|_| "{}".to_string())
+58
View File
@@ -115,6 +115,64 @@ pub fn bar_custom(
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();
+3
View File
@@ -16,3 +16,6 @@ sysinfo = "0.39.6"
thiserror.workspace = true
tracing.workspace = true
ureq = { version = "3.4.2", features = ["json"] }
[dev-dependencies]
clap = { workspace = true, features = ["derive"] }
+4
View File
@@ -31,6 +31,10 @@ impl Engine {
"amps".to_string(),
Box::new(AmpsPlugin::new(&config, &args)),
);
plugins.insert(
"containers".to_string(),
Box::new(ContainersPlugin::new(&config, &args)),
);
plugins.insert(
"core".to_string(),
Box::new(CorePlugin::new(&config, &args)),
@@ -0,0 +1,212 @@
//! Minimal Docker Engine API client (Unix socket or TCP).
//!
//! Implements just what the containers plugin needs: HTTP/1.1 requests with
//! `Connection: close`, chunked response decoding, and API version
//! negotiation (`/version`).
use anyhow::{Context, Result, bail};
use serde_json::Value;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
pub struct DockerApi {
unix_path: Option<PathBuf>,
tcp_addr: Option<String>,
api_version: Option<String>,
timeout: Duration,
}
impl std::fmt::Debug for DockerApi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DockerApi")
.field("unix_path", &self.unix_path)
.field("tcp_addr", &self.tcp_addr)
.field("api_version", &self.api_version)
.finish()
}
}
impl DockerApi {
/// Build an API client from a Docker base URL (`unix://`, `tcp://` or
/// `http://`). Returns None if the socket/address is missing.
pub fn connect(base_url: &str) -> Option<Self> {
let (unix_path, tcp_addr) = if let Some(path) = base_url.strip_prefix("unix://") {
let path = PathBuf::from(path);
if !path.exists() {
return None;
}
(Some(path), None)
} else if let Some(addr) = base_url.strip_prefix("tcp://") {
(None, Some(addr.to_string()))
} else if let Some(addr) = base_url.strip_prefix("http://") {
(None, Some(addr.to_string()))
} else {
return None;
};
Some(Self {
unix_path,
tcp_addr,
api_version: None,
timeout: Duration::from_secs(5),
})
}
/// Build an API client from `DOCKER_HOST` (default `unix:///var/run/docker.sock`).
pub fn from_env() -> Option<Self> {
let base_url = std::env::var("DOCKER_HOST")
.unwrap_or_else(|_| "unix:///var/run/docker.sock".to_string());
Self::connect(&base_url)
}
/// Negotiate the API version and check the daemon is reachable.
pub fn ping(&mut self) -> Result<()> {
let version = self.get_json_unversioned("/version")?;
let api_version = version
.get("ApiVersion")
.and_then(Value::as_str)
.context("missing ApiVersion in /version response")?;
self.api_version = Some(api_version.to_string());
Ok(())
}
fn versioned(&self, path: &str) -> String {
match &self.api_version {
Some(version) => format!("/v{version}{path}"),
None => path.to_string(),
}
}
/// GET a versioned API path and decode the JSON body.
pub fn get_json(&self, path: &str) -> Result<Value> {
let body = self.request(&self.versioned(path))?;
serde_json::from_str(&body).with_context(|| format!("invalid JSON from {path}"))
}
pub fn get_json_unversioned(&self, path: &str) -> Result<Value> {
let body = self.request(path)?;
serde_json::from_str(&body).with_context(|| format!("invalid JSON from {path}"))
}
fn request(&self, path: &str) -> Result<String> {
let response = if let Some(unix_path) = &self.unix_path {
let mut stream = UnixStream::connect(unix_path)
.with_context(|| format!("cannot connect to {}", unix_path.display()))?;
stream.set_read_timeout(Some(self.timeout)).ok();
stream.set_write_timeout(Some(self.timeout)).ok();
let request = http_request(path);
stream.write_all(request.as_bytes())?;
read_response(&mut stream)?
} else if let Some(addr) = &self.tcp_addr {
let mut stream =
TcpStream::connect(addr).with_context(|| format!("cannot connect to {addr}"))?;
stream.set_read_timeout(Some(self.timeout)).ok();
stream.set_write_timeout(Some(self.timeout)).ok();
let request = http_request(path);
stream.write_all(request.as_bytes())?;
read_response(&mut stream)?
} else {
bail!("no Docker endpoint configured");
};
Ok(response)
}
}
fn http_request(path: &str) -> String {
format!(
"GET {path} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nConnection: close\r\n\r\n"
)
}
fn read_response(stream: &mut impl Read) -> Result<String> {
let mut raw = Vec::new();
stream.read_to_end(&mut raw)?;
parse_http_response(&raw)
}
fn parse_http_response(raw: &[u8]) -> Result<String> {
let split = raw
.windows(4)
.position(|window| window == b"\r\n\r\n")
.context("malformed HTTP response (no header separator)")?;
let header_text = String::from_utf8_lossy(&raw[..split]);
let body = &raw[split + 4..];
let status_line = header_text.lines().next().unwrap_or_default();
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.context("malformed HTTP status line")?;
let mut chunked = false;
for line in header_text.lines().skip(1) {
if let Some((name, value)) = line.split_once(':')
&& name.eq_ignore_ascii_case("transfer-encoding")
&& value.to_lowercase().contains("chunked")
{
chunked = true;
}
}
let body = if chunked {
decode_chunked(body)?
} else {
body.to_vec()
};
let text = String::from_utf8_lossy(&body).to_string();
if status >= 400 {
let snippet: String = text.chars().take(200).collect();
bail!("HTTP {status}: {snippet}");
}
Ok(text)
}
fn decode_chunked(body: &[u8]) -> Result<Vec<u8>> {
let mut decoded = Vec::new();
let mut position = 0;
loop {
let Some(end) = body[position..].windows(2).position(|w| w == b"\r\n") else {
bail!("malformed chunked body (missing size line)");
};
let size_line = String::from_utf8_lossy(&body[position..position + end]);
let size_hex = size_line.split(';').next().unwrap_or("").trim();
let size = usize::from_str_radix(size_hex, 16).context("invalid chunk size")?;
position += end + 2;
if size == 0 {
break;
}
if position + size > body.len() {
bail!("malformed chunked body (truncated chunk)");
}
decoded.extend_from_slice(&body[position..position + size]);
position += size + 2; // skip the chunk data and trailing CRLF
}
Ok(decoded)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_plain_response() {
let raw = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 7\r\n\r\n{\"a\":1}";
assert_eq!(parse_http_response(raw).unwrap(), "{\"a\":1}");
}
#[test]
fn test_parse_chunked_response() {
let raw =
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7\r\n{\"a\":1}\r\n0\r\n\r\n";
assert_eq!(parse_http_response(raw).unwrap(), "{\"a\":1}");
}
#[test]
fn test_error_status() {
let raw = b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found";
assert!(parse_http_response(raw).is_err());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,366 @@
//! Containers plugin (Docker and Podman).
mod docker_api;
mod engine;
mod render;
pub use engine::ContainerEngine;
use crate::fields;
use glances_core::plugin::{Field, HistoryItem, Plugin, PluginModel, UpdateContext};
use glances_core::processes::{processes, sort_stats};
use glances_core::{Args, Config, Decoration};
use serde_json::{Value, json};
pub struct ContainersPlugin {
pub model: PluginModel,
engines: Vec<ContainerEngine>,
pub sort_key: String,
pub disable_stats: Vec<String>,
pub max_name_size: usize,
pub show_engine_name: bool,
pub show_pod_name: bool,
}
impl ContainersPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let fields = fields! {
"name" => Field::new("Container name"),
"id" => Field::new("Container ID"),
"image" => Field::new("Container image"),
"status" => Field::new("Container status"),
"created" => Field::new("Container creation date"),
"command" => Field::new("Container command"),
"cpu_percent" => Field::new("Container CPU consumption").unit("percent"),
"cpu_limit" => Field::new("Container CPU limit").unit("number"),
"memory_inactive_file" => Field::new("Container memory inactive file").unit("byte"),
"memory_limit" => Field::new("Container memory limit").unit("byte"),
"memory_usage" => Field::new("Container memory usage").unit("byte"),
"io_rx" => Field::new("Container IO bytes read rate").unit("bytepersecond"),
"io_wx" => Field::new("Container IO bytes write rate").unit("bytepersecond"),
"network_rx" => Field::new("Container network RX bitrate").unit("bitpersecond"),
"network_tx" => Field::new("Container network TX bitrate").unit("bitpersecond"),
"ports" => Field::new("Container ports"),
"uptime" => Field::new("Container uptime"),
"engine" => Field::new("Container engine (Docker, Podman, and LXD are currently supported)"),
"pod_name" => Field::new("Pod name (only with Podman)"),
"pod_id" => Field::new("Pod ID (only with Podman)"),
};
let items_history = vec![HistoryItem::new(
"cpu_percent",
"Container CPU consumption in %",
)];
let mut model = PluginModel::new(
"containers",
fields,
items_history,
Value::Array(Vec::new()),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
model.set_item_key("name");
let disable_stats = model.get_conf_value("disable_stats", "");
let max_name_size = config
.get_int_value("containers", "max_name_size", 20)
.max(1) as usize;
// Engines: Docker (DOCKER_HOST or the default socket) and Podman.
let mut engines = Vec::new();
if let Some(engine) = ContainerEngine::docker() {
engines.push(engine);
}
let podman_sock = model
.get_conf_value("podman_sock", "")
.first()
.cloned()
.unwrap_or_else(default_podman_socket);
if let Some(engine) = ContainerEngine::podman(&podman_sock) {
engines.push(engine);
}
Self {
model,
engines,
sort_key: "cpu_percent".to_string(),
disable_stats,
max_name_size,
show_engine_name: false,
show_pod_name: false,
}
}
pub fn engines(&self) -> &[ContainerEngine] {
&self.engines
}
}
/// Default Podman socket (same as the Python implementation, but using the
/// real runtime directory when available).
fn default_podman_socket() -> String {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
&& !runtime_dir.is_empty()
{
return format!("unix://{runtime_dir}/podman/podman.sock");
}
let uid = unsafe { libc::getuid() };
format!("unix:///run/user/{uid}/podman/podman.sock")
}
impl Plugin for ContainersPlugin {
fn name(&self) -> &str {
"containers"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn get_key(&self) -> Option<&'static str> {
Some("name")
}
fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let all = self
.model
.get_conf_value("all", "")
.first()
.map(|value| value.to_lowercase() == "true")
.unwrap_or(false);
let mut stats: Vec<Value> = Vec::new();
for engine in self.engines.iter_mut() {
for container in engine.update(all) {
let Some(object) = container.as_object() else {
continue;
};
let name = object
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if self.model.is_hide(&name, "") {
continue;
}
let mut container = container;
if let Some(object) = container.as_object_mut() {
object.insert("engine".to_string(), json!(engine.kind.name()));
}
stats.push(container);
}
}
// Sort like the process list (the TUI sort keys drive the container sort).
let process_sort_key = processes().lock().unwrap().sort_key.clone();
let (sort_by, secondary, reverse) = match process_sort_key.as_str() {
"memory_percent" => ("memory_usage", "cpu_percent", true),
"name" => ("name", "cpu_percent", false),
_ => ("cpu_percent", "memory_usage", true),
};
sort_stats(&mut stats, sort_by, secondary, reverse);
self.sort_key = sort_by.to_string();
Ok(Value::Array(stats))
}
fn update_views(&mut self) {
self.model.update_views(Some("name"));
let items: Vec<(String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
match &self.model.stats {
Value::Array(list) => list
.iter()
.filter_map(|container| {
let name = container.get("name")?.as_str()?.to_string();
let cpu = container
.get("cpu")
.and_then(|cpu| cpu.get("total"))
.and_then(Value::as_f64);
let usage = container
.get("memory")
.and_then(|memory| memory.get("usage"))
.and_then(Value::as_f64);
let inactive = container
.get("memory")
.and_then(|memory| memory.get("inactive_file"))
.and_then(Value::as_f64);
let limit = container
.get("memory")
.and_then(|memory| memory.get("limit"))
.and_then(Value::as_f64);
Some((name, cpu, usage, inactive, limit))
})
.collect(),
_ => Vec::new(),
};
for (name, cpu, usage, inactive, limit) in items {
if let Some(cpu) = cpu {
let mut alert = self.model.get_alert(
cpu,
0.0,
100.0,
true,
false,
Some("cpu"),
Some(&name),
false,
);
if alert == Decoration::Default {
alert = self.model.get_alert(
cpu,
0.0,
100.0,
true,
false,
Some("cpu"),
None,
false,
);
}
self.model.set_view_decoration(Some(&name), "cpu", alert);
}
if let (Some(usage), Some(limit)) = (usage, limit) {
let usage = usage - inactive.unwrap_or(0.0);
let mut alert = self.model.get_alert(
usage,
0.0,
limit,
true,
false,
Some("mem"),
Some(&name),
false,
);
if alert == Decoration::Default {
alert = self.model.get_alert(
usage,
0.0,
limit,
true,
false,
Some("mem"),
None,
false,
);
}
self.model.set_view_decoration(Some(&name), "mem", alert);
}
}
// Display engine and pod columns only when relevant.
let engines: std::collections::HashSet<&str> = self
.model
.stats
.as_array()
.map(|list| {
list.iter()
.filter_map(|c| c.get("engine").and_then(Value::as_str))
.collect()
})
.unwrap_or_default();
self.show_engine_name = engines.len() > 1;
self.show_pod_name = self
.model
.stats
.as_array()
.map(|list| {
list.iter()
.any(|c| c.get("pod_name").is_some_and(|v| !v.is_null()))
})
.unwrap_or(false);
}
/// Only export the containers: the sub-dicts used to compute rates are
/// removed (same as the Python `get_export`).
fn export(&self) -> Value {
let mut stats = self.model.stats.clone();
if let Value::Array(list) = &mut stats {
for container in list.iter_mut() {
if let Some(object) = container.as_object_mut() {
for key in ["cpu", "io", "memory", "network"] {
// shift_remove keeps the remaining key order stable.
object.shift_remove(key);
}
}
}
}
stats
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
render::render(self, ctx)
}
}
/// Human readable sort keys for the TUI title.
pub fn sort_for_human(sort_key: &str) -> &'static str {
match sort_key {
"io_counters" => "disk IO",
"cpu_percent" => "CPU consumption",
"memory_usage" => "memory consumption",
"cpu_times" => "uptime",
"name" => "container name",
_ => "None",
}
}
/// Status decoration (port of the plugin `container_alert`).
pub fn container_alert(status: &str) -> Decoration {
match status {
"running" | "healthy" => Decoration::Ok,
"dead" | "unhealthy" => Decoration::Error,
"created" | "exited" => Decoration::Warning,
"paused" | "restarting" => Decoration::Careful,
_ => Decoration::Info,
}
}
/// Expose the engine kind for tests and the TUI.
pub use engine::EngineKind as ContainerEngineKind;
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn plugin() -> ContainersPlugin {
let args = Args::parse_from(["glances"]);
let config = Config::empty(false);
ContainersPlugin::new(&config, &args)
}
#[test]
fn test_export_strips_computation_fields() {
let mut plugin = plugin();
plugin.model.stats = json!([{
"key": "name",
"name": "c1",
"cpu": {"total": 1.0},
"io": {"ior": 2},
"memory": {"usage": 3},
"network": {"rx": 4},
"cpu_percent": 1.0
}]);
let export = plugin.export();
let container = &export.as_array().unwrap()[0];
assert!(container.get("cpu").is_none());
assert!(container.get("io").is_none());
assert!(container.get("memory").is_none());
assert!(container.get("network").is_none());
assert_eq!(container["name"], "c1");
// Key order is preserved after the removals.
let keys: Vec<&String> = container.as_object().unwrap().keys().collect();
assert_eq!(keys, vec!["key", "name", "cpu_percent"]);
}
}
@@ -0,0 +1,277 @@
//! TUI rendering of the containers plugin (port of the plugin `build_*` methods).
use super::{ContainersPlugin, container_alert, sort_for_human};
use glances_core::plugin::StatLine;
use glances_core::{Decoration, RenderContext};
use serde_json::Value;
fn line(plugin: &ContainersPlugin, msg: impl Into<String>, decoration: Decoration) -> StatLine {
plugin
.model
.curse_add_line(msg, decoration, false, false, false)
}
fn add(ret: &mut Vec<StatLine>, plugin: &ContainersPlugin, msg: String) {
ret.push(line(plugin, msg, Decoration::Default));
}
pub fn render(plugin: &mut ContainersPlugin, ctx: &RenderContext) -> Vec<StatLine> {
let mut ret = Vec::new();
let Some(containers) = plugin.model.stats.as_array() else {
return ret;
};
if containers.is_empty() {
return ret;
}
let name_max_width = plugin
.max_name_size
.min(
containers
.iter()
.filter_map(|container| container.get("name").and_then(Value::as_str))
.map(|name| name.chars().count())
.max()
.unwrap_or(0),
)
.max(1);
// Title.
ret.push(line(plugin, "CONTAINERS", Decoration::Title));
if containers.len() > 1 {
add(&mut ret, plugin, format!(" {}", containers.len()));
add(
&mut ret,
plugin,
format!(" sorted by {}", sort_for_human(&plugin.sort_key)),
);
}
if !plugin.show_engine_name {
let engine = containers
.first()
.and_then(|container| container.get("engine"))
.and_then(Value::as_str)
.unwrap_or("");
add(&mut ret, plugin, format!(" (served by {engine})"));
}
ret.push(plugin.model.curse_new_line());
// Header.
ret.push(plugin.model.curse_new_line());
if plugin.show_engine_name {
add(&mut ret, plugin, format!(" {:<6}", "Engine"));
}
if plugin.show_pod_name {
add(&mut ret, plugin, format!(" {:<12}", "Pod"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "name") {
let decoration = if plugin.sort_key == "name" {
Decoration::Sort
} else {
Decoration::Default
};
ret.push(line(
plugin,
format!(" {:<width$}", "Name", width = name_max_width),
decoration,
));
}
if !plugin.disable_stats.iter().any(|stat| stat == "status") {
add(&mut ret, plugin, format!("{:>10}", "Status"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "uptime") {
add(&mut ret, plugin, format!("{:>10}", "Uptime"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "cpu") {
let decoration = if plugin.sort_key == "cpu_percent" {
Decoration::Sort
} else {
Decoration::Default
};
ret.push(line(plugin, format!("{:>6}", "CPU%"), decoration));
}
if !plugin.disable_stats.iter().any(|stat| stat == "mem") {
let decoration = if plugin.sort_key == "memory_usage" {
Decoration::Sort
} else {
Decoration::Default
};
ret.push(line(plugin, format!("{:>7}", "MEM"), decoration));
add(&mut ret, plugin, format!("/{:<7}", "MAX"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "diskio") {
add(&mut ret, plugin, format!("{:>7}", "IOR/s"));
add(&mut ret, plugin, format!(" {:<7}", "IOW/s"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "networkio") {
add(&mut ret, plugin, format!("{:>7}", "Rx/s"));
add(&mut ret, plugin, format!(" {:<7}", "Tx/s"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "ports") {
add(&mut ret, plugin, format!("{:16}", "Ports"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "command") {
add(&mut ret, plugin, format!(" {:8}", "Command"));
}
// Rows.
for container in containers {
ret.push(plugin.model.curse_new_line());
if plugin.show_engine_name {
let engine = container
.get("engine")
.and_then(Value::as_str)
.unwrap_or("");
add(&mut ret, plugin, format!(" {engine:<6}"));
}
if plugin.show_pod_name {
let pod_id = container
.get("pod_id")
.and_then(Value::as_str)
.unwrap_or("-");
add(&mut ret, plugin, format!(" {pod_id:<12}"));
}
let name = container.get("name").and_then(Value::as_str).unwrap_or("");
if !plugin.disable_stats.iter().any(|stat| stat == "name") {
let truncated: String = name.chars().take(name_max_width).collect();
add(
&mut ret,
plugin,
format!(" {truncated:<width$}", width = name_max_width),
);
}
if !plugin.disable_stats.iter().any(|stat| stat == "status") {
let status = container
.get("status")
.and_then(Value::as_str)
.unwrap_or("");
let truncated: String = status.chars().take(10).collect();
ret.push(line(
plugin,
format!("{truncated:>10}"),
container_alert(status),
));
}
if !plugin.disable_stats.iter().any(|stat| stat == "uptime") {
let uptime = container
.get("uptime")
.and_then(Value::as_str)
.unwrap_or("");
let text = if uptime.is_empty() {
"_".to_string()
} else {
uptime.to_string()
};
add(&mut ret, plugin, format!("{text:>10}"));
}
if !plugin.disable_stats.iter().any(|stat| stat == "cpu") {
let total = container
.get("cpu")
.and_then(|cpu| cpu.get("total"))
.and_then(Value::as_f64);
let decoration = plugin.model.view_decoration(Some(name), "cpu");
let msg = match total {
Some(total) => format!("{total:>6.1}"),
None => format!("{:>6}", "_"),
};
ret.push(line(plugin, msg, decoration));
}
if !plugin.disable_stats.iter().any(|stat| stat == "mem") {
let memory = container.get("memory").cloned().unwrap_or(Value::Null);
let usage = memory.get("usage").and_then(Value::as_f64);
let inactive = memory
.get("inactive_file")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let limit = memory.get("limit").and_then(Value::as_f64);
let decoration = plugin.model.view_decoration(Some(name), "mem");
let msg = match usage {
Some(usage) => format!(
"{:>7}",
plugin.model.auto_unit(usage - inactive, false, "K")
),
None => format!("{:>7}", "_"),
};
ret.push(line(plugin, msg, decoration));
let msg = match limit {
Some(limit) => format!("/{:<7}", plugin.model.auto_unit(limit, false, "K")),
None => format!("/{:<7}", "_"),
};
add(&mut ret, plugin, msg);
}
if !plugin.disable_stats.iter().any(|stat| stat == "diskio") {
let io_rx = container.get("io_rx").and_then(Value::as_i64);
let io_wx = container.get("io_wx").and_then(Value::as_i64);
let msg = match io_rx {
Some(value) => format!(
"{:>7}",
format!("{}B", plugin.model.auto_unit(value as f64, false, "K"))
),
None => format!("{:>7}", "_"),
};
add(&mut ret, plugin, msg);
let msg = match io_wx {
Some(value) => format!(
" {:<7}",
format!("{}B", plugin.model.auto_unit(value as f64, false, "K"))
),
None => format!(" {:<7}", "_"),
};
add(&mut ret, plugin, msg);
}
if !plugin.disable_stats.iter().any(|stat| stat == "networkio") {
let (to_bit, unit) = if ctx.args.byte { (1.0, "") } else { (8.0, "b") };
let network_rx = container.get("network_rx").and_then(Value::as_i64);
let network_tx = container.get("network_tx").and_then(Value::as_i64);
let msg = match network_rx {
Some(value) => format!(
"{:>7}",
format!(
"{}{unit}",
plugin.model.auto_unit(value as f64 * to_bit, false, "K")
)
),
None => format!("{:>7}", "_"),
};
add(&mut ret, plugin, msg);
let msg = match network_tx {
Some(value) => format!(
" {:<7}",
format!(
"{}{unit}",
plugin.model.auto_unit(value as f64 * to_bit, false, "K")
)
),
None => format!(" {:<7}", "_"),
};
add(&mut ret, plugin, msg);
}
if !plugin.disable_stats.iter().any(|stat| stat == "ports") {
let ports = container.get("ports").and_then(Value::as_str).unwrap_or("");
let text = if ports.is_empty() {
"_".to_string()
} else {
ports.to_string()
};
ret.push(plugin.model.curse_add_line(
format!("{text:16}"),
Decoration::Default,
false,
false,
true,
));
}
if !plugin.disable_stats.iter().any(|stat| stat == "command") {
let command = container.get("command").and_then(Value::as_str);
let msg = match command {
Some(command) if !command.is_empty() => format!(" {command}"),
_ => " _".to_string(),
};
ret.push(
plugin
.model
.curse_add_line(msg, Decoration::Default, false, false, true),
);
}
}
ret
}
@@ -4,6 +4,7 @@
//! smart) are not ported yet; see the README status section.
pub mod amps;
pub mod containers;
pub mod core;
pub mod cpu;
pub mod diskio;
@@ -31,6 +32,7 @@ pub mod version;
pub mod wifi;
pub use amps::AmpsPlugin;
pub use containers::ContainersPlugin;
pub use core::CorePlugin;
pub use cpu::CpuPlugin;
pub use diskio::DiskioPlugin;
+27 -1
View File
@@ -389,6 +389,32 @@ impl Tui {
}
let mut y = area.y;
// Containers (above the process count, like the Python right sidebar).
if let Some(plugin) = engine.plugins.get_mut("containers")
&& plugin.is_enabled()
{
let ctx = render_ctx(args, &engine.runtime, right_width as usize);
let lines = split_lines(plugin.render(&ctx));
if !lines.is_empty() {
let max_height = (area.y + area.height).saturating_sub(y).saturating_sub(4);
let height = (lines.len() as u16).min(max_height);
if height > 0 {
draw_lines(
frame,
Rect {
x: right_x,
y,
width: right_width,
height,
},
&lines,
args,
);
y += height;
}
}
}
// Process count.
if let Some(plugin) = engine.plugins.get_mut("processcount")
&& plugin.is_enabled()
@@ -916,7 +942,7 @@ mod tests {
"--disable-plugin",
"all",
"--enable-plugin",
"cpu,mem,load,processlist,processcount,network,fs,diskio,now,system,uptime,quicklook",
"cpu,mem,load,processlist,processcount,containers,network,fs,diskio,now,system,uptime,quicklook",
]);
args.time = Some(1.0);
let config = Config::empty(false);
+5 -2
View File
@@ -159,12 +159,15 @@ def main():
plugins = args.plugins or DEFAULT_PLUGINS
errors = []
for plugin in plugins:
# The containers plugin needs a couple of refreshes for its rates to
# become available (both implementations warm up their second sample).
iterations = max(args.iterations, 6) if plugin == "containers" else args.iterations
extra = ["-C", args.config] if args.config else []
rust, rust_error = run(args.rust, [plugin], args.iterations, extra)
rust, rust_error = run(args.rust, [plugin], iterations, extra)
if rust_error is not None:
errors.append(f"{plugin}: Rust run failed: {rust_error}")
continue
python, python_error = run_python(args.python, args.config, [plugin], args.iterations, [])
python, python_error = run_python(args.python, args.config, [plugin], iterations, [])
if python_error is not None:
errors.append(f"{plugin}: Python run failed: {python_error}")
continue