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 = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"clap",
|
||||||
"glances-core",
|
"glances-core",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ exporters.
|
|||||||
| Terminal UI (ratatui) | done |
|
| Terminal UI (ratatui) | done |
|
||||||
| stdout / CSV / JSON / fetch / issue modes | 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 |
|
| 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 |
|
| Extended plugins | folders, amps, ports, raid, wifi, ip, containers (Docker) |
|
||||||
| Not ported yet | containers, vms, gpu/npu/mpp, connections, smart, cloud |
|
| 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 |
|
| Python-specific flags | `--trace-malloc`, `--api-doc`, `--api-restful-doc` rejected with a clear error |
|
||||||
|
|
||||||
## Build and run
|
## Build and run
|
||||||
@@ -53,6 +53,12 @@ $ python3 tools/parity.py # all implemented plugins
|
|||||||
$ python3 tools/parity.py cpu mem # selected 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
|
## Workspace layout
|
||||||
|
|
||||||
| Crate | Purpose |
|
| Crate | Purpose |
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ pub fn render_stdout_json(engine: &Engine, plugins: &[String]) -> String {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if plugin.is_enabled() {
|
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())
|
serde_json::to_string(&Value::Object(result)).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
|||||||
@@ -115,6 +115,64 @@ pub fn bar_custom(
|
|||||||
bar
|
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`).
|
/// 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> {
|
pub fn string_value_to_float(s: &str) -> Option<f64> {
|
||||||
let cleaned: String = s.chars().filter(|c| *c != ' ').collect();
|
let cleaned: String = s.chars().filter(|c| *c != ' ').collect();
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ sysinfo = "0.39.6"
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
ureq = { version = "3.4.2", features = ["json"] }
|
ureq = { version = "3.4.2", features = ["json"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
clap = { workspace = true, features = ["derive"] }
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ impl Engine {
|
|||||||
"amps".to_string(),
|
"amps".to_string(),
|
||||||
Box::new(AmpsPlugin::new(&config, &args)),
|
Box::new(AmpsPlugin::new(&config, &args)),
|
||||||
);
|
);
|
||||||
|
plugins.insert(
|
||||||
|
"containers".to_string(),
|
||||||
|
Box::new(ContainersPlugin::new(&config, &args)),
|
||||||
|
);
|
||||||
plugins.insert(
|
plugins.insert(
|
||||||
"core".to_string(),
|
"core".to_string(),
|
||||||
Box::new(CorePlugin::new(&config, &args)),
|
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.
|
//! smart) are not ported yet; see the README status section.
|
||||||
|
|
||||||
pub mod amps;
|
pub mod amps;
|
||||||
|
pub mod containers;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod cpu;
|
pub mod cpu;
|
||||||
pub mod diskio;
|
pub mod diskio;
|
||||||
@@ -31,6 +32,7 @@ pub mod version;
|
|||||||
pub mod wifi;
|
pub mod wifi;
|
||||||
|
|
||||||
pub use amps::AmpsPlugin;
|
pub use amps::AmpsPlugin;
|
||||||
|
pub use containers::ContainersPlugin;
|
||||||
pub use core::CorePlugin;
|
pub use core::CorePlugin;
|
||||||
pub use cpu::CpuPlugin;
|
pub use cpu::CpuPlugin;
|
||||||
pub use diskio::DiskioPlugin;
|
pub use diskio::DiskioPlugin;
|
||||||
|
|||||||
@@ -389,6 +389,32 @@ impl Tui {
|
|||||||
}
|
}
|
||||||
let mut y = area.y;
|
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.
|
// Process count.
|
||||||
if let Some(plugin) = engine.plugins.get_mut("processcount")
|
if let Some(plugin) = engine.plugins.get_mut("processcount")
|
||||||
&& plugin.is_enabled()
|
&& plugin.is_enabled()
|
||||||
@@ -916,7 +942,7 @@ mod tests {
|
|||||||
"--disable-plugin",
|
"--disable-plugin",
|
||||||
"all",
|
"all",
|
||||||
"--enable-plugin",
|
"--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);
|
args.time = Some(1.0);
|
||||||
let config = Config::empty(false);
|
let config = Config::empty(false);
|
||||||
|
|||||||
+5
-2
@@ -159,12 +159,15 @@ def main():
|
|||||||
plugins = args.plugins or DEFAULT_PLUGINS
|
plugins = args.plugins or DEFAULT_PLUGINS
|
||||||
errors = []
|
errors = []
|
||||||
for plugin in plugins:
|
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 []
|
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:
|
if rust_error is not None:
|
||||||
errors.append(f"{plugin}: Rust run failed: {rust_error}")
|
errors.append(f"{plugin}: Rust run failed: {rust_error}")
|
||||||
continue
|
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:
|
if python_error is not None:
|
||||||
errors.append(f"{plugin}: Python run failed: {python_error}")
|
errors.append(f"{plugin}: Python run failed: {python_error}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user