M1: core engine, config, plugin model and core plugins

- Cargo workspace (core, plugins, tui, cli) with drop-in compatible CLI
- Python-compatible config file handling, limits, views, rates, MMM, history
- process collection via sysinfo + procfs, filters, thresholds, events, actions
- 20 core plugins (cpu, percpu, mem, memswap, load, fs, network, diskio, ...)
- stdout, stdout-csv, stdout-json, --issue, --modules-list output modes
- differential test harness against the Python implementation
This commit is contained in:
glances-rs
2026-10-09 23:25:35 +00:00
commit 975385da5d
61 changed files with 12049 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "glances-cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "glances"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap = { workspace = true, features = ["derive", "wrap_help"] }
clap_complete.workspace = true
ctrlc = "3.5.2"
glances-core = { version = "0.1.0", path = "../glances-core" }
glances-plugins = { version = "0.1.0", path = "../glances-plugins" }
glances-tui = { version = "0.1.0", path = "../glances-tui" }
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+43
View File
@@ -0,0 +1,43 @@
//! Logging setup (port of `glances.logger`).
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
/// Return the Glances log file path.
pub fn log_filename() -> PathBuf {
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".local").join("share")));
if let Some(base) = base {
if base.is_dir() {
let dir = base.join("glances");
let _ = std::fs::create_dir_all(&dir);
return dir.join("glances.log");
}
}
std::env::temp_dir().join(format!("glances-{}.log", std::env::var("USER").unwrap_or_else(|_| "user".into())))
}
/// Initialise the logger: everything goes to the log file, warnings and
/// errors also go to stderr.
pub fn init(debug: bool) {
let log_file = log_filename();
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
.ok();
let level = if debug { "debug" } else { "info" };
let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
if let Some(file) = file {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(file)
.with_ansi(false)
.with_target(true)
.try_init()
.ok();
}
}
+191
View File
@@ -0,0 +1,191 @@
//! The `glances` binary.
mod logging;
mod outputs;
mod tui_placeholder;
use anyhow::Result;
use clap::{CommandFactory, Parser};
use glances_core::config::Config;
use glances_core::Args;
use glances_plugins::engine::Engine;
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
static RUNNING: AtomicBool = AtomicBool::new(true);
fn main() {
let args = Args::parse();
if args.version {
println!("{}", args.version_message(&logging::log_filename().display().to_string()));
return;
}
if let Some(shell) = &args.print_completion {
print_completion(shell);
return;
}
if let Some(feature) = args.unsupported() {
eprintln!("glances: error: {feature} is not supported by this Rust implementation");
std::process::exit(2);
}
logging::init(args.debug);
if let Err(err) = run(args) {
tracing::error!("{err}");
eprintln!("glances: {err}");
std::process::exit(1);
}
}
fn run(mut args: Args) -> Result<()> {
install_signal_handler();
let config = Config::new(args.conf_file.as_deref(), args.disable_config_exec);
// The configuration key can be overwritten from the command line (-t).
if args.time.is_none() {
let refresh = if config.has_section("global") {
config.get_float_value("global", "refresh", 2.0)
} else {
2.0
};
args.time = Some(refresh);
}
// --memory-leak: quiet mode, 60 seconds, 1 second refresh.
if args.memory_leak {
args.quiet = true;
if args.stop_after.is_none() {
args.stop_after = Some(60);
}
args.time = Some(1.0);
args.disable_history = true;
}
let mut engine = Engine::new(args.clone(), config);
if args.modules_list {
display_modules_list(&engine);
return Ok(());
}
if args.stdout_issue {
engine.update();
print!("{}", outputs::render_issue(&engine));
return Ok(());
}
if args.stdout.is_some() || args.stdout_json.is_some() || args.stdout_csv.is_some() {
return run_stdout(&mut engine, &args);
}
if args.stdout_fetch {
return tui_placeholder::run_fetch(&mut engine, &args);
}
if args.quiet {
return run_quiet(&mut engine, &args);
}
tui_placeholder::run(&mut engine, &args)
}
fn display_modules_list(engine: &Engine) {
let mut plugins = engine.get_plugins_list(false);
plugins.sort();
println!("Plugins list: {}", plugins.join(", "));
println!("Exporters list: ");
}
fn print_completion(shell: &str) {
let mut command = Args::command();
match shell {
"bash" => clap_complete::generate(clap_complete::Shell::Bash, &mut command, "glances", &mut std::io::stdout()),
"zsh" => clap_complete::generate(clap_complete::Shell::Zsh, &mut command, "glances", &mut std::io::stdout()),
"fish" => clap_complete::generate(clap_complete::Shell::Fish, &mut command, "glances", &mut std::io::stdout()),
other => {
eprintln!("glances: error: unsupported shell for completion: {other} (use bash, zsh or fish)");
std::process::exit(2);
}
}
}
fn install_signal_handler() {
let _ = ctrlc::set_handler(|| {
RUNNING.store(false, Ordering::SeqCst);
});
}
fn sleep_with_signal(duration: f64) {
let deadline = Instant::now() + Duration::from_secs_f64(duration.max(0.0));
while RUNNING.load(Ordering::SeqCst) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
}
fn run_stdout(engine: &mut Engine, args: &Args) -> Result<()> {
let refresh = args.time.unwrap_or(2.0);
let iterations = args.stop_after;
let mut csv = outputs::CsvRenderer::new();
let stdout_items = args.stdout.as_deref().map(outputs::build_stdout_list);
let json_plugins = args.stdout_json.as_deref().map(|value| {
value.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>()
});
let csv_plugins = args.stdout_csv.as_deref().map(|value| {
value
.split(',')
.map(|p| p.split('.').next().unwrap_or(p).trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
});
let mut count: i64 = 0;
while RUNNING.load(Ordering::SeqCst) {
let start = Instant::now();
engine.update();
let output = if let Some(items) = &stdout_items {
outputs::render_stdout(engine, items)
} else if let Some(plugins) = &json_plugins {
format!("{}\n", outputs::render_stdout_json(engine, plugins))
} else if let Some(plugins) = &csv_plugins {
format!("{}\n", csv.render(engine, plugins))
} else {
String::new()
};
print!("{output}");
std::io::stdout().flush().ok();
count += 1;
if let Some(max) = iterations {
if count >= max {
break;
}
}
let elapsed = start.elapsed().as_secs_f64();
sleep_with_signal(refresh - elapsed);
}
Ok(())
}
fn run_quiet(engine: &mut Engine, args: &Args) -> Result<()> {
let refresh = args.time.unwrap_or(2.0);
let mut count: i64 = 0;
while RUNNING.load(Ordering::SeqCst) {
let start = Instant::now();
engine.update();
count += 1;
if let Some(max) = args.stop_after {
if count >= max {
break;
}
}
let elapsed = start.elapsed().as_secs_f64();
sleep_with_signal(refresh - elapsed);
}
Ok(())
}
+236
View File
@@ -0,0 +1,236 @@
//! Terminal output modes: `--stdout`, `--stdout-csv`, `--stdout-json`.
use glances_plugins::engine::Engine;
use serde_json::{Map, Value, json};
/// One `<plugin>[.<key>].<attribute>` item of the stdout modes.
#[derive(Debug, Clone, PartialEq)]
pub struct StdoutItem {
pub plugin: String,
pub key: Option<String>,
pub attribute: Option<String>,
}
pub fn build_stdout_list(value: &str) -> Vec<StdoutItem> {
value
.split(',')
.map(|item| {
let parts: Vec<&str> = item.split('.').collect();
match parts.len() {
1 => StdoutItem { plugin: parts[0].to_string(), key: None, attribute: None },
2 => StdoutItem {
plugin: parts[0].to_string(),
key: None,
attribute: Some(parts[1].to_string()),
},
_ => StdoutItem {
plugin: parts[0].to_string(),
key: Some(parts[1].to_string()),
attribute: Some(parts[2].to_string()),
},
}
})
.collect()
}
/// `--stdout`: one stat per line.
pub fn render_stdout(engine: &Engine, items: &[StdoutItem]) -> String {
let mut out = String::new();
for item in items {
let Some(plugin) = engine.get_plugin(&item.plugin) else {
continue;
};
if !plugin.is_enabled() {
continue;
}
let stat = plugin.export();
match &item.attribute {
Some(attribute) => match &stat {
Value::Object(map) => {
if let Some(value) = map.get(attribute) {
out.push_str(&format!("{}.{}: {}\n", item.plugin, attribute, glances_core::py_str(value)));
} else {
tracing::error!("Can not display stat {}.{} (KeyError)", item.plugin, attribute);
}
}
Value::Array(list) => {
for entry in list {
let Some(entry) = entry.as_object() else {
continue;
};
let entry_key = entry
.get("key")
.and_then(Value::as_str)
.and_then(|key_field| entry.get(key_field))
.map(glances_core::py_str);
let Some(entry_key) = entry_key else {
continue;
};
if let Some(wanted) = &item.key {
if wanted != &entry_key {
continue;
}
}
if let Some(value) = entry.get(attribute) {
out.push_str(&format!(
"{}.{}.{}: {}\n",
item.plugin,
entry_key,
attribute,
glances_core::py_str(value)
));
}
}
}
_ => {}
},
None => {
out.push_str(&format!("{}: {}\n", item.plugin, glances_core::py_repr(&stat)));
}
}
}
out
}
/// `--stdout-json`: one JSON object per line.
pub fn render_stdout_json(engine: &Engine, plugins: &[String]) -> String {
let mut result = Map::new();
for name in plugins {
let Some(plugin) = engine.get_plugin(name) else {
continue;
};
if plugin.is_enabled() {
result.insert(name.clone(), plugin.export());
}
}
serde_json::to_string(&Value::Object(result)).unwrap_or_else(|_| "{}".to_string())
}
/// `--stdout-csv`: all the stats in one line, with a header.
#[derive(Debug, Default)]
pub struct CsvRenderer {
header: bool,
list_keys: std::collections::HashMap<String, Vec<String>>,
header_field_names: std::collections::HashMap<String, Vec<String>>,
}
impl CsvRenderer {
pub fn new() -> Self {
Self { header: true, ..Default::default() }
}
pub fn render(&mut self, engine: &Engine, plugins: &[String]) -> String {
let mut line = String::new();
for name in plugins {
let Some(plugin) = engine.get_plugin(name) else {
continue;
};
if !plugin.is_enabled() {
continue;
}
let stat = plugin.export();
if self.header {
line.push_str(&self.build_header(name, &stat));
} else {
line.push_str(&self.build_data(name, &stat));
}
}
self.header = false;
line.pop();
line
}
fn build_header(&mut self, plugin: &str, stat: &Value) -> String {
let mut line = String::new();
match stat {
Value::Object(map) => {
for key in map.keys() {
line.push_str(&format!("{plugin}.{key},"));
}
}
Value::Array(list) => {
let mut keys_order = Vec::new();
for entry in list {
let Some(entry) = entry.as_object() else {
continue;
};
let Some(identity_field) = entry.get("key").and_then(Value::as_str) else {
continue;
};
let Some(identity) = entry.get(identity_field) else {
continue;
};
let identity = glances_core::py_str(identity);
keys_order.push(identity.clone());
for key in entry.keys() {
line.push_str(&format!("{plugin}.{identity}.{key},"));
}
}
self.list_keys.insert(plugin.to_string(), keys_order);
if let Some(first) = list.iter().find_map(|entry| entry.as_object()) {
self.header_field_names.insert(plugin.to_string(), first.keys().cloned().collect());
}
}
_ => {
line.push_str(&format!("{plugin},"));
}
}
line
}
fn build_data(&self, plugin: &str, stat: &Value) -> String {
let mut line = String::new();
match stat {
Value::Object(map) => {
for value in map.values() {
line.push_str(&format!("{},", glances_core::py_str(value)));
}
}
Value::Array(list) => {
let mut current = std::collections::HashMap::new();
for entry in list {
let Some(entry) = entry.as_object() else {
continue;
};
let Some(identity_field) = entry.get("key").and_then(Value::as_str) else {
continue;
};
let Some(identity) = entry.get(identity_field) else {
continue;
};
current.insert(glances_core::py_str(identity), entry.clone());
}
let empty = Map::new();
for identity in self.list_keys.get(plugin).map(|k| k.as_slice()).unwrap_or(&[]) {
let entry = current.get(identity).unwrap_or(&empty);
for field in self.header_field_names.get(plugin).map(|f| f.as_slice()).unwrap_or(&[]) {
let value = entry.get(field.as_str());
let text = match value {
Some(value) => glances_core::py_str(value),
None => "N/A".to_string(),
};
line.push_str(&format!("{text},"));
}
}
}
_ => {
line.push_str(&format!("{},", glances_core::py_str(stat)));
}
}
line
}
}
/// `--issue`: dump every plugin stats (used for bug reports).
pub fn render_issue(engine: &Engine) -> String {
let mut out = String::new();
out.push_str(&format!("Glances version: {}\n", crate::VERSION));
for name in engine.get_plugins_list(false) {
let Some(plugin) = engine.get_plugin(&name) else {
continue;
};
let enabled = if plugin.is_enabled() { "enabled" } else { "disabled" };
out.push_str(&format!("{name} ({enabled}): {}\n", glances_core::py_repr(plugin.stats())));
}
out
}
+14
View File
@@ -0,0 +1,14 @@
//! Temporary placeholder for the terminal UI and the fetch mode.
//! The real TUI lives in the `glances-tui` crate (next milestone).
use anyhow::Result;
use glances_core::Args;
use glances_plugins::engine::Engine;
pub fn run(_engine: &mut Engine, _args: &Args) -> Result<()> {
anyhow::bail!("terminal UI is not implemented yet (use --stdout, --stdout-csv or --stdout-json)")
}
pub fn run_fetch(_engine: &mut Engine, _args: &Args) -> Result<()> {
anyhow::bail!("fetch mode is not implemented yet")
}