From 8385900ac168ce9e53d95472cf935388f14d2360 Mon Sep 17 00:00:00 2001 From: glances-rs Date: Fri, 9 Oct 2026 23:35:23 +0000 Subject: [PATCH] M2: terminal UI (ratatui) - layout: header, top menu (quicklook, cpu, mem, swap, load), left sidebar, process list and alert footer - decorations/colors, bars, per-plugin render implementations - hotkeys: quit, help, modes, sorting, cursor, nice, kill, filters, toggles - TestBackend layout test --- Cargo.lock | 2 + crates/glances-cli/src/main.rs | 9 +- crates/glances-cli/src/outputs.rs | 26 +- crates/glances-cli/src/tui_placeholder.rs | 4 - crates/glances-core/src/plugin.rs | 123 ++- crates/glances-core/src/state.rs | 11 + crates/glances-core/src/units.rs | 29 + crates/glances-plugins/src/collectors.rs | 34 +- crates/glances-plugins/src/engine.rs | 11 + crates/glances-plugins/src/plugins/cpu.rs | 74 ++ crates/glances-plugins/src/plugins/diskio.rs | 73 ++ crates/glances-plugins/src/plugins/fs.rs | 71 ++ crates/glances-plugins/src/plugins/irq.rs | 33 + crates/glances-plugins/src/plugins/load.rs | 44 + crates/glances-plugins/src/plugins/mem.rs | 38 + crates/glances-plugins/src/plugins/memswap.rs | 24 + crates/glances-plugins/src/plugins/network.rs | 111 +++ crates/glances-plugins/src/plugins/now.rs | 20 +- crates/glances-plugins/src/plugins/percpu.rs | 71 +- .../src/plugins/processcount.rs | 54 ++ .../src/plugins/processlist.rs | 284 ++++++- .../glances-plugins/src/plugins/quicklook.rs | 104 +++ crates/glances-plugins/src/plugins/sensors.rs | 49 ++ crates/glances-plugins/src/plugins/system.rs | 13 + crates/glances-plugins/src/plugins/uptime.rs | 13 + crates/glances-tui/Cargo.toml | 4 + crates/glances-tui/src/lib.rs | 755 +++++++++++++++++- 27 files changed, 2053 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dae6963..97deebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,6 +669,8 @@ dependencies = [ name = "glances-tui" version = "0.1.0" dependencies = [ + "anyhow", + "clap", "crossterm", "glances-core", "glances-plugins", diff --git a/crates/glances-cli/src/main.rs b/crates/glances-cli/src/main.rs index 0daf86d..a57fb98 100644 --- a/crates/glances-cli/src/main.rs +++ b/crates/glances-cli/src/main.rs @@ -92,7 +92,7 @@ fn run(mut args: Args) -> Result<()> { return run_quiet(&mut engine, &args); } - tui_placeholder::run(&mut engine, &args) + glances_tui::run(&mut engine) } fn display_modules_list(engine: &Engine) { @@ -139,8 +139,11 @@ fn run_stdout(engine: &mut Engine, args: &Args) -> Result<()> { 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()) + .map(|p| match p.split_once('.') { + Some((plugin, attribute)) => (plugin.trim().to_string(), Some(attribute.to_string())), + None => (p.trim().to_string(), None), + }) + .filter(|(p, _)| !p.is_empty()) .collect::>() }); diff --git a/crates/glances-cli/src/outputs.rs b/crates/glances-cli/src/outputs.rs index 0f1fc8a..6adbb21 100644 --- a/crates/glances-cli/src/outputs.rs +++ b/crates/glances-cli/src/outputs.rs @@ -1,7 +1,7 @@ //! Terminal output modes: `--stdout`, `--stdout-csv`, `--stdout-json`. use glances_plugins::engine::Engine; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; /// One `[.].` item of the stdout modes. #[derive(Debug, Clone, PartialEq)] @@ -119,9 +119,9 @@ impl CsvRenderer { Self { header: true, ..Default::default() } } - pub fn render(&mut self, engine: &Engine, plugins: &[String]) -> String { + pub fn render(&mut self, engine: &Engine, items: &[(String, Option)]) -> String { let mut line = String::new(); - for name in plugins { + for (name, attribute) in items { let Some(plugin) = engine.get_plugin(name) else { continue; }; @@ -130,9 +130,9 @@ impl CsvRenderer { } let stat = plugin.export(); if self.header { - line.push_str(&self.build_header(name, &stat)); + line.push_str(&self.build_header(name, attribute.as_deref(), &stat)); } else { - line.push_str(&self.build_data(name, &stat)); + line.push_str(&self.build_data(name, attribute.as_deref(), &stat)); } } self.header = false; @@ -140,8 +140,12 @@ impl CsvRenderer { line } - fn build_header(&mut self, plugin: &str, stat: &Value) -> String { + fn build_header(&mut self, plugin: &str, attribute: Option<&str>, stat: &Value) -> String { let mut line = String::new(); + if let Some(attribute) = attribute { + line.push_str(&format!("{plugin}.{attribute},")); + return line; + } match stat { Value::Object(map) => { for key in map.keys() { @@ -178,8 +182,16 @@ impl CsvRenderer { line } - fn build_data(&self, plugin: &str, stat: &Value) -> String { + fn build_data(&self, plugin: &str, attribute: Option<&str>, stat: &Value) -> String { let mut line = String::new(); + if let Some(attribute) = attribute { + let text = match stat { + Value::Object(map) => map.get(attribute).map(glances_core::py_str).unwrap_or_else(|| "N/A".to_string()), + _ => "N/A".to_string(), + }; + line.push_str(&format!("{text},")); + return line; + } match stat { Value::Object(map) => { for value in map.values() { diff --git a/crates/glances-cli/src/tui_placeholder.rs b/crates/glances-cli/src/tui_placeholder.rs index 8e55dfc..bcd6258 100644 --- a/crates/glances-cli/src/tui_placeholder.rs +++ b/crates/glances-cli/src/tui_placeholder.rs @@ -5,10 +5,6 @@ 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") } diff --git a/crates/glances-core/src/plugin.rs b/crates/glances-core/src/plugin.rs index d9df8c9..a5f80be 100644 --- a/crates/glances-core/src/plugin.rs +++ b/crates/glances-core/src/plugin.rs @@ -9,7 +9,7 @@ use crate::history::History; use crate::state::Runtime; use crate::thresholds::thresholds; use crate::timer::Timer; -use crate::units::as_f64; +use crate::units::{as_f64, auto_unit}; use indexmap::IndexMap; use serde_json::{Map, Value, json}; use std::collections::VecDeque; @@ -399,6 +399,18 @@ impl PluginModel { } } + /// Return the list-valued limit (used for nice/status alerts). + pub fn get_limit_list(&self, criticality: &str, stat_name: &str) -> Option> { + let stat_name = stat_name.to_lowercase(); + if let Some(Limit::List(items)) = self.limits.get(&format!("{stat_name}_{criticality}")) { + return Some(items.clone()); + } + if let Some(Limit::List(items)) = self.limits.get(&format!("{}_{criticality}", self.name)) { + return Some(items.clone()); + } + None + } + /// Return the limit value for the given criticality. pub fn get_limit(&self, criticality: &str, stat_name: &str) -> Option { let stat_name = stat_name.to_lowercase(); @@ -958,6 +970,113 @@ impl PluginModel { self.stats.clone() } + /// Create a TUI line (port of `curse_add_line`). + pub fn curse_add_line( + &self, + msg: impl Into, + decoration: Decoration, + optional: bool, + additional: bool, + splittable: bool, + ) -> StatLine { + StatLine { + msg: msg.into(), + decoration, + optional, + additional, + splittable, + } + } + + /// New line marker (port of `curse_new_line`). + pub fn curse_new_line(&self) -> StatLine { + StatLine::new("\n") + } + + /// Add a `key: value` pair to the TUI (port of `curse_add_stat`). + pub fn curse_add_stat(&self, key: &str, width: Option, header: &str, separator: &str, trailer: &str) -> Vec { + let Some(value) = self.stats.get(key) else { + return Vec::new(); + }; + let definition = self.fields.get(key); + + let key_name = definition + .and_then(|f| f.short_name.clone()) + .unwrap_or_else(|| key.to_string()); + let unit_short = definition.map(|f| f.short_unit()).unwrap_or(""); + let unit_type = definition.map(|f| f.unit_type()).unwrap_or("float"); + let value = if definition.map(|f| f.rate).unwrap_or(false) { + self.stats.get(&format!("{key}_rate_per_sec")).unwrap_or(&Value::Null) + } else { + value + }; + let value = as_f64(value); + + let (msg_item, msg_value) = match width { + None => { + let item = format!("{header}{key_name}{separator}"); + let value_text = match value { + None => "-".to_string(), + Some(v) => { + if unit_type == "float" { + format!("{v:.1}{unit_short}") + } else if let Some(min_symbol) = definition.and_then(|f| f.min_symbol.clone()) { + format!("{}{unit_short}", auto_unit(v, false, &min_symbol, "-")) + } else { + format!("{}{unit_short}", v as i64) + } + } + }; + (item, value_text) + } + Some(width) => { + let item = format!("{header}{key_name: format!("{:>5}", "-"), + Some(v) => { + if unit_type == "float" { + format!("{v:>5.1}{unit_short}") + } else if let Some(min_symbol) = definition.and_then(|f| f.min_symbol.clone()) { + format!("{:>5}{unit_short}", auto_unit(v, false, &min_symbol, "-")) + } else { + format!("{:>5}{unit_short}", v as i64) + } + } + }; + (item, value_text) + } + }; + let item = format!("{msg_item}{trailer}"); + + let decoration = if value.is_some() { self.view_decoration(None, key) } else { Decoration::Default }; + let optional = self.view_bool(None, key, "optional"); + + vec![ + self.curse_add_line(item, Decoration::Default, optional, false, false), + self.curse_add_line(msg_value, decoration, optional, false, false), + ] + } + + /// Return the fields to display for a plugin (port of the display flags). + pub fn field_optional(&self, field: &str) -> bool { + self.fields.get(field).map(|f| f.optional).unwrap_or(false) + } + + /// Return the trend message (port of `trend_msg`). + pub fn trend_msg(&self, trend: Option, significant: f64) -> String { + match trend { + None => " ".to_string(), + Some(trend) if trend > significant => "↑".to_string(), + Some(trend) if trend < significant => "↓".to_string(), + _ => "-".to_string(), + } + } + + /// Human readable auto unit (port of the plugin `auto_unit` method). + pub fn auto_unit(&self, number: f64, low_precision: bool, min_symbol: &str) -> String { + auto_unit(number, low_precision, min_symbol, "-") + } + /// Update the history with the current stats. pub fn update_stats_history(&mut self) { if !self.history_enabled { @@ -1137,7 +1256,7 @@ pub trait Plugin: Send + Sync { fn exit(&mut self) {} /// Render the plugin block in the TUI. - fn render(&self, ctx: &RenderContext) -> Vec { + fn render(&mut self, ctx: &RenderContext) -> Vec { let _ = ctx; Vec::new() } diff --git a/crates/glances-core/src/state.rs b/crates/glances-core/src/state.rs index 5c22659..ec29153 100644 --- a/crates/glances-core/src/state.rs +++ b/crates/glances-core/src/state.rs @@ -3,6 +3,10 @@ /// Runtime state shared between the engine, the plugins and the TUI. #[derive(Debug, Clone)] pub struct Runtime { + /// Effective set of enabled plugins (kept up to date by the engine/TUI). + pub enabled_plugins: std::collections::HashSet, + /// Irix mode (task's CPU usage divided by the number of CPUs). + pub disable_irix: bool, /// Display Rx and Tx sum instead of per interface values. pub network_sum: bool, /// Display cumulative network values instead of rates. @@ -38,6 +42,8 @@ pub struct Runtime { impl Runtime { pub fn new(args: &crate::args::Args, fs_free_space_config: bool) -> Self { Self { + enabled_plugins: std::collections::HashSet::new(), + disable_irix: args.disable_irix, network_sum: false, network_cumul: false, percpu: args.percpu, @@ -55,4 +61,9 @@ impl Runtime { reset_minmax_tag: false, } } + + /// Return True if the given plugin is enabled. + pub fn is_plugin_enabled(&self, name: &str) -> bool { + self.enabled_plugins.contains(name) + } } diff --git a/crates/glances-core/src/units.rs b/crates/glances-core/src/units.rs index e414a50..2499aad 100644 --- a/crates/glances-core/src/units.rs +++ b/crates/glances-core/src/units.rs @@ -67,6 +67,35 @@ pub fn auto_unit(number: f64, low_precision: bool, min_symbol: &str, none_symbol format!("{number:.decimal_precision$}") } +/// Render a progress bar the way the Python `Bar` class does. +pub fn bar_percent(percent: f64, size: usize, bar_char: &str, display_value: bool) -> String { + let size = if display_value { size.saturating_sub(6) } else { size }; + let value = percent.clamp(0.0, 100.0); + let scaled = size as f64 * value / 100.0; + let whole = scaled.floor() as usize; + let frac = scaled - whole as f64; + let mut bar = bar_char.repeat(whole); + let mut whole = whole; + if frac > 0.0 { + // curses_bars = [empty; 5] + [bar; 5]: index 5..7 draws a partial block. + if (frac * 8.0) as usize >= 5 { + bar.push_str(bar_char); + } else { + bar.push(' '); + } + whole += 1; + } + bar.push_str(&" ".repeat(size.saturating_sub(whole))); + if display_value { + if percent >= 100.0 { + bar = format!("{bar} {}{:3.0}%", if percent > 100.0 { ">" } else { " " }, 100.0); + } else { + bar = format!("{bar}{percent:5.1}%"); + } + } + bar +} + /// Parse a value like `'12.5 MB'` into a float (port of `string_value_to_float`). pub fn string_value_to_float(s: &str) -> Option { let cleaned: String = s.chars().filter(|c| *c != ' ').collect(); diff --git a/crates/glances-plugins/src/collectors.rs b/crates/glances-plugins/src/collectors.rs index 3277d6a..c4589fb 100644 --- a/crates/glances-plugins/src/collectors.rs +++ b/crates/glances-plugins/src/collectors.rs @@ -179,6 +179,12 @@ pub fn page_size() -> u64 { /// Read the load average (1, 5, 15 minutes). pub fn read_loadavg() -> Option<(f64, f64, f64)> { + let mut loadavg = [0.0f64; 3]; + let count = unsafe { libc::getloadavg(loadavg.as_mut_ptr(), 3) }; + if count == 3 { + return Some((loadavg[0], loadavg[1], loadavg[2])); + } + // Fallback on /proc/loadavg (less precise). let content = std::fs::read_to_string("/proc/loadavg").ok()?; let fields: Vec<&str> = content.split_whitespace().collect(); Some(( @@ -558,7 +564,31 @@ pub fn read_number_file(path: &str) -> Option { std::fs::read_to_string(path).ok().and_then(|v| v.trim().parse::().ok()) } -/// Get the current date formatted with a Python strftime format. +/// Get the current date formatted with a C strftime format (same semantics +/// as Python's `time.strftime`). pub fn strftime(format: &str) -> String { - chrono::Local::now().format(format).to_string() + let Ok(c_format) = std::ffi::CString::new(format) else { + return String::new(); + }; + unsafe { + let now = libc::time(std::ptr::null_mut()); + let mut tm: libc::tm = std::mem::zeroed(); + if libc::localtime_r(&now, &mut tm).is_null() { + return String::new(); + } + for size in [256usize, 4096] { + let mut buffer = vec![0u8; size]; + let len = libc::strftime( + buffer.as_mut_ptr() as *mut libc::c_char, + buffer.len(), + c_format.as_ptr(), + &tm, + ); + if len > 0 { + buffer.truncate(len); + return String::from_utf8_lossy(&buffer).to_string(); + } + } + } + String::new() } diff --git a/crates/glances-plugins/src/engine.rs b/crates/glances-plugins/src/engine.rs index c5eb6c2..dddfecc 100644 --- a/crates/glances-plugins/src/engine.rs +++ b/crates/glances-plugins/src/engine.rs @@ -54,9 +54,20 @@ impl Engine { let mut engine = Self { args, config, runtime, plugins }; engine.apply_plugin_options(); engine.apply_process_options(); + engine.sync_enabled(); engine } + /// Refresh the runtime set of enabled plugins. + pub fn sync_enabled(&mut self) { + self.runtime.enabled_plugins = self + .plugins + .iter() + .filter(|(_, plugin)| plugin.is_enabled()) + .map(|(name, _)| name.clone()) + .collect(); + } + fn apply_plugin_options(&mut self) { // Disable plugins from the configuration file. for section in self.config.sections() { diff --git a/crates/glances-plugins/src/plugins/cpu.rs b/crates/glances-plugins/src/plugins/cpu.rs index 19b2293..73a9495 100644 --- a/crates/glances-plugins/src/plugins/cpu.rs +++ b/crates/glances-plugins/src/plugins/cpu.rs @@ -138,4 +138,78 @@ impl Plugin for CpuPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() || ctx.runtime.percpu { + return ret; + } + let idle_tag = s.get("user").is_none(); + + // Line 1: total + idle + ctx_switches. + ret.push(self.model.curse_add_line(format!("{:8}", "CPU"), glances_core::Decoration::Title, false, false, false)); + if let Some(total) = s.get("total").and_then(Value::as_f64) { + ret.push(self.model.curse_add_line( + format!("{total:>4.1}%"), + self.model.view_decoration(None, "total"), + false, + false, + false, + )); + } + if s.get("idle").is_some() && !idle_tag { + let optional = self.model.view_bool(None, "idle", "optional"); + ret.push(self.model.curse_add_line(" idle ", glances_core::Decoration::Default, optional, false, false)); + if let Some(idle) = s.get("idle").and_then(Value::as_f64) { + ret.push(self.model.curse_add_line( + format!("{idle:>4.1}%"), + self.model.view_decoration(None, "idle"), + optional, + false, + false, + )); + } + } + ret.extend(self.model.curse_add_stat("ctx_switches", Some(15), " ", "", "")); + + // Line 2. + ret.push(self.model.curse_new_line()); + if !idle_tag { + ret.extend(self.model.curse_add_stat("user", Some(15), "", "", "")); + } else { + ret.extend(self.model.curse_add_stat("idle", Some(15), "", "", "")); + } + ret.extend(self.model.curse_add_stat("irq", Some(14), " ", "", "")); + ret.extend(self.model.curse_add_stat("interrupts", Some(15), " ", "", "")); + + // Line 3. + ret.push(self.model.curse_new_line()); + if !idle_tag { + ret.extend(self.model.curse_add_stat("system", Some(15), "", "", "")); + } else { + ret.extend(self.model.curse_add_stat("core", Some(15), "", "", "")); + } + ret.extend(self.model.curse_add_stat("nice", Some(14), " ", "", "")); + if s.get("soft_interrupts").is_some() { + ret.extend(self.model.curse_add_stat("soft_interrupts", Some(15), " ", "", "")); + } else { + ret.extend(self.model.curse_add_stat("ctx_switches", Some(15), " ", "", "")); + } + + // Line 4. + ret.push(self.model.curse_new_line()); + if s.get("iowait").is_some() { + ret.extend(self.model.curse_add_stat("iowait", Some(15), "", "", "")); + } else { + ret.extend(self.model.curse_add_stat("dpc", Some(15), "", "", "")); + } + ret.extend(self.model.curse_add_stat("steal", Some(14), " ", "", "")); + if s.get("guest").is_some() { + ret.extend(self.model.curse_add_stat("guest", Some(14), " ", "", "")); + } else { + ret.extend(self.model.curse_add_stat("syscalls", Some(15), " ", "", "")); + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/diskio.rs b/crates/glances-plugins/src/plugins/diskio.rs index 9c9f503..caf8f71 100644 --- a/crates/glances-plugins/src/plugins/diskio.rs +++ b/crates/glances-plugins/src/plugins/diskio.rs @@ -160,4 +160,77 @@ impl Plugin for DiskioPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + if self.model.stats.as_array().map(|a| a.is_empty()).unwrap_or(true) || ctx.max_width == 0 { + return ret; + } + let name_max_width = ctx.max_width.saturating_sub(13); + ret.push(self.model.curse_add_line( + format!("{:8}", "IOR/s"), glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>7}", "IOW/s"), glances_core::Decoration::Default, false, false, false)); + } else if ctx.args.diskio_latency { + ret.push(self.model.curse_add_line(format!("{:>8}", "ms/opR"), glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>7}", "ms/opW"), glances_core::Decoration::Default, false, false, false)); + } else { + ret.push(self.model.curse_add_line(format!("{:>8}", "R/s"), glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>7}", "W/s"), glances_core::Decoration::Default, false, false, false)); + } + let stats = self.model.sorted_stats(); + let Some(items) = stats.as_array() else { + return ret; + }; + for i in items { + let disk_real_name = i.get("disk_name").and_then(Value::as_str).unwrap_or("").to_string(); + if self + .model + .hide_zero_fields + .iter() + .all(|field| self.model.view_bool(Some(&disk_real_name), field, "hidden")) + { + continue; + } + let mut disk_name = i + .get("alias") + .and_then(Value::as_str) + .map(|s| s.to_string()) + .unwrap_or_else(|| disk_real_name.clone()); + if disk_name.chars().count() > name_max_width { + disk_name = format!("{}_", disk_name.chars().take(name_max_width).collect::()); + } + ret.push(self.model.curse_new_line()); + ret.push(self.model.curse_add_line( + format!("{:7}"), self.model.view_decoration(Some(&disk_real_name), "read_count"), false, false, false)); + ret.push(self.model.curse_add_line(format!("{tx:>7}"), self.model.view_decoration(Some(&disk_real_name), "write_count"), false, false, false)); + } else if ctx.args.diskio_latency { + let rx = self.model.auto_unit(i.get("read_latency").and_then(Value::as_f64).unwrap_or(0.0), true, "K"); + let tx = self.model.auto_unit(i.get("write_latency").and_then(Value::as_f64).unwrap_or(0.0), true, "K"); + ret.push(self.model.curse_add_line(format!("{rx:>7}"), self.model.view_decoration(Some(&disk_real_name), "read_latency"), false, false, false)); + ret.push(self.model.curse_add_line(format!("{tx:>7}"), self.model.view_decoration(Some(&disk_real_name), "write_latency"), false, false, false)); + } else { + let rx = self.model.auto_unit(i.get("read_bytes_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0), false, "K"); + let tx = self.model.auto_unit(i.get("write_bytes_rate_per_sec").and_then(Value::as_f64).unwrap_or(0.0), false, "K"); + ret.push(self.model.curse_add_line(format!("{rx:>7}"), self.model.view_decoration(Some(&disk_real_name), "read_bytes"), false, false, false)); + ret.push(self.model.curse_add_line(format!("{tx:>7}"), self.model.view_decoration(Some(&disk_real_name), "write_bytes"), false, false, false)); + } + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/fs.rs b/crates/glances-plugins/src/plugins/fs.rs index b40966c..02cb96b 100644 --- a/crates/glances-plugins/src/plugins/fs.rs +++ b/crates/glances-plugins/src/plugins/fs.rs @@ -117,4 +117,75 @@ impl Plugin for FsPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + if self.model.stats.as_array().map(|a| a.is_empty()).unwrap_or(true) || ctx.max_width == 0 { + return ret; + } + let name_max_width = ctx.max_width.saturating_sub(13); + ret.push(self.model.curse_add_line( + format!("{:8}", "Free"), glances_core::Decoration::Default, false, false, false)); + } else { + ret.push(self.model.curse_add_line(format!("{:>8}", "Used"), glances_core::Decoration::Default, false, false, false)); + } + ret.push(self.model.curse_add_line(format!("{:>7}", "Total"), glances_core::Decoration::Default, false, false, false)); + + let Some(items) = self.model.stats.as_array() else { + return ret; + }; + let mut sorted = items.clone(); + sorted.sort_by_key(|i| i.get("mnt_point").and_then(Value::as_str).unwrap_or("").to_string()); + for i in &sorted { + let mnt_point_raw = i.get("mnt_point").and_then(Value::as_str).unwrap_or("").to_string(); + let device = i.get("device_name").and_then(Value::as_str).unwrap_or("").to_string(); + let mut mnt_point = i + .get("alias") + .and_then(Value::as_str) + .map(|s| s.to_string()) + .unwrap_or_else(|| mnt_point_raw.clone()); + let device_basename = device.rsplit('/').next().unwrap_or("").to_string(); + if mnt_point.chars().count() + device_basename.chars().count() <= name_max_width.saturating_sub(3) { + mnt_point = format!("{mnt_point} ({device_basename})"); + } else if mnt_point.chars().count() > name_max_width { + mnt_point = format!("{}_", mnt_point.chars().take(name_max_width).collect::()); + } + ret.push(self.model.curse_new_line()); + ret.push(self.model.curse_add_line( + format!("{:7}", self.model.auto_unit(value, false, "K")), + self.model.view_decoration(Some(&mnt_point_raw), "used"), + false, + false, + false, + )); + let size = i.get("size").and_then(Value::as_f64).unwrap_or(0.0); + ret.push(self.model.curse_add_line( + format!("{:>7}", self.model.auto_unit(size, false, "K")), + glances_core::Decoration::Default, + false, + false, + false, + )); + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/irq.rs b/crates/glances-plugins/src/plugins/irq.rs index 5b4a23b..7a0f0b6 100644 --- a/crates/glances-plugins/src/plugins/irq.rs +++ b/crates/glances-plugins/src/plugins/irq.rs @@ -98,4 +98,37 @@ impl Plugin for IrqPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + if self.model.stats.as_array().map(|a| a.is_empty()).unwrap_or(true) || ctx.max_width == 0 { + return ret; + } + let name_max_width = ctx.max_width.saturating_sub(7); + ret.push(self.model.curse_add_line( + format!("{:9}", "Rate/s"), glances_core::Decoration::Default, false, false, false)); + let Some(items) = self.model.stats.as_array() else { + return ret; + }; + for i in items { + let line = i.get("irq_line").and_then(Value::as_str).unwrap_or(""); + let rate = i.get("irq_rate").and_then(Value::as_i64).unwrap_or(0); + ret.push(self.model.curse_new_line()); + ret.push(self.model.curse_add_line( + format!("{:(), width = name_max_width), + glances_core::Decoration::Default, + false, + false, + false, + )); + ret.push(self.model.curse_add_line(format!("{rate:>9}"), glances_core::Decoration::Default, false, false, false)); + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/load.rs b/crates/glances-plugins/src/plugins/load.rs index 2ef2a37..6e118a7 100644 --- a/crates/glances-plugins/src/plugins/load.rs +++ b/crates/glances-plugins/src/plugins/load.rs @@ -78,4 +78,48 @@ impl Plugin for LoadPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() || s.as_object().map(|o| o.is_empty()).unwrap_or(true) { + return ret; + } + ret.push(self.model.curse_add_line(format!("{:4}", "LOAD"), glances_core::Decoration::Title, false, false, false)); + ret.push(self.model.curse_add_line( + format!(" {:1}", self.model.trend_msg(self.model.get_trend("min1", 30), 1.0)), + glances_core::Decoration::Default, + false, + false, + false, + )); + if let Some(cpucore) = s.get("cpucore").and_then(Value::as_f64) { + if cpucore > 0.0 { + ret.push(self.model.curse_add_line(format!("{:3}core", cpucore as i64), glances_core::Decoration::Default, false, false, false)); + } + } + for load_time in ["1", "5", "15"] { + ret.push(self.model.curse_new_line()); + ret.push(self.model.curse_add_line(format!("{:7}", format!("{load_time} min")), glances_core::Decoration::Default, false, false, false)); + let stat = s.get(&format!("min{load_time}")).and_then(Value::as_f64).unwrap_or(0.0); + let value = if ctx.runtime.disable_irix { + let cores = s.get("cpucore").and_then(Value::as_f64).unwrap_or(0.0); + if cores != 0.0 { + format!("{:>5.1}%", stat / cores * 100.0) + } else { + format!("{stat:>6.2}") + } + } else { + format!("{stat:>6.2}") + }; + ret.push(self.model.curse_add_line( + value, + self.model.view_decoration(None, &format!("min{load_time}")), + false, + false, + false, + )); + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/mem.rs b/crates/glances-plugins/src/plugins/mem.rs index 6bc7103..7ecc655 100644 --- a/crates/glances-plugins/src/plugins/mem.rs +++ b/crates/glances-plugins/src/plugins/mem.rs @@ -113,4 +113,42 @@ impl Plugin for MemPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, _ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() { + return ret; + } + // First line: total% + trend + active. + ret.push(self.model.curse_add_line(format!("{}", "MEM"), glances_core::Decoration::Title, false, false, false)); + ret.push(self.model.curse_add_line(format!(" {:2}", self.model.trend_msg(self.model.get_trend("percent", 30), 1.0)), glances_core::Decoration::Default, false, false, false)); + if let Some(percent) = s.get("percent").and_then(Value::as_f64) { + ret.push(self.model.curse_add_line( + format!("{:>6.1}%", percent), + self.model.view_decoration(None, "percent"), + false, + false, + false, + )); + } + ret.extend(self.model.curse_add_stat("active", Some(16), " ", "", "")); + // Second line: total + inactive. + ret.push(self.model.curse_new_line()); + ret.extend(self.model.curse_add_stat("total", Some(15), "", "", "")); + ret.extend(self.model.curse_add_stat("inactive", Some(16), " ", "", "")); + // Third line: available + buffers. + ret.push(self.model.curse_new_line()); + if self.available { + ret.extend(self.model.curse_add_stat("available", Some(15), "", "", "")); + } else { + ret.extend(self.model.curse_add_stat("used", Some(15), "", "", "")); + } + ret.extend(self.model.curse_add_stat("buffers", Some(16), " ", "", "")); + // Fourth line: free + cached. + ret.push(self.model.curse_new_line()); + ret.extend(self.model.curse_add_stat("free", Some(15), "", "", "")); + ret.extend(self.model.curse_add_stat("cached", Some(16), " ", "", "")); + ret + } } diff --git a/crates/glances-plugins/src/plugins/memswap.rs b/crates/glances-plugins/src/plugins/memswap.rs index 197bd9a..43cd706 100644 --- a/crates/glances-plugins/src/plugins/memswap.rs +++ b/crates/glances-plugins/src/plugins/memswap.rs @@ -82,4 +82,28 @@ impl Plugin for MemSwapPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, _ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() { + return ret; + } + ret.push(self.model.curse_add_line("SWAP", glances_core::Decoration::Title, false, false, false)); + ret.push(self.model.curse_add_line(" ", glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line( + format!("{:>6.1}%", s.get("percent").and_then(Value::as_f64).unwrap_or(0.0)), + self.model.view_decoration(None, "percent"), + false, + false, + false, + )); + ret.push(self.model.curse_new_line()); + ret.extend(self.model.curse_add_stat("total", Some(15), "", "", "")); + ret.push(self.model.curse_new_line()); + ret.extend(self.model.curse_add_stat("used", Some(15), "", "", "")); + ret.push(self.model.curse_new_line()); + ret.extend(self.model.curse_add_stat("free", Some(15), "", "", "")); + ret + } } diff --git a/crates/glances-plugins/src/plugins/network.rs b/crates/glances-plugins/src/plugins/network.rs index 24945eb..3db9986 100644 --- a/crates/glances-plugins/src/plugins/network.rs +++ b/crates/glances-plugins/src/plugins/network.rs @@ -38,6 +38,9 @@ impl NetworkPlugin { args.disable_config_exec, ); model.set_item_key("interface_name"); + model.hide_zero = config.get_bool_value("network", "hide_zero", false); + model.hide_zero_fields = + vec!["bytes_recv_rate_per_sec".to_string(), "bytes_sent_rate_per_sec".to_string()]; let hide_no_up = config.has_section("network") && config.get_bool_value("network", "hide_no_up", false); let hide_no_ip = config.has_section("network") && config.get_bool_value("network", "hide_no_ip", false); Self { model, hide_no_up, hide_no_ip } @@ -136,4 +139,112 @@ impl Plugin for NetworkPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + if ctx.max_width == 0 { + return ret; + } + let name_max_width = ctx.max_width.saturating_sub(12); + // Header + ret.push(self.model.curse_add_line( + format!("{:14}", "Rx+Tx"), glances_core::Decoration::Default, false, false, false)); + } else { + ret.push(self.model.curse_add_line(format!("{:>7}", "Rx"), glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>7}", "Tx"), glances_core::Decoration::Default, false, false, false)); + } + } else if ctx.runtime.network_sum { + ret.push(self.model.curse_add_line(format!("{:>14}", "Rx+Tx/s"), glances_core::Decoration::Default, false, false, false)); + } else { + ret.push(self.model.curse_add_line(format!("{:>7}", "Rx/s"), glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>7}", "Tx/s"), glances_core::Decoration::Default, false, false, false)); + } + + let stats = self.model.sorted_stats(); + let Some(items) = stats.as_array() else { + return ret; + }; + for i in items { + let item_key = i.get("interface_name").and_then(Value::as_str).unwrap_or("").to_string(); + // Hide stats never different from zero. + if self + .model + .hide_zero_fields + .iter() + .all(|field| self.model.view_bool(Some(&item_key), field, "hidden")) + { + continue; + } + let to_bit = if ctx.args.byte { 1.0 } else { 8.0 }; + let unit = if ctx.args.byte { "" } else { "b" }; + let (rx, tx, ax) = if ctx.runtime.network_cumul { + let recv = i.get("bytes_recv").and_then(Value::as_f64); + let sent = i.get("bytes_sent").and_then(Value::as_f64); + let all = i.get("bytes_all").and_then(Value::as_f64); + match (recv, sent, all) { + (Some(rx), Some(tx), Some(ax)) => ( + format!("{}{unit}", self.model.auto_unit(rx * to_bit, false, "K")), + format!("{}{unit}", self.model.auto_unit(tx * to_bit, false, "K")), + format!("{}{unit}", self.model.auto_unit(ax * to_bit, false, "K")), + ), + _ => continue, + } + } else { + let recv = i.get("bytes_recv_rate_per_sec").and_then(Value::as_f64); + let sent = i.get("bytes_sent_rate_per_sec").and_then(Value::as_f64); + let all = i.get("bytes_all_rate_per_sec").and_then(Value::as_f64); + match (recv, sent, all) { + (Some(rx), Some(tx), Some(ax)) => ( + format!("{}{unit}", self.model.auto_unit(rx * to_bit, false, "K")), + format!("{}{unit}", self.model.auto_unit(tx * to_bit, false, "K")), + format!("{}{unit}", self.model.auto_unit(ax * to_bit, false, "K")), + ), + _ => continue, + } + }; + let mut if_name = i + .get("alias") + .and_then(Value::as_str) + .map(|s| s.to_string()) + .unwrap_or_else(|| item_key.split(':').next().unwrap_or("").to_string()); + if if_name.len() > name_max_width { + if_name = format!("_{}", &if_name[if_name.len() - name_max_width + 1..]); + } + ret.push(self.model.curse_new_line()); + ret.push(self.model.curse_add_line( + format!("{if_name:14}"), glances_core::Decoration::Default, false, false, false)); + } else { + ret.push(self.model.curse_add_line( + format!("{rx:>7}"), + self.model.view_decoration(Some(&item_key), "bytes_recv"), + false, + false, + false, + )); + ret.push(self.model.curse_add_line( + format!("{tx:>7}"), + self.model.view_decoration(Some(&item_key), "bytes_sent"), + false, + false, + false, + )); + } + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/now.rs b/crates/glances-plugins/src/plugins/now.rs index 091509e..4bd983f 100644 --- a/crates/glances-plugins/src/plugins/now.rs +++ b/crates/glances-plugins/src/plugins/now.rs @@ -43,22 +43,21 @@ impl Plugin for NowPlugin { } fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result { - let now = chrono::Local::now(); - let iso = now.format("%Y-%m-%dT%H:%M:%S%:z").to_string(); + let iso = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string(); let strftime = if !ctx.args.strftime_format.is_empty() { ctx.args.strftime_format.clone() } else { ctx.config.get_value("global", "strftime_format", Some("")).unwrap_or_default() }; let custom = if !strftime.is_empty() { - now.format(&strftime).to_string() + crate::collectors::strftime(&strftime) } else { // Choose the tz format depending on the timezone name length. - let tz_name = now.format("%Z").to_string(); + let tz_name = crate::collectors::strftime("%Z"); if tz_name.len() > 6 { - now.format("%Y-%m-%d %H:%M:%S %z").to_string() + crate::collectors::strftime("%Y-%m-%d %H:%M:%S %z") } else { - now.format("%Y-%m-%d %H:%M:%S %Z").to_string() + crate::collectors::strftime("%Y-%m-%d %H:%M:%S %Z") } }; Ok(json!({"iso": iso, "custom": custom})) @@ -67,4 +66,13 @@ impl Plugin for NowPlugin { fn display_curse(&self) -> bool { true } + + fn align(&self) -> glances_core::Align { + glances_core::Align::Left + } + + fn render(&mut self, _ctx: &glances_core::RenderContext) -> Vec { + let custom = self.model.stats.get("custom").and_then(Value::as_str).unwrap_or(""); + vec![self.model.curse_add_line(format!("{custom:<23}"), glances_core::Decoration::Default, false, false, false)] + } } diff --git a/crates/glances-plugins/src/plugins/percpu.rs b/crates/glances-plugins/src/plugins/percpu.rs index 345604c..a119454 100644 --- a/crates/glances-plugins/src/plugins/percpu.rs +++ b/crates/glances-plugins/src/plugins/percpu.rs @@ -8,6 +8,7 @@ use serde_json::Value; pub struct PerCpuPlugin { pub model: PluginModel, + pub max_cpu_display: usize, } impl PerCpuPlugin { @@ -39,7 +40,8 @@ impl PerCpuPlugin { args.disable_config_exec, ); model.set_item_key("cpu_number"); - Self { model } + let max_cpu_display = config.get_int_value("percpu", "max_cpu_display", 4).max(1) as usize; + Self { model, max_cpu_display } } } @@ -64,4 +66,71 @@ impl Plugin for PerCpuPlugin { let mut state = cpu_percent().lock().unwrap(); Ok(Value::Array(state.get_percpu().to_vec())) } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let items: Vec = self.model.stats.as_array().cloned().unwrap_or_default(); + if items.is_empty() || !ctx.runtime.percpu { + return ret; + } + let items = &items; + let quicklook_disabled = !ctx.runtime.is_plugin_enabled("quicklook"); + let mut header = vec!["user", "system", "iowait", "idle", "irq", "nice", "steal", "guest"]; + if quicklook_disabled { + ret.push(self.model.curse_add_line(format!("{:5}", "CPU"), glances_core::Decoration::Title, false, false, false)); + header.insert(0, "total"); + } + for stat in &header { + ret.push(self.model.curse_add_line(format!("{stat:>7}"), glances_core::Decoration::Default, false, false, false)); + } + // Sort by total and keep the top max_cpu_display cores. + let mut sorted: Vec = items.clone(); + if sorted.len() > self.max_cpu_display { + sorted.sort_by(|a, b| { + b.get("total") + .and_then(Value::as_f64) + .unwrap_or(0.0) + .partial_cmp(&a.get("total").and_then(Value::as_f64).unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + let displayed: Vec = sorted.iter().take(self.max_cpu_display).cloned().collect(); + for cpu in &displayed { + ret.push(self.model.curse_new_line()); + if quicklook_disabled { + let cpu_id = cpu.get("cpu_number").and_then(Value::as_i64).unwrap_or(0); + let msg = if cpu_id < 10 { format!("CPU{cpu_id:1} ") } else { format!("{cpu_id:4} ") }; + ret.push(self.model.curse_add_line(msg, glances_core::Decoration::Default, false, false, false)); + } + for stat in &header { + let value = cpu.get(*stat).and_then(Value::as_f64); + let decoration = match value { + Some(value) => self.model.get_alert(value, 0.0, 100.0, true, false, Some(stat), None, false), + None => glances_core::Decoration::Default, + }; + let msg = match value { + Some(value) => format!("{value:6.1}%"), + None => format!("{:>6}%", "?"), + }; + ret.push(self.model.curse_add_line(msg, decoration, false, false, false)); + } + } + if items.len() > self.max_cpu_display { + ret.push(self.model.curse_new_line()); + if quicklook_disabled { + ret.push(self.model.curse_add_line("CPU* ", glances_core::Decoration::Default, false, false, false)); + } + for stat in &header { + let values: Vec = sorted + .iter() + .skip(self.max_cpu_display) + .filter_map(|cpu| cpu.get(*stat).and_then(Value::as_f64)) + .collect(); + let mean = if values.is_empty() { 0.0 } else { values.iter().sum::() / values.len() as f64 }; + let decoration = self.model.get_alert(mean, 0.0, 100.0, true, false, Some(stat), None, false); + ret.push(self.model.curse_add_line(format!("{mean:6.1}%"), decoration, false, false, false)); + } + } + ret + } } diff --git a/crates/glances-plugins/src/plugins/processcount.rs b/crates/glances-plugins/src/plugins/processcount.rs index 59050f2..fc22801 100644 --- a/crates/glances-plugins/src/plugins/processcount.rs +++ b/crates/glances-plugins/src/plugins/processcount.rs @@ -69,4 +69,58 @@ impl Plugin for ProcessCountPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() { + return ret; + } + if ctx.args.disable_process { + ret.push(self.model.curse_add_line("PROCESSES DISABLED (press 'z' to display)", glances_core::Decoration::Default, false, false, false)); + return ret; + } + // Filter line. + { + let procs = glances_core::processes::processes().lock().unwrap(); + if let Some(filter) = procs.filter.filter() { + ret.push(self.model.curse_add_line("Processes filter:", glances_core::Decoration::Title, false, false, false)); + let mut msg = format!(" {filter} "); + if let Some(key) = procs.filter.filter_key() { + msg += &format!("on column {key} "); + } + ret.push(self.model.curse_add_line(msg, glances_core::Decoration::Filter, false, false, false)); + ret.push(self.model.curse_add_line("('ENTER' to edit, 'E' to reset)", glances_core::Decoration::Default, false, false, false)); + ret.push(self.model.curse_new_line()); + } + } + ret.push(self.model.curse_add_line("TASKS", glances_core::Decoration::Title, false, false, false)); + let total = s.get("total").and_then(Value::as_i64).unwrap_or(0); + ret.push(self.model.curse_add_line(format!("{total:>4}"), glances_core::Decoration::Default, false, false, false)); + if let Some(thread) = s.get("thread").and_then(Value::as_i64) { + ret.push(self.model.curse_add_line(format!(" ({thread} thr),"), glances_core::Decoration::Default, false, false, false)); + } + let mut other = total; + if let Some(running) = s.get("running").and_then(Value::as_i64) { + other -= running; + ret.push(self.model.curse_add_line(format!(" {running} run,"), glances_core::Decoration::Default, false, false, false)); + } + if let Some(sleeping) = s.get("sleeping").and_then(Value::as_i64) { + other -= sleeping; + ret.push(self.model.curse_add_line(format!(" {sleeping} slp,"), glances_core::Decoration::Default, false, false, false)); + } + ret.push(self.model.curse_add_line(format!(" {other} oth "), glances_core::Decoration::Default, false, false, false)); + let procs = glances_core::processes::processes().lock().unwrap(); + let mut msg = if ctx.args.programs { "Programs" } else { "Threads" }.to_string(); + let sort_human = glances_core::processes::sort_for_human(&procs.sort_key); + if procs.auto_sort { + msg += " sorted automatically"; + ret.push(self.model.curse_add_line(msg, glances_core::Decoration::Default, false, false, false)); + msg = format!(" by {sort_human}"); + } else { + msg += &format!(" sorted by {sort_human}"); + } + ret.push(self.model.curse_add_line(msg, glances_core::Decoration::Default, false, false, false)); + ret + } } diff --git a/crates/glances-plugins/src/plugins/processlist.rs b/crates/glances-plugins/src/plugins/processlist.rs index a6a21b7..9bc7b97 100644 --- a/crates/glances-plugins/src/plugins/processlist.rs +++ b/crates/glances-plugins/src/plugins/processlist.rs @@ -8,6 +8,8 @@ use serde_json::Value; pub struct ProcessListPlugin { pub model: PluginModel, + pub nb_log_core: usize, + pub disable_virtual_memory: bool, } impl ProcessListPlugin { @@ -65,7 +67,9 @@ impl ProcessListPlugin { if let Some(sort_key) = args.sort_processes_key { processes().lock().unwrap().set_sort_key(sort_key.as_str(), false); } - Self { model } + let nb_log_core = crate::collectors::logical_core_count(); + let disable_virtual_memory = config.get_bool_value("processlist", "disable_virtual_memory", false); + Self { model, nb_log_core, disable_virtual_memory } } } @@ -109,6 +113,284 @@ impl Plugin for ProcessListPlugin { fn align(&self) -> glances_core::Align { glances_core::Align::Bottom } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + use glances_core::Decoration; + let mut ret = Vec::new(); + let items: Vec = self.model.stats.as_array().cloned().unwrap_or_default(); + let Some(_) = items.first() else { + return ret; + }; + if ctx.args.disable_process { + return ret; + } + // Values used for the alerts and the layout. + let (max_cpu, max_mem, sort_key) = { + let procs = glances_core::processes::processes().lock().unwrap(); + ( + procs.max_values.get("cpu_percent").copied().unwrap_or(0.0), + procs.max_values.get("memory_percent").copied().unwrap_or(0.0), + procs.sort_key.clone(), + ) + }; + let cursor = ctx.runtime.cursor_position; + let disable_cursor = ctx.args.disable_cursor; + let short_name = ctx.runtime.process_short_name; + let wide = ctx.max_width >= 105; + + // Header. + if ctx.runtime.disable_irix { + ret.push(self.model.curse_add_line(format!("{:<6} ", "CPUi"), Decoration::Sort, false, false, false)); + } else { + ret.push(self.model.curse_add_line(format!("{:<6} ", "CPU%"), Decoration::Sort, false, false, false)); + } + ret.push(self.model.curse_add_line(format!("{:<5} ", "MEM%"), Decoration::Default, false, false, false)); + if wide { + if !self.disable_virtual_memory { + ret.push(self.model.curse_add_line(format!("{:<5} ", "VIRT"), Decoration::Default, true, false, false)); + } + ret.push(self.model.curse_add_line(format!("{:<5} ", "RES"), Decoration::Default, true, false, false)); + } + ret.push(self.model.curse_add_line(format!("{:>5} ", "PID"), Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:<10} ", "USER"), Decoration::Default, false, false, false)); + if wide { + ret.push(self.model.curse_add_line(format!("{:>8} ", "TIME+"), Decoration::Default, true, false, false)); + } + ret.push(self.model.curse_add_line(format!("{:<3} ", "THR"), Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>3} ", "NI"), Decoration::Default, false, false, false)); + ret.push(self.model.curse_add_line(format!("{:>1} ", "S"), Decoration::Default, false, false, false)); + if wide { + ret.push(self.model.curse_add_line(format!("{:>4} ", "R/s"), Decoration::Default, true, true, false)); + ret.push(self.model.curse_add_line(format!("{:<4} ", "W/s"), Decoration::Default, true, true, false)); + } + if wide { + ret.push(self.model.curse_add_line(format!("{:>3} ", "CPU"), Decoration::Default, true, false, false)); + } + ret.push(self.model.curse_add_line("Command", Decoration::Sort, false, false, false)); + let _ = sort_key; + + let max_pid_size = { + let procs = glances_core::processes::processes().lock().unwrap(); + procs.pid_max().map(|m| m.to_string().len()).unwrap_or(6) + }; + + for (position, p) in items.iter().enumerate() { + let Some(p) = p.as_object() else { + continue; + }; + let selected = position as i64 == cursor; + ret.push(self.model.curse_new_line()); + ret.push(self.model.curse_add_line( + if selected && !disable_cursor { ">" } else { " " }, + Decoration::Selected, + false, + false, + false, + )); + + // CPU%. + let cpu_percent = p.get("cpu_percent").and_then(Value::as_f64).unwrap_or(0.0); + let cpu_value = if ctx.runtime.disable_irix && self.nb_log_core != 0 { + cpu_percent / self.nb_log_core as f64 + } else { + cpu_percent + }; + let cpu_decoration = self.model.get_alert( + cpu_percent, + 0.0, + 100.0, + true, + cpu_percent == max_cpu, + Some("cpu"), + None, + false, + ); + ret.push(self.model.curse_add_line(format!("{cpu_value:<6.1}"), cpu_decoration, false, false, false)); + + // MEM%. + let memory_percent = p.get("memory_percent").and_then(Value::as_f64).unwrap_or(0.0); + let mem_decoration = self.model.get_alert( + memory_percent, + 0.0, + 100.0, + true, + memory_percent == max_mem, + Some("mem"), + None, + false, + ); + ret.push(self.model.curse_add_line(format!("{memory_percent:<5.1} "), mem_decoration, false, false, false)); + + // VIRT and RES. + let memory_info = p.get("memory_info"); + if wide { + if !self.disable_virtual_memory { + let vms = memory_info.and_then(|m| m.get("vms")).and_then(Value::as_f64).unwrap_or(0.0); + ret.push(self.model.curse_add_line( + format!("{:<5} ", self.model.auto_unit(vms, false, "K")), + Decoration::Default, + true, + false, + false, + )); + } + let rss = memory_info.and_then(|m| m.get("rss")).and_then(Value::as_f64).unwrap_or(0.0); + ret.push(self.model.curse_add_line( + format!("{:<5} ", self.model.auto_unit(rss, false, "K")), + Decoration::Default, + true, + false, + false, + )); + } + + // PID. + let pid = p.get("pid").and_then(Value::as_i64).unwrap_or(0); + ret.push(self.model.curse_add_line(format!("{pid:>max_pid_size$} "), Decoration::Default, false, false, false)); + + // USER. + let username = p.get("username").and_then(Value::as_str).unwrap_or("?"); + let username: String = username.chars().take(9).collect(); + ret.push(self.model.curse_add_line(format!("{username:<10} "), Decoration::Default, false, false, false)); + + // TIME+. + if wide { + let times = p.get("cpu_times"); + let user = times.and_then(|t| t.get("user")).and_then(Value::as_f64).unwrap_or(0.0); + let system = times.and_then(|t| t.get("system")).and_then(Value::as_f64).unwrap_or(0.0); + let total = (user + system) as i64; + let hours = total / 3600; + let minutes = (total % 3600) / 60; + let seconds = total % 60; + let (msg, decoration) = if hours > 99 { + (format!("{hours:<7}h"), Decoration::Default) + } else if hours > 0 { + (format!("{hours}h{minutes}:{seconds:02}"), Decoration::CpuTime) + } else { + (format!("{minutes}:{seconds:02}"), Decoration::Default) + }; + ret.push(self.model.curse_add_line(format!("{msg:>8} "), decoration, true, false, false)); + } + + // THR. + if let Some(threads) = p.get("num_threads").and_then(Value::as_i64) { + ret.push(self.model.curse_add_line(format!("{threads:<3} "), Decoration::Default, false, false, false)); + } else { + ret.push(self.model.curse_add_line(format!("{:<3} ", "?"), Decoration::Default, false, false, false)); + } + + // NI. + let nice_value = p.get("nice").and_then(Value::as_i64).unwrap_or(0); + let nice_decoration = self.nice_alert(nice_value); + ret.push(self.model.curse_add_line(format!("{nice_value:>3} "), nice_decoration, false, false, false)); + + // Status. + let status = p.get("status").and_then(Value::as_str).unwrap_or("?"); + let status_decoration = self.status_alert(status); + ret.push(self.model.curse_add_line(format!("{status:>1} "), status_decoration, false, false, false)); + + // IO rates. + if wide { + let rate = |slot: usize| -> Option { + let counters = p.get("io_counters")?.as_array()?; + let io_tag = counters.get(4)?.as_i64().unwrap_or(0); + let time_since_update = p.get("time_since_update")?.as_f64()?; + if io_tag != 1 || time_since_update == 0.0 { + return None; + } + let current = counters.get(slot)?.as_f64()?; + let old = counters.get(if slot == 0 { 2 } else { 3 })?.as_f64()?; + let io = (current - old) / time_since_update; + Some(if io == 0.0 { "0".to_string() } else { self.model.auto_unit(io, true, "K") }) + }; + for (slot, width) in [(0usize, 4usize), (1, 4)] { + match rate(slot) { + Some(value) => ret.push(self.model.curse_add_line(format!("{value:>width$} "), Decoration::Default, true, true, false)), + None => ret.push(self.model.curse_add_line(format!("{:>width$} ", "?"), Decoration::Default, true, true, false)), + } + } + } + + // CPU core. + if wide { + let cpu_num = p.get("cpu_num").and_then(Value::as_i64); + let msg = match cpu_num { + Some(value) if value >= 0 => format!("{value:>3} "), + _ => format!("{:>3} ", "-"), + }; + ret.push(self.model.curse_add_line(msg, Decoration::Default, true, false, false)); + } + + // Command. + let name = p.get("name").and_then(Value::as_str).unwrap_or("?"); + let cmdline: Vec = p + .get("cmdline") + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect()) + .unwrap_or_default(); + let process_decoration = if selected && !disable_cursor { Decoration::ProcessSelected } else { Decoration::Process }; + if !cmdline.is_empty() { + let first = &cmdline[0]; + let (path, cmd) = if first.starts_with(name) { + (String::new(), first.clone()) + } else { + match first.rsplit_once('/') { + Some((path, cmd)) => (path.to_string(), cmd.to_string()), + None => (String::new(), first.clone()), + } + }; + let arguments = cmdline[1..].join(" "); + if !path.is_empty() && std::path::Path::new(&path).is_dir() && !short_name { + ret.push(self.model.curse_add_line(format!("{path}/"), Decoration::Default, false, false, true)); + ret.push(self.model.curse_add_line(cmd, process_decoration, false, false, true)); + } else { + ret.push(self.model.curse_add_line(cmd, process_decoration, false, false, true)); + } + if !arguments.is_empty() { + let msg = format!(" {}", glances_core::units::replace_special_chars(&arguments, ' ')); + ret.push(self.model.curse_add_line(msg, Decoration::Default, false, false, true)); + } + } else { + ret.push(self.model.curse_add_line(name.to_string(), process_decoration, false, false, true)); + } + } + ret + } +} + +impl ProcessListPlugin { + fn nice_alert(&self, value: i64) -> glances_core::Decoration { + let value = value.to_string(); + for (criticality, decoration) in [ + ("nice_critical", glances_core::Decoration::Critical), + ("nice_warning", glances_core::Decoration::Warning), + ("nice_careful", glances_core::Decoration::Careful), + ("nice_ok", glances_core::Decoration::Ok), + ] { + if let Some(items) = self.model.get_limit_list(criticality, "processlist") { + if items.iter().any(|item| item == &value) { + return decoration; + } + } + } + glances_core::Decoration::Default + } + + fn status_alert(&self, value: &str) -> glances_core::Decoration { + for (criticality, decoration) in [ + ("status_critical", glances_core::Decoration::Critical), + ("status_warning", glances_core::Decoration::Warning), + ("status_careful", glances_core::Decoration::Careful), + ("status_ok", glances_core::Decoration::Ok), + ] { + if let Some(items) = self.model.get_limit_list(criticality, "processlist") { + if items.iter().any(|item| item == value) { + return decoration; + } + } + } + if value == "R" { glances_core::Decoration::Ok } else { glances_core::Decoration::Default } + } } #[allow(dead_code)] diff --git a/crates/glances-plugins/src/plugins/quicklook.rs b/crates/glances-plugins/src/plugins/quicklook.rs index dcb0f8e..a008870 100644 --- a/crates/glances-plugins/src/plugins/quicklook.rs +++ b/crates/glances-plugins/src/plugins/quicklook.rs @@ -163,6 +163,110 @@ impl Plugin for QuicklookPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() || ctx.max_width == 0 { + return ret; + } + // System information: CPU name + frequency. + let freq = match (s.get("cpu_hz_current").and_then(Value::as_f64), s.get("cpu_hz").and_then(Value::as_f64)) { + (Some(current), Some(max)) => format!(" {:.2}/{:.2}GHz", current / 1e9, max / 1e9), + (Some(current), None) => format!(" {:.2}GHz", current / 1e9), + _ => String::new(), + }; + let cpu_name = s.get("cpu_name").and_then(Value::as_str).unwrap_or(""); + let name = if (ctx.max_width as i64 - freq.len() as i64 + 7) > 0 { + format!("{:> = Vec::new(); + let mut push_line = |msg: String, bar_key: &str, percent: f64, decoration: glances_core::Decoration| { + let bar = glances_core::units::bar_percent(percent, bar_size, &bar_char, true); + lines.push(vec![ + self.model.curse_add_line(msg, glances_core::Decoration::Default, false, false, false), + self.model.curse_add_line("[", glances_core::Decoration::Bold, false, false, false), + self.model.curse_add_line(bar, decoration, false, false, false), + self.model.curse_add_line("]", glances_core::Decoration::Bold, false, false, false), + self.model.curse_add_line(" ", glances_core::Decoration::Default, false, false, false), + ]); + let _ = bar_key; + }; + + let stats_list = self.stats_list.clone(); + for key in &stats_list { + if key == "cpu" && ctx.runtime.percpu { + if let Some(percpu) = s.get("percpu").and_then(Value::as_array) { + let mut sorted = percpu.clone(); + if sorted.len() > self.max_cpu_display { + sorted.sort_by(|a, b| { + b.get("total") + .and_then(Value::as_f64) + .unwrap_or(0.0) + .partial_cmp(&a.get("total").and_then(Value::as_f64).unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + for cpu in sorted.iter().take(self.max_cpu_display) { + let cpu_id = cpu.get("cpu_number").and_then(Value::as_i64).unwrap_or(0); + let total = cpu.get("total").and_then(Value::as_f64).unwrap_or(0.0); + let msg = if cpu_id < 10 { format!("{:<3}{cpu_id} ", key.to_uppercase()) } else { format!("{cpu_id:4} ") }; + let decoration = self.decoration_for_percpu(cpu_id); + push_line(msg, key, total, decoration); + } + if percpu.len() > self.max_cpu_display { + let hidden: Vec = sorted + .iter() + .skip(self.max_cpu_display) + .filter_map(|cpu| cpu.get("total").and_then(Value::as_f64)) + .collect(); + let mean = if hidden.is_empty() { 0.0 } else { hidden.iter().sum::() / hidden.len() as f64 }; + let decoration = self.decoration_for_percpu(-1); + push_line(format!("{:<3}* ", key.to_uppercase()), key, mean, decoration); + } + } + } else if let Some(value) = s.get(key).and_then(Value::as_f64) { + let decoration = self.model.view_decoration(None, key); + push_line(format!("{:<4} ", key.to_uppercase()), key, value, decoration); + } + } + for line in lines { + ret.extend(line); + ret.push(self.model.curse_new_line()); + } + ret.pop(); + ret + } +} + +impl QuicklookPlugin { + fn decoration_for_percpu(&self, cpu_id: i64) -> glances_core::Decoration { + let key = if cpu_id < 0 { "other".to_string() } else { cpu_id.to_string() }; + match self.model.get_view(None, "percpu_decoration", &key) { + Value::String(name) => glances_core::Decoration::from_name(&name), + _ => glances_core::Decoration::Default, + } + } } /// Return the style for a CPU percentage without the get_alert side effects. diff --git a/crates/glances-plugins/src/plugins/sensors.rs b/crates/glances-plugins/src/plugins/sensors.rs index 03b0a2f..2175ba9 100644 --- a/crates/glances-plugins/src/plugins/sensors.rs +++ b/crates/glances-plugins/src/plugins/sensors.rs @@ -210,6 +210,55 @@ impl Plugin for SensorsPlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let Some(items) = self.model.stats.as_array() else { + return ret; + }; + if items.is_empty() || ctx.max_width == 0 { + return ret; + } + let name_max_width = ctx.max_width.saturating_sub(12); + ret.push(self.model.curse_add_line( + format!("{:(), width = name_max_width), + glances_core::Decoration::Default, + false, + false, + false, + )); + let Some(value) = value else { + continue; + }; + let (value, unit) = if ctx.args.fahrenheit && sensor_type != "battery" && sensor_type != "fan_speed" { + (glances_core::to_fahrenheit(value), "F") + } else { + (value, unit) + }; + ret.push(self.model.curse_add_line( + format!("{:>14}", format!("{value:.0}{unit}")), + self.model.view_decoration(Some(&item_key), "value"), + false, + false, + false, + )); + } + ret + } } fn system_thresholds(value: f64, warning: Option, critical: Option) -> Decoration { diff --git a/crates/glances-plugins/src/plugins/system.rs b/crates/glances-plugins/src/plugins/system.rs index 2a0b2b1..f0cb405 100644 --- a/crates/glances-plugins/src/plugins/system.rs +++ b/crates/glances-plugins/src/plugins/system.rs @@ -77,6 +77,19 @@ impl Plugin for SystemPlugin { fn align(&self) -> glances_core::Align { glances_core::Align::Right } + + fn render(&mut self, _ctx: &glances_core::RenderContext) -> Vec { + let mut ret = Vec::new(); + let s = &self.model.stats; + if !s.is_object() { + return ret; + } + let hostname = s.get("hostname").and_then(Value::as_str).unwrap_or(""); + ret.push(self.model.curse_add_line(hostname, glances_core::Decoration::Title, false, false, false)); + let hr_name = s.get("hr_name").and_then(Value::as_str).unwrap_or(""); + ret.push(self.model.curse_add_line(format!(" {hr_name} "), glances_core::Decoration::Default, true, false, false)); + ret + } } /// Logical core count helper shared by the load plugin. diff --git a/crates/glances-plugins/src/plugins/uptime.rs b/crates/glances-plugins/src/plugins/uptime.rs index cd7e973..a7ea3a0 100644 --- a/crates/glances-plugins/src/plugins/uptime.rs +++ b/crates/glances-plugins/src/plugins/uptime.rs @@ -67,4 +67,17 @@ impl Plugin for UptimePlugin { fn display_curse(&self) -> bool { true } + + fn render(&mut self, _ctx: &glances_core::RenderContext) -> Vec { + if self.model.stats.is_null() { + return Vec::new(); + } + vec![self.model.curse_add_line( + format!("Uptime: {}", glances_core::py_str(&self.model.stats)), + glances_core::Decoration::Default, + false, + false, + false, + )] + } } diff --git a/crates/glances-tui/Cargo.toml b/crates/glances-tui/Cargo.toml index b7d9e6a..71edf9f 100644 --- a/crates/glances-tui/Cargo.toml +++ b/crates/glances-tui/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow.workspace = true crossterm = "0.29.0" glances-core = { version = "0.1.0", path = "../glances-core" } glances-plugins = { version = "0.1.0", path = "../glances-plugins" } @@ -11,3 +12,6 @@ indexmap.workspace = true ratatui = "0.30.2" serde_json.workspace = true tracing.workspace = true + +[dev-dependencies] +clap = { workspace = true, features = ["derive"] } diff --git a/crates/glances-tui/src/lib.rs b/crates/glances-tui/src/lib.rs index b93cf3f..58bebc2 100644 --- a/crates/glances-tui/src/lib.rs +++ b/crates/glances-tui/src/lib.rs @@ -1,14 +1,759 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +//! Terminal user interface (port of the Glances curses interface). + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; +use glances_core::plugin::{Align, Decoration, StatLine}; +use glances_core::processes::processes; +use glances_core::{Args, RenderContext}; +use glances_plugins::engine::Engine; +use ratatui::Frame; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use std::io::Stdout; +use std::time::{Duration, Instant}; + +const LEFT_SIDEBAR: [&str; 8] = ["network", "diskio", "fs", "irq", "sensors", "now", "folders", "raid"]; +const TOP_PLUGINS: [&str; 6] = ["cpu", "percpu", "mem", "memswap", "load", "gpu"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Popup { + None, + Help, + Filter, + Kill, } +pub struct Tui { + popup: Popup, + input: String, + quit: bool, +} + +/// Run the terminal interface until the user quits. +pub fn run(engine: &mut Engine) -> Result<()> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = ratatui::Terminal::new(backend)?; + + let mut tui = Tui { popup: Popup::None, input: String::new(), quit: false }; + let result = run_loop(&mut terminal, engine, &mut tui); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result +} + +fn run_loop( + terminal: &mut ratatui::Terminal>, + engine: &mut Engine, + tui: &mut Tui, +) -> Result<()> { + let refresh = engine.args.time.unwrap_or(2.0); + let mut count: i64 = 0; + while !tui.quit { + let start = Instant::now(); + engine.update(); + tui.draw(terminal, engine)?; + + count += 1; + if let Some(max) = engine.args.stop_after { + if count >= max { + break; + } + } + + let elapsed = start.elapsed().as_secs_f64(); + let timeout = Duration::from_secs_f64((refresh - elapsed).max(0.05)); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + if !event::poll(remaining)? { + break; + } + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => tui.handle_key(engine, key), + Event::Resize(_, _) => break, + _ => {} + } + } + } + Ok(()) +} + +impl Tui { + fn draw(&self, terminal: &mut ratatui::Terminal>, engine: &mut Engine) -> Result<()> { + terminal.draw(|frame| draw_frame(frame, engine, self))?; + Ok(()) + } +} + +fn draw_frame(frame: &mut Frame, engine: &mut Engine, tui: &Tui) { + let args = engine.args.clone(); + let args = &args; + let area = frame.area(); + if area.width < 40 || area.height < 10 { + return; + } + if tui.popup == Popup::Help { + tui.draw_help(frame, area); + return; + } + + // Header. + let header_height = 1u16; + let top_height = tui.top_height(engine, args, area.width); + let footer_height = if tui.popup == Popup::Filter || tui.popup == Popup::Kill { 3 } else { 0 }; + let main_height = area.height.saturating_sub(header_height + top_height + footer_height); + + let header_area = Rect { x: 0, y: 0, width: area.width, height: header_height }; + let top_area = Rect { x: 0, y: header_height, width: area.width, height: top_height }; + let main_area = Rect { x: 0, y: header_height + top_height, width: area.width, height: main_height }; + let footer_area = + Rect { x: 0, y: area.height.saturating_sub(footer_height), width: area.width, height: footer_height }; + + tui.draw_header(frame, header_area, engine, args); + tui.draw_top(frame, top_area, engine, args); + tui.draw_main(frame, main_area, engine, args); + if footer_height > 0 { + tui.draw_popup(frame, footer_area); + } +} + +impl Tui { + + fn top_height(&self, engine: &mut Engine, args: &Args, width: u16) -> u16 { + if args.disable_top { + return 1; + } + let mut height = 1usize; + for name in TOP_PLUGINS { + if let Some(plugin) = engine.plugins.get_mut(name) { + if !plugin.is_enabled() { + continue; + } + let ctx = render_ctx(args, &engine.runtime, width as usize); + let lines = split_lines(plugin.render(&ctx)); + height = height.max(lines.len()); + } + } + // Quicklook. + if let Some(plugin) = engine.plugins.get_mut("quicklook") { + if plugin.is_enabled() && !args.disable_quicklook { + let ql_width = quicklook_width(width); + let ctx = render_ctx(args, &engine.runtime, ql_width as usize); + let lines = split_lines(plugin.render(&ctx)); + height = height.max(lines.len()); + } + } + (height.min(20) as u16).max(1) + } + + fn draw_header( + &self, + frame: &mut Frame, + area: Rect, + engine: &mut Engine, + args: &Args, + ) { + let x = area.x; + // System information. + if let Some(plugin) = engine.plugins.get_mut("system") { + if plugin.is_enabled() { + let ctx = render_ctx(args, &engine.runtime, area.width as usize); + let lines = split_lines(plugin.render(&ctx)); + let text: String = lines.first().map(|l| l.iter().map(|s| s.msg.clone()).collect()).unwrap_or_default(); + let style = lines + .first() + .and_then(|l| l.first()) + .map(|s| style_for(s.decoration, args)) + .unwrap_or_default(); + let width = text.chars().count() as u16; + frame.render_widget(Paragraph::new(text).style(style), Rect { x, y: area.y, width: width.min(area.width), height: 1 }); + } + } + // Uptime on the right. + if let Some(plugin) = engine.plugins.get_mut("uptime") { + if plugin.is_enabled() { + let ctx = render_ctx(args, &engine.runtime, area.width as usize); + let lines = split_lines(plugin.render(&ctx)); + let text: String = lines.first().map(|l| l.iter().map(|s| s.msg.clone()).collect()).unwrap_or_default(); + let width = text.chars().count() as u16; + if width + 2 < area.width { + frame.render_widget( + Paragraph::new(text), + Rect { x: area.width.saturating_sub(width), y: area.y, width, height: 1 }, + ); + } + } + } + } + + fn draw_top(&self, frame: &mut Frame, area: Rect, engine: &mut Engine, args: &Args) { + let mut x = area.x; + // Collect the rendered blocks with their natural widths. + struct Block { + name: String, + lines: Vec>, + natural_width: usize, + } + let mut blocks: Vec = Vec::new(); + + if !args.disable_quicklook { + if let Some(plugin) = engine.plugins.get_mut("quicklook") { + if plugin.is_enabled() { + let width = quicklook_width(area.width) as usize; + let ctx = render_ctx(args, &engine.runtime, width); + let lines = split_lines(plugin.render(&ctx)); + let natural = lines.iter().map(|l| l.iter().map(|s| s.msg.chars().count()).sum::()).max().unwrap_or(10); + blocks.push(Block { name: "quicklook".to_string(), lines, natural_width: natural.max(10) }); + } + } + } + for name in TOP_PLUGINS { + if let Some(plugin) = engine.plugins.get_mut(name) { + if !plugin.is_enabled() { + continue; + } + let ctx = render_ctx(args, &engine.runtime, area.width as usize); + let lines = split_lines(plugin.render(&ctx)); + let natural = lines.iter().map(|l| l.iter().map(|s| s.msg.chars().count()).sum::()).max().unwrap_or(10); + blocks.push(Block { name: name.to_string(), lines, natural_width: natural.max(10) }); + } + } + if blocks.is_empty() { + return; + } + + // Allocate the available width proportionally to the natural widths. + let total_natural: usize = blocks.iter().map(|b| b.natural_width).sum(); + let spacing = 2usize * blocks.len().saturating_sub(1); + let available = (area.width as usize).saturating_sub(spacing).max(blocks.len() * 8); + let mut widths: Vec = Vec::new(); + for block in &blocks { + let mut width = (available * block.natural_width / total_natural).max(8); + width = width.min(block.natural_width + 2); + widths.push(width as u16); + } + // Adjust the last block if the total exceeds the screen. + let total: usize = widths.iter().map(|w| *w as usize).sum::() + spacing; + if total > area.width as usize { + let excess = total - area.width as usize; + if let Some(last) = widths.last_mut() { + *last = last.saturating_sub(excess as u16).max(8); + } + } + + for (block, width) in blocks.into_iter().zip(widths) { + if x + width > area.x + area.width { + break; + } + draw_lines(frame, Rect { x, y: area.y, width, height: area.height }, &block.lines, args); + x += width + 2; + } + } + + fn draw_main(&self, frame: &mut Frame, area: Rect, engine: &mut Engine, args: &Args) { + if area.height == 0 { + return; + } + let left_width = if args.disable_left_sidebar { + 0 + } else { + (area.width as i32 - 105).clamp(23, 34) as u16 + }; + if left_width > 0 && area.width > left_width + 20 { + let mut y = area.y; + for name in LEFT_SIDEBAR { + if y >= area.y + area.height { + break; + } + let Some(plugin) = engine.plugins.get_mut(name) else { + continue; + }; + if !plugin.is_enabled() { + continue; + } + let ctx = render_ctx(args, &engine.runtime, left_width as usize); + let lines = split_lines(plugin.render(&ctx)); + if lines.is_empty() { + continue; + } + let height = (lines.len() as u16).min(area.y + area.height - y); + y += 1; // separator/blank line + if y >= area.y + area.height { + break; + } + let height = height.min(area.y + area.height - y); + draw_lines(frame, Rect { x: area.x, y, width: left_width, height }, &lines, args); + y += height; + } + } + + // Right side: process count + processes + alert footer. + let right_x = area.x + left_width + if left_width > 0 { 1 } else { 0 }; + let right_width = area.width.saturating_sub(right_x - area.x); + if right_width < 10 { + return; + } + let mut y = area.y; + + // Process count. + if let Some(plugin) = engine.plugins.get_mut("processcount") { + if plugin.is_enabled() { + let ctx = render_ctx(args, &engine.runtime, right_width as usize); + let lines = split_lines(plugin.render(&ctx)); + let height = (lines.len() as u16).min(2); + draw_lines(frame, Rect { x: right_x, y, width: right_width, height }, &lines, args); + y += height; + } + } + + // Alerts footer (up to 4 lines). + let event_lines = self.event_lines(engine, right_width as usize); + let alert_height = event_lines.len().min(5) as u16; + let list_height = area.y + area.height - y; + let process_height = list_height.saturating_sub(alert_height); + + let list_name = if args.programs { "programlist" } else { "processlist" }; + if let Some(plugin) = engine.plugins.get_mut(list_name) { + if plugin.is_enabled() && process_height > 1 { + let ctx = render_ctx(args, &engine.runtime, right_width as usize); + let mut lines = split_lines(plugin.render(&ctx)); + lines.truncate(process_height as usize); + draw_lines(frame, Rect { x: right_x, y, width: right_width, height: process_height }, &lines, args); + } + } + y += process_height; + if alert_height > 0 { + draw_lines( + frame, + Rect { x: right_x, y, width: right_width, height: alert_height }, + &event_lines, + args, + ); + } + } + + fn event_lines(&self, engine: &Engine, _width: usize) -> Vec> { + let events = glances_core::events::events().lock().unwrap(); + let mut lines = Vec::new(); + if events.is_empty() { + return lines; + } + lines.push(vec![StatLine::new("EVENTS HISTORY").decoration(Decoration::Title)]); + for event in events.events.iter().rev().take(4) { + let state = match event.state.as_str() { + "CRITICAL" => Decoration::CriticalLog, + "WARNING" => Decoration::WarningLog, + _ => Decoration::Default, + }; + let end = if event.is_ongoing() { String::new() } else { " (ended)".to_string() }; + lines.push(vec![ + StatLine::new(format!("{} ", event.state)).decoration(state), + StatLine::new(format!("{} (avg {:.1}){}", event.r#type, event.avg, end)), + ]); + } + let _ = engine; + lines + } + + fn draw_popup(&self, frame: &mut Frame, area: Rect) { + let (title, prompt) = match self.popup { + Popup::Filter => ("Filter", format!("Process filter: {}_", self.input)), + Popup::Kill => ("Kill process", "Confirm ([y]es/[n]o): ".to_string()), + _ => return, + }; + let block = Block::default().borders(Borders::ALL).title(title); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(prompt).block(block), area); + } + + fn draw_help(&self, frame: &mut Frame, area: Rect) { + let lines = vec![ + Line::from("Glances-rs help"), + Line::from(""), + Line::from("q / ESC Quit"), + Line::from("h Toggle this help"), + Line::from("0 Irix mode (disable-irix)"), + Line::from("1 Per CPU mode"), + Line::from("2 Disable left sidebar"), + Line::from("3 Disable quicklook"), + Line::from("4 Full quicklook"), + Line::from("5 Disable top menu"), + Line::from("6 Mean GPU mode"), + Line::from("a c m i o p t u Sort processes (auto/cpu/mem/io/cpu_time/name/time/user)"), + Line::from(" Cycle sort key"), + Line::from("UP/DOWN Select process"), + Line::from("+ / - Increase/decrease nice level"), + Line::from("k Kill selected process"), + Line::from("f / E Edit/erase process filter"), + Line::from("z Disable/enable processes"), + Line::from("e Extended stats for the selected process"), + Line::from("w / x Clean warning/critical events"), + Line::from("b Byte mode (network)"), + Line::from("T / U Network sum / cumulative"), + Line::from("B / L Disk IOPS / latency"), + Line::from("F FS free space"), + Line::from("S Sparkline"), + Line::from("M Reset min/max"), + Line::from("d n s l D C I V W P K Toggle plugins"), + ]; + let block = Block::default().borders(Borders::ALL).title("Help"); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: false }), area); + } + + fn handle_key(&mut self, engine: &mut Engine, key: KeyEvent) { + let args = &mut engine.args; + // Popup handling first. + match self.popup { + Popup::Help => { + self.popup = Popup::None; + return; + } + Popup::Filter => match key.code { + KeyCode::Enter => { + let filter = self.input.clone(); + let mut procs = processes().lock().unwrap(); + procs.filter.set_filter(if filter.is_empty() { None } else { Some(&filter) }); + self.popup = Popup::None; + } + KeyCode::Esc => self.popup = Popup::None, + KeyCode::Backspace => { + self.input.pop(); + } + KeyCode::Char(c) => self.input.push(c), + _ => {} + }, + Popup::Kill => match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let cursor = engine.runtime.cursor_position.max(0) as usize; + let pid = processes() + .lock() + .unwrap() + .get_list(false) + .get(cursor) + .and_then(|p| p.get("pid")) + .and_then(serde_json::Value::as_i64); + if let Some(pid) = pid { + processes().lock().unwrap().kill(pid); + } + self.popup = Popup::None; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => self.popup = Popup::None, + _ => {} + }, + Popup::None => {} + } + if self.popup != Popup::None { + return; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.quit = true, + KeyCode::Char('h') => self.popup = Popup::Help, + KeyCode::Char('0') => engine.runtime.disable_irix = !engine.runtime.disable_irix, + KeyCode::Char('1') => { + args.percpu = !args.percpu; + engine.runtime.percpu = args.percpu; + } + KeyCode::Char('2') => args.disable_left_sidebar = !args.disable_left_sidebar, + KeyCode::Char('3') => args.disable_quicklook = !args.disable_quicklook, + KeyCode::Char('4') => args.full_quicklook = !args.full_quicklook, + KeyCode::Char('5') => args.disable_top = !args.disable_top, + KeyCode::Char('6') => { + args.meangpu = !args.meangpu; + engine.runtime.meangpu = args.meangpu; + } + KeyCode::Char('/') => engine.runtime.process_short_name = !engine.runtime.process_short_name, + KeyCode::Char('a') => set_sort(engine, "auto"), + KeyCode::Char('c') => set_sort(engine, "cpu_percent"), + KeyCode::Char('m') => set_sort(engine, "memory_percent"), + KeyCode::Char('i') => set_sort(engine, "io_counters"), + KeyCode::Char('o') => set_sort(engine, "cpu_num"), + KeyCode::Char('p') => set_sort(engine, "name"), + KeyCode::Char('t') => set_sort(engine, "cpu_times"), + KeyCode::Char('u') => set_sort(engine, "username"), + KeyCode::Char('<') => cycle_sort(engine, false), + KeyCode::Char('>') => cycle_sort(engine, true), + KeyCode::Up => move_cursor(engine, -1), + KeyCode::Down => move_cursor(engine, 1), + KeyCode::Char('+') => nice_change(engine, true), + KeyCode::Char('-') => nice_change(engine, false), + KeyCode::Char('k') => self.popup = Popup::Kill, + KeyCode::Char('f') => { + self.input = processes().lock().unwrap().filter.filter_input().unwrap_or("").to_string(); + self.popup = Popup::Filter; + } + KeyCode::Char('E') => processes().lock().unwrap().filter.set_filter(None), + KeyCode::Char('z') => toggle_processes(engine), + KeyCode::Char('e') => toggle_extended(engine), + KeyCode::Char('w') => { + glances_core::events::events().lock().unwrap().clean(false); + } + KeyCode::Char('x') => { + glances_core::events::events().lock().unwrap().clean(true); + } + KeyCode::Char('b') => args.byte = !args.byte, + KeyCode::Char('T') => engine.runtime.network_sum = !engine.runtime.network_sum, + KeyCode::Char('U') => engine.runtime.network_cumul = !engine.runtime.network_cumul, + KeyCode::Char('B') => args.diskio_iops = !args.diskio_iops, + KeyCode::Char('L') => args.diskio_latency = !args.diskio_latency, + KeyCode::Char('F') => engine.runtime.fs_free_space = !engine.runtime.fs_free_space, + KeyCode::Char('S') => engine.runtime.sparkline = !engine.runtime.sparkline, + KeyCode::Char('M') => engine.runtime.reset_minmax_tag = !engine.runtime.reset_minmax_tag, + KeyCode::Char('j') => { + args.programs = !args.programs; + engine.runtime.programs = args.programs; + } + KeyCode::Char(c @ ('d' | 'n' | 's' | 'l' | 'D' | 'C' | 'I' | 'V' | 'W' | 'P' | 'K' | 'R')) => { + toggle_plugin_by_key(engine, c); + } + _ => {} + } + } +} + +fn render_ctx<'a>(args: &'a Args, runtime: &'a glances_core::Runtime, max_width: usize) -> RenderContext<'a> { + RenderContext { args, runtime, max_width } +} + +fn quicklook_width(width: u16) -> u16 { + ((width as f64 * 0.28) as u16).clamp(24, 48) +} + +fn split_lines(lines: Vec) -> Vec> { + let mut result: Vec> = vec![Vec::new()]; + for line in lines { + if line.msg == "\n" { + result.push(Vec::new()); + } else { + result.last_mut().unwrap().push(line); + } + } + result +} + +/// Draw a list of lines into a rect (a line group is a row). +fn draw_lines(frame: &mut Frame, area: Rect, lines: &[Vec], args: &Args) { + if area.width == 0 || area.height == 0 { + return; + } + let buffer = frame.buffer_mut(); + for (row, line) in lines.iter().take(area.height as usize).enumerate() { + let y = area.y + row as u16; + let mut x = area.x; + let mut decoration = Decoration::Default; + for segment in line { + if segment.decoration != Decoration::Default { + decoration = segment.decoration; + } + let style = style_for(decoration, args); + for c in segment.msg.chars() { + if x >= area.x + area.width { + break; + } + if let Some(cell) = buffer.cell_mut((x, y)) { + cell.set_char(c); + cell.set_style(style); + } + x += 1; + } + if x >= area.x + area.width { + break; + } + } + } +} + +fn style_for(decoration: Decoration, args: &Args) -> Style { + let bold = if args.disable_bold { Modifier::empty() } else { Modifier::BOLD }; + let bg = args.disable_bg; + match decoration { + Decoration::Default | Decoration::Password => Style::default(), + Decoration::Underline => Style::default().add_modifier(Modifier::UNDERLINED), + Decoration::Bold | Decoration::Selected | Decoration::Sort | Decoration::Filter => { + Style::default().add_modifier(bold | Modifier::UNDERLINED) + } + Decoration::Title => Style::default().add_modifier(bold), + Decoration::Ok | Decoration::Process | Decoration::Status => Style::default().fg(Color::Green), + Decoration::Max => Style::default().fg(Color::Green).add_modifier(bold), + Decoration::Careful | Decoration::Info => Style::default().fg(Color::Blue), + Decoration::CarefulLog => { + if bg { + Style::default().fg(Color::Blue).add_modifier(bold) + } else { + Style::default().bg(Color::Blue).add_modifier(bold) + } + } + Decoration::Warning => Style::default().fg(Color::Magenta).add_modifier(bold), + Decoration::WarningLog => { + if bg { + Style::default().fg(Color::Magenta).add_modifier(bold) + } else { + Style::default().bg(Color::Magenta).add_modifier(bold) + } + } + Decoration::Critical => Style::default().fg(Color::Red).add_modifier(bold), + Decoration::CriticalLog => { + if bg { + Style::default().fg(Color::Red).add_modifier(bold) + } else { + Style::default().bg(Color::Red).add_modifier(bold) + } + } + Decoration::OkLog => { + if bg { + Style::default().fg(Color::Green).add_modifier(bold) + } else { + Style::default().bg(Color::Green).add_modifier(bold) + } + } + Decoration::CpuTime => Style::default().fg(Color::Magenta), + Decoration::ProcessSelected => Style::default().fg(Color::Green).add_modifier(bold | Modifier::UNDERLINED), + Decoration::Error => Style::default().fg(Color::Red), + Decoration::Separator => Style::default().fg(Color::DarkGray), + } +} + +fn set_sort(_engine: &mut Engine, key: &str) { + let mut procs = processes().lock().unwrap(); + procs.set_sort_key(key, key != "auto"); + procs.auto_sort = key == "auto"; +} + +fn cycle_sort(engine: &mut Engine, forward: bool) { + const KEYS: [&str; 7] = ["cpu_percent", "memory_percent", "username", "cpu_times", "io_counters", "name", "cpu_num"]; + let current = processes().lock().unwrap().sort_key.clone(); + let index = KEYS.iter().position(|k| *k == current).unwrap_or(0); + let next = if forward { + (index + 1) % KEYS.len() + } else { + (index + KEYS.len() - 1) % KEYS.len() + }; + set_sort(engine, KEYS[next]); +} + +fn move_cursor(engine: &mut Engine, delta: i64) { + let len = processes().lock().unwrap().get_list(false).len() as i64; + let max = (len - 1).max(0); + engine.runtime.cursor_position = (engine.runtime.cursor_position + delta).clamp(0, max); +} + +fn nice_change(engine: &mut Engine, increase: bool) { + let cursor = engine.runtime.cursor_position.max(0) as usize; + let pid = processes() + .lock() + .unwrap() + .get_list(false) + .get(cursor) + .and_then(|p| p.get("pid")) + .and_then(serde_json::Value::as_i64); + if let Some(pid) = pid { + let procs = processes().lock().unwrap(); + if increase { + procs.nice_increase(pid); + } else { + procs.nice_decrease(pid); + } + } +} + +fn toggle_processes(engine: &mut Engine) { + let enabled = engine.plugins.get("processlist").map(|p| p.is_enabled()).unwrap_or(false); + for name in ["processcount", "processlist", "programlist"] { + if let Some(plugin) = engine.plugins.get_mut(name) { + plugin.set_enabled(!enabled); + } + } + engine.sync_enabled(); +} + +fn toggle_extended(engine: &mut Engine) { + let procs = processes().lock().unwrap(); + if procs.disable_extended_tag { + drop(procs); + processes().lock().unwrap().enable_extended(); + engine.args.enable_process_extended = true; + } else { + drop(procs); + processes().lock().unwrap().disable_extended(); + engine.args.enable_process_extended = false; + } +} + +fn toggle_plugin_by_key(engine: &mut Engine, key: char) { + let name = match key { + 'd' => "diskio", + 'n' => "network", + 's' => "sensors", + 'l' => "alert", + 'D' => "containers", + 'C' => "cloud", + 'I' => "ip", + 'V' => "vms", + 'W' => "wifi", + 'P' => "ports", + 'K' => "connections", + 'R' => "raid", + _ => return, + }; + if let Some(plugin) = engine.plugins.get_mut(name) { + let enabled = plugin.is_enabled(); + plugin.set_enabled(!enabled); + engine.sync_enabled(); + } +} + +/// Keep the `Align` import alive for future use. +#[allow(dead_code)] +fn unused(_: Align) {} + #[cfg(test)] mod tests { use super::*; + use ratatui::backend::TestBackend; + + fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { + let area = buffer.area(); + let mut out = String::new(); + for y in 0..area.height { + for x in 0..area.width { + if let Some(cell) = buffer.cell((x, y)) { + out.push_str(cell.symbol()); + } + } + out.push('\n'); + } + out + } #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + fn test_tui_layout_renders() { + use clap::Parser; + use glances_core::Config; + let mut args = Args::parse_from(["glances", "--disable-plugin", "all", "--enable-plugin", "cpu,mem,load,processlist,processcount,network,fs,diskio,now,system,uptime,quicklook"]); + args.time = Some(1.0); + let config = Config::empty(false); + let mut engine = Engine::new(args.clone(), config); + engine.update(); + engine.update(); + let backend = TestBackend::new(140, 40); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let tui = Tui { popup: Popup::None, input: String::new(), quit: false }; + terminal.draw(|frame| draw_frame(frame, &mut engine, &tui)).unwrap(); + let text = buffer_to_string(terminal.backend().buffer()); + println!("{text}"); + assert!(text.contains("CPU")); + assert!(text.contains("MEM")); + assert!(text.contains("Command") || text.contains("python") || text.contains("COMMAND")); } }