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:
Generated
+1
@@ -753,6 +753,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"glances-core",
|
||||
"indexmap",
|
||||
"libc",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user