Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a0dc27846 | |||
| 5bc250a738 | |||
| 5c3ac8b15e | |||
| bdfff942f7 | |||
| 47ab1e387d | |||
| 966ba27b1e | |||
| 6c6c9144bd | |||
| 3fdcec8047 | |||
| 1fcaf4a670 | |||
| 885e19f7fd | |||
| a7b69b8ae7 | |||
| 2d290f40b2 | |||
| ad1fcaa27b | |||
| 60ab4d4f9e | |||
| 67034c84b9 | |||
| c62c7fa698 | |||
| 0b1d8c0a73 | |||
| c77aa6eaaa | |||
| 8a0e68f0e3 | |||
| 2d653fe9ae | |||
| caba78004e | |||
| 77bf08a978 | |||
| 929870f8b6 | |||
| 7aae852b7b | |||
| 40f3ff66d8 | |||
| 1c1beddb55 | |||
| 620d1f10b6 |
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.215"
|
||||
version = "0.1.240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -301,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.215"
|
||||
version = "0.1.240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -309,6 +309,7 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"cm-dashboard-shared",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"lettre",
|
||||
"reqwest",
|
||||
@@ -324,7 +325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.215"
|
||||
version = "0.1.240"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
@@ -552,6 +553,21 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
@@ -559,6 +575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -567,12 +584,34 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
@@ -591,8 +630,11 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.216"
|
||||
version = "0.1.241"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -20,4 +20,5 @@ gethostname = { workspace = true }
|
||||
chrono-tz = "0.8"
|
||||
toml = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
reqwest = { version = "0.11", features = ["json", "blocking"] }
|
||||
reqwest = { version = "0.11", features = ["json", "blocking"] }
|
||||
futures = "0.3"
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use gethostname::gethostname;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::interval;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
@@ -19,13 +19,22 @@ use crate::collectors::{
|
||||
use crate::notifications::NotificationManager;
|
||||
use cm_dashboard_shared::AgentData;
|
||||
|
||||
/// Wrapper for collectors with timing information
|
||||
struct TimedCollector {
|
||||
collector: Box<dyn Collector>,
|
||||
interval: Duration,
|
||||
last_collection: Option<Instant>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
pub struct Agent {
|
||||
hostname: String,
|
||||
config: AgentConfig,
|
||||
zmq_handler: ZmqHandler,
|
||||
collectors: Vec<Box<dyn Collector>>,
|
||||
collectors: Vec<TimedCollector>,
|
||||
notification_manager: NotificationManager,
|
||||
previous_status: Option<SystemStatus>,
|
||||
cached_agent_data: AgentData,
|
||||
}
|
||||
|
||||
/// Track system component status for change detection
|
||||
@@ -55,36 +64,78 @@ impl Agent {
|
||||
config.zmq.publisher_port
|
||||
);
|
||||
|
||||
// Initialize collectors
|
||||
let mut collectors: Vec<Box<dyn Collector>> = Vec::new();
|
||||
|
||||
// Initialize collectors with timing information
|
||||
let mut collectors: Vec<TimedCollector> = Vec::new();
|
||||
|
||||
// Add enabled collectors
|
||||
if config.collectors.cpu.enabled {
|
||||
collectors.push(Box::new(CpuCollector::new(config.collectors.cpu.clone())));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(CpuCollector::new(config.collectors.cpu.clone())),
|
||||
interval: Duration::from_secs(config.collectors.cpu.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "CPU".to_string(),
|
||||
});
|
||||
info!("CPU collector initialized with {}s interval", config.collectors.cpu.interval_seconds);
|
||||
}
|
||||
|
||||
|
||||
if config.collectors.memory.enabled {
|
||||
collectors.push(Box::new(MemoryCollector::new(config.collectors.memory.clone())));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(MemoryCollector::new(config.collectors.memory.clone())),
|
||||
interval: Duration::from_secs(config.collectors.memory.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "Memory".to_string(),
|
||||
});
|
||||
info!("Memory collector initialized with {}s interval", config.collectors.memory.interval_seconds);
|
||||
}
|
||||
|
||||
|
||||
if config.collectors.disk.enabled {
|
||||
collectors.push(Box::new(DiskCollector::new(config.collectors.disk.clone())));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(DiskCollector::new(config.collectors.disk.clone())),
|
||||
interval: Duration::from_secs(config.collectors.disk.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "Disk".to_string(),
|
||||
});
|
||||
info!("Disk collector initialized with {}s interval", config.collectors.disk.interval_seconds);
|
||||
}
|
||||
|
||||
|
||||
if config.collectors.systemd.enabled {
|
||||
collectors.push(Box::new(SystemdCollector::new(config.collectors.systemd.clone())));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(SystemdCollector::new(config.collectors.systemd.clone())),
|
||||
interval: Duration::from_secs(config.collectors.systemd.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "Systemd".to_string(),
|
||||
});
|
||||
info!("Systemd collector initialized with {}s interval", config.collectors.systemd.interval_seconds);
|
||||
}
|
||||
|
||||
|
||||
if config.collectors.backup.enabled {
|
||||
collectors.push(Box::new(BackupCollector::new()));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(BackupCollector::new()),
|
||||
interval: Duration::from_secs(config.collectors.backup.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "Backup".to_string(),
|
||||
});
|
||||
info!("Backup collector initialized with {}s interval", config.collectors.backup.interval_seconds);
|
||||
}
|
||||
|
||||
if config.collectors.network.enabled {
|
||||
collectors.push(Box::new(NetworkCollector::new(config.collectors.network.clone())));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(NetworkCollector::new(config.collectors.network.clone())),
|
||||
interval: Duration::from_secs(config.collectors.network.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "Network".to_string(),
|
||||
});
|
||||
info!("Network collector initialized with {}s interval", config.collectors.network.interval_seconds);
|
||||
}
|
||||
|
||||
if config.collectors.nixos.enabled {
|
||||
collectors.push(Box::new(NixOSCollector::new(config.collectors.nixos.clone())));
|
||||
collectors.push(TimedCollector {
|
||||
collector: Box::new(NixOSCollector::new(config.collectors.nixos.clone())),
|
||||
interval: Duration::from_secs(config.collectors.nixos.interval_seconds),
|
||||
last_collection: None,
|
||||
name: "NixOS".to_string(),
|
||||
});
|
||||
info!("NixOS collector initialized with {}s interval", config.collectors.nixos.interval_seconds);
|
||||
}
|
||||
|
||||
info!("Initialized {} collectors", collectors.len());
|
||||
@@ -93,6 +144,9 @@ impl Agent {
|
||||
let notification_manager = NotificationManager::new(&config.notifications, &hostname)?;
|
||||
info!("Notification manager initialized");
|
||||
|
||||
// Initialize cached agent data
|
||||
let cached_agent_data = AgentData::new(hostname.clone(), env!("CARGO_PKG_VERSION").to_string());
|
||||
|
||||
Ok(Self {
|
||||
hostname,
|
||||
config,
|
||||
@@ -100,6 +154,7 @@ impl Agent {
|
||||
collectors,
|
||||
notification_manager,
|
||||
previous_status: None,
|
||||
cached_agent_data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,24 +204,47 @@ impl Agent {
|
||||
async fn collect_and_broadcast(&mut self) -> Result<()> {
|
||||
debug!("Starting structured data collection");
|
||||
|
||||
// Initialize empty AgentData
|
||||
let mut agent_data = AgentData::new(self.hostname.clone(), env!("CARGO_PKG_VERSION").to_string());
|
||||
// Collect data from collectors whose intervals have elapsed
|
||||
// Update cached_agent_data with new data
|
||||
let now = Instant::now();
|
||||
for timed_collector in &mut self.collectors {
|
||||
let should_collect = match timed_collector.last_collection {
|
||||
None => true, // First collection
|
||||
Some(last_time) => now.duration_since(last_time) >= timed_collector.interval,
|
||||
};
|
||||
|
||||
// Collect data from all collectors
|
||||
for collector in &self.collectors {
|
||||
if let Err(e) = collector.collect_structured(&mut agent_data).await {
|
||||
error!("Collector failed: {}", e);
|
||||
// Continue with other collectors even if one fails
|
||||
if should_collect {
|
||||
if let Err(e) = timed_collector.collector.collect_structured(&mut self.cached_agent_data).await {
|
||||
error!("Collector {} failed: {}", timed_collector.name, e);
|
||||
// Update last_collection time even on failure to prevent immediate retries
|
||||
timed_collector.last_collection = Some(now);
|
||||
} else {
|
||||
timed_collector.last_collection = Some(now);
|
||||
debug!(
|
||||
"Collected from {} ({}s interval)",
|
||||
timed_collector.name,
|
||||
timed_collector.interval.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamp on cached data
|
||||
self.cached_agent_data.timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Clone for notification check (to avoid borrow issues)
|
||||
let agent_data_snapshot = self.cached_agent_data.clone();
|
||||
|
||||
// Check for status changes and send notifications
|
||||
if let Err(e) = self.check_status_changes_and_notify(&agent_data).await {
|
||||
if let Err(e) = self.check_status_changes_and_notify(&agent_data_snapshot).await {
|
||||
error!("Failed to check status changes: {}", e);
|
||||
}
|
||||
|
||||
// Broadcast the structured data via ZMQ
|
||||
if let Err(e) = self.zmq_handler.publish_agent_data(&agent_data).await {
|
||||
// Broadcast the cached structured data via ZMQ
|
||||
if let Err(e) = self.zmq_handler.publish_agent_data(&agent_data_snapshot).await {
|
||||
error!("Failed to broadcast agent data: {}", e);
|
||||
} else {
|
||||
debug!("Successfully broadcast structured agent data");
|
||||
|
||||
@@ -142,10 +142,16 @@ impl BackupCollector {
|
||||
// Build service list for this disk
|
||||
let services: Vec<String> = backup_status.services.keys().cloned().collect();
|
||||
|
||||
// Calculate total archives across all services on this disk
|
||||
let total_archives: i64 = backup_status.services.values()
|
||||
// Get min and max archive counts to detect inconsistencies
|
||||
let archives_min: i64 = backup_status.services.values()
|
||||
.map(|service| service.archive_count)
|
||||
.sum();
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
let archives_max: i64 = backup_status.services.values()
|
||||
.map(|service| service.archive_count)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Create disk data
|
||||
let disk_data = BackupDiskData {
|
||||
@@ -160,7 +166,8 @@ impl BackupCollector {
|
||||
disk_total_gb: total_gb,
|
||||
usage_status,
|
||||
services,
|
||||
total_archives,
|
||||
archives_min,
|
||||
archives_max,
|
||||
};
|
||||
|
||||
disks.push(disk_data);
|
||||
|
||||
@@ -119,6 +119,81 @@ impl CpuCollector {
|
||||
utils::parse_u64(content.trim())
|
||||
}
|
||||
|
||||
/// Collect static CPU information from /proc/cpuinfo (only once at startup)
|
||||
async fn collect_cpu_info(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
let content = utils::read_proc_file("/proc/cpuinfo")?;
|
||||
|
||||
let mut model_name: Option<String> = None;
|
||||
let mut core_count: u32 = 0;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("model name") {
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
let full_name = line[colon_pos + 1..].trim();
|
||||
// Extract just the model number (e.g., "i7-9700" from "Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz")
|
||||
let model = Self::extract_cpu_model(full_name);
|
||||
if model_name.is_none() {
|
||||
model_name = Some(model);
|
||||
}
|
||||
}
|
||||
} else if line.starts_with("processor") {
|
||||
core_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
agent_data.system.cpu.model_name = model_name;
|
||||
if core_count > 0 {
|
||||
agent_data.system.cpu.core_count = Some(core_count);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract CPU model number from full model name
|
||||
/// Examples:
|
||||
/// - "Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz" -> "i7-9700"
|
||||
/// - "AMD Ryzen 9 5950X 16-Core Processor" -> "Ryzen 9 5950X"
|
||||
fn extract_cpu_model(full_name: &str) -> String {
|
||||
// Look for common Intel patterns: i3, i5, i7, i9, Xeon
|
||||
if let Some(pos) = full_name.find("i3-") {
|
||||
if let Some(end) = full_name[pos..].find(' ') {
|
||||
return full_name[pos..pos + end].to_string();
|
||||
}
|
||||
}
|
||||
if let Some(pos) = full_name.find("i5-") {
|
||||
if let Some(end) = full_name[pos..].find(' ') {
|
||||
return full_name[pos..pos + end].to_string();
|
||||
}
|
||||
}
|
||||
if let Some(pos) = full_name.find("i7-") {
|
||||
if let Some(end) = full_name[pos..].find(' ') {
|
||||
return full_name[pos..pos + end].to_string();
|
||||
}
|
||||
}
|
||||
if let Some(pos) = full_name.find("i9-") {
|
||||
if let Some(end) = full_name[pos..].find(' ') {
|
||||
return full_name[pos..pos + end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Look for AMD Ryzen pattern
|
||||
if let Some(pos) = full_name.find("Ryzen") {
|
||||
// Extract "Ryzen X XXXX" pattern
|
||||
let after_ryzen = &full_name[pos..];
|
||||
let parts: Vec<&str> = after_ryzen.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
return format!("{} {} {}", parts[0], parts[1], parts[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return first 20 characters or full name if shorter
|
||||
if full_name.len() > 20 {
|
||||
full_name[..20].to_string()
|
||||
} else {
|
||||
full_name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect CPU C-state (idle depth) and populate AgentData with top 3 C-states by usage
|
||||
async fn collect_cstate(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
// Read C-state usage from first CPU (representative of overall system)
|
||||
@@ -192,6 +267,11 @@ impl Collector for CpuCollector {
|
||||
debug!("Collecting CPU metrics");
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Collect static CPU info (only once at startup)
|
||||
if agent_data.system.cpu.model_name.is_none() || agent_data.system.cpu.core_count.is_none() {
|
||||
self.collect_cpu_info(agent_data).await?;
|
||||
}
|
||||
|
||||
// Collect load averages (always available)
|
||||
self.collect_load_averages(agent_data).await?;
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ use async_trait::async_trait;
|
||||
use cm_dashboard_shared::{AgentData, DriveData, FilesystemData, PoolData, HysteresisThresholds, Status};
|
||||
|
||||
use crate::config::DiskConfig;
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use std::process::Command as StdCommand;
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
use super::{Collector, CollectorError};
|
||||
|
||||
@@ -67,8 +66,9 @@ impl DiskCollector {
|
||||
|
||||
/// Collect all storage data and populate AgentData
|
||||
async fn collect_storage_data(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
let start_time = Instant::now();
|
||||
debug!("Starting clean storage collection");
|
||||
// Clear drives and pools to prevent duplicates when updating cached data
|
||||
agent_data.system.storage.drives.clear();
|
||||
agent_data.system.storage.pools.clear();
|
||||
|
||||
// Step 1: Get mount points and their backing devices
|
||||
let mount_devices = self.get_mount_devices().await?;
|
||||
@@ -104,9 +104,6 @@ impl DiskCollector {
|
||||
self.populate_drives_data(&physical_drives, &smart_data, agent_data)?;
|
||||
self.populate_pools_data(&mergerfs_pools, &smart_data, agent_data)?;
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
debug!("Storage collection completed in {:?}", elapsed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -114,7 +111,7 @@ impl DiskCollector {
|
||||
async fn get_mount_devices(&self) -> Result<HashMap<String, String>, CollectorError> {
|
||||
use super::run_command_with_timeout;
|
||||
|
||||
let mut cmd = Command::new("lsblk");
|
||||
let mut cmd = TokioCommand::new("lsblk");
|
||||
cmd.args(&["-rn", "-o", "NAME,MOUNTPOINT"]);
|
||||
|
||||
let output = run_command_with_timeout(cmd, 2).await
|
||||
@@ -141,7 +138,6 @@ impl DiskCollector {
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Found {} mounted block devices", mount_devices.len());
|
||||
Ok(mount_devices)
|
||||
}
|
||||
|
||||
@@ -154,8 +150,8 @@ impl DiskCollector {
|
||||
Ok((total, used)) => {
|
||||
filesystem_usage.insert(mount_point.clone(), (total, used));
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to get filesystem info for {}: {}", mount_point, e);
|
||||
Err(_e) => {
|
||||
// Silently skip filesystems we can't read
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,8 +172,6 @@ impl DiskCollector {
|
||||
// Only add if we don't already have usage data for this mount point
|
||||
if !filesystem_usage.contains_key(&mount_point) {
|
||||
if let Ok((total, used)) = self.get_filesystem_info(&mount_point) {
|
||||
debug!("Added MergerFS filesystem usage for {}: {}GB total, {}GB used",
|
||||
mount_point, total as f32 / (1024.0 * 1024.0 * 1024.0), used as f32 / (1024.0 * 1024.0 * 1024.0));
|
||||
filesystem_usage.insert(mount_point, (total, used));
|
||||
}
|
||||
}
|
||||
@@ -189,7 +183,7 @@ impl DiskCollector {
|
||||
|
||||
/// Get filesystem info for a single mount point
|
||||
fn get_filesystem_info(&self, mount_point: &str) -> Result<(u64, u64), CollectorError> {
|
||||
let output = std::process::Command::new("timeout")
|
||||
let output = StdCommand::new("timeout")
|
||||
.args(&["2", "df", "--block-size=1", mount_point])
|
||||
.output()
|
||||
.map_err(|e| CollectorError::SystemRead {
|
||||
@@ -252,9 +246,8 @@ impl DiskCollector {
|
||||
} else {
|
||||
mount_point.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
|
||||
|
||||
if pool_name.is_empty() {
|
||||
debug!("Skipping mergerfs pool with empty name: {}", mount_point);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -282,8 +275,7 @@ impl DiskCollector {
|
||||
// Categorize as data vs parity drives
|
||||
let (data_drives, parity_drives) = match self.categorize_pool_drives(&all_member_paths) {
|
||||
Ok(drives) => drives,
|
||||
Err(e) => {
|
||||
debug!("Failed to categorize drives for pool {}: {}. Skipping.", mount_point, e);
|
||||
Err(_e) => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -298,8 +290,7 @@ impl DiskCollector {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Found {} mergerfs pools", pools.len());
|
||||
|
||||
Ok(pools)
|
||||
}
|
||||
|
||||
@@ -386,9 +377,9 @@ impl DiskCollector {
|
||||
device.to_string()
|
||||
}
|
||||
|
||||
/// Get SMART data for drives
|
||||
/// Get SMART data for drives in parallel
|
||||
async fn get_smart_data_for_drives(&self, physical_drives: &[PhysicalDrive], mergerfs_pools: &[MergerfsPool]) -> HashMap<String, SmartData> {
|
||||
let mut smart_data = HashMap::new();
|
||||
use futures::future::join_all;
|
||||
|
||||
// Collect all drive names
|
||||
let mut all_drives = std::collections::HashSet::new();
|
||||
@@ -404,9 +395,24 @@ impl DiskCollector {
|
||||
}
|
||||
}
|
||||
|
||||
// Get SMART data for each drive
|
||||
for drive_name in all_drives {
|
||||
if let Ok(data) = self.get_smart_data(&drive_name).await {
|
||||
// Collect SMART data for all drives in parallel
|
||||
let futures: Vec<_> = all_drives
|
||||
.iter()
|
||||
.map(|drive_name| {
|
||||
let drive = drive_name.clone();
|
||||
async move {
|
||||
let result = self.get_smart_data(&drive).await;
|
||||
(drive, result)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = join_all(futures).await;
|
||||
|
||||
// Build HashMap from results
|
||||
let mut smart_data = HashMap::new();
|
||||
for (drive_name, result) in results {
|
||||
if let Ok(data) = result {
|
||||
smart_data.insert(drive_name, data);
|
||||
}
|
||||
}
|
||||
@@ -420,7 +426,7 @@ impl DiskCollector {
|
||||
|
||||
// Use direct smartctl (no sudo) - service has CAP_SYS_RAWIO and CAP_SYS_ADMIN capabilities
|
||||
// For NVMe drives, specify device type explicitly
|
||||
let mut cmd = Command::new("smartctl");
|
||||
let mut cmd = TokioCommand::new("smartctl");
|
||||
if drive_name.starts_with("nvme") {
|
||||
cmd.args(&["-d", "nvme", "-a", &format!("/dev/{}", drive_name)]);
|
||||
} else {
|
||||
@@ -435,8 +441,10 @@ impl DiskCollector {
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
if !output.status.success() {
|
||||
// Return unknown data rather than failing completely
|
||||
// Note: smartctl returns non-zero exit codes for warnings (like exit code 32
|
||||
// for "temperature was high in the past"), but the output data is still valid.
|
||||
// Only check if we got any output at all, don't reject based on exit code.
|
||||
if output_str.is_empty() {
|
||||
return Ok(SmartData {
|
||||
health: "UNKNOWN".to_string(),
|
||||
serial_number: None,
|
||||
@@ -444,7 +452,7 @@ impl DiskCollector {
|
||||
wear_percent: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
let mut health = "UNKNOWN".to_string();
|
||||
let mut serial_number = None;
|
||||
let mut temperature = None;
|
||||
@@ -763,7 +771,7 @@ impl DiskCollector {
|
||||
/// Get drive information for a mount path
|
||||
fn get_drive_info_for_path(&self, path: &str) -> anyhow::Result<PoolDrive> {
|
||||
// Use lsblk to find the backing device with timeout
|
||||
let output = Command::new("timeout")
|
||||
let output = StdCommand::new("timeout")
|
||||
.args(&["2", "lsblk", "-rn", "-o", "NAME,MOUNTPOINT"])
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run lsblk: {}", e))?;
|
||||
@@ -785,20 +793,13 @@ impl DiskCollector {
|
||||
|
||||
// Extract base device name (e.g., "sda1" -> "sda")
|
||||
let base_device = self.extract_base_device(&format!("/dev/{}", device));
|
||||
|
||||
// Get temperature from SMART data if available
|
||||
let temperature = if let Ok(smart_data) = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.get_smart_data(&base_device))
|
||||
}) {
|
||||
smart_data.temperature_celsius
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
// Temperature will be filled in later from parallel SMART collection
|
||||
// Don't collect it here to avoid sequential blocking with problematic async nesting
|
||||
Ok(PoolDrive {
|
||||
name: base_device,
|
||||
mount_point: path.to_string(),
|
||||
temperature_celsius: temperature,
|
||||
temperature_celsius: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -200,13 +200,16 @@ impl Collector for MemoryCollector {
|
||||
debug!("Collecting memory metrics");
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Clear tmpfs list to prevent duplicates when updating cached data
|
||||
agent_data.system.memory.tmpfs.clear();
|
||||
|
||||
// Parse memory info from /proc/meminfo
|
||||
let info = self.parse_meminfo().await?;
|
||||
|
||||
// Populate memory data directly
|
||||
self.populate_memory_data(&info, agent_data).await?;
|
||||
|
||||
// Collect tmpfs data
|
||||
// Collect tmpfs data
|
||||
self.populate_tmpfs_data(agent_data).await?;
|
||||
|
||||
let duration = start.elapsed();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use cm_dashboard_shared::{AgentData};
|
||||
use std::process::{Command, Output};
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub mod backup;
|
||||
pub mod cpu;
|
||||
@@ -16,16 +15,34 @@ pub mod systemd;
|
||||
pub use error::CollectorError;
|
||||
|
||||
/// Run a command with a timeout to prevent blocking
|
||||
pub async fn run_command_with_timeout(mut cmd: Command, timeout_secs: u64) -> std::io::Result<Output> {
|
||||
/// Properly kills the process if timeout is exceeded
|
||||
pub async fn run_command_with_timeout(mut cmd: tokio::process::Command, timeout_secs: u64) -> std::io::Result<Output> {
|
||||
use tokio::time::timeout;
|
||||
use std::process::Stdio;
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
match timeout(timeout_duration, tokio::task::spawn_blocking(move || cmd.output())).await {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(e)) => Err(std::io::Error::new(std::io::ErrorKind::Other, e)),
|
||||
Err(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("Command timed out after {} seconds", timeout_secs)
|
||||
)),
|
||||
// Configure stdio to capture output
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let pid = child.id();
|
||||
|
||||
match timeout(timeout_duration, child.wait_with_output()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Timeout - force kill the process using system kill command
|
||||
if let Some(process_id) = pid {
|
||||
let _ = tokio::process::Command::new("kill")
|
||||
.args(&["-9", &process_id.to_string()])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("Command timed out after {} seconds", timeout_secs)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,13 +152,26 @@ impl SystemdCollector {
|
||||
|
||||
sub_services.push(SubServiceData {
|
||||
name: image_name.to_string(),
|
||||
service_status: self.calculate_service_status(&image_name, &image_status),
|
||||
service_status: Status::Info, // Informational only, no status icon
|
||||
metrics,
|
||||
service_type: "image".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if service_name == "openvpn-vpn-connection" && status_info.active_state == "active" {
|
||||
if let Some(external_ip) = self.get_vpn_external_ip() {
|
||||
let metrics = Vec::new();
|
||||
|
||||
sub_services.push(SubServiceData {
|
||||
name: format!("ip: {}", external_ip),
|
||||
service_status: Status::Info,
|
||||
metrics,
|
||||
service_type: "vpn_route".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create complete service data
|
||||
let service_data = ServiceData {
|
||||
name: service_name.clone(),
|
||||
@@ -836,11 +849,43 @@ impl SystemdCollector {
|
||||
_ => value, // Assume bytes if no unit
|
||||
}
|
||||
}
|
||||
|
||||
/// Get VPN external IP by querying through the vpn namespace
|
||||
fn get_vpn_external_ip(&self) -> Option<String> {
|
||||
let output = Command::new("timeout")
|
||||
.args(&[
|
||||
"5",
|
||||
"sudo",
|
||||
"ip",
|
||||
"netns",
|
||||
"exec",
|
||||
"vpn",
|
||||
"curl",
|
||||
"-s",
|
||||
"--max-time",
|
||||
"4",
|
||||
"https://ifconfig.me"
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
let ip = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !ip.is_empty() && ip.contains('.') {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Collector for SystemdCollector {
|
||||
async fn collect_structured(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
// Clear services to prevent duplicates when updating cached data
|
||||
agent_data.services.clear();
|
||||
|
||||
// Use cached complete data if available and fresh
|
||||
if let Some(cached_complete_services) = self.get_cached_complete_services() {
|
||||
for service_data in cached_complete_services {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.216"
|
||||
version = "0.1.241"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -142,6 +142,7 @@ impl Theme {
|
||||
/// Get color for status level
|
||||
pub fn status_color(status: Status) -> Color {
|
||||
match status {
|
||||
Status::Info => Self::muted_text(), // Gray for informational data
|
||||
Status::Ok => Self::success(),
|
||||
Status::Inactive => Self::muted_text(), // Gray for inactive services in service list
|
||||
Status::Pending => Self::highlight(), // Blue for pending
|
||||
@@ -240,6 +241,7 @@ impl StatusIcons {
|
||||
/// Get status icon symbol
|
||||
pub fn get_icon(status: Status) -> &'static str {
|
||||
match status {
|
||||
Status::Info => "", // No icon for informational data
|
||||
Status::Ok => "●",
|
||||
Status::Inactive => "○", // Empty circle for inactive services
|
||||
Status::Pending => "◉", // Hollow circle for pending
|
||||
@@ -254,6 +256,7 @@ impl StatusIcons {
|
||||
pub fn create_status_spans(status: Status, text: &str) -> Vec<ratatui::text::Span<'static>> {
|
||||
let icon = Self::get_icon(status);
|
||||
let status_color = match status {
|
||||
Status::Info => Theme::muted_text(), // Gray for info
|
||||
Status::Ok => Theme::success(), // Green
|
||||
Status::Inactive => Theme::muted_text(), // Gray for inactive services
|
||||
Status::Pending => Theme::highlight(), // Blue
|
||||
|
||||
@@ -11,6 +11,74 @@ use tracing::debug;
|
||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Column visibility configuration based on terminal width
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ColumnVisibility {
|
||||
show_name: bool,
|
||||
show_status: bool,
|
||||
show_ram: bool,
|
||||
show_uptime: bool,
|
||||
show_restarts: bool,
|
||||
}
|
||||
|
||||
impl ColumnVisibility {
|
||||
/// Calculate actual width needed for all columns
|
||||
const NAME_WIDTH: u16 = 23;
|
||||
const STATUS_WIDTH: u16 = 10;
|
||||
const RAM_WIDTH: u16 = 8;
|
||||
const UPTIME_WIDTH: u16 = 8;
|
||||
const RESTARTS_WIDTH: u16 = 5;
|
||||
const COLUMN_SPACING: u16 = 1; // Space between columns
|
||||
|
||||
/// Determine which columns to show based on available width
|
||||
/// Priority order: Name > Status > RAM > Uptime > Restarts
|
||||
fn from_width(width: u16) -> Self {
|
||||
// Calculate cumulative widths for each configuration
|
||||
let minimal = Self::NAME_WIDTH + Self::COLUMN_SPACING + Self::STATUS_WIDTH; // 34
|
||||
let with_ram = minimal + Self::COLUMN_SPACING + Self::RAM_WIDTH; // 43
|
||||
let with_uptime = with_ram + Self::COLUMN_SPACING + Self::UPTIME_WIDTH; // 52
|
||||
let full = with_uptime + Self::COLUMN_SPACING + Self::RESTARTS_WIDTH; // 58
|
||||
|
||||
if width >= full {
|
||||
// Show all columns
|
||||
Self {
|
||||
show_name: true,
|
||||
show_status: true,
|
||||
show_ram: true,
|
||||
show_uptime: true,
|
||||
show_restarts: true,
|
||||
}
|
||||
} else if width >= with_uptime {
|
||||
// Hide restarts
|
||||
Self {
|
||||
show_name: true,
|
||||
show_status: true,
|
||||
show_ram: true,
|
||||
show_uptime: true,
|
||||
show_restarts: false,
|
||||
}
|
||||
} else if width >= with_ram {
|
||||
// Hide uptime and restarts
|
||||
Self {
|
||||
show_name: true,
|
||||
show_status: true,
|
||||
show_ram: true,
|
||||
show_uptime: false,
|
||||
show_restarts: false,
|
||||
}
|
||||
} else {
|
||||
// Minimal: Name + Status only
|
||||
Self {
|
||||
show_name: true,
|
||||
show_status: true,
|
||||
show_ram: false,
|
||||
show_uptime: false,
|
||||
show_restarts: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Services widget displaying hierarchical systemd service statuses
|
||||
#[derive(Clone)]
|
||||
pub struct ServicesWidget {
|
||||
@@ -76,16 +144,19 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
/// Format parent service line - returns text without icon for span formatting
|
||||
fn format_parent_service_line(&self, name: &str, info: &ServiceInfo) -> String {
|
||||
// Truncate long service names to fit layout (account for icon space)
|
||||
let short_name = if name.len() > 22 {
|
||||
format!("{}...", &name[..19])
|
||||
fn format_parent_service_line(&self, name: &str, info: &ServiceInfo, columns: ColumnVisibility) -> String {
|
||||
// Truncate long service names to fit layout
|
||||
// NAME_WIDTH - 3 chars for "..." = max displayable chars
|
||||
let max_name_len = (ColumnVisibility::NAME_WIDTH - 3) as usize;
|
||||
let short_name = if name.len() > max_name_len {
|
||||
format!("{}...", &name[..max_name_len.saturating_sub(3)])
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Convert Status enum to display text
|
||||
let status_str = match info.widget_status {
|
||||
Status::Info => "", // Shouldn't happen for parent services
|
||||
Status::Ok => "active",
|
||||
Status::Inactive => "inactive",
|
||||
Status::Critical => "failed",
|
||||
@@ -129,10 +200,25 @@ impl ServicesWidget {
|
||||
}
|
||||
});
|
||||
|
||||
format!(
|
||||
"{:<23} {:<10} {:<8} {:<8} {:<5}",
|
||||
short_name, status_str, memory_str, uptime_str, restart_str
|
||||
)
|
||||
// Build format string based on column visibility
|
||||
let mut parts = Vec::new();
|
||||
if columns.show_name {
|
||||
parts.push(format!("{:<width$}", short_name, width = ColumnVisibility::NAME_WIDTH as usize));
|
||||
}
|
||||
if columns.show_status {
|
||||
parts.push(format!("{:<width$}", status_str, width = ColumnVisibility::STATUS_WIDTH as usize));
|
||||
}
|
||||
if columns.show_ram {
|
||||
parts.push(format!("{:<width$}", memory_str, width = ColumnVisibility::RAM_WIDTH as usize));
|
||||
}
|
||||
if columns.show_uptime {
|
||||
parts.push(format!("{:<width$}", uptime_str, width = ColumnVisibility::UPTIME_WIDTH as usize));
|
||||
}
|
||||
if columns.show_restarts {
|
||||
parts.push(format!("{:<width$}", restart_str, width = ColumnVisibility::RESTARTS_WIDTH as usize));
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +240,7 @@ impl ServicesWidget {
|
||||
// Get status icon and text
|
||||
let icon = StatusIcons::get_icon(info.widget_status);
|
||||
let status_color = match info.widget_status {
|
||||
Status::Info => Theme::muted_text(),
|
||||
Status::Ok => Theme::success(),
|
||||
Status::Inactive => Theme::muted_text(),
|
||||
Status::Pending => Theme::highlight(),
|
||||
@@ -174,6 +261,7 @@ impl ServicesWidget {
|
||||
} else {
|
||||
// Convert Status enum to display text for sub-services
|
||||
match info.widget_status {
|
||||
Status::Info => "",
|
||||
Status::Ok => "active",
|
||||
Status::Inactive => "inactive",
|
||||
Status::Critical => "failed",
|
||||
@@ -185,34 +273,34 @@ impl ServicesWidget {
|
||||
};
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
|
||||
// Docker images use docker whale icon
|
||||
if info.service_type == "image" {
|
||||
vec![
|
||||
if info.widget_status == Status::Info {
|
||||
// Informational data - no status icon, show metrics if available
|
||||
let mut spans = vec![
|
||||
// Indentation and tree prefix
|
||||
ratatui::text::Span::styled(
|
||||
format!(" {} ", tree_symbol),
|
||||
Typography::tree(),
|
||||
),
|
||||
// Docker icon (simple character for performance)
|
||||
ratatui::text::Span::styled(
|
||||
"D ".to_string(),
|
||||
Style::default().fg(Theme::highlight()).bg(Theme::background()),
|
||||
),
|
||||
// Service name
|
||||
// Service name (no icon)
|
||||
ratatui::text::Span::styled(
|
||||
format!("{:<18} ", short_name),
|
||||
Style::default()
|
||||
.fg(Theme::secondary_text())
|
||||
.bg(Theme::background()),
|
||||
),
|
||||
// Status/metrics text
|
||||
ratatui::text::Span::styled(
|
||||
];
|
||||
|
||||
// Add metrics if available (e.g., Docker image size)
|
||||
if !status_str.is_empty() {
|
||||
spans.push(ratatui::text::Span::styled(
|
||||
status_str,
|
||||
Style::default()
|
||||
.fg(Theme::secondary_text())
|
||||
.bg(Theme::background()),
|
||||
),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
spans
|
||||
} else {
|
||||
vec![
|
||||
// Indentation and tree prefix
|
||||
@@ -476,11 +564,28 @@ impl ServicesWidget {
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(inner_area);
|
||||
|
||||
// Header
|
||||
let header = format!(
|
||||
"{:<25} {:<10} {:<8} {:<8} {:<5}",
|
||||
"Service:", "Status:", "RAM:", "Uptime:", "↻:"
|
||||
);
|
||||
// Determine which columns to show based on available width
|
||||
let columns = ColumnVisibility::from_width(inner_area.width);
|
||||
|
||||
// Build header based on visible columns
|
||||
let mut header_parts = Vec::new();
|
||||
if columns.show_name {
|
||||
header_parts.push(format!("{:<width$}", "Service:", width = ColumnVisibility::NAME_WIDTH as usize));
|
||||
}
|
||||
if columns.show_status {
|
||||
header_parts.push(format!("{:<width$}", "Status:", width = ColumnVisibility::STATUS_WIDTH as usize));
|
||||
}
|
||||
if columns.show_ram {
|
||||
header_parts.push(format!("{:<width$}", "RAM:", width = ColumnVisibility::RAM_WIDTH as usize));
|
||||
}
|
||||
if columns.show_uptime {
|
||||
header_parts.push(format!("{:<width$}", "Uptime:", width = ColumnVisibility::UPTIME_WIDTH as usize));
|
||||
}
|
||||
if columns.show_restarts {
|
||||
header_parts.push(format!("{:<width$}", "↻:", width = ColumnVisibility::RESTARTS_WIDTH as usize));
|
||||
}
|
||||
let header = header_parts.join(" ");
|
||||
|
||||
let header_para = Paragraph::new(header).style(Typography::muted());
|
||||
frame.render_widget(header_para, content_chunks[0]);
|
||||
|
||||
@@ -492,11 +597,11 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
// Render the services list
|
||||
self.render_services(frame, content_chunks[1], is_focused);
|
||||
self.render_services(frame, content_chunks[1], is_focused, columns);
|
||||
}
|
||||
|
||||
/// Render services list
|
||||
fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
|
||||
fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, columns: ColumnVisibility) {
|
||||
// Build hierarchical service list for display
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||
|
||||
@@ -506,7 +611,7 @@ impl ServicesWidget {
|
||||
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
// Add parent service line
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info, columns);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None));
|
||||
|
||||
// Add sub-services for this parent (if any)
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct SystemWidget {
|
||||
cpu_load_5min: Option<f32>,
|
||||
cpu_load_15min: Option<f32>,
|
||||
cpu_cstates: Vec<cm_dashboard_shared::CStateInfo>,
|
||||
cpu_model_name: Option<String>,
|
||||
cpu_core_count: Option<u32>,
|
||||
cpu_status: Status,
|
||||
|
||||
// Memory metrics
|
||||
@@ -97,6 +99,8 @@ impl SystemWidget {
|
||||
cpu_load_5min: None,
|
||||
cpu_load_15min: None,
|
||||
cpu_cstates: Vec::new(),
|
||||
cpu_model_name: None,
|
||||
cpu_core_count: None,
|
||||
cpu_status: Status::Unknown,
|
||||
memory_usage_percent: None,
|
||||
memory_used_gb: None,
|
||||
@@ -184,6 +188,8 @@ impl Widget for SystemWidget {
|
||||
self.cpu_load_5min = Some(cpu.load_5min);
|
||||
self.cpu_load_15min = Some(cpu.load_15min);
|
||||
self.cpu_cstates = cpu.cstates.clone();
|
||||
self.cpu_model_name = cpu.model_name.clone();
|
||||
self.cpu_core_count = cpu.core_count;
|
||||
self.cpu_status = Status::Ok;
|
||||
|
||||
// Extract memory data directly
|
||||
@@ -566,9 +572,15 @@ impl SystemWidget {
|
||||
}
|
||||
|
||||
// Show usage with status and archive count
|
||||
let archive_display = if disk.archives_min == disk.archives_max {
|
||||
format!("{}", disk.archives_min)
|
||||
} else {
|
||||
format!("{}-{}", disk.archives_min, disk.archives_max)
|
||||
};
|
||||
|
||||
let usage_text = format!(
|
||||
"Usage: ({}) {:.0}% {:.0}GB/{:.0}GB",
|
||||
disk.total_archives,
|
||||
archive_display,
|
||||
disk.disk_usage_percent,
|
||||
disk.disk_used_gb,
|
||||
disk.disk_total_gb
|
||||
@@ -824,11 +836,31 @@ impl SystemWidget {
|
||||
lines.push(Line::from(cpu_spans));
|
||||
|
||||
let cstate_text = self.format_cpu_cstate();
|
||||
let has_cpu_info = self.cpu_model_name.is_some() || self.cpu_core_count.is_some();
|
||||
let cstate_tree = if has_cpu_info { " ├─ " } else { " └─ " };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
Span::styled(cstate_tree, Typography::tree()),
|
||||
Span::styled(format!("C-state: {}", cstate_text), Typography::secondary())
|
||||
]));
|
||||
|
||||
// CPU model and core count (if available)
|
||||
if let (Some(model), Some(cores)) = (&self.cpu_model_name, self.cpu_core_count) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
Span::styled(format!("{} ({} cores)", model, cores), Typography::secondary())
|
||||
]));
|
||||
} else if let Some(model) = &self.cpu_model_name {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
Span::styled(model.clone(), Typography::secondary())
|
||||
]));
|
||||
} else if let Some(cores) = self.cpu_core_count {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
Span::styled(format!("{} cores", cores), Typography::secondary())
|
||||
]));
|
||||
}
|
||||
|
||||
// RAM section
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("RAM:", Typography::widget_title())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.216"
|
||||
version = "0.1.241"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -57,6 +57,11 @@ pub struct CpuData {
|
||||
pub temperature_celsius: Option<f32>,
|
||||
pub load_status: Status,
|
||||
pub temperature_status: Status,
|
||||
// Static CPU information (collected once at startup)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub core_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Memory monitoring data
|
||||
@@ -195,7 +200,8 @@ pub struct BackupDiskData {
|
||||
pub disk_total_gb: f32,
|
||||
pub usage_status: Status,
|
||||
pub services: Vec<String>,
|
||||
pub total_archives: i64,
|
||||
pub archives_min: i64,
|
||||
pub archives_max: i64,
|
||||
}
|
||||
|
||||
impl AgentData {
|
||||
@@ -218,6 +224,8 @@ impl AgentData {
|
||||
temperature_celsius: None,
|
||||
load_status: Status::Unknown,
|
||||
temperature_status: Status::Unknown,
|
||||
model_name: None,
|
||||
core_count: None,
|
||||
},
|
||||
memory: MemoryData {
|
||||
usage_percent: 0.0,
|
||||
|
||||
@@ -82,12 +82,13 @@ impl MetricValue {
|
||||
/// Health status for metrics
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Status {
|
||||
Inactive, // Lowest priority
|
||||
Unknown, //
|
||||
Offline, //
|
||||
Pending, //
|
||||
Ok, // 5th place - good status has higher priority than unknown states
|
||||
Warning, //
|
||||
Info, // Lowest priority - informational data with no status (no icon)
|
||||
Inactive, //
|
||||
Unknown, //
|
||||
Offline, //
|
||||
Pending, //
|
||||
Ok, // Good status has higher priority than unknown states
|
||||
Warning, //
|
||||
Critical, // Highest priority
|
||||
}
|
||||
|
||||
@@ -223,6 +224,17 @@ impl HysteresisThresholds {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
Status::Info => {
|
||||
// Informational data shouldn't be used with hysteresis calculations
|
||||
// Treat like Unknown if it somehow ends up here
|
||||
if value >= self.critical_high {
|
||||
Status::Critical
|
||||
} else if value >= self.warning_high {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user