From ea9debf71bcefb3c54ee9c202130f19fb2d71f41 Mon Sep 17 00:00:00 2001 From: glances-rs Date: Sat, 10 Oct 2026 00:24:39 +0000 Subject: [PATCH] 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 --- Cargo.lock | 1 + README.md | 10 +- crates/glances-cli/src/outputs.rs | 4 +- crates/glances-core/src/units.rs | 58 + crates/glances-plugins/Cargo.toml | 3 + crates/glances-plugins/src/engine.rs | 4 + .../src/plugins/containers/docker_api.rs | 212 ++++ .../src/plugins/containers/engine.rs | 1070 +++++++++++++++++ .../src/plugins/containers/mod.rs | 366 ++++++ .../src/plugins/containers/render.rs | 277 +++++ crates/glances-plugins/src/plugins/mod.rs | 2 + crates/glances-tui/src/lib.rs | 28 +- tools/parity.py | 7 +- 13 files changed, 2036 insertions(+), 6 deletions(-) create mode 100644 crates/glances-plugins/src/plugins/containers/docker_api.rs create mode 100644 crates/glances-plugins/src/plugins/containers/engine.rs create mode 100644 crates/glances-plugins/src/plugins/containers/mod.rs create mode 100644 crates/glances-plugins/src/plugins/containers/render.rs diff --git a/Cargo.lock b/Cargo.lock index e1cb995..cc498db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,6 +753,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "clap", "glances-core", "indexmap", "libc", diff --git a/README.md b/README.md index 850fe8e..041f807 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/glances-cli/src/outputs.rs b/crates/glances-cli/src/outputs.rs index 869c137..4fec14a 100644 --- a/crates/glances-cli/src/outputs.rs +++ b/crates/glances-cli/src/outputs.rs @@ -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()) diff --git a/crates/glances-core/src/units.rs b/crates/glances-core/src/units.rs index 0e7fea9..b901808 100644 --- a/crates/glances-core/src/units.rs +++ b/crates/glances-core/src/units.rs @@ -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, + now: chrono::DateTime, +) -> 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 { let cleaned: String = s.chars().filter(|c| *c != ' ').collect(); diff --git a/crates/glances-plugins/Cargo.toml b/crates/glances-plugins/Cargo.toml index 476c285..ee20eb1 100644 --- a/crates/glances-plugins/Cargo.toml +++ b/crates/glances-plugins/Cargo.toml @@ -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"] } diff --git a/crates/glances-plugins/src/engine.rs b/crates/glances-plugins/src/engine.rs index b963ccd..03eab85 100644 --- a/crates/glances-plugins/src/engine.rs +++ b/crates/glances-plugins/src/engine.rs @@ -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)), diff --git a/crates/glances-plugins/src/plugins/containers/docker_api.rs b/crates/glances-plugins/src/plugins/containers/docker_api.rs new file mode 100644 index 0000000..6f389e3 --- /dev/null +++ b/crates/glances-plugins/src/plugins/containers/docker_api.rs @@ -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, + tcp_addr: Option, + api_version: Option, + 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 { + 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 { + 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 { + 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 { + let body = self.request(path)?; + serde_json::from_str(&body).with_context(|| format!("invalid JSON from {path}")) + } + + fn request(&self, path: &str) -> Result { + 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 { + let mut raw = Vec::new(); + stream.read_to_end(&mut raw)?; + parse_http_response(&raw) +} + +fn parse_http_response(raw: &[u8]) -> Result { + 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> { + 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()); + } +} diff --git a/crates/glances-plugins/src/plugins/containers/engine.rs b/crates/glances-plugins/src/plugins/containers/engine.rs new file mode 100644 index 0000000..7a87ce2 --- /dev/null +++ b/crates/glances-plugins/src/plugins/containers/engine.rs @@ -0,0 +1,1070 @@ +//! Container engines (Docker and Podman) and stats computation. + +use super::docker_api::DockerApi; +use glances_core::units::pretty_date; +use serde_json::{Map, Value, json}; +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +const ACTIVE_STATUS: [&str; 3] = ["running", "healthy", "paused"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EngineKind { + Docker, + Podman, +} + +impl EngineKind { + pub fn name(self) -> &'static str { + match self { + EngineKind::Docker => "docker", + EngineKind::Podman => "podman", + } + } +} + +/// Previous sample kept per container to compute rates. +#[derive(Debug, Clone)] +struct Previous { + cpu_total: u64, + cpu_system: u64, + io: Value, + network: Value, + timestamp: Instant, +} + +pub struct ContainerEngine { + pub kind: EngineKind, + api: DockerApi, + previous: HashMap, + image_cache: HashMap, + error_logged: bool, + pods: HashMap, +} + +impl std::fmt::Debug for ContainerEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ContainerEngine") + .field("kind", &self.kind) + .finish() + } +} + +impl ContainerEngine { + /// Docker engine from `DOCKER_HOST` (or the default socket). + pub fn docker() -> Option { + DockerApi::from_env().map(|api| Self::new(EngineKind::Docker, api)) + } + + /// Podman engine from the configured socket. + pub fn podman(socket: &str) -> Option { + DockerApi::connect(socket).map(|api| Self::new(EngineKind::Podman, api)) + } + + fn new(kind: EngineKind, api: DockerApi) -> Self { + Self { + kind, + api, + previous: HashMap::new(), + image_cache: HashMap::new(), + error_logged: false, + pods: HashMap::new(), + } + } + + /// List the containers and return their stats (Python schema). + pub fn update(&mut self, all: bool) -> Vec { + if let Err(err) = self.api.ping() { + if !self.error_logged { + tracing::warn!( + "containers ({}) plugin - Can't connect to engine ({err})", + self.kind.name() + ); + self.error_logged = true; + } + return Vec::new(); + } + + let list = match self.api.get_json(&format!("/containers/json?all={all}")) { + Ok(list) => list, + Err(err) => { + if !self.error_logged { + tracing::error!( + "containers ({}) plugin - Can't get containers list ({err})", + self.kind.name() + ); + self.error_logged = true; + } + return Vec::new(); + } + }; + self.error_logged = false; + + let Some(items) = list.as_array() else { + return Vec::new(); + }; + let current_ids: HashSet = items + .iter() + .filter_map(|item| { + item.get("Id") + .and_then(Value::as_str) + .map(|s| s.to_string()) + }) + .collect(); + self.previous.retain(|id, _| current_ids.contains(id)); + self.image_cache.retain(|id, _| current_ids.contains(id)); + + if self.kind == EngineKind::Podman { + self.refresh_pods(); + } + + let mut result = Vec::new(); + for item in items { + let Some(id) = item.get("Id").and_then(Value::as_str) else { + continue; + }; + let Ok(inspect) = self.api.get_json(&format!("/containers/{id}/json")) else { + continue; + }; + result.push(self.generate_stats(item, &inspect)); + } + result + } + + fn refresh_pods(&mut self) { + // Best effort: the Podman libpod API exposes the pod list. + self.pods.clear(); + for path in ["/libpod/pods/json", "/pods/json"] { + if let Ok(value) = self.api.get_json_unversioned(path) + && let Some(items) = value.as_array() + { + for pod in items { + let id = pod + .get("Id") + .or_else(|| pod.get("ID")) + .and_then(Value::as_str) + .unwrap_or(""); + let name = pod.get("Name").and_then(Value::as_str).unwrap_or(""); + if !id.is_empty() { + self.pods.insert(id.to_string(), name.to_string()); + } + } + return; + } + } + } + + fn image_for(&mut self, item: &Value) -> Value { + let Some(id) = item.get("Id").and_then(Value::as_str) else { + return json!(""); + }; + if let Some(cached) = self.image_cache.get(id) { + return cached.clone(); + } + let image_id = item.get("ImageID").and_then(Value::as_str).unwrap_or(""); + let image = match self.api.get_json(&format!("/images/{image_id}/json")) { + Ok(inspect) => { + let tags: Vec = inspect + .get("RepoTags") + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(|t| t.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if self.kind == EngineKind::Docker { + // Python quirk: the image is a 1-tuple, serialized as an array. + json!([tags.join(",")]) + } else { + json!(tags.join(",")) + } + } + // Python sets an empty string when the image inspect fails. + Err(_) => json!(""), + }; + self.image_cache.insert(id.to_string(), image.clone()); + image + } + + fn generate_stats(&mut self, item: &Value, inspect: &Value) -> Value { + let state = inspect.get("State").cloned().unwrap_or(Value::Null); + let status = if let Some(status) = state.as_str() { + // Podman exposes State as a plain string. + status.to_string() + } else { + state + .get("Health") + .and_then(|health| health.get("Status")) + .and_then(Value::as_str) + .or_else(|| state.get("Status").and_then(Value::as_str)) + .unwrap_or("") + .to_string() + }; + let name = inspect + .get("Name") + .and_then(Value::as_str) + .unwrap_or("") + .trim_start_matches('/') + .to_string(); + let id = item + .get("Id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let created = inspect + .get("Created") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let image = self.image_for(item); + + if self.kind == EngineKind::Podman { + return self.generate_podman_stats(inspect, &name, &id, image); + } + + let command = container_command(inspect); + let mut stats = Map::new(); + stats.insert("key".to_string(), json!("name")); + stats.insert("name".to_string(), json!(name)); + stats.insert("id".to_string(), json!(id)); + stats.insert("status".to_string(), json!(status)); + stats.insert("created".to_string(), json!(created)); + stats.insert("command".to_string(), command); + stats.insert("io".to_string(), json!({})); + stats.insert("cpu".to_string(), json!({})); + stats.insert("memory".to_string(), json!({})); + stats.insert("network".to_string(), json!({})); + stats.insert("io_rx".to_string(), Value::Null); + stats.insert("io_wx".to_string(), Value::Null); + stats.insert("cpu_percent".to_string(), Value::Null); + stats.insert("memory_percent".to_string(), Value::Null); + stats.insert("network_rx".to_string(), Value::Null); + stats.insert("network_tx".to_string(), Value::Null); + stats.insert("ports".to_string(), json!("")); + stats.insert("uptime".to_string(), Value::Null); + stats.insert("image".to_string(), image); + + if !ACTIVE_STATUS.contains(&status.as_str()) { + return Value::Object(stats); + } + + let api_stats = self + .api + .get_json(&format!("/containers/{id}/stats?stream=false")) + .unwrap_or(Value::Null); + let activity = self.docker_activity(&id, &api_stats, inspect); + if let Some(object) = activity.as_object() { + for (key, value) in object { + stats.insert(key.clone(), value.clone()); + } + } + + let cpu = stats.get("cpu").cloned().unwrap_or(json!({})); + let memory = stats.get("memory").cloned().unwrap_or(json!({})); + let io = stats.get("io").cloned().unwrap_or(json!({})); + let network = stats.get("network").cloned().unwrap_or(json!({})); + + stats.insert( + "cpu_percent".to_string(), + cpu.get("total").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "cpu_limit".to_string(), + cpu.get("limit").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "memory_usage".to_string(), + memory.get("usage").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "memory_inactive_file".to_string(), + memory.get("inactive_file").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "memory_limit".to_string(), + memory.get("limit").cloned().unwrap_or(Value::Null), + ); + + if let (Some(ior), Some(iow), Some(elapsed)) = ( + io.get("ior").and_then(Value::as_i64), + io.get("iow").and_then(Value::as_i64), + io.get("time_since_update").and_then(Value::as_i64), + ) { + stats.insert("io_rx".to_string(), json!(ior / elapsed)); + stats.insert("io_wx".to_string(), json!(iow / elapsed)); + } + if let (Some(rx), Some(tx), Some(elapsed)) = ( + network.get("rx").and_then(Value::as_i64), + network.get("tx").and_then(Value::as_i64), + network.get("time_since_update").and_then(Value::as_i64), + ) { + stats.insert("network_rx".to_string(), json!(rx / elapsed)); + stats.insert("network_tx".to_string(), json!(tx / elapsed)); + } + + let started_at = state.get("StartedAt").and_then(Value::as_str).unwrap_or(""); + stats.insert("uptime".to_string(), json!(container_uptime(started_at))); + + let command = container_command(inspect); + let command = match command { + Value::Array(items) => { + let text = items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" "); + json!(glances_core::units::replace_special_chars(&text, ' ')) + } + other => other, + }; + stats.insert("command".to_string(), command); + stats.insert("ports".to_string(), json!(container_ports(inspect))); + + Value::Object(stats) + } + + fn generate_podman_stats( + &mut self, + inspect: &Value, + name: &str, + id: &str, + image: Value, + ) -> Value { + let created = inspect.get("Created").and_then(Value::as_str).unwrap_or(""); + let status = inspect.get("State").and_then(Value::as_str).unwrap_or(""); + let command = inspect.get("Command").cloned().unwrap_or(json!([])); + let command = match &command { + Value::String(text) => json!([text]), + Value::Array(items) => json!(items.clone()), + _ => json!([]), + }; + let mut stats = Map::new(); + stats.insert("key".to_string(), json!("name")); + stats.insert("name".to_string(), json!(name)); + stats.insert("id".to_string(), json!(id)); + stats.insert("image".to_string(), image); + stats.insert("status".to_string(), json!(status)); + stats.insert("created".to_string(), json!(created)); + stats.insert("command".to_string(), command.clone()); + stats.insert("io".to_string(), json!({})); + stats.insert("cpu".to_string(), json!({})); + stats.insert("memory".to_string(), json!({})); + stats.insert("network".to_string(), json!({})); + stats.insert("io_rx".to_string(), Value::Null); + stats.insert("io_wx".to_string(), Value::Null); + stats.insert("cpu_percent".to_string(), Value::Null); + stats.insert("memory_percent".to_string(), Value::Null); + stats.insert("network_rx".to_string(), Value::Null); + stats.insert("network_tx".to_string(), Value::Null); + stats.insert("ports".to_string(), json!("")); + stats.insert("uptime".to_string(), Value::Null); + + let api_stats = self + .api + .get_json(&format!("/containers/{id}/stats?stream=false")) + .unwrap_or(Value::Null); + let activity = self.podman_activity(id, &api_stats, inspect); + if let Some(object) = activity.as_object() { + for (key, value) in object { + stats.insert(key.clone(), value.clone()); + } + } + + let cpu = stats.get("cpu").cloned().unwrap_or(json!({})); + let memory = stats.get("memory").cloned().unwrap_or(json!({})); + let io = stats.get("io").cloned().unwrap_or(json!({})); + let network = stats.get("network").cloned().unwrap_or(json!({})); + + stats.insert( + "cpu_percent".to_string(), + cpu.get("total").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "cpu_limit".to_string(), + cpu.get("limit").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "memory_usage".to_string(), + memory.get("usage").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "memory_inactive_file".to_string(), + memory.get("inactive_file").cloned().unwrap_or(Value::Null), + ); + stats.insert( + "memory_limit".to_string(), + memory.get("limit").cloned().unwrap_or(Value::Null), + ); + + if let (Some(ior), Some(iow), Some(elapsed)) = ( + io.get("ior").and_then(Value::as_i64), + io.get("iow").and_then(Value::as_i64), + io.get("time_since_update").and_then(Value::as_i64), + ) { + stats.insert("io_rx".to_string(), json!(ior / elapsed)); + stats.insert("io_wx".to_string(), json!(iow / elapsed)); + } + if let (Some(rx), Some(tx), Some(elapsed)) = ( + network.get("rx").and_then(Value::as_i64), + network.get("tx").and_then(Value::as_i64), + network.get("time_since_update").and_then(Value::as_i64), + ) { + stats.insert("network_rx".to_string(), json!(rx / elapsed)); + stats.insert("network_tx".to_string(), json!(tx / elapsed)); + } + + let started_at = inspect + .get("StartedAt") + .map(podman_started_at) + .unwrap_or_default(); + stats.insert("uptime".to_string(), json!(container_uptime(&started_at))); + + let command = match &command { + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" "), + _ => String::new(), + }; + stats.insert( + "command".to_string(), + json!(glances_core::units::replace_special_chars(&command, ' ')), + ); + stats.insert("ports".to_string(), json!(container_ports(inspect))); + + // Pod association (best effort). + if let Some(pod_id) = inspect + .get("Pod") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()) + { + stats.insert( + "pod_name".to_string(), + json!(self.pods.get(pod_id).cloned().unwrap_or_default()), + ); + stats.insert("pod_id".to_string(), json!(pod_id)); + } + + Value::Object(stats) + } + + fn docker_activity(&mut self, id: &str, api_stats: &Value, inspect: &Value) -> Value { + let previous = self.previous.get(id).cloned(); + let elapsed = previous + .as_ref() + .map(|p| p.timestamp.elapsed().as_secs_f64().max(1.0).round() as i64) + .unwrap_or(1); + + let cpu_stats = api_stats.get("cpu_stats").cloned().unwrap_or(Value::Null); + let cpu_total = cpu_stats + .get("cpu_usage") + .and_then(|usage| usage.get("total_usage")) + .and_then(Value::as_u64); + let cpu_system = cpu_stats.get("system_cpu_usage").and_then(Value::as_u64); + let nb_core = cpu_stats + .get("online_cpus") + .and_then(Value::as_u64) + .or_else(|| { + cpu_stats + .get("cpu_usage") + .and_then(|usage| usage.get("percpu_usage")) + .and_then(Value::as_array) + .map(|cores| cores.len() as u64) + }) + .unwrap_or(0); + + let cpu = match (cpu_total, cpu_system, previous.as_ref()) { + (Some(total), Some(system), Some(previous)) + if previous.cpu_total > 0 && previous.cpu_system > 0 => + { + let cpu_delta = total.saturating_sub(previous.cpu_total); + let system_delta = system.saturating_sub(previous.cpu_system); + let total_percent = if system_delta > 0 { + (cpu_delta as f64 / system_delta as f64) * nb_core as f64 * 100.0 + } else { + 0.0 + }; + let mut cpu = Map::new(); + cpu.insert("total".to_string(), json!(total_percent)); + cpu.insert("limit".to_string(), json!(cpu_limit(inspect, nb_core))); + Value::Object(cpu) + } + _ => json!({"total": 0.0}), + }; + + // Memory. + let memory_stats = api_stats + .get("memory_stats") + .cloned() + .unwrap_or(Value::Null); + let mut memory = Map::new(); + if let (Some(usage), Some(limit)) = ( + memory_stats.get("usage").and_then(Value::as_u64), + memory_stats.get("limit").and_then(Value::as_u64), + ) { + memory.insert("usage".to_string(), json!(usage)); + memory.insert("limit".to_string(), json!(limit)); + if let Some(inactive_file) = memory_stats + .get("stats") + .and_then(|stats| stats.get("inactive_file")) + .and_then(Value::as_u64) + { + memory.insert("inactive_file".to_string(), json!(inactive_file)); + } + } + let memory = Value::Object(memory); + + // Block IO. + let io = docker_io(api_stats, previous.as_ref(), elapsed); + // Network. + let network = docker_network(api_stats, previous.as_ref(), elapsed); + + self.previous.insert( + id.to_string(), + Previous { + cpu_total: cpu_total.unwrap_or(0), + cpu_system: cpu_system.unwrap_or(0), + io: io.clone(), + network: network.clone(), + timestamp: Instant::now(), + }, + ); + + json!({"io": io, "memory": memory, "network": network, "cpu": cpu}) + } + + fn podman_activity(&mut self, id: &str, api_stats: &Value, inspect: &Value) -> Value { + // Podman may return the stats inside a "Stats" array. + let api_stats = api_stats + .get("Stats") + .and_then(Value::as_array) + .and_then(|stats| stats.first()) + .unwrap_or(api_stats); + let previous = self.previous.get(id).cloned(); + let elapsed = previous + .as_ref() + .map(|p| p.timestamp.elapsed().as_secs_f64().max(1.0).round() as i64) + .unwrap_or(1); + + let mut cpu = Map::new(); + if let Some(total) = api_stats.get("CPU").and_then(Value::as_f64) { + cpu.insert("total".to_string(), json!(total)); + let nb_core = crate::collectors::logical_core_count() as u64; + cpu.insert( + "limit".to_string(), + json!(podman_cpu_limit(inspect, nb_core)), + ); + } + + let mut memory = Map::new(); + if let (Some(usage), Some(limit)) = ( + api_stats.get("MemUsage").and_then(Value::as_u64), + api_stats.get("MemLimit").and_then(Value::as_u64), + ) { + memory.insert("usage".to_string(), json!(usage)); + memory.insert("limit".to_string(), json!(limit)); + } + + let mut io = Map::new(); + if let (Some(ior), Some(iow)) = ( + api_stats.get("BlockInput").and_then(Value::as_i64), + api_stats.get("BlockOutput").and_then(Value::as_i64), + ) { + io.insert("ior".to_string(), json!(ior)); + io.insert("iow".to_string(), json!(iow)); + io.insert("time_since_update".to_string(), json!(1)); + } + + let mut network = Map::new(); + if api_stats.get("Network").is_none() { + if let (Some(rx), Some(tx)) = ( + api_stats.get("NetInput").and_then(Value::as_i64), + api_stats.get("NetOutput").and_then(Value::as_i64), + ) { + network.insert("rx".to_string(), json!(rx)); + network.insert("tx".to_string(), json!(tx)); + network.insert("time_since_update".to_string(), json!(1)); + } + } else if let Some(interfaces) = api_stats.get("Network").and_then(Value::as_object) { + let cumulative_rx: u64 = interfaces + .values() + .filter_map(|interface| interface.get("RxBytes").and_then(Value::as_u64)) + .sum(); + let cumulative_tx: u64 = interfaces + .values() + .filter_map(|interface| interface.get("TxBytes").and_then(Value::as_u64)) + .sum(); + network.insert("cumulative_rx".to_string(), json!(cumulative_rx)); + network.insert("cumulative_tx".to_string(), json!(cumulative_tx)); + if let Some(previous) = previous.as_ref() { + let old_rx = previous + .network + .get("cumulative_rx") + .and_then(Value::as_u64) + .unwrap_or(0); + let old_tx = previous + .network + .get("cumulative_tx") + .and_then(Value::as_u64) + .unwrap_or(0); + network.insert("time_since_update".to_string(), json!(elapsed)); + network.insert( + "rx".to_string(), + json!(cumulative_rx.saturating_sub(old_rx)), + ); + network.insert( + "tx".to_string(), + json!(cumulative_tx.saturating_sub(old_tx)), + ); + } + } + + self.previous.insert( + id.to_string(), + Previous { + cpu_total: 0, + cpu_system: 0, + io: Value::Object(io.clone()), + network: Value::Object(network.clone()), + timestamp: Instant::now(), + }, + ); + + json!({"io": io, "memory": memory, "network": network, "cpu": cpu}) + } +} + +/// Command list (Entrypoint + Cmd) from the inspect payload. +fn container_command(inspect: &Value) -> Value { + let mut command: Vec = Vec::new(); + if let Some(entrypoint) = inspect + .get("Config") + .and_then(|config| config.get("Entrypoint")) + .and_then(Value::as_array) + { + command.extend( + entrypoint + .iter() + .filter_map(Value::as_str) + .map(|s| s.to_string()), + ); + } + if let Some(cmd) = inspect + .get("Config") + .and_then(|config| config.get("Cmd")) + .and_then(Value::as_array) + { + command.extend(cmd.iter().filter_map(Value::as_str).map(|s| s.to_string())); + } + if command.is_empty() { + Value::Null + } else { + json!(command) + } +} + +/// Ports string from `NetworkSettings.Ports` (e.g. `8080->80/tcp`). +fn container_ports(inspect: &Value) -> String { + let Some(ports) = inspect + .get("NetworkSettings") + .and_then(|settings| settings.get("Ports")) + else { + return String::new(); + }; + let Some(ports) = ports.as_object() else { + return String::new(); + }; + let mut result = Vec::new(); + for (container_port, bindings) in ports { + match bindings.as_array().and_then(|bindings| bindings.first()) { + Some(binding) => { + let host_port = binding + .get("HostPort") + .and_then(Value::as_str) + .unwrap_or(""); + result.push(format!("{host_port}->{container_port}")); + } + None => result.push(container_port.clone()), + } + } + result.join(",") +} + +/// CPU limit from the inspect HostConfig (Docker semantics). +fn cpu_limit(inspect: &Value, nb_core: u64) -> f64 { + let host_config = inspect.get("HostConfig").cloned().unwrap_or(Value::Null); + let nano_cpus = host_config + .get("NanoCpus") + .and_then(Value::as_i64) + .unwrap_or(0); + if nano_cpus > 0 { + return nano_cpus as f64 / 1e9; + } + let cpu_quota = host_config + .get("CpuQuota") + .and_then(Value::as_i64) + .unwrap_or(0); + let cpu_period = host_config + .get("CpuPeriod") + .and_then(Value::as_i64) + .unwrap_or(100_000); + if cpu_quota > 0 && cpu_period > 0 { + return cpu_quota as f64 / cpu_period as f64; + } + nb_core as f64 +} + +fn podman_cpu_limit(inspect: &Value, nb_core: u64) -> f64 { + let host_config = inspect.get("HostConfig").cloned().unwrap_or(Value::Null); + let nano_cpus = host_config + .get("NanoCpus") + .and_then(Value::as_i64) + .unwrap_or(0); + if nano_cpus > 0 { + return nano_cpus as f64 / 1e9; + } + let cpu_quota = host_config + .get("CpuQuota") + .and_then(Value::as_i64) + .unwrap_or(0); + let cpu_period = host_config + .get("CpuPeriod") + .and_then(Value::as_i64) + .unwrap_or(0); + if cpu_quota > 0 && cpu_period > 0 { + return cpu_quota as f64 / cpu_period as f64; + } + nb_core as f64 +} + +fn docker_io(api_stats: &Value, previous: Option<&Previous>, elapsed: i64) -> Value { + let entries = api_stats + .get("blkio_stats") + .and_then(|blkio| blkio.get("io_service_bytes_recursive")) + .and_then(Value::as_array); + let Some(entries) = entries else { + return json!({}); + }; + let value_for = |operation: &str| -> Option { + entries + .iter() + .find(|entry| { + entry + .get("op") + .and_then(Value::as_str) + .map(|op| op.eq_ignore_ascii_case(operation)) + .unwrap_or(false) + }) + .and_then(|entry| entry.get("value")) + .and_then(Value::as_u64) + }; + let (Some(cumulative_ior), Some(cumulative_iow)) = (value_for("read"), value_for("write")) + else { + return json!({}); + }; + let mut io = Map::new(); + io.insert("cumulative_ior".to_string(), json!(cumulative_ior)); + io.insert("cumulative_iow".to_string(), json!(cumulative_iow)); + if let Some(previous) = previous { + let old_ior = previous.io.get("cumulative_ior").and_then(Value::as_u64); + let old_iow = previous.io.get("cumulative_iow").and_then(Value::as_u64); + if let (Some(old_ior), Some(old_iow)) = (old_ior, old_iow) { + io.insert("time_since_update".to_string(), json!(elapsed)); + io.insert( + "ior".to_string(), + json!(cumulative_ior.saturating_sub(old_ior) as i64), + ); + io.insert( + "iow".to_string(), + json!(cumulative_iow.saturating_sub(old_iow) as i64), + ); + } + } + Value::Object(io) +} + +fn docker_network(api_stats: &Value, previous: Option<&Previous>, elapsed: i64) -> Value { + let interfaces = api_stats.get("networks").and_then(Value::as_object); + let Some(interfaces) = interfaces else { + return json!({}); + }; + let valid: Vec<&Value> = interfaces + .iter() + .filter(|(name, stats)| { + *name != "lo" && stats.get("rx_bytes").is_some() && stats.get("tx_bytes").is_some() + }) + .map(|(_, stats)| stats) + .collect(); + if valid.is_empty() { + return json!({}); + } + let cumulative_rx: u64 = valid + .iter() + .filter_map(|stats| stats.get("rx_bytes").and_then(Value::as_u64)) + .sum(); + let cumulative_tx: u64 = valid + .iter() + .filter_map(|stats| stats.get("tx_bytes").and_then(Value::as_u64)) + .sum(); + let mut network = Map::new(); + network.insert("cumulative_rx".to_string(), json!(cumulative_rx)); + network.insert("cumulative_tx".to_string(), json!(cumulative_tx)); + if let Some(previous) = previous { + let old_rx = previous + .network + .get("cumulative_rx") + .and_then(Value::as_u64); + let old_tx = previous + .network + .get("cumulative_tx") + .and_then(Value::as_u64); + if let (Some(old_rx), Some(old_tx)) = (old_rx, old_tx) { + network.insert("time_since_update".to_string(), json!(elapsed)); + network.insert( + "rx".to_string(), + json!(cumulative_rx.saturating_sub(old_rx) as i64), + ); + network.insert( + "tx".to_string(), + json!(cumulative_tx.saturating_sub(old_tx) as i64), + ); + } + } + Value::Object(network) +} + +/// Compute the container uptime string from an RFC3339 start time. +fn container_uptime(started_at: &str) -> String { + if started_at.is_empty() { + return String::new(); + } + match chrono::DateTime::parse_from_rfc3339(started_at) { + Ok(started) => { + let started = started.with_timezone(&chrono::Local); + pretty_date(started, chrono::Local::now()) + } + Err(_) => String::new(), + } +} + +fn podman_started_at(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Number(number) => number + .as_i64() + .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + + fn http_ok(body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + } + + fn stats_sample( + cpu_total: u64, + system: u64, + read_bytes: u64, + write_bytes: u64, + rx: u64, + tx: u64, + ) -> String { + serde_json::json!({ + "read": "2026-10-10T00:00:00Z", + "cpu_stats": { + "cpu_usage": {"total_usage": cpu_total, "percpu_usage": [1, 2, 3, 4]}, + "system_cpu_usage": system, + "online_cpus": 4 + }, + "precpu_stats": {}, + "memory_stats": {"usage": 1048576, "limit": 2097152, "stats": {"inactive_file": 4096}}, + "blkio_stats": {"io_service_bytes_recursive": [ + {"op": "Read", "value": read_bytes}, + {"op": "Write", "value": write_bytes} + ]}, + "networks": { + "eth0": {"rx_bytes": rx, "tx_bytes": tx}, + "lo": {"rx_bytes": 10, "tx_bytes": 10} + } + }) + .to_string() + } + + fn inspect(name: &str, status: &str) -> String { + serde_json::json!({ + "Name": name, + "Created": "2026-10-10T00:00:00.000000000Z", + "State": {"Status": status, "StartedAt": "2026-10-10T00:00:00Z"}, + "Config": {"Entrypoint": null, "Cmd": ["sleep", "300"]}, + "HostConfig": {"NanoCpus": 0, "CpuQuota": 0, "CpuPeriod": 100000}, + "NetworkSettings": {"Ports": {"80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}]}} + }) + .to_string() + } + + fn list() -> String { + serde_json::json!([ + { + "Id": "abc123", + "ImageID": "sha256:image1", + "Image": "alpine:3.24", + "Names": ["/test-container"], + "State": "running", + "Status": "Up 5 minutes" + }, + { + "Id": "def456", + "ImageID": "sha256:image1", + "Image": "alpine:3.24", + "Names": ["/stopped-container"], + "State": "exited", + "Status": "Exited (0) 2 weeks ago" + } + ]) + .to_string() + } + + /// Serve canned Docker API responses on a temporary Unix socket. + fn spawn_fake_docker(socket: &std::path::Path) { + let listener = UnixListener::bind(socket).unwrap(); + std::thread::spawn(move || { + let mut stats_calls = 0; + for _ in 0..20 { + let Ok((mut stream, _)) = listener.accept() else { + break; + }; + let mut buffer = [0u8; 8192]; + let Ok(size) = stream.read(&mut buffer) else { + continue; + }; + let request = String::from_utf8_lossy(&buffer[..size]).to_string(); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("") + .to_string(); + let body = if path.contains("/version") { + serde_json::json!({"ApiVersion": "1.41"}).to_string() + } else if path.contains("/containers/json") { + list() + } else if path.contains("/containers/abc123/json") { + inspect("/test-container", "running") + } else if path.contains("/containers/def456/json") { + inspect("/stopped-container", "exited") + } else if path.contains("/containers/abc123/stats") { + stats_calls += 1; + if stats_calls == 1 { + stats_sample(200_000_000, 1_000_000_000, 1000, 2000, 100, 50) + } else { + stats_sample(300_000_000, 2_000_000_000, 1500, 2600, 160, 90) + } + } else if path.contains("/images/") { + serde_json::json!({"RepoTags": ["alpine:3.24"]}).to_string() + } else { + "{}".to_string() + }; + let _ = stream.write_all(http_ok(&body).as_bytes()); + } + }); + } + + fn fake_engine(socket: &std::path::Path) -> ContainerEngine { + let api = DockerApi::connect(&format!("unix://{}", socket.display())).unwrap(); + ContainerEngine::new(EngineKind::Docker, api) + } + + #[test] + fn test_docker_engine_schema_and_rates() { + let dir = std::env::temp_dir().join(format!("glances-docker-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let socket = dir.join("docker.sock"); + let _ = std::fs::remove_file(&socket); + spawn_fake_docker(&socket); + let mut engine = fake_engine(&socket); + + // First update: cumulative counters only. + let first = engine.update(false); + assert_eq!(first.len(), 2); + let active = &first[0]; + let keys: Vec<&String> = active.as_object().unwrap().keys().collect(); + assert_eq!( + keys, + vec![ + "key", + "name", + "id", + "status", + "created", + "command", + "io", + "cpu", + "memory", + "network", + "io_rx", + "io_wx", + "cpu_percent", + "memory_percent", + "network_rx", + "network_tx", + "ports", + "uptime", + "image", + "cpu_limit", + "memory_usage", + "memory_inactive_file", + "memory_limit", + ] + ); + assert_eq!(active["name"], "test-container"); + assert_eq!(active["image"], serde_json::json!(["alpine:3.24"])); + assert_eq!(active["command"], "sleep 300"); + assert_eq!(active["ports"], "8080->80/tcp"); + assert_eq!(active["cpu"]["total"], 0.0); + assert!(active["io"].get("ior").is_none()); + assert!(active["network"].get("rx").is_none()); + assert_eq!(active["io_rx"], serde_json::Value::Null); + assert_eq!(active["memory_usage"], 1048576); + assert_eq!(active["memory_inactive_file"], 4096); + assert_eq!(active["memory_limit"], 2097152); + + // Stopped container: early return schema. + let stopped = &first[1]; + assert_eq!(stopped["name"], "stopped-container"); + assert!(stopped["command"].is_array()); + assert_eq!(stopped["io"], serde_json::json!({})); + assert_eq!(stopped["cpu_percent"], serde_json::Value::Null); + assert_eq!(stopped["uptime"], serde_json::Value::Null); + assert!(stopped.as_object().unwrap().get("cpu_limit").is_none()); + + // Second update: rates and CPU are computed. + let second = engine.update(false); + let active = &second[0]; + assert_eq!(active["cpu"]["total"], 40.0); + assert_eq!(active["cpu"]["limit"], 4.0); + assert_eq!(active["io"]["ior"], 500); + assert_eq!(active["io"]["iow"], 600); + assert_eq!(active["io_rx"], 500); + assert_eq!(active["io_wx"], 600); + assert_eq!(active["network_rx"], 60); + assert_eq!(active["network_tx"], 40); + assert!(!active["uptime"].as_str().unwrap().is_empty()); + + let _ = std::fs::remove_file(&socket); + let _ = std::fs::remove_dir(&dir); + } +} diff --git a/crates/glances-plugins/src/plugins/containers/mod.rs b/crates/glances-plugins/src/plugins/containers/mod.rs new file mode 100644 index 0000000..0f33ac3 --- /dev/null +++ b/crates/glances-plugins/src/plugins/containers/mod.rs @@ -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, + pub sort_key: String, + pub disable_stats: Vec, + 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 { + let all = self + .model + .get_conf_value("all", "") + .first() + .map(|value| value.to_lowercase() == "true") + .unwrap_or(false); + + let mut stats: Vec = 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, Option, Option, Option)> = + 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 { + 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"]); + } +} diff --git a/crates/glances-plugins/src/plugins/containers/render.rs b/crates/glances-plugins/src/plugins/containers/render.rs new file mode 100644 index 0000000..d56f45d --- /dev/null +++ b/crates/glances-plugins/src/plugins/containers/render.rs @@ -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, decoration: Decoration) -> StatLine { + plugin + .model + .curse_add_line(msg, decoration, false, false, false) +} + +fn add(ret: &mut Vec, plugin: &ContainersPlugin, msg: String) { + ret.push(line(plugin, msg, Decoration::Default)); +} + +pub fn render(plugin: &mut ContainersPlugin, ctx: &RenderContext) -> Vec { + 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!(" {: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: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 +} diff --git a/crates/glances-plugins/src/plugins/mod.rs b/crates/glances-plugins/src/plugins/mod.rs index d6a4010..a3b1788 100644 --- a/crates/glances-plugins/src/plugins/mod.rs +++ b/crates/glances-plugins/src/plugins/mod.rs @@ -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; diff --git a/crates/glances-tui/src/lib.rs b/crates/glances-tui/src/lib.rs index ebe834f..c453936 100644 --- a/crates/glances-tui/src/lib.rs +++ b/crates/glances-tui/src/lib.rs @@ -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); diff --git a/tools/parity.py b/tools/parity.py index 3d54e89..0fd6a15 100644 --- a/tools/parity.py +++ b/tools/parity.py @@ -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