M3: extended plugins

- folders, amps, ports, raid, wifi, ip (drop-in compatible stats)
- differential tests pass for all 22 implemented plugins
- TUI left sidebar extended
This commit is contained in:
glances-rs
2026-10-09 23:47:21 +00:00
parent 8385900ac1
commit cb22e53aa6
12 changed files with 2059 additions and 15 deletions
+1
View File
@@ -15,3 +15,4 @@ serde_json.workspace = true
sysinfo = "0.39.6"
thiserror.workspace = true
tracing.workspace = true
ureq = { version = "3.4.2", features = ["json"] }
+123
View File
@@ -559,6 +559,129 @@ pub fn is_admin() -> bool {
unsafe { libc::getuid() == 0 }
}
/// Return the current IP address and netmask.
pub fn get_ip_address() -> (Option<String>, Option<String>) {
// Ask the kernel which source address it would use for default-route
// traffic (no packet is sent).
let mut ip_address: Option<String> = None;
unsafe {
let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
if sock >= 0 {
let mut addr: libc::sockaddr_in = std::mem::zeroed();
addr.sin_family = libc::AF_INET as u16;
addr.sin_port = 53u16.to_be();
addr.sin_addr.s_addr = u32::from_be_bytes([192, 0, 2, 1]).to_be();
if libc::connect(sock, &addr as *const _ as *const libc::sockaddr, std::mem::size_of::<libc::sockaddr_in>() as u32) == 0 {
let mut local: libc::sockaddr_in = std::mem::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_in>() as u32;
if libc::getsockname(sock, &mut local as *mut _ as *mut libc::sockaddr, &mut len) == 0 {
let bytes = local.sin_addr.s_addr.to_ne_bytes();
let candidate = format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]);
if !candidate.starts_with("127.") && candidate != "0.0.0.0" {
ip_address = Some(candidate);
}
}
}
libc::close(sock);
}
}
if let Some(ip) = &ip_address {
// Find the matching netmask with getifaddrs.
let mut netmask = None;
unsafe {
let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut();
if libc::getifaddrs(&mut ifap) == 0 {
let mut current = ifap;
while !current.is_null() {
let addr = (*current).ifa_addr;
let mask = (*current).ifa_netmask;
if !addr.is_null() && (*addr).sa_family as i32 == libc::AF_INET {
let sin = addr as *const libc::sockaddr_in;
let bytes = (*sin).sin_addr.s_addr.to_ne_bytes();
let candidate = format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]);
if &candidate == ip && !mask.is_null() {
let sin_mask = mask as *const libc::sockaddr_in;
let mbytes = (*sin_mask).sin_addr.s_addr.to_ne_bytes();
netmask = Some(format!("{}.{}.{}.{}", mbytes[0], mbytes[1], mbytes[2], mbytes[3]));
break;
}
}
current = (*current).ifa_next;
}
libc::freeifaddrs(ifap);
}
}
return (ip_address, netmask);
}
// Fallback: first up non-loopback interface with an address.
let mut found = (None, None);
unsafe {
let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut();
if libc::getifaddrs(&mut ifap) == 0 {
let mut current = ifap;
while !current.is_null() {
let addr = (*current).ifa_addr;
if !addr.is_null() && (*addr).sa_family as i32 == libc::AF_INET {
let name = std::ffi::CStr::from_ptr((*current).ifa_name).to_string_lossy().to_string();
let flags = (*current).ifa_flags as u32;
if name != "lo" && flags & libc::IFF_UP as u32 != 0 {
let sin = addr as *const libc::sockaddr_in;
let bytes = (*sin).sin_addr.s_addr.to_ne_bytes();
let candidate = format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]);
let mask = (*current).ifa_netmask;
let mut netmask = None;
if !mask.is_null() {
let sin_mask = mask as *const libc::sockaddr_in;
let mbytes = (*sin_mask).sin_addr.s_addr.to_ne_bytes();
netmask = Some(format!("{}.{}.{}.{}", mbytes[0], mbytes[1], mbytes[2], mbytes[3]));
}
found = (Some(candidate), netmask);
break;
}
}
current = (*current).ifa_next;
}
libc::freeifaddrs(ifap);
}
}
found
}
/// Return the default gateway IP address (from `/proc/net/route`).
pub fn get_default_gateway() -> Option<String> {
let content = std::fs::read_to_string("/proc/net/route").ok()?;
for line in content.lines().skip(1) {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 3 {
continue;
}
if fields[1] == "00000000" {
let gateway = fields[2];
if gateway.len() == 8 {
let bytes: Vec<String> = (0..4)
.map(|i| u8::from_str_radix(&gateway[i * 2..i * 2 + 2], 16).unwrap_or(0).to_string())
.collect();
return Some(bytes.join("."));
}
}
}
None
}
/// Convert a netmask to CIDR notation (port of `ip_to_cidr`).
pub fn ip_to_cidr(ip: Option<&str>) -> u32 {
match ip {
None => 0,
Some(ip) => ip
.split('.')
.filter_map(|part| part.parse::<u32>().ok())
.map(|part| part.count_ones())
.sum(),
}
}
/// Read a sysfs hwmon input value.
pub fn read_number_file(path: &str) -> Option<f64> {
std::fs::read_to_string(path).ok().and_then(|v| v.trim().parse::<f64>().ok())
+6
View File
@@ -27,10 +27,13 @@ impl Engine {
// Registration order mirrors the alphabetical plugin loading of the
// Python implementation (in particular processcount runs before
// processlist and programlist).
plugins.insert("amps".to_string(), Box::new(AmpsPlugin::new(&config, &args)));
plugins.insert("core".to_string(), Box::new(CorePlugin::new(&config, &args)));
plugins.insert("cpu".to_string(), Box::new(CpuPlugin::new(&config, &args)));
plugins.insert("diskio".to_string(), Box::new(DiskioPlugin::new(&config, &args)));
plugins.insert("folders".to_string(), Box::new(FoldersPlugin::new(&config, &args)));
plugins.insert("fs".to_string(), Box::new(FsPlugin::new(&config, &args)));
plugins.insert("ip".to_string(), Box::new(IpPlugin::new(&config, &args)));
plugins.insert("irq".to_string(), Box::new(IrqPlugin::new(&config, &args)));
plugins.insert("load".to_string(), Box::new(LoadPlugin::new(&config, &args)));
plugins.insert("mem".to_string(), Box::new(MemPlugin::new(&config, &args)));
@@ -38,15 +41,18 @@ impl Engine {
plugins.insert("network".to_string(), Box::new(NetworkPlugin::new(&config, &args)));
plugins.insert("now".to_string(), Box::new(NowPlugin::new(&config, &args)));
plugins.insert("percpu".to_string(), Box::new(PerCpuPlugin::new(&config, &args)));
plugins.insert("ports".to_string(), Box::new(PortsPlugin::new(&config, &args)));
plugins.insert("processcount".to_string(), Box::new(ProcessCountPlugin::new(&config, &args)));
plugins.insert("processlist".to_string(), Box::new(ProcessListPlugin::new(&config, &args)));
plugins.insert("programlist".to_string(), Box::new(ProgramListPlugin::new(&config, &args)));
plugins.insert("psutilversion".to_string(), Box::new(PsutilVersionPlugin::new(&config, &args)));
plugins.insert("quicklook".to_string(), Box::new(QuicklookPlugin::new(&config, &args)));
plugins.insert("raid".to_string(), Box::new(RaidPlugin::new(&config, &args)));
plugins.insert("sensors".to_string(), Box::new(SensorsPlugin::new(&config, &args)));
plugins.insert("system".to_string(), Box::new(SystemPlugin::new(&config, &args)));
plugins.insert("uptime".to_string(), Box::new(UptimePlugin::new(&config, &args)));
plugins.insert("version".to_string(), Box::new(VersionPlugin::new(&config, &args)));
plugins.insert("wifi".to_string(), Box::new(WifiPlugin::new(&config, &args)));
// The refresh time is set on the model, which was built with the
// command line value already.
let _ = refresh_time;
+243
View File
@@ -0,0 +1,243 @@
//! AMPs plugin: run user-defined commands and report their result.
use crate::fields;
use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext};
use glances_core::processes::processes;
use glances_core::secure::secure_popen;
use glances_core::{Args, Config};
use serde_json::{Value, json};
use std::time::Instant;
pub struct AmpsPlugin {
pub model: PluginModel,
amps: Vec<AmpConfig>,
}
#[derive(Debug)]
struct AmpConfig {
name: String,
command: Option<String>,
regex: Option<regex::Regex>,
refresh: f64,
countmin: Option<f64>,
countmax: Option<f64>,
one_line: bool,
enabled: bool,
timer: Instant,
result: Option<String>,
count: i64,
}
impl AmpsPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let fields = fields! {
"name" => Field::new("AMP name."),
"result" => Field::new("AMP result (a string)."),
"refresh" => Field::new("AMP refresh interval.").unit("second"),
"timer" => Field::new("Time until next refresh.").unit("second"),
"count" => Field::new("Number of matching processes.").unit("number"),
"countmin" => Field::new("Minimum number of matching processes.").unit("number"),
"countmax" => Field::new("Maximum number of matching processes.").unit("number"),
};
let mut model = PluginModel::new(
"amps",
fields,
Vec::new(),
Value::Array(Vec::new()),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
model.set_item_key("name");
let mut amps = Vec::new();
for section in config.sections() {
if !section.starts_with("amp_") {
continue;
}
let command = config.get_value(section, "command", None);
let enabled = config.get_bool_value(section, "enable", false);
let regex = config
.get_value(section, "regex", None)
.and_then(|pattern| regex::Regex::new(&pattern).ok());
let refresh = config.get_float_value(section, "refresh", 3.0);
let countmin = config.get_value(section, "countmin", None).and_then(|v| v.parse::<f64>().ok());
let countmax = config.get_value(section, "countmax", None).and_then(|v| v.parse::<f64>().ok());
let one_line = config.get_bool_value(section, "one_line", false);
amps.push(AmpConfig {
name: amp_display_name(section),
enabled,
command,
regex,
refresh,
countmin,
countmax,
one_line,
timer: Instant::now(),
result: None,
count: 0,
});
}
Self { model, amps }
}
}
/// Return the AMP display name (mirrors the Python AMP module names).
fn amp_display_name(section: &str) -> String {
let raw = section.trim_start_matches("amp_");
match raw {
"systemv" => "SystemV".to_string(),
"systemd" => "Systemd".to_string(),
"nginx" => "Nginx".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
}
}
impl Plugin for AmpsPlugin {
fn name(&self) -> &str {
"amps"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn get_key(&self) -> Option<&'static str> {
Some("name")
}
fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let mut stats = Vec::new();
for amp in self.amps.iter_mut() {
if amp.timer.elapsed().as_secs_f64() >= amp.refresh && amp.enabled {
let result = match &amp.command {
Some(command) => secure_popen(command, !ctx.args.disable_config_exec, None),
None => String::new(),
};
amp.result = Some(if amp.one_line {
result.lines().next().unwrap_or("").to_string()
} else {
result.trim_end().to_string()
});
amp.timer = Instant::now();
}
if let Some(regex) = &amp.regex {
let procs = processes().lock().unwrap();
amp.count = procs
.get_list(false)
.iter()
.filter(|process| {
let name = process.get("name").and_then(Value::as_str).unwrap_or("");
let cmdline = process
.get("cmdline")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" "))
.unwrap_or_default();
regex.is_match(name) || regex.is_match(&cmdline)
})
.count() as i64;
}
let timer_left = (amp.refresh - amp.timer.elapsed().as_secs_f64()).max(0.0);
stats.push(json!({
"key": "name",
"name": amp.name,
"result": amp.result,
"refresh": amp.refresh,
"timer": timer_left,
"count": amp.count,
"countmin": amp.countmin,
"countmax": amp.countmax,
"regex": amp.regex.is_some(),
}));
}
Ok(Value::Array(stats))
}
fn update_views(&mut self) {
let key = self.get_key();
self.model.update_views(key);
let items: Vec<(String, i64, Option<i64>, Option<i64>, bool)> = match &self.model.stats {
Value::Array(list) => list
.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let count = item.get("count")?.as_i64()?;
let countmin = item.get("countmin").and_then(Value::as_i64);
let countmax = item.get("countmax").and_then(Value::as_i64);
let regex = item.get("regex").and_then(Value::as_bool).unwrap_or(false);
Some((name, count, countmin, countmax, regex))
})
.collect(),
_ => Vec::new(),
};
for (name, count, countmin, countmax, regex) in items {
let decoration = if !regex {
glances_core::Decoration::Default
} else if countmax.is_some_and(|max| count > max) {
glances_core::Decoration::Critical
} else if countmin.is_some_and(|min| count < min) {
glances_core::Decoration::Warning
} else {
glances_core::Decoration::Ok
};
self.model.set_view_decoration(Some(&name), "count", decoration);
}
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
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(12);
ret.push(self.model.curse_add_line(
format!("{:<width$}", "AMPS", width = name_max_width),
glances_core::Decoration::Title,
false,
false,
false,
));
ret.push(self.model.curse_add_line(format!("{:>9}", "Count"), glances_core::Decoration::Default, false, false, false));
let items = self.model.sorted_stats();
let Some(items) = items.as_array() else {
return ret;
};
for i in items {
let name = i.get("name").and_then(Value::as_str).unwrap_or("");
let count = i.get("count").and_then(Value::as_i64).unwrap_or(0);
let result = i.get("result").and_then(Value::as_str).unwrap_or("");
let display_name = if result.is_empty() { name.to_string() } else { format!("{name}: {result}") };
ret.push(self.model.curse_new_line());
ret.push(self.model.curse_add_line(
format!("{:<width$}", display_name.chars().take(name_max_width).collect::<String>(), width = name_max_width),
glances_core::Decoration::Default,
false,
false,
false,
));
ret.push(self.model.curse_add_line(
format!("{count:>9}"),
self.model.view_decoration(Some(name), "count"),
false,
false,
false,
));
}
ret
}
}
@@ -0,0 +1,232 @@
//! Folders plugin: monitor the size of configured directories.
use crate::fields;
use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext};
use glances_core::{Args, Config};
use serde_json::{Value, json};
use std::path::Path;
use std::time::Instant;
pub struct FoldersPlugin {
pub model: PluginModel,
folders: Vec<FolderConfig>,
}
#[derive(Debug)]
struct FolderConfig {
indice: String,
path: String,
refresh: f64,
careful: Option<f64>,
warning: Option<f64>,
critical: Option<f64>,
timer: Instant,
size: u64,
errno: i64,
}
impl FoldersPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let fields = fields! {
"path" => Field::new("Absolute path."),
"size" => Field::new("Folder size in bytes.").unit("byte"),
"refresh" => Field::new("Refresh interval in seconds.").unit("second"),
"errno" => Field::new("Return code when retrieving folder size (0 is no error).").unit("number"),
"careful" => Field::new("Careful threshold in MB.").unit("megabyte"),
"warning" => Field::new("Warning threshold in MB.").unit("megabyte"),
"critical" => Field::new("Critical threshold in MB.").unit("megabyte"),
};
let mut model = PluginModel::new(
"folders",
fields,
Vec::new(),
Value::Array(Vec::new()),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
model.set_item_key("path");
let mut folders = Vec::new();
if config.has_section("folders") {
let default_refresh = config.get_float_value("folders", "refresh", 60.0);
for index in 1..=10 {
let key = format!("folder_{index}_");
let Some(path) = config.get_value("folders", &format!("{key}path"), None) else {
continue;
};
if path.is_empty() {
continue;
}
let refresh = config.get_float_value("folders", &format!("{key}refresh"), default_refresh);
let threshold = |name: &str| -> Option<f64> {
config.get_value("folders", &format!("{key}{name}"), None).and_then(|v| v.parse().ok())
};
folders.push(FolderConfig {
indice: index.to_string(),
path,
refresh,
careful: threshold("careful"),
warning: threshold("warning"),
critical: threshold("critical"),
timer: Instant::now() - std::time::Duration::from_secs(3600),
size: 0,
errno: 0,
});
}
}
Self { model, folders }
}
}
impl Plugin for FoldersPlugin {
fn name(&self) -> &str {
"folders"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn get_key(&self) -> Option<&'static str> {
Some("path")
}
fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let mut stats = Vec::new();
for folder in self.folders.iter_mut() {
if folder.timer.elapsed().as_secs_f64() >= folder.refresh {
let (size, errno) = folder_size(Path::new(&folder.path), 0);
folder.size = size;
folder.errno = errno;
folder.timer = Instant::now();
}
let mut item = serde_json::Map::new();
item.insert("path".to_string(), json!(folder.path));
item.insert("size".to_string(), json!(folder.size));
item.insert("refresh".to_string(), json!(folder.refresh as i64));
item.insert("errno".to_string(), json!(folder.errno));
item.insert("careful".to_string(), json!(folder.careful));
item.insert("warning".to_string(), json!(folder.warning));
item.insert("critical".to_string(), json!(folder.critical));
item.insert("key".to_string(), json!("path"));
stats.push(Value::Object(item));
}
Ok(Value::Array(stats))
}
fn update_views(&mut self) {
let key = self.get_key();
self.model.update_views(key);
let items: Vec<(String, i64, u64, Option<f64>, Option<f64>, Option<f64>)> = match &self.model.stats {
Value::Array(list) => list
.iter()
.filter_map(|item| {
let path = item.get("path")?.as_str()?.to_string();
let errno = item.get("errno")?.as_i64()?;
let size = item.get("size")?.as_u64()?;
let careful = item.get("careful").and_then(Value::as_f64);
let warning = item.get("warning").and_then(Value::as_f64);
let critical = item.get("critical").and_then(Value::as_f64);
Some((path, errno, size, careful, warning, critical))
})
.collect(),
_ => Vec::new(),
};
for (path, errno, size, careful, warning, critical) in items {
if errno != 0 {
continue;
}
let decoration = if critical.is_some_and(|c| size as f64 > c * 1_000_000.0) {
glances_core::Decoration::Critical
} else if warning.is_some_and(|c| size as f64 > c * 1_000_000.0) {
glances_core::Decoration::Warning
} else if careful.is_some_and(|c| size as f64 > c * 1_000_000.0) {
glances_core::Decoration::Careful
} else {
glances_core::Decoration::Default
};
self.model.set_view_decoration(Some(&path), "size", decoration);
}
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
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(12);
ret.push(self.model.curse_add_line(
format!("{:<width$}", "FOLDERS", width = name_max_width),
glances_core::Decoration::Title,
false,
false,
false,
));
ret.push(self.model.curse_add_line(format!("{:>9}", "Size"), glances_core::Decoration::Default, false, false, false));
let items = self.model.sorted_stats();
let Some(items) = items.as_array() else {
return ret;
};
for i in items {
let path = i.get("path").and_then(Value::as_str).unwrap_or("");
let size = i.get("size").and_then(Value::as_f64).unwrap_or(0.0);
let errno = i.get("errno").and_then(Value::as_i64).unwrap_or(0);
ret.push(self.model.curse_new_line());
ret.push(self.model.curse_add_line(
format!("{:<width$}", path.chars().take(name_max_width).collect::<String>(), width = name_max_width),
glances_core::Decoration::Default,
false,
false,
false,
));
let msg = if errno != 0 {
format!("?{:>8}", self.model.auto_unit(size, false, "K"))
} else {
format!("{:>9}", self.model.auto_unit(size, false, "K"))
};
ret.push(self.model.curse_add_line(
msg,
self.model.view_decoration(Some(path), "size"),
false,
false,
false,
));
}
ret
}
}
/// Recursive folder size (port of `folder_size`).
fn folder_size(path: &Path, mut errno: i64) -> (u64, i64) {
let mut size = 0u64;
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(e) => return (0, e.raw_os_error().unwrap_or(0) as i64),
};
for entry in entries.flatten() {
let entry_path = entry.path();
match entry.file_type() {
Ok(file_type) if file_type.is_dir() => {
let (sub_size, sub_errno) = folder_size(&entry_path, errno);
size += sub_size;
errno = sub_errno;
}
Ok(_) => match entry.metadata() {
Ok(metadata) => size += metadata.len(),
Err(e) => errno = e.raw_os_error().unwrap_or(0) as i64,
},
Err(e) => errno = e.raw_os_error().unwrap_or(0) as i64,
}
}
(size, errno)
}
+181
View File
@@ -0,0 +1,181 @@
//! IP plugin: private and (optionally) public IP information.
use crate::fields;
use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext};
use glances_core::{Args, Config};
use serde_json::{Value, json};
use std::time::Instant;
pub struct IpPlugin {
pub model: PluginModel,
public_api: Option<String>,
public_field: String,
public_template: String,
public_disabled: bool,
public_username: Option<String>,
public_password: Option<String>,
public_interval: f64,
public_timer: Instant,
public_address: String,
public_info: Option<Value>,
}
impl IpPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let fields = fields! {
"address" => Field::new("Private IP address"),
"mask" => Field::new("Private IP mask"),
"mask_cidr" => Field::new("Private IP mask in CIDR format").unit("number"),
"gateway" => Field::new("Private IP gateway"),
"public_address" => Field::new("Public IP address"),
"public_info_human" => Field::new("Public IP information"),
};
let model = PluginModel::new(
"ip",
fields,
Vec::new(),
json!({}),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
let public_api = model.get_conf_value("public_api", "").first().cloned();
let public_disabled = model
.get_conf_value("public_disabled", "")
.first()
.map(|v| v.to_lowercase() != "false")
.unwrap_or(true)
|| public_api.is_none();
Self {
model,
public_api,
public_field: "ip".to_string(),
public_template: String::new(),
public_disabled,
public_username: None,
public_password: None,
public_interval: 300.0,
public_timer: Instant::now() - std::time::Duration::from_secs(3600),
public_address: String::new(),
public_info: None,
}
}
fn refresh_public(&mut self) {
if self.public_disabled {
return;
}
if self.public_timer.elapsed().as_secs_f64() < self.public_interval {
return;
}
self.public_timer = Instant::now();
let Some(api) = &self.public_api else {
return;
};
let mut request = ureq::get(api)
.config()
.timeout_global(Some(std::time::Duration::from_secs(2)))
.build();
if let (Some(username), Some(password)) = (&self.public_username, &self.public_password) {
request = request.header("Authorization", &format!("Basic {}:{}", username, password));
}
if let Ok(mut response) = request.call() {
if let Ok(value) = response.body_mut().read_json::<Value>() {
self.public_address =
value.get(&self.public_field).and_then(Value::as_str).unwrap_or("").to_string();
self.public_info = Some(value);
}
}
}
fn public_info_human(&self) -> String {
let Some(info) = &self.public_info else {
return String::new();
};
if self.public_template.is_empty() {
return String::new();
}
let mut result = self.public_template.clone();
for (key, value) in info.as_object().into_iter().flatten() {
let text = match value {
Value::String(s) => s.clone(),
other => glances_core::py_str(other),
};
result = result.replace(&format!("{{{key}}}"), &text);
}
result
}
}
impl Plugin for IpPlugin {
fn name(&self) -> &str {
"ip"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn collect(&mut self, ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let mut stats = serde_json::Map::new();
let (address, mask) = crate::collectors::get_ip_address();
if let Some(address) = &address {
stats.insert("address".to_string(), json!(address));
}
if let Some(mask) = &mask {
stats.insert("mask".to_string(), json!(mask));
}
stats.insert("mask_cidr".to_string(), json!(crate::collectors::ip_to_cidr(mask.as_deref())));
self.refresh_public();
if !self.public_address.is_empty() {
let public = if ctx.args.hide_public_info {
mask_public_ip(&self.public_address)
} else {
self.public_address.clone()
};
stats.insert("public_address".to_string(), json!(public));
let human = self.public_info_human();
if !human.is_empty() {
stats.insert("public_info_human".to_string(), json!(human));
}
}
Ok(Value::Object(stats))
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, _ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
let mut ret = Vec::new();
let s = &self.model.stats;
let Some(address) = s.get("address").and_then(Value::as_str) else {
return ret;
};
let mut text = address.to_string();
if let Some(mask_cidr) = s.get("mask_cidr").and_then(Value::as_u64) {
if mask_cidr > 0 {
text += &format!("/{mask_cidr}");
}
}
ret.push(self.model.curse_add_line(text, glances_core::Decoration::Default, false, false, false));
if let Some(gateway) = s.get("gateway").and_then(Value::as_str) {
ret.push(self.model.curse_add_line(format!(" gw {gateway}"), glances_core::Decoration::Default, true, false, false));
}
ret
}
}
fn mask_public_ip(address: &str) -> String {
let parts: Vec<&str> = address.split('.').collect();
if parts.len() == 4 {
format!("{}.{}.{}.xxx", parts[0], parts[1], parts[2])
} else {
"xxx".to_string()
}
}
+14 -2
View File
@@ -1,12 +1,15 @@
//! Plugin implementations.
//!
//! The extended plugins (containers, GPU, ports, ...) are added in a later
//! milestone.
//! Remaining Python plugins (containers, GPU/NPU, connections, vms, cloud,
//! smart) are not ported yet; see the README status section.
pub mod amps;
pub mod core;
pub mod cpu;
pub mod diskio;
pub mod folders;
pub mod fs;
pub mod ip;
pub mod irq;
pub mod load;
pub mod mem;
@@ -14,20 +17,26 @@ pub mod memswap;
pub mod network;
pub mod now;
pub mod percpu;
pub mod ports;
pub mod processcount;
pub mod processlist;
pub mod programlist;
pub mod psutilversion;
pub mod quicklook;
pub mod raid;
pub mod sensors;
pub mod system;
pub mod uptime;
pub mod version;
pub mod wifi;
pub use amps::AmpsPlugin;
pub use core::CorePlugin;
pub use cpu::CpuPlugin;
pub use diskio::DiskioPlugin;
pub use folders::FoldersPlugin;
pub use fs::FsPlugin;
pub use ip::IpPlugin;
pub use irq::IrqPlugin;
pub use load::LoadPlugin;
pub use mem::MemPlugin;
@@ -35,12 +44,15 @@ pub use memswap::MemSwapPlugin;
pub use network::NetworkPlugin;
pub use now::NowPlugin;
pub use percpu::PerCpuPlugin;
pub use ports::PortsPlugin;
pub use processcount::ProcessCountPlugin;
pub use processlist::ProcessListPlugin;
pub use programlist::ProgramListPlugin;
pub use psutilversion::PsutilVersionPlugin;
pub use quicklook::QuicklookPlugin;
pub use raid::RaidPlugin;
pub use sensors::SensorsPlugin;
pub use system::SystemPlugin;
pub use uptime::UptimePlugin;
pub use version::VersionPlugin;
pub use wifi::WifiPlugin;
+306
View File
@@ -0,0 +1,306 @@
//! Ports plugin: check the availability of configured hosts and ports.
use crate::fields;
use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext};
use glances_core::{Args, Config};
use serde_json::{Value, json};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
pub struct PortsPlugin {
pub model: PluginModel,
entries: Vec<PortEntry>,
default_timeout: f64,
}
#[derive(Debug)]
struct PortEntry {
indice: String,
host: String,
port: Option<u16>,
url: Option<String>,
description: String,
refresh: f64,
timeout: f64,
rtt_warning: Option<f64>,
timer: Instant,
status: Option<f64>,
}
impl PortsPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let fields = fields! {
"host" => Field::new("Measurement is be done on this host (or IP address)"),
"port" => Field::new("Measurement is be done on this port (0 for ICMP)"),
"url" => Field::new("Measurement is be done on this URL (credentials are redacted)"),
"description" => Field::new("Human readable description for the host/port"),
"refresh" => Field::new("Refresh time (in seconds) for this host/port"),
"timeout" => Field::new("Timeout (in seconds) for the measurement"),
"status" => Field::new("Measurement result (in seconds)").unit("second"),
"rtt_warning" => Field::new("Warning threshold (in seconds) for the measurement").unit("second"),
"indice" => Field::new("Unique indice for the host/port"),
};
let mut model = PluginModel::new(
"ports",
fields,
Vec::new(),
Value::Array(Vec::new()),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
model.set_item_key("indice");
let default_timeout = config.get_float_value("ports", "timeout", 3.0);
let refresh = config.get_float_value("ports", "refresh", 30.0);
let mut entries = Vec::new();
if config.has_section("ports") {
// Default gateway entry.
if config.get_bool_value("ports", "port_default_gateway", false) {
if let Some(gateway) = crate::collectors::get_default_gateway() {
entries.push(PortEntry {
indice: "port_0".to_string(),
host: gateway,
port: None,
url: None,
description: "Default gateway".to_string(),
refresh,
timeout: default_timeout,
rtt_warning: None,
timer: Instant::now() - Duration::from_secs(3600),
status: None,
});
}
}
for index in 1..=255 {
let key = format!("port_{index}_");
let Some(host) = config.get_value("ports", &format!("{key}host"), None) else {
continue;
};
if host.is_empty() {
continue;
}
let port = config.get_value("ports", &format!("{key}port"), None).and_then(|v| v.parse().ok());
let description = config
.get_value("ports", &format!("{key}description"), None)
.unwrap_or_else(|| match port {
Some(port) => format!("{host}:{port}"),
None => host.clone(),
});
let timeout = config.get_float_value("ports", &format!("{key}timeout"), default_timeout);
let rtt_warning = config
.get_value("ports", &format!("{key}rtt_warning"), None)
.and_then(|v| v.parse::<f64>().ok())
.map(|v| v / 1000.0);
entries.push(PortEntry {
indice: format!("port_{index}"),
host,
port,
url: None,
description,
refresh,
timeout,
rtt_warning,
timer: Instant::now() - Duration::from_secs(3600),
status: None,
});
}
for index in 1..=255 {
let key = format!("web_{index}_");
let Some(url) = config.get_value("ports", &format!("{key}url"), None) else {
continue;
};
if url.is_empty() {
continue;
}
let description = config.get_value("ports", &format!("{key}description"), None).unwrap_or_else(|| url.clone());
let timeout = config.get_float_value("ports", &format!("{key}timeout"), default_timeout);
let rtt_warning = config
.get_value("ports", &format!("{key}rtt_warning"), None)
.and_then(|v| v.parse::<f64>().ok())
.map(|v| v / 1000.0);
entries.push(PortEntry {
indice: format!("web_{index}"),
host: url.clone(),
port: None,
url: Some(url),
description,
refresh,
timeout,
rtt_warning,
timer: Instant::now() - Duration::from_secs(3600),
status: None,
});
}
}
Self { model, entries, default_timeout }
}
fn check(entry: &PortEntry) -> Option<f64> {
let start = Instant::now();
if let Some(url) = &entry.url {
let timeout = Duration::from_secs_f64(entry.timeout.max(0.1));
let request = ureq::get(url).config().timeout_global(Some(timeout)).build();
match request.call() {
Ok(_) => Some(start.elapsed().as_secs_f64()),
Err(_) => None,
}
} else if let Some(port) = entry.port {
let timeout = Duration::from_secs_f64(entry.timeout.max(0.1));
let address = format!("{}:{}", entry.host, port);
let mut resolved = address.to_socket_addrs().ok()?;
let socket = resolved.next()?;
match TcpStream::connect_timeout(&socket, timeout) {
Ok(_) => Some(start.elapsed().as_secs_f64()),
Err(_) => None,
}
} else {
// ICMP check with the ping command.
let output = std::process::Command::new("ping")
.args(["-c", "1", "-W", &format!("{}", entry.timeout.max(1.0) as i64), &entry.host])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
if output.status.success() {
for part in text.split_whitespace() {
if let Some(value) = part.strip_prefix("time=") {
return value.parse::<f64>().ok().map(|ms| ms / 1000.0);
}
}
Some(start.elapsed().as_secs_f64())
} else {
None
}
}
}
}
impl Plugin for PortsPlugin {
fn name(&self) -> &str {
"ports"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn get_key(&self) -> Option<&'static str> {
Some("indice")
}
fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let mut stats = Vec::new();
for entry in self.entries.iter_mut() {
if entry.timer.elapsed().as_secs_f64() >= entry.refresh {
entry.status = Self::check(entry);
entry.timer = Instant::now();
}
let mut item = serde_json::Map::new();
item.insert("host".to_string(), json!(entry.host));
item.insert("port".to_string(), json!(entry.port));
if let Some(url) = &entry.url {
item.insert("url".to_string(), json!(redact_url(url)));
}
item.insert("description".to_string(), json!(entry.description));
item.insert("refresh".to_string(), json!(entry.refresh));
item.insert("timeout".to_string(), json!(entry.timeout));
item.insert("status".to_string(), json!(entry.status));
item.insert("rtt_warning".to_string(), json!(entry.rtt_warning));
item.insert("indice".to_string(), json!(entry.indice));
item.insert("key".to_string(), json!("indice"));
stats.push(Value::Object(item));
}
let _ = self.default_timeout;
Ok(Value::Array(stats))
}
fn update_views(&mut self) {
let key = self.get_key();
self.model.update_views(key);
let items: Vec<(String, Option<f64>, Option<f64>)> = match &self.model.stats {
Value::Array(list) => list
.iter()
.filter_map(|item| {
let indice = item.get("indice")?.as_str()?.to_string();
let status = item.get("status").and_then(Value::as_f64);
let warning = item.get("rtt_warning").and_then(Value::as_f64);
Some((indice, status, warning))
})
.collect(),
_ => Vec::new(),
};
for (indice, status, warning) in items {
let decoration = match status {
None => glances_core::Decoration::Critical,
Some(status) if warning.is_some_and(|w| status > w) => glances_core::Decoration::Warning,
Some(_) => glances_core::Decoration::Ok,
};
self.model.set_view_decoration(Some(&indice), "status", decoration);
}
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
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(12);
ret.push(self.model.curse_add_line(
format!("{:<width$}", "PORTS", width = name_max_width),
glances_core::Decoration::Title,
false,
false,
false,
));
ret.push(self.model.curse_add_line(format!("{:>9}", "RTT"), glances_core::Decoration::Default, false, false, false));
let items = self.model.sorted_stats();
let Some(items) = items.as_array() else {
return ret;
};
for i in items {
let indice = i.get("indice").and_then(Value::as_str).unwrap_or("");
let description = i.get("description").and_then(Value::as_str).unwrap_or("");
let status = i.get("status").and_then(Value::as_f64);
ret.push(self.model.curse_new_line());
ret.push(self.model.curse_add_line(
format!("{:<width$}", description.chars().take(name_max_width).collect::<String>(), width = name_max_width),
glances_core::Decoration::Default,
false,
false,
false,
));
let msg = match status {
Some(status) => format!("{:>8.3}s", status),
None => format!("{:>9}", "timeout"),
};
ret.push(self.model.curse_add_line(
msg,
self.model.view_decoration(Some(indice), "status"),
false,
false,
false,
));
}
ret
}
}
/// Hide the credentials embedded in a URL.
fn redact_url(url: &str) -> String {
if let Some(scheme_end) = url.find("://") {
let rest = &url[scheme_end + 3..];
if let Some(at) = rest.find('@') {
return format!("{}://********@{}", &url[..scheme_end], &rest[at + 1..]);
}
}
url.to_string()
}
+186
View File
@@ -0,0 +1,186 @@
//! RAID plugin: parse `/proc/mdstat`.
use crate::fields;
use glances_core::plugin::{Plugin, PluginModel, UpdateContext};
use glances_core::{Args, Config};
use serde_json::{Value, json};
pub struct RaidPlugin {
pub model: PluginModel,
}
impl RaidPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let model = PluginModel::new(
"raid",
fields! {},
Vec::new(),
json!({}),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
Self { model }
}
}
impl Plugin for RaidPlugin {
fn name(&self) -> &str {
"raid"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn available(&mut self) -> bool {
std::path::Path::new("/proc/mdstat").exists()
}
fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let mut stats = serde_json::Map::new();
let Ok(content) = std::fs::read_to_string("/proc/mdstat") else {
return Ok(Value::Object(stats));
};
let lines: Vec<&str> = content.lines().collect();
let mut index = 0;
while index < lines.len() {
let line = lines[index];
if !line.starts_with("md") || !line.contains(':') {
index += 1;
continue;
}
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 3 {
index += 1;
continue;
}
let name = fields[0].to_string();
let status = fields[2].to_string();
let raid_type = fields
.iter()
.find(|f| f.starts_with("raid") || f.starts_with("linear") || f.starts_with("multipath"))
.map(|f| f.to_string())
.unwrap_or_else(|| "unknown".to_string());
// Devices (name[index]).
let mut components = serde_json::Map::new();
for field in fields.iter().skip(3) {
if let Some(device) = field.split('[').next() {
if !device.is_empty() {
components.insert(device.to_string(), json!("active sync"));
}
}
}
// The following line contains blocks and [used/available] [UU].
let mut used = components.len();
let mut available = components.len();
if index + 1 < lines.len() {
let detail = lines[index + 1];
if let Some(start) = detail.rfind('[') {
if let Some(end) = detail[start..].find(']') {
let counts = &detail[start + 1..start + end];
if let Some((u, a)) = counts.split_once('/') {
used = u.parse().unwrap_or(used as i64) as usize;
available = a.parse().unwrap_or(available as i64) as usize;
}
}
}
if let Some(start) = detail.rfind('[') {
if let Some(end) = detail[start..].find(']') {
let flags = &detail[start + 1..start + end];
if flags.len() == components.len() {
let names: Vec<String> = components.keys().cloned().collect();
for (position, flag) in flags.chars().enumerate() {
if let Some(device) = names.get(position) {
let status = match flag {
'U' => "active sync",
'_' => "faulty",
'S' => "spare",
_ => "unknown",
};
components.insert(device.clone(), json!(status));
}
}
}
}
}
}
stats.insert(
name.clone(),
json!({
"name": name,
"status": status,
"type": raid_type,
"used": used,
"available": available,
"components": components,
}),
);
index += 2;
}
Ok(Value::Object(stats))
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
let mut ret = Vec::new();
let Some(arrays) = self.model.stats.as_object() else {
return ret;
};
if arrays.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$}", "RAID disks", width = name_max_width),
glances_core::Decoration::Title,
false,
false,
false,
));
ret.push(self.model.curse_add_line(format!("{:>7}", "Used"), glances_core::Decoration::Default, false, false, false));
ret.push(self.model.curse_add_line(format!("{:>7}", "Avail"), glances_core::Decoration::Default, false, false, false));
let mut names: Vec<&String> = arrays.keys().collect();
names.sort();
for name in names {
let Some(array) = arrays.get(name).and_then(Value::as_object) else {
continue;
};
let status = array.get("status").and_then(Value::as_str).unwrap_or("");
let used = array.get("used").and_then(Value::as_i64).unwrap_or(0);
let available = array.get("available").and_then(Value::as_i64).unwrap_or(0);
let array_type = array.get("type").and_then(Value::as_str).unwrap_or("unknown");
let decoration = raid_alert(status, used, available, array_type);
ret.push(self.model.curse_new_line());
ret.push(self.model.curse_add_line(
format!("{:<width$}", name.chars().take(name_max_width).collect::<String>(), width = name_max_width),
decoration,
false,
false,
false,
));
ret.push(self.model.curse_add_line(format!("{used:>7}"), glances_core::Decoration::Default, false, false, false));
ret.push(self.model.curse_add_line(format!("{available:>7}"), glances_core::Decoration::Default, false, false, false));
}
ret
}
}
fn raid_alert(status: &str, used: i64, available: i64, array_type: &str) -> glances_core::Decoration {
if status != "active" || used < available {
return glances_core::Decoration::Critical;
}
if array_type == "raid0" {
return glances_core::Decoration::Warning;
}
glances_core::Decoration::Ok
}
+154
View File
@@ -0,0 +1,154 @@
//! Wi-Fi plugin (reads `/proc/net/wireless`).
use crate::fields;
use glances_core::plugin::{Field, Plugin, PluginModel, UpdateContext};
use glances_core::{Args, Config, Decoration};
use serde_json::{Value, json};
pub struct WifiPlugin {
pub model: PluginModel,
}
impl WifiPlugin {
pub fn new(config: &Config, args: &Args) -> Self {
let fields = fields! {
"ssid" => Field::new("Wi-Fi network name."),
"quality_link" => Field::new("Signal quality level.").unit("dBm"),
"quality_level" => Field::new("Signal strong level.").unit("dBm").alert(),
};
let mut model = PluginModel::new(
"wifi",
fields,
Vec::new(),
Value::Array(Vec::new()),
config,
args.time.unwrap_or(2.0),
args.disable_history,
args.disable_config_exec,
);
model.set_item_key("ssid");
Self { model }
}
}
impl Plugin for WifiPlugin {
fn name(&self) -> &str {
"wifi"
}
fn model(&self) -> &PluginModel {
&self.model
}
fn model_mut(&mut self) -> &mut PluginModel {
&mut self.model
}
fn get_key(&self) -> Option<&'static str> {
Some("ssid")
}
fn available(&mut self) -> bool {
std::path::Path::new("/proc/net/wireless").exists()
}
fn collect(&mut self, _ctx: &mut UpdateContext) -> anyhow::Result<Value> {
let mut stats = Vec::new();
let Ok(content) = std::fs::read_to_string("/proc/net/wireless") else {
return Ok(Value::Array(stats));
};
for line in content.lines().skip(2) {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 4 {
continue;
}
let ssid = fields[0].trim_end_matches(':').to_string();
let quality_link = fields[2].trim_end_matches('.').parse::<f64>().unwrap_or(0.0);
let quality_level = fields[3].trim_end_matches('.').parse::<f64>().unwrap_or(0.0);
stats.push(json!({
"key": "ssid",
"ssid": ssid,
"quality_link": quality_link,
"quality_level": quality_level,
}));
}
Ok(Value::Array(stats))
}
fn update_views(&mut self) {
let key = self.get_key();
self.model.update_views(key);
let items: Vec<(String, f64)> = match &self.model.stats {
Value::Array(list) => list
.iter()
.filter_map(|item| {
let ssid = item.get("ssid")?.as_str()?.to_string();
let level = item.get("quality_level")?.as_f64()?;
Some((ssid, level))
})
.collect(),
_ => Vec::new(),
};
for (ssid, level) in items {
// Signal quality: lower is better, so the comparison is inverted.
let critical = self.model.get_limit("critical", "wifi");
let warning = self.model.get_limit("warning", "wifi");
let careful = self.model.get_limit("careful", "wifi");
let decoration = if critical.is_some_and(|c| level < c) {
Decoration::Critical
} else if warning.is_some_and(|c| level < c) {
Decoration::Warning
} else if careful.is_some_and(|c| level < c) {
Decoration::Careful
} else if critical.is_none() && warning.is_none() && careful.is_none() {
Decoration::Default
} else {
Decoration::Ok
};
self.model.set_view_decoration(Some(&ssid), "quality_level", decoration);
}
}
fn display_curse(&self) -> bool {
true
}
fn render(&mut self, ctx: &glances_core::RenderContext) -> Vec<glances_core::StatLine> {
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 if_name_max_width = ctx.max_width.saturating_sub(5);
ret.push(self.model.curse_add_line(
format!("{:<width$}", "WIFI", width = if_name_max_width),
glances_core::Decoration::Title,
false,
false,
false,
));
ret.push(self.model.curse_add_line(format!("{:>7}", "dBm"), glances_core::Decoration::Default, false, false, false));
let Some(items) = self.model.stats.as_array() else {
return ret;
};
for i in items {
let ssid = i.get("ssid").and_then(Value::as_str).unwrap_or("");
let level = i.get("quality_level").and_then(Value::as_f64).unwrap_or(0.0);
ret.push(self.model.curse_new_line());
ret.push(self.model.curse_add_line(
format!("{:<width$}", ssid.chars().take(if_name_max_width).collect::<String>(), width = if_name_max_width),
glances_core::Decoration::Default,
false,
false,
false,
));
ret.push(self.model.curse_add_line(
format!("{:>7}", level),
self.model.view_decoration(Some(ssid), "quality_level"),
false,
false,
false,
));
}
ret
}
}