Implement comprehensive backup monitoring and fix timestamp issues
- Add BackupCollector for reading TOML status files with disk space metrics - Implement BackupWidget with disk usage display and service status details - Fix backup script disk space parsing by adding missing capture_output=True - Update backup widget to show actual disk usage instead of repository size - Fix timestamp parsing to use backup completion time instead of start time - Resolve timezone issues by using UTC timestamps in backup script - Add disk identification metrics (product name, serial number) to backup status - Enhance UI layout with proper backup monitoring integration
This commit is contained in:
parent
8a36472a3d
commit
125111ee99
@ -54,16 +54,33 @@ impl Agent {
|
||||
}
|
||||
|
||||
pub async fn run(&mut self, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>) -> Result<()> {
|
||||
info!("Starting agent main loop");
|
||||
info!("Starting agent main loop with separated collection and transmission");
|
||||
|
||||
// CRITICAL: Collect ALL data immediately at startup before entering the loop
|
||||
info!("Performing initial FORCE collection of all metrics at startup");
|
||||
if let Err(e) = self.collect_all_metrics_force().await {
|
||||
error!("Failed to collect initial metrics: {}", e);
|
||||
} else {
|
||||
info!("Initial metric collection completed - all data cached and ready");
|
||||
}
|
||||
|
||||
// Separate intervals for collection and transmission
|
||||
let mut collection_interval = interval(Duration::from_secs(self.config.collection_interval_seconds));
|
||||
let mut transmission_interval = interval(Duration::from_secs(1)); // ZMQ broadcast every 1 second
|
||||
let mut notification_check_interval = interval(Duration::from_secs(30)); // Check notifications every 30s
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = collection_interval.tick() => {
|
||||
if let Err(e) = self.collect_and_publish_metrics().await {
|
||||
error!("Failed to collect and publish metrics: {}", e);
|
||||
// Only collect and cache metrics, no ZMQ transmission
|
||||
if let Err(e) = self.collect_metrics_only().await {
|
||||
error!("Failed to collect metrics: {}", e);
|
||||
}
|
||||
}
|
||||
_ = transmission_interval.tick() => {
|
||||
// Send all cached metrics via ZMQ every 1 second
|
||||
if let Err(e) = self.broadcast_all_cached_metrics().await {
|
||||
error!("Failed to broadcast cached metrics: {}", e);
|
||||
}
|
||||
}
|
||||
_ = notification_check_interval.tick() => {
|
||||
@ -87,10 +104,29 @@ impl Agent {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_and_publish_metrics(&mut self) -> Result<()> {
|
||||
debug!("Starting metric collection cycle");
|
||||
async fn collect_all_metrics_force(&mut self) -> Result<()> {
|
||||
info!("Starting FORCE metric collection for startup");
|
||||
|
||||
// Collect all metrics from all collectors
|
||||
// Force collect all metrics from all collectors immediately
|
||||
let metrics = self.metric_manager.collect_all_metrics_force().await?;
|
||||
|
||||
if metrics.is_empty() {
|
||||
error!("No metrics collected during force collection!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Force collected and cached {} metrics", metrics.len());
|
||||
|
||||
// Check for status changes and send notifications
|
||||
self.check_status_changes(&metrics).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_metrics_only(&mut self) -> Result<()> {
|
||||
debug!("Starting metric collection cycle (cache only)");
|
||||
|
||||
// Collect all metrics from all collectors and cache them
|
||||
let metrics = self.metric_manager.collect_all_metrics().await?;
|
||||
|
||||
if metrics.is_empty() {
|
||||
@ -98,16 +134,32 @@ impl Agent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Collected {} metrics", metrics.len());
|
||||
debug!("Collected and cached {} metrics", metrics.len());
|
||||
|
||||
// Check for status changes and send notifications
|
||||
self.check_status_changes(&metrics).await;
|
||||
|
||||
// Create and send message
|
||||
let message = MetricMessage::new(self.hostname.clone(), metrics);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_all_cached_metrics(&mut self) -> Result<()> {
|
||||
debug!("Broadcasting all cached metrics via ZMQ");
|
||||
|
||||
// Get all cached metrics from the metric manager
|
||||
let cached_metrics = self.metric_manager.get_all_cached_metrics().await?;
|
||||
|
||||
if cached_metrics.is_empty() {
|
||||
debug!("No cached metrics to broadcast");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Broadcasting {} cached metrics", cached_metrics.len());
|
||||
|
||||
// Create and send message with all cached data
|
||||
let message = MetricMessage::new(self.hostname.clone(), cached_metrics);
|
||||
self.zmq_handler.publish_metrics(&message).await?;
|
||||
|
||||
debug!("Metrics published successfully");
|
||||
debug!("Cached metrics broadcasted successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -146,7 +198,7 @@ impl Agent {
|
||||
match command {
|
||||
AgentCommand::CollectNow => {
|
||||
info!("Processing CollectNow command");
|
||||
if let Err(e) = self.collect_and_publish_metrics().await {
|
||||
if let Err(e) = self.collect_metrics_only().await {
|
||||
error!("Failed to collect metrics on command: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
5
agent/src/cache/manager.rs
vendored
5
agent/src/cache/manager.rs
vendored
@ -45,6 +45,11 @@ impl MetricCacheManager {
|
||||
pub async fn get_all_valid_metrics(&self) -> Vec<Metric> {
|
||||
self.cache.get_all_valid_metrics().await
|
||||
}
|
||||
|
||||
/// Get all cached metrics (including expired ones) for broadcasting
|
||||
pub async fn get_all_cached_metrics(&self) -> Vec<Metric> {
|
||||
self.cache.get_all_cached_metrics().await
|
||||
}
|
||||
|
||||
/// Cache warm-up: collect and cache high-priority metrics
|
||||
pub async fn warm_cache<F>(&self, collector_fn: F)
|
||||
|
||||
16
agent/src/cache/mod.rs
vendored
16
agent/src/cache/mod.rs
vendored
@ -113,6 +113,22 @@ impl ConfigurableCache {
|
||||
|
||||
valid_metrics
|
||||
}
|
||||
|
||||
/// Get all cached metrics (including expired ones) for broadcasting
|
||||
pub async fn get_all_cached_metrics(&self) -> Vec<Metric> {
|
||||
if !self.config.enabled {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let cache = self.cache.read().await;
|
||||
let mut all_metrics = Vec::new();
|
||||
|
||||
for cached_metric in cache.values() {
|
||||
all_metrics.push(cached_metric.metric.clone());
|
||||
}
|
||||
|
||||
all_metrics
|
||||
}
|
||||
|
||||
/// Background cleanup of old entries
|
||||
async fn cleanup_old_entries(&self, cache: &mut HashMap<String, CachedMetric>) {
|
||||
|
||||
388
agent/src/collectors/backup.rs
Normal file
388
agent/src/collectors/backup.rs
Normal file
@ -0,0 +1,388 @@
|
||||
use async_trait::async_trait;
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status, SharedError};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::{Collector, CollectorError, utils};
|
||||
use tracing::error;
|
||||
|
||||
/// Backup collector that reads TOML status files for borgbackup metrics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupCollector {
|
||||
pub backup_status_file: String,
|
||||
pub max_age_hours: u64,
|
||||
}
|
||||
|
||||
impl BackupCollector {
|
||||
pub fn new(backup_status_file: Option<String>, max_age_hours: u64) -> Self {
|
||||
Self {
|
||||
backup_status_file: backup_status_file.unwrap_or_else(|| "/var/lib/backup/backup-status.toml".to_string()),
|
||||
max_age_hours,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_backup_status(&self) -> Result<BackupStatusToml, CollectorError> {
|
||||
let content = fs::read_to_string(&self.backup_status_file)
|
||||
.await
|
||||
.map_err(|e| CollectorError::SystemRead {
|
||||
path: self.backup_status_file.clone(),
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
|
||||
toml::from_str(&content).map_err(|e| CollectorError::Parse {
|
||||
value: "backup status TOML".to_string(),
|
||||
error: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_backup_status(&self, backup_status: &BackupStatusToml) -> Status {
|
||||
// Parse the start time to check age - handle both RFC3339 and local timestamp formats
|
||||
let start_time = match chrono::DateTime::parse_from_rfc3339(&backup_status.start_time) {
|
||||
Ok(dt) => dt.with_timezone(&Utc),
|
||||
Err(_) => {
|
||||
// Try parsing as naive datetime and assume UTC
|
||||
match chrono::NaiveDateTime::parse_from_str(&backup_status.start_time, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||
Ok(naive_dt) => naive_dt.and_utc(),
|
||||
Err(_) => {
|
||||
error!("Failed to parse backup timestamp: {}", backup_status.start_time);
|
||||
return Status::Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let hours_since_backup = Utc::now().signed_duration_since(start_time).num_hours();
|
||||
|
||||
// Check overall backup status
|
||||
match backup_status.status.as_str() {
|
||||
"success" => {
|
||||
if hours_since_backup > self.max_age_hours as i64 {
|
||||
Status::Warning // Backup too old
|
||||
} else {
|
||||
Status::Ok
|
||||
}
|
||||
},
|
||||
"failed" => Status::Critical,
|
||||
"running" => Status::Ok, // Currently running is OK
|
||||
_ => Status::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_service_status(&self, service: &ServiceStatus) -> Status {
|
||||
match service.status.as_str() {
|
||||
"completed" => {
|
||||
if service.exit_code == 0 {
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Critical
|
||||
}
|
||||
},
|
||||
"failed" => Status::Critical,
|
||||
"disabled" => Status::Warning, // Service intentionally disabled
|
||||
"running" => Status::Ok,
|
||||
_ => Status::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_to_gb(bytes: u64) -> f32 {
|
||||
bytes as f32 / (1024.0 * 1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Collector for BackupCollector {
|
||||
fn name(&self) -> &str {
|
||||
"backup"
|
||||
}
|
||||
|
||||
async fn collect(&self) -> Result<Vec<Metric>, CollectorError> {
|
||||
let backup_status = self.read_backup_status().await?;
|
||||
let mut metrics = Vec::new();
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
// Overall backup status
|
||||
let overall_status = self.calculate_backup_status(&backup_status);
|
||||
metrics.push(Metric {
|
||||
name: "backup_overall_status".to_string(),
|
||||
value: MetricValue::String(match overall_status {
|
||||
Status::Ok => "ok".to_string(),
|
||||
Status::Warning => "warning".to_string(),
|
||||
Status::Critical => "critical".to_string(),
|
||||
Status::Unknown => "unknown".to_string(),
|
||||
}),
|
||||
status: overall_status,
|
||||
timestamp,
|
||||
description: Some(format!("Backup: {} at {}", backup_status.status, backup_status.start_time)),
|
||||
unit: None,
|
||||
});
|
||||
|
||||
// Backup duration
|
||||
metrics.push(Metric {
|
||||
name: "backup_duration_seconds".to_string(),
|
||||
value: MetricValue::Integer(backup_status.duration_seconds),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Duration of last backup run".to_string()),
|
||||
unit: Some("seconds".to_string()),
|
||||
});
|
||||
|
||||
// Last backup timestamp - use last_updated (when backup finished) instead of start_time
|
||||
let last_updated_dt_result = chrono::DateTime::parse_from_rfc3339(&backup_status.last_updated)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.or_else(|_| {
|
||||
// Try parsing as naive datetime and assume UTC
|
||||
chrono::NaiveDateTime::parse_from_str(&backup_status.last_updated, "%Y-%m-%dT%H:%M:%S%.f")
|
||||
.map(|naive_dt| naive_dt.and_utc())
|
||||
});
|
||||
|
||||
if let Ok(last_updated_dt) = last_updated_dt_result {
|
||||
metrics.push(Metric {
|
||||
name: "backup_last_run_timestamp".to_string(),
|
||||
value: MetricValue::Integer(last_updated_dt.timestamp()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Timestamp of last backup completion".to_string()),
|
||||
unit: Some("unix_timestamp".to_string()),
|
||||
});
|
||||
} else {
|
||||
error!("Failed to parse backup timestamp for last_run_timestamp: {}", backup_status.last_updated);
|
||||
}
|
||||
|
||||
// Individual service metrics
|
||||
for (service_name, service) in &backup_status.services {
|
||||
let service_status = self.calculate_service_status(service);
|
||||
|
||||
// Service status
|
||||
metrics.push(Metric {
|
||||
name: format!("backup_service_{}_status", service_name),
|
||||
value: MetricValue::String(match service_status {
|
||||
Status::Ok => "ok".to_string(),
|
||||
Status::Warning => "warning".to_string(),
|
||||
Status::Critical => "critical".to_string(),
|
||||
Status::Unknown => "unknown".to_string(),
|
||||
}),
|
||||
status: service_status,
|
||||
timestamp,
|
||||
description: Some(format!("Backup service {} status: {}", service_name, service.status)),
|
||||
unit: None,
|
||||
});
|
||||
|
||||
// Service exit code
|
||||
metrics.push(Metric {
|
||||
name: format!("backup_service_{}_exit_code", service_name),
|
||||
value: MetricValue::Integer(service.exit_code),
|
||||
status: if service.exit_code == 0 { Status::Ok } else { Status::Critical },
|
||||
timestamp,
|
||||
description: Some(format!("Exit code for backup service {}", service_name)),
|
||||
unit: None,
|
||||
});
|
||||
|
||||
// Repository archive count
|
||||
metrics.push(Metric {
|
||||
name: format!("backup_service_{}_archive_count", service_name),
|
||||
value: MetricValue::Integer(service.archive_count),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some(format!("Number of archives in {} repository", service_name)),
|
||||
unit: Some("archives".to_string()),
|
||||
});
|
||||
|
||||
// Repository size in GB
|
||||
let repo_size_gb = Self::bytes_to_gb(service.repo_size_bytes);
|
||||
metrics.push(Metric {
|
||||
name: format!("backup_service_{}_repo_size_gb", service_name),
|
||||
value: MetricValue::Float(repo_size_gb),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some(format!("Repository size for {} in GB", service_name)),
|
||||
unit: Some("GB".to_string()),
|
||||
});
|
||||
|
||||
// Repository path for reference
|
||||
metrics.push(Metric {
|
||||
name: format!("backup_service_{}_repo_path", service_name),
|
||||
value: MetricValue::String(service.repo_path.clone()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some(format!("Repository path for {}", service_name)),
|
||||
unit: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Total number of services
|
||||
metrics.push(Metric {
|
||||
name: "backup_total_services".to_string(),
|
||||
value: MetricValue::Integer(backup_status.services.len() as i64),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Total number of backup services".to_string()),
|
||||
unit: Some("services".to_string()),
|
||||
});
|
||||
|
||||
// Calculate total repository size
|
||||
let total_size_bytes: u64 = backup_status.services.values()
|
||||
.map(|s| s.repo_size_bytes)
|
||||
.sum();
|
||||
let total_size_gb = Self::bytes_to_gb(total_size_bytes);
|
||||
metrics.push(Metric {
|
||||
name: "backup_total_repo_size_gb".to_string(),
|
||||
value: MetricValue::Float(total_size_gb),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Total size of all backup repositories".to_string()),
|
||||
unit: Some("GB".to_string()),
|
||||
});
|
||||
|
||||
// Disk space metrics for backup directory
|
||||
if let Some(ref disk_space) = backup_status.disk_space {
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_total_gb".to_string(),
|
||||
value: MetricValue::Float(disk_space.total_gb as f32),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Total disk space available for backups".to_string()),
|
||||
unit: Some("GB".to_string()),
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_used_gb".to_string(),
|
||||
value: MetricValue::Float(disk_space.used_gb as f32),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Used disk space on backup drive".to_string()),
|
||||
unit: Some("GB".to_string()),
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_available_gb".to_string(),
|
||||
value: MetricValue::Float(disk_space.available_gb as f32),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Available disk space on backup drive".to_string()),
|
||||
unit: Some("GB".to_string()),
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_usage_percent".to_string(),
|
||||
value: MetricValue::Float(disk_space.usage_percent as f32),
|
||||
status: if disk_space.usage_percent >= 95.0 {
|
||||
Status::Critical
|
||||
} else if disk_space.usage_percent >= 85.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
},
|
||||
timestamp,
|
||||
description: Some("Backup disk usage percentage".to_string()),
|
||||
unit: Some("percent".to_string()),
|
||||
});
|
||||
|
||||
// Add disk identification metrics if available from disk_space
|
||||
if let Some(ref product_name) = disk_space.product_name {
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_product_name".to_string(),
|
||||
value: MetricValue::String(product_name.clone()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Backup disk product name from SMART data".to_string()),
|
||||
unit: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref serial_number) = disk_space.serial_number {
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_serial_number".to_string(),
|
||||
value: MetricValue::String(serial_number.clone()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Backup disk serial number from SMART data".to_string()),
|
||||
unit: None,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Add standalone disk identification metrics from TOML fields
|
||||
if let Some(ref product_name) = backup_status.disk_product_name {
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_product_name".to_string(),
|
||||
value: MetricValue::String(product_name.clone()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Backup disk product name from SMART data".to_string()),
|
||||
unit: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref serial_number) = backup_status.disk_serial_number {
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_serial_number".to_string(),
|
||||
value: MetricValue::String(serial_number.clone()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some("Backup disk serial number from SMART data".to_string()),
|
||||
unit: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Count services by status
|
||||
let mut status_counts = HashMap::new();
|
||||
for service in backup_status.services.values() {
|
||||
*status_counts.entry(service.status.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for (status_name, count) in status_counts {
|
||||
metrics.push(Metric {
|
||||
name: format!("backup_services_{}_count", status_name),
|
||||
value: MetricValue::Integer(count),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
description: Some(format!("Number of services with status: {}", status_name)),
|
||||
unit: Some("services".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(metrics)
|
||||
}
|
||||
}
|
||||
|
||||
/// TOML structure for backup status file
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BackupStatusToml {
|
||||
pub backup_name: String,
|
||||
pub start_time: String,
|
||||
pub current_time: String,
|
||||
pub duration_seconds: i64,
|
||||
pub status: String,
|
||||
pub last_updated: String,
|
||||
pub disk_space: Option<DiskSpace>,
|
||||
pub disk_product_name: Option<String>,
|
||||
pub disk_serial_number: Option<String>,
|
||||
pub services: HashMap<String, ServiceStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DiskSpace {
|
||||
pub total_bytes: u64,
|
||||
pub used_bytes: u64,
|
||||
pub available_bytes: u64,
|
||||
pub total_gb: f64,
|
||||
pub used_gb: f64,
|
||||
pub available_gb: f64,
|
||||
pub usage_percent: f64,
|
||||
// Optional disk identification fields
|
||||
pub product_name: Option<String>,
|
||||
pub serial_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ServiceStatus {
|
||||
pub status: String,
|
||||
pub exit_code: i64,
|
||||
pub repo_path: String,
|
||||
pub archive_count: i64,
|
||||
pub repo_size_bytes: u64,
|
||||
}
|
||||
@ -173,152 +173,7 @@ impl CpuCollector {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Collect top CPU consuming process using ps command for accurate percentages
|
||||
async fn collect_top_cpu_process(&self) -> Result<Option<Metric>, CollectorError> {
|
||||
use std::process::Command;
|
||||
|
||||
// Use ps to get current CPU percentages, sorted by CPU usage
|
||||
let output = Command::new("ps")
|
||||
.arg("aux")
|
||||
.arg("--sort=-%cpu")
|
||||
.arg("--no-headers")
|
||||
.output()
|
||||
.map_err(|e| CollectorError::SystemRead {
|
||||
path: "ps command".to_string(),
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse lines and find the first non-ps process (to avoid catching our own ps command)
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 11 {
|
||||
// ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
|
||||
let pid = parts[1];
|
||||
let cpu_percent = parts[2];
|
||||
let full_command = parts[10..].join(" ");
|
||||
|
||||
// Skip ps processes to avoid catching our own ps command
|
||||
if full_command.contains("ps aux") || full_command.starts_with("ps ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract just the command name (basename of executable)
|
||||
let command_name = if let Some(first_part) = parts.get(10) {
|
||||
// Get just the executable name, not the full path
|
||||
if let Some(basename) = first_part.split('/').last() {
|
||||
basename.to_string()
|
||||
} else {
|
||||
first_part.to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
};
|
||||
|
||||
// Validate CPU percentage is reasonable (not over 100% per core)
|
||||
if let Ok(cpu_val) = cpu_percent.parse::<f32>() {
|
||||
if cpu_val > 1000.0 {
|
||||
// Skip obviously wrong values
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let process_info = format!("{} (PID {}) {}%", command_name, pid, cpu_percent);
|
||||
|
||||
return Ok(Some(Metric::new(
|
||||
"top_cpu_process".to_string(),
|
||||
MetricValue::String(process_info),
|
||||
Status::Ok,
|
||||
).with_description("Process consuming the most CPU".to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(Metric::new(
|
||||
"top_cpu_process".to_string(),
|
||||
MetricValue::String("No processes found".to_string()),
|
||||
Status::Ok,
|
||||
).with_description("Process consuming the most CPU".to_string())))
|
||||
}
|
||||
|
||||
/// Collect top RAM consuming process using ps command for accurate memory usage
|
||||
async fn collect_top_ram_process(&self) -> Result<Option<Metric>, CollectorError> {
|
||||
use std::process::Command;
|
||||
|
||||
// Use ps to get current memory usage, sorted by memory
|
||||
let output = Command::new("ps")
|
||||
.arg("aux")
|
||||
.arg("--sort=-%mem")
|
||||
.arg("--no-headers")
|
||||
.output()
|
||||
.map_err(|e| CollectorError::SystemRead {
|
||||
path: "ps command".to_string(),
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse lines and find the first non-ps process (to avoid catching our own ps command)
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 11 {
|
||||
// ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
|
||||
let pid = parts[1];
|
||||
let mem_percent = parts[3];
|
||||
let rss_kb = parts[5]; // RSS in KB
|
||||
let full_command = parts[10..].join(" ");
|
||||
|
||||
// Skip ps processes to avoid catching our own ps command
|
||||
if full_command.contains("ps aux") || full_command.starts_with("ps ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract just the command name (basename of executable)
|
||||
let command_name = if let Some(first_part) = parts.get(10) {
|
||||
// Get just the executable name, not the full path
|
||||
if let Some(basename) = first_part.split('/').last() {
|
||||
basename.to_string()
|
||||
} else {
|
||||
first_part.to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
};
|
||||
|
||||
// Convert RSS from KB to MB
|
||||
if let Ok(rss_kb_val) = rss_kb.parse::<u64>() {
|
||||
let rss_mb = rss_kb_val as f32 / 1024.0;
|
||||
|
||||
// Skip processes with very little memory (likely temporary commands)
|
||||
if rss_mb < 1.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let process_info = format!("{} (PID {}) {:.1}MB", command_name, pid, rss_mb);
|
||||
|
||||
return Ok(Some(Metric::new(
|
||||
"top_ram_process".to_string(),
|
||||
MetricValue::String(process_info),
|
||||
Status::Ok,
|
||||
).with_description("Process consuming the most RAM".to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(Metric::new(
|
||||
"top_ram_process".to_string(),
|
||||
MetricValue::String("No processes found".to_string()),
|
||||
Status::Ok,
|
||||
).with_description("Process consuming the most RAM".to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -347,15 +202,6 @@ impl Collector for CpuCollector {
|
||||
metrics.push(freq_metric);
|
||||
}
|
||||
|
||||
// Collect top CPU process (optional)
|
||||
if let Some(top_cpu_metric) = self.collect_top_cpu_process().await? {
|
||||
metrics.push(top_cpu_metric);
|
||||
}
|
||||
|
||||
// Collect top RAM process (optional)
|
||||
if let Some(top_ram_metric) = self.collect_top_ram_process().await? {
|
||||
metrics.push(top_ram_metric);
|
||||
}
|
||||
|
||||
let duration = start.elapsed();
|
||||
debug!("CPU collection completed in {:?} with {} metrics", duration, metrics.len());
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
use tracing::debug;
|
||||
|
||||
use super::{Collector, CollectorError, PerformanceMetrics};
|
||||
|
||||
/// Information about a mounted disk
|
||||
#[derive(Debug, Clone)]
|
||||
struct MountedDisk {
|
||||
device: String, // e.g., "/dev/nvme0n1p1"
|
||||
physical_device: String, // e.g., "/dev/nvme0n1"
|
||||
mount_point: String, // e.g., "/"
|
||||
filesystem: String, // e.g., "ext4"
|
||||
size: String, // e.g., "120G"
|
||||
used: String, // e.g., "45G"
|
||||
available: String, // e.g., "75G"
|
||||
usage_percent: f32, // e.g., 38.5
|
||||
}
|
||||
|
||||
/// Disk usage collector for monitoring filesystem sizes
|
||||
pub struct DiskCollector {
|
||||
// Immutable collector for caching compatibility
|
||||
@ -71,6 +85,142 @@ impl DiskCollector {
|
||||
Ok((total_bytes, used_bytes))
|
||||
}
|
||||
|
||||
/// Get root filesystem disk usage
|
||||
fn get_root_filesystem_usage(&self) -> Result<(u64, u64, f32)> {
|
||||
let (total_bytes, used_bytes) = self.get_filesystem_info("/")?;
|
||||
let usage_percent = (used_bytes as f64 / total_bytes as f64) * 100.0;
|
||||
Ok((total_bytes, used_bytes, usage_percent as f32))
|
||||
}
|
||||
|
||||
/// Get all mounted disks with their mount points and underlying devices
|
||||
fn get_mounted_disks(&self) -> Result<Vec<MountedDisk>> {
|
||||
let output = Command::new("df")
|
||||
.arg("-h")
|
||||
.arg("--output=source,target,fstype,size,used,avail,pcent")
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!("df command failed"));
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8(output.stdout)?;
|
||||
let mut mounted_disks = Vec::new();
|
||||
|
||||
for line in output_str.lines().skip(1) { // Skip header
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 7 {
|
||||
let source = fields[0];
|
||||
let target = fields[1];
|
||||
let fstype = fields[2];
|
||||
let size = fields[3];
|
||||
let used = fields[4];
|
||||
let avail = fields[5];
|
||||
let pcent_str = fields[6];
|
||||
|
||||
// Skip special filesystems
|
||||
if source.starts_with("/dev/") &&
|
||||
!fstype.contains("tmpfs") &&
|
||||
!fstype.contains("devtmpfs") &&
|
||||
!target.starts_with("/proc") &&
|
||||
!target.starts_with("/sys") &&
|
||||
!target.starts_with("/dev") {
|
||||
|
||||
// Extract percentage
|
||||
let usage_percent = pcent_str
|
||||
.trim_end_matches('%')
|
||||
.parse::<f32>()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// Get underlying physical device
|
||||
let physical_device = self.get_physical_device(source)?;
|
||||
|
||||
mounted_disks.push(MountedDisk {
|
||||
device: source.to_string(),
|
||||
physical_device,
|
||||
mount_point: target.to_string(),
|
||||
filesystem: fstype.to_string(),
|
||||
size: size.to_string(),
|
||||
used: used.to_string(),
|
||||
available: avail.to_string(),
|
||||
usage_percent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mounted_disks)
|
||||
}
|
||||
|
||||
/// Get the physical device for a given device (resolves symlinks, gets parent device)
|
||||
fn get_physical_device(&self, device: &str) -> Result<String> {
|
||||
// For NVMe: /dev/nvme0n1p1 -> /dev/nvme0n1
|
||||
if device.contains("nvme") && device.contains("p") {
|
||||
if let Some(base) = device.split('p').next() {
|
||||
return Ok(base.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// For SATA: /dev/sda1 -> /dev/sda
|
||||
if device.starts_with("/dev/sd") && device.len() > 8 {
|
||||
return Ok(device[..8].to_string()); // Keep /dev/sdX
|
||||
}
|
||||
|
||||
// For VirtIO: /dev/vda1 -> /dev/vda
|
||||
if device.starts_with("/dev/vd") && device.len() > 8 {
|
||||
return Ok(device[..8].to_string());
|
||||
}
|
||||
|
||||
// If no partition detected, return as-is
|
||||
Ok(device.to_string())
|
||||
}
|
||||
|
||||
/// Get SMART health for a specific physical device
|
||||
fn get_smart_health(&self, device: &str) -> (String, f32) {
|
||||
if let Ok(output) = Command::new("smartctl")
|
||||
.arg("-H")
|
||||
.arg(device)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
let health_status = if output_str.contains("PASSED") {
|
||||
"PASSED"
|
||||
} else if output_str.contains("FAILED") {
|
||||
"FAILED"
|
||||
} else {
|
||||
"UNKNOWN"
|
||||
};
|
||||
|
||||
// Try to get temperature
|
||||
let temperature = if let Ok(temp_output) = Command::new("smartctl")
|
||||
.arg("-A")
|
||||
.arg(device)
|
||||
.output()
|
||||
{
|
||||
let temp_str = String::from_utf8_lossy(&temp_output.stdout);
|
||||
// Look for temperature in SMART attributes
|
||||
for line in temp_str.lines() {
|
||||
if line.contains("Temperature") && line.contains("Celsius") {
|
||||
if let Some(temp_part) = line.split_whitespace().nth(9) {
|
||||
if let Ok(temp) = temp_part.parse::<f32>() {
|
||||
return (health_status.to_string(), temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
return (health_status.to_string(), temperature);
|
||||
}
|
||||
}
|
||||
|
||||
("UNKNOWN".to_string(), 0.0)
|
||||
}
|
||||
|
||||
|
||||
/// Calculate status based on usage percentage
|
||||
fn calculate_usage_status(&self, used_bytes: u64, total_bytes: u64) -> Status {
|
||||
if total_bytes == 0 {
|
||||
@ -88,6 +238,38 @@ impl DiskCollector {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse size string (e.g., "120G", "45M") to GB value
|
||||
fn parse_size_to_gb(&self, size_str: &str) -> f32 {
|
||||
let size_str = size_str.trim();
|
||||
if size_str.is_empty() || size_str == "-" {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Extract numeric part and unit
|
||||
let (num_str, unit) = if let Some(last_char) = size_str.chars().last() {
|
||||
if last_char.is_alphabetic() {
|
||||
let num_part = &size_str[..size_str.len()-1];
|
||||
let unit_part = &size_str[size_str.len()-1..];
|
||||
(num_part, unit_part)
|
||||
} else {
|
||||
(size_str, "")
|
||||
}
|
||||
} else {
|
||||
(size_str, "")
|
||||
};
|
||||
|
||||
let number: f32 = num_str.parse().unwrap_or(0.0);
|
||||
|
||||
match unit.to_uppercase().as_str() {
|
||||
"T" | "TB" => number * 1024.0,
|
||||
"G" | "GB" => number,
|
||||
"M" | "MB" => number / 1024.0,
|
||||
"K" | "KB" => number / (1024.0 * 1024.0),
|
||||
"B" | "" => number / (1024.0 * 1024.0 * 1024.0),
|
||||
_ => number, // Assume GB if unknown unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -98,11 +280,186 @@ impl Collector for DiskCollector {
|
||||
|
||||
async fn collect(&self) -> Result<Vec<Metric>, CollectorError> {
|
||||
let start_time = Instant::now();
|
||||
debug!("Collecting disk metrics");
|
||||
debug!("Collecting multi-disk metrics");
|
||||
|
||||
let mut metrics = Vec::new();
|
||||
|
||||
// Monitor /tmp directory size
|
||||
// Collect all mounted disks
|
||||
match self.get_mounted_disks() {
|
||||
Ok(mounted_disks) => {
|
||||
debug!("Found {} mounted disks", mounted_disks.len());
|
||||
|
||||
// Group disks by physical device to avoid duplicate SMART checks
|
||||
let mut physical_devices: std::collections::HashMap<String, Vec<&MountedDisk>> = std::collections::HashMap::new();
|
||||
for disk in &mounted_disks {
|
||||
physical_devices.entry(disk.physical_device.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(disk);
|
||||
}
|
||||
|
||||
// Generate metrics for each mounted disk
|
||||
for (disk_index, disk) in mounted_disks.iter().enumerate() {
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
// Parse size strings to get actual values for calculations
|
||||
let size_gb = self.parse_size_to_gb(&disk.size);
|
||||
let used_gb = self.parse_size_to_gb(&disk.used);
|
||||
let avail_gb = self.parse_size_to_gb(&disk.available);
|
||||
|
||||
// Calculate status based on usage percentage
|
||||
let status = if disk.usage_percent >= 95.0 {
|
||||
Status::Critical
|
||||
} else if disk.usage_percent >= 85.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
|
||||
// Device and mount point info
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_device", disk_index),
|
||||
value: MetricValue::String(disk.device.clone()),
|
||||
unit: None,
|
||||
description: Some(format!("Device: {}", disk.device)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_mount_point", disk_index),
|
||||
value: MetricValue::String(disk.mount_point.clone()),
|
||||
unit: None,
|
||||
description: Some(format!("Mount: {}", disk.mount_point)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_filesystem", disk_index),
|
||||
value: MetricValue::String(disk.filesystem.clone()),
|
||||
unit: None,
|
||||
description: Some(format!("FS: {}", disk.filesystem)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Size metrics
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_total_gb", disk_index),
|
||||
value: MetricValue::Float(size_gb),
|
||||
unit: Some("GB".to_string()),
|
||||
description: Some(format!("Total: {}", disk.size)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_used_gb", disk_index),
|
||||
value: MetricValue::Float(used_gb),
|
||||
unit: Some("GB".to_string()),
|
||||
description: Some(format!("Used: {}", disk.used)),
|
||||
status,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_available_gb", disk_index),
|
||||
value: MetricValue::Float(avail_gb),
|
||||
unit: Some("GB".to_string()),
|
||||
description: Some(format!("Available: {}", disk.available)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_usage_percent", disk_index),
|
||||
value: MetricValue::Float(disk.usage_percent),
|
||||
unit: Some("%".to_string()),
|
||||
description: Some(format!("Usage: {:.1}%", disk.usage_percent)),
|
||||
status,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Physical device name (for SMART health grouping)
|
||||
let physical_device_name = disk.physical_device
|
||||
.strip_prefix("/dev/")
|
||||
.unwrap_or(&disk.physical_device);
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_{}_physical_device", disk_index),
|
||||
value: MetricValue::String(physical_device_name.to_string()),
|
||||
unit: None,
|
||||
description: Some(format!("Physical: {}", physical_device_name)),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Add SMART health metrics for each unique physical device
|
||||
for (physical_device, disks) in physical_devices {
|
||||
let (health_status, temperature) = self.get_smart_health(&physical_device);
|
||||
let device_name = physical_device.strip_prefix("/dev/").unwrap_or(&physical_device);
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
let health_status_enum = match health_status.as_str() {
|
||||
"PASSED" => Status::Ok,
|
||||
"FAILED" => Status::Critical,
|
||||
_ => Status::Unknown,
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_smart_{}_health", device_name),
|
||||
value: MetricValue::String(health_status.clone()),
|
||||
unit: None,
|
||||
description: Some(format!("SMART Health: {}", health_status)),
|
||||
status: health_status_enum,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
if temperature > 0.0 {
|
||||
let temp_status = if temperature >= 70.0 {
|
||||
Status::Critical
|
||||
} else if temperature >= 60.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("disk_smart_{}_temperature", device_name),
|
||||
value: MetricValue::Float(temperature),
|
||||
unit: Some("°C".to_string()),
|
||||
description: Some(format!("Temperature: {:.0}°C", temperature)),
|
||||
status: temp_status,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add disk count metric
|
||||
metrics.push(Metric {
|
||||
name: "disk_count".to_string(),
|
||||
value: MetricValue::Integer(mounted_disks.len() as i64),
|
||||
unit: None,
|
||||
description: Some(format!("Total mounted disks: {}", mounted_disks.len())),
|
||||
status: Status::Ok,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to get mounted disks: {}", e);
|
||||
metrics.push(Metric {
|
||||
name: "disk_count".to_string(),
|
||||
value: MetricValue::Integer(0),
|
||||
unit: None,
|
||||
description: Some(format!("Error: {}", e)),
|
||||
status: Status::Unknown,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor /tmp directory size (keep existing functionality)
|
||||
match self.get_directory_size("/tmp") {
|
||||
Ok(tmp_size_bytes) => {
|
||||
let tmp_size_mb = tmp_size_bytes as f64 / (1024.0 * 1024.0);
|
||||
@ -161,7 +518,7 @@ impl Collector for DiskCollector {
|
||||
}
|
||||
|
||||
let collection_time = start_time.elapsed();
|
||||
debug!("Disk collection completed in {:?} with {} metrics",
|
||||
debug!("Multi-disk collection completed in {:?} with {} metrics",
|
||||
collection_time, metrics.len());
|
||||
|
||||
Ok(metrics)
|
||||
|
||||
@ -7,6 +7,7 @@ pub mod cpu;
|
||||
pub mod memory;
|
||||
pub mod disk;
|
||||
pub mod systemd;
|
||||
pub mod backup;
|
||||
pub mod error;
|
||||
|
||||
pub use error::CollectorError;
|
||||
|
||||
@ -25,6 +25,12 @@ struct ServiceCacheState {
|
||||
last_discovery_time: Option<Instant>,
|
||||
/// How often to rediscover services (5 minutes)
|
||||
discovery_interval_seconds: u64,
|
||||
/// Cached nginx site latency metrics
|
||||
nginx_site_metrics: Vec<Metric>,
|
||||
/// Last time nginx sites were checked
|
||||
last_nginx_check_time: Option<Instant>,
|
||||
/// How often to check nginx site latency (30 seconds)
|
||||
nginx_check_interval_seconds: u64,
|
||||
}
|
||||
|
||||
impl SystemdCollector {
|
||||
@ -35,6 +41,9 @@ impl SystemdCollector {
|
||||
monitored_services: Vec::new(),
|
||||
last_discovery_time: None,
|
||||
discovery_interval_seconds: 300, // 5 minutes
|
||||
nginx_site_metrics: Vec::new(),
|
||||
last_nginx_check_time: None,
|
||||
nginx_check_interval_seconds: 30, // 30 seconds for nginx sites
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -71,6 +80,32 @@ impl SystemdCollector {
|
||||
Ok(state.monitored_services.clone())
|
||||
}
|
||||
|
||||
/// Get nginx site metrics, checking them if cache is expired
|
||||
fn get_nginx_site_metrics(&self) -> Vec<Metric> {
|
||||
let mut state = self.state.write().unwrap();
|
||||
|
||||
// Check if we need to refresh nginx site metrics
|
||||
let needs_refresh = match state.last_nginx_check_time {
|
||||
None => true, // First time
|
||||
Some(last_time) => {
|
||||
let elapsed = last_time.elapsed().as_secs();
|
||||
elapsed >= state.nginx_check_interval_seconds
|
||||
}
|
||||
};
|
||||
|
||||
if needs_refresh {
|
||||
// Only check nginx sites if nginx service is active
|
||||
if state.monitored_services.iter().any(|s| s.contains("nginx")) {
|
||||
debug!("Refreshing nginx site latency metrics (interval: {}s)", state.nginx_check_interval_seconds);
|
||||
let fresh_metrics = self.get_nginx_sites();
|
||||
state.nginx_site_metrics = fresh_metrics;
|
||||
state.last_nginx_check_time = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
state.nginx_site_metrics.clone()
|
||||
}
|
||||
|
||||
/// Auto-discover interesting services to monitor
|
||||
fn discover_services(&self) -> Result<Vec<String>> {
|
||||
let output = Command::new("systemctl")
|
||||
@ -88,22 +123,86 @@ impl SystemdCollector {
|
||||
let output_str = String::from_utf8(output.stdout)?;
|
||||
let mut services = Vec::new();
|
||||
|
||||
// Interesting service patterns to monitor
|
||||
let interesting_patterns = [
|
||||
"nginx", "apache", "httpd", "gitea", "docker", "mysql", "postgresql",
|
||||
"redis", "ssh", "sshd", "postfix", "mosquitto", "grafana", "prometheus",
|
||||
"vaultwarden", "unifi", "immich", "plex", "jellyfin", "transmission",
|
||||
"syncthing", "nextcloud", "owncloud", "mariadb", "mongodb"
|
||||
// Skip setup/certificate services that don't need monitoring (from legacy)
|
||||
let excluded_services = [
|
||||
"mosquitto-certs",
|
||||
"immich-setup",
|
||||
"phpfpm-kryddorten",
|
||||
"phpfpm-mariehall2",
|
||||
"acme-haasp.net",
|
||||
"acme-selfsigned-haasp",
|
||||
"borgbackup",
|
||||
"haasp-site-deploy",
|
||||
"mosquitto-backup",
|
||||
"nginx-config-reload",
|
||||
"sshd-keygen",
|
||||
];
|
||||
|
||||
// Define patterns for services we want to monitor (from legacy)
|
||||
let interesting_services = [
|
||||
// Web applications
|
||||
"gitea",
|
||||
"immich",
|
||||
"vaultwarden",
|
||||
"unifi",
|
||||
"wordpress",
|
||||
"nginx",
|
||||
"httpd",
|
||||
// Databases
|
||||
"postgresql",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"redis",
|
||||
"mongodb",
|
||||
"mongod",
|
||||
// Backup and storage
|
||||
"borg",
|
||||
"rclone",
|
||||
// Container runtimes
|
||||
"docker",
|
||||
// CI/CD services
|
||||
"gitea-actions",
|
||||
"gitea-runner",
|
||||
"actions-runner",
|
||||
// Network services
|
||||
"sshd",
|
||||
"dnsmasq",
|
||||
// MQTT and IoT services
|
||||
"mosquitto",
|
||||
"mqtt",
|
||||
// PHP-FPM services
|
||||
"phpfpm",
|
||||
// Home automation
|
||||
"haasp",
|
||||
// Backup services
|
||||
"backup",
|
||||
];
|
||||
|
||||
for line in output_str.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 4 && fields[0].ends_with(".service") {
|
||||
let service_name = fields[0].trim_end_matches(".service");
|
||||
debug!("Processing service: '{}'", service_name);
|
||||
|
||||
// Skip excluded services first
|
||||
let mut is_excluded = false;
|
||||
for excluded in &excluded_services {
|
||||
if service_name.contains(excluded) {
|
||||
debug!("EXCLUDING service '{}' because it matches pattern '{}'", service_name, excluded);
|
||||
is_excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if is_excluded {
|
||||
debug!("Skipping excluded service: '{}'", service_name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this service matches our interesting patterns
|
||||
for pattern in &interesting_patterns {
|
||||
if service_name.contains(pattern) {
|
||||
for pattern in &interesting_services {
|
||||
if service_name.contains(pattern) || pattern.contains(service_name) {
|
||||
debug!("INCLUDING service '{}' because it matches pattern '{}'", service_name, pattern);
|
||||
services.push(service_name.to_string());
|
||||
break;
|
||||
}
|
||||
@ -571,140 +670,7 @@ impl SystemdCollector {
|
||||
Some(estimated_gb)
|
||||
}
|
||||
|
||||
/// Get nginx virtual hosts/sites
|
||||
fn get_nginx_sites(&self) -> Vec<Metric> {
|
||||
let mut metrics = Vec::new();
|
||||
|
||||
// Check sites-enabled directory
|
||||
let output = Command::new("ls")
|
||||
.arg("/etc/nginx/sites-enabled/")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
let site_name = line.trim();
|
||||
if !site_name.is_empty() && site_name != "default" {
|
||||
// Check if site config is valid
|
||||
let test_output = Command::new("nginx")
|
||||
.arg("-t")
|
||||
.arg("-c")
|
||||
.arg(format!("/etc/nginx/sites-enabled/{}", site_name))
|
||||
.output();
|
||||
|
||||
let status = match test_output {
|
||||
Ok(out) if out.status.success() => Status::Ok,
|
||||
_ => Status::Warning,
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("service_nginx_site_{}_status", site_name),
|
||||
value: MetricValue::String(if status == Status::Ok { "active".to_string() } else { "error".to_string() }),
|
||||
unit: None,
|
||||
description: Some(format!("Nginx site {} configuration status", site_name)),
|
||||
status,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// Get docker containers
|
||||
fn get_docker_containers(&self) -> Vec<Metric> {
|
||||
let mut metrics = Vec::new();
|
||||
|
||||
let output = Command::new("docker")
|
||||
.arg("ps")
|
||||
.arg("-a")
|
||||
.arg("--format")
|
||||
.arg("{{.Names}}\t{{.Status}}\t{{.State}}")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 3 {
|
||||
let container_name = parts[0].trim();
|
||||
let status_info = parts[1].trim();
|
||||
let state = parts[2].trim();
|
||||
|
||||
let status = match state.to_lowercase().as_str() {
|
||||
"running" => Status::Ok,
|
||||
"exited" | "dead" => Status::Warning,
|
||||
"paused" | "restarting" => Status::Warning,
|
||||
_ => Status::Critical,
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("service_docker_container_{}_status", container_name),
|
||||
value: MetricValue::String(state.to_string()),
|
||||
unit: None,
|
||||
description: Some(format!("Docker container {} status: {}", container_name, status_info)),
|
||||
status,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
});
|
||||
|
||||
// Get container memory usage
|
||||
if state == "running" {
|
||||
if let Some(memory_mb) = self.get_container_memory(container_name) {
|
||||
metrics.push(Metric {
|
||||
name: format!("service_docker_container_{}_memory_mb", container_name),
|
||||
value: MetricValue::Float(memory_mb),
|
||||
unit: Some("MB".to_string()),
|
||||
description: Some(format!("Docker container {} memory usage", container_name)),
|
||||
status: Status::Ok,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// Get container memory usage
|
||||
fn get_container_memory(&self, container_name: &str) -> Option<f32> {
|
||||
let output = Command::new("docker")
|
||||
.arg("stats")
|
||||
.arg("--no-stream")
|
||||
.arg("--format")
|
||||
.arg("{{.MemUsage}}")
|
||||
.arg(container_name)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8(output.stdout).ok()?;
|
||||
let mem_usage = output_str.trim();
|
||||
|
||||
// Parse format like "123.4MiB / 4GiB"
|
||||
if let Some(used_part) = mem_usage.split(" / ").next() {
|
||||
if used_part.ends_with("MiB") {
|
||||
let num_str = used_part.trim_end_matches("MiB");
|
||||
return num_str.parse::<f32>().ok();
|
||||
} else if used_part.ends_with("GiB") {
|
||||
let num_str = used_part.trim_end_matches("GiB");
|
||||
if let Ok(gb) = num_str.parse::<f32>() {
|
||||
return Some(gb * 1024.0); // Convert to MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -770,13 +736,11 @@ impl Collector for SystemdCollector {
|
||||
|
||||
// Sub-service metrics for specific services
|
||||
if service.contains("nginx") && active_status == "active" {
|
||||
let nginx_sites = self.get_nginx_sites();
|
||||
metrics.extend(nginx_sites);
|
||||
metrics.extend(self.get_nginx_site_metrics());
|
||||
}
|
||||
|
||||
if service.contains("docker") && active_status == "active" {
|
||||
let docker_containers = self.get_docker_containers();
|
||||
metrics.extend(docker_containers);
|
||||
metrics.extend(self.get_docker_containers());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@ -795,4 +759,321 @@ impl Collector for SystemdCollector {
|
||||
fn get_performance_metrics(&self) -> Option<PerformanceMetrics> {
|
||||
None // Performance tracking handled by cache system
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemdCollector {
|
||||
/// Get nginx sites with latency checks
|
||||
fn get_nginx_sites(&self) -> Vec<Metric> {
|
||||
let mut metrics = Vec::new();
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
// Discover nginx sites from configuration
|
||||
let sites = self.discover_nginx_sites();
|
||||
|
||||
for (site_name, url) in &sites {
|
||||
match self.check_site_latency(url) {
|
||||
Ok(latency_ms) => {
|
||||
let status = if latency_ms < 500.0 {
|
||||
Status::Ok
|
||||
} else if latency_ms < 2000.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Critical
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("service_nginx_{}_latency_ms", site_name),
|
||||
value: MetricValue::Float(latency_ms),
|
||||
unit: Some("ms".to_string()),
|
||||
description: Some(format!("Response time for {}", url)),
|
||||
status,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Site is unreachable
|
||||
metrics.push(Metric {
|
||||
name: format!("service_nginx_{}_latency_ms", site_name),
|
||||
value: MetricValue::Float(-1.0), // Use -1 to indicate error
|
||||
unit: Some("ms".to_string()),
|
||||
description: Some(format!("Response time for {} (unreachable)", url)),
|
||||
status: Status::Critical,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// Get docker containers as sub-services
|
||||
fn get_docker_containers(&self) -> Vec<Metric> {
|
||||
let mut metrics = Vec::new();
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
// Check if docker is available
|
||||
let output = Command::new("docker")
|
||||
.arg("ps")
|
||||
.arg("--format")
|
||||
.arg("{{.Names}},{{.Status}}")
|
||||
.output();
|
||||
|
||||
let output = match output {
|
||||
Ok(out) if out.status.success() => out,
|
||||
_ => return metrics, // Docker not available or failed
|
||||
};
|
||||
|
||||
let output_str = match String::from_utf8(output.stdout) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return metrics,
|
||||
};
|
||||
|
||||
for line in output_str.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split(',').collect();
|
||||
if parts.len() >= 2 {
|
||||
let container_name = parts[0].trim();
|
||||
let status_str = parts[1].trim();
|
||||
|
||||
let status = if status_str.contains("Up") {
|
||||
Status::Ok
|
||||
} else if status_str.contains("Exited") {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Critical
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: format!("service_docker_{}_status", container_name),
|
||||
value: MetricValue::String(status_str.to_string()),
|
||||
unit: None,
|
||||
description: Some(format!("Docker container {} status", container_name)),
|
||||
status,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// Check site latency using curl GET requests
|
||||
fn check_site_latency(&self, url: &str) -> Result<f32, Box<dyn std::error::Error>> {
|
||||
let _start = std::time::Instant::now();
|
||||
|
||||
let output = Command::new("curl")
|
||||
.arg("-X")
|
||||
.arg("GET") // Explicitly use GET method
|
||||
.arg("-s")
|
||||
.arg("-o")
|
||||
.arg("/dev/null")
|
||||
.arg("-w")
|
||||
.arg("%{time_total}")
|
||||
.arg("--max-time")
|
||||
.arg("5") // 5 second timeout
|
||||
.arg("--connect-timeout")
|
||||
.arg("2") // 2 second connection timeout
|
||||
.arg("--location") // Follow redirects
|
||||
.arg("--fail") // Fail on HTTP errors (4xx, 5xx)
|
||||
.arg(url)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("Curl GET request failed for {}", url).into());
|
||||
}
|
||||
|
||||
let time_str = String::from_utf8(output.stdout)?;
|
||||
let time_seconds: f32 = time_str.trim().parse()?;
|
||||
let time_ms = time_seconds * 1000.0;
|
||||
|
||||
Ok(time_ms)
|
||||
}
|
||||
|
||||
/// Discover nginx sites from configuration files (like the old working implementation)
|
||||
fn discover_nginx_sites(&self) -> Vec<(String, String)> {
|
||||
use tracing::debug;
|
||||
|
||||
// Use the same approach as the old working agent: get nginx config from systemd
|
||||
let config_content = match self.get_nginx_config_from_systemd() {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
debug!("Could not get nginx config from systemd, trying nginx -T fallback");
|
||||
match self.get_nginx_config_via_command() {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
debug!("Could not get nginx config via any method");
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the config content to extract sites
|
||||
self.parse_nginx_config_for_sites(&config_content)
|
||||
}
|
||||
|
||||
/// Get nginx config from systemd service definition (NixOS compatible)
|
||||
fn get_nginx_config_from_systemd(&self) -> Option<String> {
|
||||
use tracing::debug;
|
||||
|
||||
let output = std::process::Command::new("systemctl")
|
||||
.args(["show", "nginx", "--property=ExecStart", "--no-pager"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
debug!("Failed to get nginx ExecStart from systemd");
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
debug!("systemctl show nginx output: {}", stdout);
|
||||
|
||||
// Parse ExecStart to extract -c config path
|
||||
for line in stdout.lines() {
|
||||
if line.starts_with("ExecStart=") {
|
||||
debug!("Found ExecStart line: {}", line);
|
||||
// Handle both traditional and NixOS systemd formats
|
||||
if let Some(config_path) = self.extract_config_path_from_exec_start(line) {
|
||||
debug!("Extracted config path: {}", config_path);
|
||||
// Read the config file
|
||||
return std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| debug!("Failed to read config file {}: {}", config_path, e))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract config path from ExecStart line
|
||||
fn extract_config_path_from_exec_start(&self, exec_start: &str) -> Option<String> {
|
||||
use tracing::debug;
|
||||
|
||||
// Remove ExecStart= prefix
|
||||
let exec_part = exec_start.strip_prefix("ExecStart=")?;
|
||||
debug!("Parsing exec part: {}", exec_part);
|
||||
|
||||
// Handle NixOS format: ExecStart={ path=...; argv[]=...nginx -c /config; ... }
|
||||
if exec_part.contains("argv[]=") {
|
||||
// Extract the part after argv[]=
|
||||
let argv_start = exec_part.find("argv[]=")?;
|
||||
let argv_part = &exec_part[argv_start + 7..]; // Skip "argv[]="
|
||||
debug!("Found NixOS argv part: {}", argv_part);
|
||||
|
||||
// Look for -c flag followed by config path
|
||||
if let Some(c_pos) = argv_part.find(" -c ") {
|
||||
let after_c = &argv_part[c_pos + 4..];
|
||||
// Find the config path (until next space or semicolon)
|
||||
let config_path = after_c.split([' ', ';']).next()?;
|
||||
return Some(config_path.to_string());
|
||||
}
|
||||
} else {
|
||||
// Handle traditional format: ExecStart=/path/nginx -c /config
|
||||
debug!("Parsing traditional format");
|
||||
if let Some(c_pos) = exec_part.find(" -c ") {
|
||||
let after_c = &exec_part[c_pos + 4..];
|
||||
let config_path = after_c.split_whitespace().next()?;
|
||||
return Some(config_path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback: get nginx config via nginx -T command
|
||||
fn get_nginx_config_via_command(&self) -> Option<String> {
|
||||
use tracing::debug;
|
||||
|
||||
let output = std::process::Command::new("nginx")
|
||||
.args(["-T"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
debug!("nginx -T failed");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Parse nginx config content to extract server names and build site list
|
||||
fn parse_nginx_config_for_sites(&self, config_content: &str) -> Vec<(String, String)> {
|
||||
use tracing::debug;
|
||||
let mut sites = Vec::new();
|
||||
let lines: Vec<&str> = config_content.lines().collect();
|
||||
let mut i = 0;
|
||||
|
||||
debug!("Parsing nginx config with {} lines", lines.len());
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.starts_with("server") && line.contains("{") {
|
||||
debug!("Found server block at line {}", i);
|
||||
if let Some(server_name) = self.parse_server_block(&lines, &mut i) {
|
||||
debug!("Extracted server name: {}", server_name);
|
||||
let url = format!("https://{}", server_name);
|
||||
// Use the full domain as the site name for clarity
|
||||
sites.push((server_name.clone(), url));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
debug!("Discovered {} nginx sites total", sites.len());
|
||||
sites
|
||||
}
|
||||
|
||||
/// Parse a server block to extract the primary server_name
|
||||
fn parse_server_block(&self, lines: &[&str], start_index: &mut usize) -> Option<String> {
|
||||
use tracing::debug;
|
||||
let mut server_names = Vec::new();
|
||||
let mut has_redirect = false;
|
||||
let mut i = *start_index + 1;
|
||||
let mut brace_count = 1;
|
||||
|
||||
// Parse until we close the server block
|
||||
while i < lines.len() && brace_count > 0 {
|
||||
let trimmed = lines[i].trim();
|
||||
|
||||
// Track braces
|
||||
brace_count += trimmed.matches('{').count();
|
||||
brace_count -= trimmed.matches('}').count();
|
||||
|
||||
// Extract server_name
|
||||
if trimmed.starts_with("server_name") {
|
||||
if let Some(names_part) = trimmed.strip_prefix("server_name") {
|
||||
let names_clean = names_part.trim().trim_end_matches(';');
|
||||
for name in names_clean.split_whitespace() {
|
||||
if name != "_" && !name.is_empty() && name.contains('.') && !name.starts_with('$') {
|
||||
server_names.push(name.to_string());
|
||||
debug!("Found server_name in block: {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for redirects (skip redirect-only servers)
|
||||
if trimmed.contains("return") && (trimmed.contains("301") || trimmed.contains("302")) {
|
||||
has_redirect = true;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
*start_index = i - 1;
|
||||
|
||||
// Only return hostnames that are not redirects and have actual content
|
||||
if !server_names.is_empty() && !has_redirect {
|
||||
Some(server_names[0].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ use std::time::Instant;
|
||||
use tracing::{info, error, debug};
|
||||
|
||||
use crate::config::{CollectorConfig, AgentConfig};
|
||||
use crate::collectors::{Collector, cpu::CpuCollector, memory::MemoryCollector, disk::DiskCollector, systemd::SystemdCollector, cached_collector::CachedCollector};
|
||||
use crate::collectors::{Collector, cpu::CpuCollector, memory::MemoryCollector, disk::DiskCollector, systemd::SystemdCollector, backup::BackupCollector, cached_collector::CachedCollector};
|
||||
use crate::cache::MetricCacheManager;
|
||||
|
||||
/// Manages all metric collectors with intelligent caching
|
||||
@ -51,6 +51,17 @@ impl MetricCollectionManager {
|
||||
collectors.push(Box::new(systemd_collector));
|
||||
info!("BENCHMARK: Systemd collector only");
|
||||
},
|
||||
Some("backup") => {
|
||||
// Backup collector only
|
||||
if config.backup.enabled {
|
||||
let backup_collector = BackupCollector::new(
|
||||
config.backup.backup_paths.first().cloned(),
|
||||
config.backup.max_age_hours
|
||||
);
|
||||
collectors.push(Box::new(backup_collector));
|
||||
info!("BENCHMARK: Backup collector only");
|
||||
}
|
||||
},
|
||||
Some("none") => {
|
||||
// No collectors - test agent loop only
|
||||
info!("BENCHMARK: No collectors enabled");
|
||||
@ -76,6 +87,15 @@ impl MetricCollectionManager {
|
||||
let systemd_collector = SystemdCollector::new();
|
||||
collectors.push(Box::new(systemd_collector));
|
||||
info!("Systemd collector initialized");
|
||||
|
||||
if config.backup.enabled {
|
||||
let backup_collector = BackupCollector::new(
|
||||
config.backup.backup_paths.first().cloned(),
|
||||
config.backup.max_age_hours
|
||||
);
|
||||
collectors.push(Box::new(backup_collector));
|
||||
info!("Backup collector initialized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +114,40 @@ impl MetricCollectionManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Force collection from ALL collectors immediately (used at startup)
|
||||
pub async fn collect_all_metrics_force(&mut self) -> Result<Vec<Metric>> {
|
||||
let mut all_metrics = Vec::new();
|
||||
let now = Instant::now();
|
||||
|
||||
info!("Force collecting from ALL {} collectors for startup", self.collectors.len());
|
||||
|
||||
// Force collection from every collector regardless of intervals
|
||||
for collector in &self.collectors {
|
||||
let collector_name = collector.name();
|
||||
|
||||
match collector.collect().await {
|
||||
Ok(metrics) => {
|
||||
info!("Force collected {} metrics from {} collector", metrics.len(), collector_name);
|
||||
|
||||
// Cache all new metrics
|
||||
for metric in &metrics {
|
||||
self.cache_manager.cache_metric(metric.clone()).await;
|
||||
}
|
||||
|
||||
all_metrics.extend(metrics);
|
||||
self.last_collection_times.insert(collector_name.to_string(), now);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Collector '{}' failed during force collection: {}", collector_name, e);
|
||||
// Continue with other collectors even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Force collection completed: {} total metrics cached", all_metrics.len());
|
||||
Ok(all_metrics)
|
||||
}
|
||||
|
||||
/// Collect metrics from all collectors with intelligent caching
|
||||
pub async fn collect_all_metrics(&mut self) -> Result<Vec<Metric>> {
|
||||
let mut all_metrics = Vec::new();
|
||||
@ -111,6 +165,7 @@ impl MetricCollectionManager {
|
||||
// Determine cache interval for this collector type - ALL REALTIME FOR FAST UPDATES
|
||||
let cache_interval_secs = match collector_name {
|
||||
"cpu" | "memory" | "disk" | "systemd" => 2, // All realtime for fast updates
|
||||
"backup" => 10, // Backup metrics every 10 seconds for testing
|
||||
_ => 2, // All realtime for fast updates
|
||||
};
|
||||
|
||||
@ -168,6 +223,13 @@ impl MetricCollectionManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all cached metrics from the cache manager
|
||||
pub async fn get_all_cached_metrics(&self) -> Result<Vec<Metric>> {
|
||||
let cached_metrics = self.cache_manager.get_all_cached_metrics().await;
|
||||
debug!("Retrieved {} cached metrics for broadcast", cached_metrics.len());
|
||||
Ok(cached_metrics)
|
||||
}
|
||||
|
||||
/// Determine which collector handles a specific metric
|
||||
fn get_collector_for_metric(&self, metric_name: &str) -> String {
|
||||
if metric_name.starts_with("cpu_") {
|
||||
@ -178,6 +240,8 @@ impl MetricCollectionManager {
|
||||
"disk".to_string()
|
||||
} else if metric_name.starts_with("service_") {
|
||||
"systemd".to_string()
|
||||
} else if metric_name.starts_with("backup_") {
|
||||
"backup".to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
@ -121,10 +121,14 @@ pub mod subscriptions {
|
||||
|
||||
/// Backup widget metric subscriptions
|
||||
pub const BACKUP_WIDGET_METRICS: &[&str] = &[
|
||||
"backup_status",
|
||||
"backup_overall_status",
|
||||
"backup_duration_seconds",
|
||||
"backup_last_run_timestamp",
|
||||
"backup_size_gb",
|
||||
"backup_duration_minutes",
|
||||
"backup_total_services",
|
||||
"backup_total_repo_size_gb",
|
||||
"backup_services_completed_count",
|
||||
"backup_services_failed_count",
|
||||
"backup_services_disabled_count",
|
||||
];
|
||||
|
||||
/// Get all metric subscriptions for a widget type
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Input handling utilities for the dashboard
|
||||
pub struct InputHandler;
|
||||
|
||||
impl InputHandler {
|
||||
/// Check if the event is a quit command (q or Ctrl+C)
|
||||
pub fn is_quit_event(event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => true,
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the event is a refresh command (r)
|
||||
pub fn is_refresh_event(event: &Event) -> bool {
|
||||
matches!(event, Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('r'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if the event is a navigation command (arrow keys)
|
||||
pub fn get_navigation_direction(event: &Event) -> Option<NavigationDirection> {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Left),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Right),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Up,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Up),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Down),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the event is an Enter key press
|
||||
pub fn is_enter_event(event: &Event) -> bool {
|
||||
matches!(event, Event::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if the event is an Escape key press
|
||||
pub fn is_escape_event(event: &Event) -> bool {
|
||||
matches!(event, Event::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}))
|
||||
}
|
||||
|
||||
/// Extract character from key event
|
||||
pub fn get_char(event: &Event) -> Option<char> {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation directions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NavigationDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl NavigationDirection {
|
||||
/// Get the opposite direction
|
||||
pub fn opposite(&self) -> Self {
|
||||
match self {
|
||||
NavigationDirection::Up => NavigationDirection::Down,
|
||||
NavigationDirection::Down => NavigationDirection::Up,
|
||||
NavigationDirection::Left => NavigationDirection::Right,
|
||||
NavigationDirection::Right => NavigationDirection::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a horizontal direction
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, NavigationDirection::Left | NavigationDirection::Right)
|
||||
}
|
||||
|
||||
/// Check if this is a vertical direction
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, NavigationDirection::Up | NavigationDirection::Down)
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Layout utilities for consistent dashboard design
|
||||
pub struct DashboardLayout;
|
||||
|
||||
impl DashboardLayout {
|
||||
/// Create the main dashboard layout (preserving legacy design)
|
||||
pub fn main_layout(area: Rect) -> [Rect; 3] {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Title bar
|
||||
Constraint::Min(0), // Main content
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
[chunks[0], chunks[1], chunks[2]]
|
||||
}
|
||||
|
||||
/// Create 2x2 grid layout for widgets (legacy layout)
|
||||
pub fn content_grid(area: Rect) -> [Rect; 4] {
|
||||
let horizontal_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
let left_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(horizontal_chunks[0]);
|
||||
|
||||
let right_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(horizontal_chunks[1]);
|
||||
|
||||
[
|
||||
left_chunks[0], // Top-left
|
||||
right_chunks[0], // Top-right
|
||||
left_chunks[1], // Bottom-left
|
||||
right_chunks[1], // Bottom-right
|
||||
]
|
||||
}
|
||||
|
||||
/// Create horizontal split layout
|
||||
pub fn horizontal_split(area: Rect, left_percentage: u16) -> [Rect; 2] {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(left_percentage),
|
||||
Constraint::Percentage(100 - left_percentage),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
[chunks[0], chunks[1]]
|
||||
}
|
||||
|
||||
/// Create vertical split layout
|
||||
pub fn vertical_split(area: Rect, top_percentage: u16) -> [Rect; 2] {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(top_percentage),
|
||||
Constraint::Percentage(100 - top_percentage),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
[chunks[0], chunks[1]]
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,21 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent};
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
style::Style,
|
||||
widgets::{Block, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info};
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
pub mod widgets;
|
||||
pub mod layout;
|
||||
pub mod theme;
|
||||
pub mod input;
|
||||
|
||||
use widgets::{CpuWidget, MemoryWidget, ServicesWidget, Widget};
|
||||
use widgets::{CpuWidget, MemoryWidget, ServicesWidget, BackupWidget, Widget};
|
||||
use crate::metrics::{MetricStore, WidgetType};
|
||||
use cm_dashboard_shared::Metric;
|
||||
use theme::Theme;
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use theme::{Theme, Layout as ThemeLayout, Typography, Components, StatusIcons};
|
||||
|
||||
/// Main TUI application
|
||||
pub struct TuiApp {
|
||||
@ -27,6 +25,8 @@ pub struct TuiApp {
|
||||
memory_widget: MemoryWidget,
|
||||
/// Services widget
|
||||
services_widget: ServicesWidget,
|
||||
/// Backup widget
|
||||
backup_widget: BackupWidget,
|
||||
/// Current active host
|
||||
current_host: Option<String>,
|
||||
/// Available hosts
|
||||
@ -45,6 +45,7 @@ impl TuiApp {
|
||||
cpu_widget: CpuWidget::new(),
|
||||
memory_widget: MemoryWidget::new(),
|
||||
services_widget: ServicesWidget::new(),
|
||||
backup_widget: BackupWidget::new(),
|
||||
current_host: None,
|
||||
available_hosts: Vec::new(),
|
||||
host_index: 0,
|
||||
@ -66,11 +67,19 @@ impl TuiApp {
|
||||
|
||||
// Update Services widget - get all metrics that start with "service_"
|
||||
let all_metrics = metric_store.get_metrics_for_host(hostname);
|
||||
let service_metrics: Vec<&Metric> = all_metrics.into_iter()
|
||||
let service_metrics: Vec<&Metric> = all_metrics.iter()
|
||||
.filter(|m| m.name.starts_with("service_"))
|
||||
.copied()
|
||||
.collect();
|
||||
self.services_widget.update_from_metrics(&service_metrics);
|
||||
|
||||
// Update Backup widget - get all metrics that start with "backup_"
|
||||
let all_backup_metrics: Vec<&Metric> = all_metrics.iter()
|
||||
.filter(|m| m.name.starts_with("backup_"))
|
||||
.copied()
|
||||
.collect();
|
||||
self.backup_widget.update_from_metrics(&all_backup_metrics);
|
||||
|
||||
self.last_update = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
@ -126,15 +135,6 @@ impl TuiApp {
|
||||
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
|
||||
}
|
||||
|
||||
/// Check if should quit
|
||||
pub fn should_quit(&self) -> bool {
|
||||
self.should_quit
|
||||
}
|
||||
|
||||
/// Get current host
|
||||
pub fn get_current_host(&self) -> Option<&str> {
|
||||
self.current_host.as_deref()
|
||||
}
|
||||
|
||||
/// Render the dashboard (real btop-style multi-panel layout)
|
||||
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
|
||||
@ -148,32 +148,30 @@ impl TuiApp {
|
||||
|
||||
// Create real btop-style layout: multi-panel with borders
|
||||
// Top section: title bar
|
||||
// Middle section: split into left (mem + disks) and right (CPU + processes)
|
||||
// Bottom: status bar
|
||||
// Bottom section: split into left (mem + disks) and right (CPU + processes)
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Title bar
|
||||
Constraint::Min(0), // Main content area
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// New layout: left panels | right services (100% height)
|
||||
let content_chunks = Layout::default()
|
||||
let content_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(45), // Left side: system, backup
|
||||
Constraint::Percentage(55), // Right side: services (100% height)
|
||||
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
|
||||
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
|
||||
])
|
||||
.split(main_chunks[1]);
|
||||
|
||||
// Left side: system on top, backup on bottom (equal height)
|
||||
let left_chunks = Layout::default()
|
||||
let left_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // System section
|
||||
Constraint::Percentage(50), // Backup section
|
||||
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
|
||||
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
|
||||
])
|
||||
.split(content_chunks[0]);
|
||||
|
||||
@ -184,157 +182,256 @@ impl TuiApp {
|
||||
self.render_system_panel(frame, left_chunks[0], metric_store);
|
||||
self.render_backup_panel(frame, left_chunks[1]);
|
||||
self.services_widget.render(frame, content_chunks[1]); // Services takes full right side
|
||||
|
||||
// Render status bar
|
||||
self.render_btop_status(frame, main_chunks[2], metric_store);
|
||||
}
|
||||
|
||||
/// Render btop-style minimal title
|
||||
fn render_btop_title(&self, frame: &mut Frame, area: Rect) {
|
||||
let title_text = if let Some(ref host) = self.current_host {
|
||||
format!("cm-dashboard • {}", host)
|
||||
} else {
|
||||
"cm-dashboard • disconnected".to_string()
|
||||
};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
let title = Paragraph::new(title_text)
|
||||
.style(Style::default()
|
||||
.fg(Theme::primary_text())
|
||||
.bg(Theme::background()));
|
||||
if self.available_hosts.is_empty() {
|
||||
let title_text = "cm-dashboard • no hosts discovered";
|
||||
let title = Paragraph::new(title_text)
|
||||
.style(Typography::title());
|
||||
frame.render_widget(title, area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create spans for each host
|
||||
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())];
|
||||
|
||||
for (i, host) in self.available_hosts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" ", Typography::title()));
|
||||
}
|
||||
|
||||
if Some(host) == self.current_host.as_ref() {
|
||||
// Selected host in bold bright white
|
||||
spans.push(Span::styled(
|
||||
host.clone(),
|
||||
Typography::title().add_modifier(Modifier::BOLD)
|
||||
));
|
||||
} else {
|
||||
// Other hosts in normal style
|
||||
spans.push(Span::styled(host.clone(), Typography::title()));
|
||||
}
|
||||
}
|
||||
|
||||
let title_line = Line::from(spans);
|
||||
let title = Paragraph::new(vec![title_line]);
|
||||
|
||||
frame.render_widget(title, area);
|
||||
}
|
||||
|
||||
/// Render title bar (legacy)
|
||||
fn render_title_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
let title = if let Some(ref host) = self.current_host {
|
||||
format!("CM Dashboard • {}", host)
|
||||
} else {
|
||||
"CM Dashboard • No Host Connected".to_string()
|
||||
};
|
||||
|
||||
let title_block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(Theme::widget_border_style())
|
||||
.title_style(Theme::title_style());
|
||||
|
||||
frame.render_widget(title_block, area);
|
||||
}
|
||||
|
||||
/// Render btop-style minimal status bar
|
||||
fn render_btop_status(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let status_text = if let Some(ref hostname) = self.current_host {
|
||||
let connected = metric_store.is_host_connected(hostname, Duration::from_secs(30));
|
||||
let status = if connected { "●" } else { "○" };
|
||||
format!("{} [←→] host [q] quit", status)
|
||||
} else {
|
||||
"○ waiting for connection...".to_string()
|
||||
};
|
||||
|
||||
let status = Paragraph::new(status_text)
|
||||
.style(Style::default()
|
||||
.fg(Theme::muted_text())
|
||||
.bg(Theme::background()));
|
||||
|
||||
frame.render_widget(status, area);
|
||||
}
|
||||
|
||||
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let system_block = Block::default().title("system").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
|
||||
let system_block = Components::widget_block("system");
|
||||
let inner_area = system_block.inner(area);
|
||||
frame.render_widget(system_block, area);
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(3), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]).split(inner_area);
|
||||
let content_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load)
|
||||
Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp)
|
||||
Constraint::Min(0) // Storage section
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
self.cpu_widget.render(frame, content_chunks[0]);
|
||||
self.memory_widget.render(frame, content_chunks[1]);
|
||||
self.render_top_cpu_process(frame, content_chunks[2], metric_store);
|
||||
self.render_top_ram_process(frame, content_chunks[3], metric_store);
|
||||
self.render_storage_section(frame, content_chunks[4]);
|
||||
self.render_storage_section(frame, content_chunks[2], metric_store);
|
||||
}
|
||||
|
||||
fn render_backup_panel(&self, frame: &mut Frame, area: Rect) {
|
||||
let backup_block = Block::default().title("backup").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
|
||||
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let backup_block = Components::widget_block("backup");
|
||||
let inner_area = backup_block.inner(area);
|
||||
frame.render_widget(backup_block, area);
|
||||
let backup_text = Paragraph::new("Backup status and metrics").style(Style::default().fg(Theme::muted_text()).bg(Theme::background()));
|
||||
frame.render_widget(backup_text, inner_area);
|
||||
self.backup_widget.render(frame, inner_area);
|
||||
}
|
||||
|
||||
fn render_top_cpu_process(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let top_cpu_text = if let Some(ref hostname) = self.current_host {
|
||||
if let Some(metric) = metric_store.get_metric(hostname, "top_cpu_process") {
|
||||
format!("Top CPU: {}", metric.value.as_string())
|
||||
|
||||
fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
if area.height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref hostname) = self.current_host {
|
||||
// Get disk count to determine how many disks to display
|
||||
let disk_count = if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") {
|
||||
count_metric.value.as_i64().unwrap_or(0) as usize
|
||||
} else {
|
||||
"Top CPU: awaiting data...".to_string()
|
||||
0
|
||||
};
|
||||
|
||||
if disk_count == 0 {
|
||||
// No disks found - show error/waiting message
|
||||
let content_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
|
||||
frame.render_widget(disk_title, content_chunks[0]);
|
||||
|
||||
let no_disks_spans = StatusIcons::create_status_spans(Status::Unknown, "No mounted disks detected");
|
||||
let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans));
|
||||
frame.render_widget(no_disks_para, content_chunks[1]);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
"Top CPU: no host".to_string()
|
||||
};
|
||||
|
||||
let top_cpu_para = Paragraph::new(top_cpu_text).style(Style::default().fg(Theme::warning()).bg(Theme::background()));
|
||||
frame.render_widget(top_cpu_para, area);
|
||||
}
|
||||
|
||||
fn render_top_ram_process(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let top_ram_text = if let Some(ref hostname) = self.current_host {
|
||||
if let Some(metric) = metric_store.get_metric(hostname, "top_ram_process") {
|
||||
format!("Top RAM: {}", metric.value.as_string())
|
||||
} else {
|
||||
"Top RAM: awaiting data...".to_string()
|
||||
}
|
||||
} else {
|
||||
"Top RAM: no host".to_string()
|
||||
};
|
||||
|
||||
let top_ram_para = Paragraph::new(top_ram_text).style(Style::default().fg(Theme::info()).bg(Theme::background()));
|
||||
frame.render_widget(top_ram_para, area);
|
||||
}
|
||||
|
||||
fn render_storage_section(&self, frame: &mut Frame, area: Rect) {
|
||||
let storage_text = Paragraph::new("Storage: NVMe health and disk usage").style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
|
||||
frame.render_widget(storage_text, area);
|
||||
}
|
||||
|
||||
/// Render status bar (legacy)
|
||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let status_text = if let Some(ref hostname) = self.current_host {
|
||||
let connected = metric_store.is_host_connected(hostname, Duration::from_secs(30));
|
||||
let connection_status = if connected { "connected" } else { "disconnected" };
|
||||
|
||||
// Group disks by physical device
|
||||
let mut physical_devices: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
|
||||
|
||||
format!(
|
||||
"Keys: [←→] hosts [r]efresh [q]uit | Status: {} | Hosts: {}/{}",
|
||||
connection_status,
|
||||
self.host_index + 1,
|
||||
self.available_hosts.len()
|
||||
)
|
||||
for disk_index in 0..disk_count {
|
||||
if let Some(physical_device_metric) = metric_store.get_metric(hostname, &format!("disk_{}_physical_device", disk_index)) {
|
||||
let physical_device = physical_device_metric.value.as_string();
|
||||
physical_devices.entry(physical_device).or_insert_with(Vec::new).push(disk_index);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate how many lines we need
|
||||
let mut total_lines_needed = 0;
|
||||
for partitions in physical_devices.values() {
|
||||
total_lines_needed += 2 + partitions.len(); // title + health + usage_per_partition
|
||||
}
|
||||
|
||||
let available_lines = area.height as usize;
|
||||
|
||||
// Create constraints dynamically based on physical devices
|
||||
let mut constraints = Vec::new();
|
||||
let mut devices_to_show = Vec::new();
|
||||
let mut current_line = 0;
|
||||
|
||||
for (physical_device, partitions) in &physical_devices {
|
||||
let lines_for_this_device = 2 + partitions.len();
|
||||
if current_line + lines_for_this_device <= available_lines {
|
||||
devices_to_show.push((physical_device.clone(), partitions.clone()));
|
||||
|
||||
// Add constraints for this device
|
||||
constraints.push(Constraint::Length(1)); // Device title
|
||||
constraints.push(Constraint::Length(1)); // Health line
|
||||
for _ in 0..partitions.len() {
|
||||
constraints.push(Constraint::Length(1)); // Usage line per partition
|
||||
}
|
||||
|
||||
current_line += lines_for_this_device;
|
||||
} else {
|
||||
break; // Can't fit more devices
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining space if any
|
||||
if constraints.len() < available_lines {
|
||||
constraints.push(Constraint::Min(0));
|
||||
}
|
||||
|
||||
let content_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
let mut chunk_index = 0;
|
||||
|
||||
// Display each physical device
|
||||
for (physical_device, partitions) in &devices_to_show {
|
||||
// Device title
|
||||
let disk_title_text = format!("Disk {}:", physical_device);
|
||||
let disk_title_para = Paragraph::new(disk_title_text).style(Typography::widget_title());
|
||||
frame.render_widget(disk_title_para, content_chunks[chunk_index]);
|
||||
chunk_index += 1;
|
||||
|
||||
// Health status (one per physical device)
|
||||
let smart_health = metric_store.get_metric(hostname, &format!("disk_smart_{}_health", physical_device))
|
||||
.map(|m| (m.value.as_string(), m.status))
|
||||
.unwrap_or_else(|| ("Unknown".to_string(), Status::Unknown));
|
||||
|
||||
let smart_temp = metric_store.get_metric(hostname, &format!("disk_smart_{}_temperature", physical_device))
|
||||
.and_then(|m| m.value.as_f32());
|
||||
|
||||
let temp_text = if let Some(temp) = smart_temp {
|
||||
format!(" {}°C", temp as i32)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let health_spans = StatusIcons::create_status_spans(
|
||||
smart_health.1,
|
||||
&format!("Health: {}{}", smart_health.0, temp_text)
|
||||
);
|
||||
let health_para = Paragraph::new(ratatui::text::Line::from(health_spans));
|
||||
frame.render_widget(health_para, content_chunks[chunk_index]);
|
||||
chunk_index += 1;
|
||||
|
||||
// Usage lines (one per partition/mount point)
|
||||
for &disk_index in partitions {
|
||||
let mount_point = metric_store.get_metric(hostname, &format!("disk_{}_mount_point", disk_index))
|
||||
.map(|m| m.value.as_string())
|
||||
.unwrap_or("?".to_string());
|
||||
|
||||
let usage_percent = metric_store.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index))
|
||||
.and_then(|m| m.value.as_f32())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let used_gb = metric_store.get_metric(hostname, &format!("disk_{}_used_gb", disk_index))
|
||||
.and_then(|m| m.value.as_f32())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let total_gb = metric_store.get_metric(hostname, &format!("disk_{}_total_gb", disk_index))
|
||||
.and_then(|m| m.value.as_f32())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let usage_status = metric_store.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index))
|
||||
.map(|m| m.status)
|
||||
.unwrap_or(Status::Unknown);
|
||||
|
||||
// Format mount point for usage line
|
||||
let mount_display = if mount_point == "/" {
|
||||
"root".to_string()
|
||||
} else if mount_point == "/boot" {
|
||||
"boot".to_string()
|
||||
} else if mount_point.starts_with("/") {
|
||||
mount_point[1..].to_string() // Remove leading slash
|
||||
} else {
|
||||
mount_point.clone()
|
||||
};
|
||||
|
||||
let usage_spans = StatusIcons::create_status_spans(
|
||||
usage_status,
|
||||
&format!("Usage @{}: {:.1}% • {:.1}/{:.1} GB", mount_display, usage_percent, used_gb, total_gb)
|
||||
);
|
||||
let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans));
|
||||
frame.render_widget(usage_para, content_chunks[chunk_index]);
|
||||
chunk_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Show truncation indicator if we couldn't display all devices
|
||||
if devices_to_show.len() < physical_devices.len() {
|
||||
if let Some(last_chunk) = content_chunks.last() {
|
||||
let truncated_count = physical_devices.len() - devices_to_show.len();
|
||||
let truncated_text = format!("... and {} more disk{}",
|
||||
truncated_count,
|
||||
if truncated_count == 1 { "" } else { "s" });
|
||||
let truncated_para = Paragraph::new(truncated_text).style(Typography::muted());
|
||||
frame.render_widget(truncated_para, *last_chunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Keys: [←→] hosts [r]efresh [q]uit | Status: No hosts | Waiting for connections...".to_string()
|
||||
};
|
||||
|
||||
let status_block = Block::default()
|
||||
.title(status_text)
|
||||
.style(Theme::status_bar_style());
|
||||
|
||||
frame.render_widget(status_block, area);
|
||||
// No host connected
|
||||
let content_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
|
||||
frame.render_widget(disk_title, content_chunks[0]);
|
||||
|
||||
let no_host_spans = StatusIcons::create_status_spans(Status::Unknown, "No host connected");
|
||||
let no_host_para = Paragraph::new(ratatui::text::Line::from(no_host_spans));
|
||||
frame.render_widget(no_host_para, content_chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render placeholder widget
|
||||
fn render_placeholder(&self, frame: &mut Frame, area: Rect, name: &str) {
|
||||
let placeholder_block = Block::default()
|
||||
.title(format!("{} • awaiting implementation", name))
|
||||
.borders(Borders::ALL)
|
||||
.style(Theme::widget_border_inactive_style())
|
||||
.title_style(Style::default().fg(Theme::muted_text()));
|
||||
|
||||
frame.render_widget(placeholder_block, area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for input events with timeout
|
||||
pub fn check_for_input(timeout: Duration) -> Result<Option<Event>> {
|
||||
if event::poll(timeout)? {
|
||||
Ok(Some(event::read()?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,143 @@
|
||||
use ratatui::style::{Color, Style, Modifier};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
use cm_dashboard_shared::Status;
|
||||
|
||||
/// Color theme for the dashboard - btop dark theme
|
||||
/// Complete terminal color palette matching your configuration
|
||||
pub struct TerminalColors {
|
||||
// Primary colors
|
||||
pub foreground: Color,
|
||||
pub dim_foreground: Color,
|
||||
pub bright_foreground: Color,
|
||||
pub background: Color,
|
||||
|
||||
// Normal colors
|
||||
pub normal_black: Color,
|
||||
pub normal_red: Color,
|
||||
pub normal_green: Color,
|
||||
pub normal_yellow: Color,
|
||||
pub normal_blue: Color,
|
||||
pub normal_magenta: Color,
|
||||
pub normal_cyan: Color,
|
||||
pub normal_white: Color,
|
||||
|
||||
// Bright colors
|
||||
pub bright_black: Color,
|
||||
pub bright_red: Color,
|
||||
pub bright_green: Color,
|
||||
pub bright_yellow: Color,
|
||||
pub bright_blue: Color,
|
||||
pub bright_magenta: Color,
|
||||
pub bright_cyan: Color,
|
||||
pub bright_white: Color,
|
||||
|
||||
// Dim colors
|
||||
pub dim_black: Color,
|
||||
pub dim_red: Color,
|
||||
pub dim_green: Color,
|
||||
pub dim_yellow: Color,
|
||||
pub dim_blue: Color,
|
||||
pub dim_magenta: Color,
|
||||
pub dim_cyan: Color,
|
||||
pub dim_white: Color,
|
||||
}
|
||||
|
||||
impl Default for TerminalColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Primary colors
|
||||
foreground: Color::Rgb(198, 198, 198), // #c6c6c6
|
||||
dim_foreground: Color::Rgb(112, 112, 112), // #707070
|
||||
bright_foreground: Color::Rgb(255, 255, 255), // #ffffff
|
||||
background: Color::Rgb(38, 38, 38), // #262626
|
||||
|
||||
// Normal colors
|
||||
normal_black: Color::Rgb(0, 0, 0), // #000000
|
||||
normal_red: Color::Rgb(215, 84, 0), // #d75400
|
||||
normal_green: Color::Rgb(175, 215, 135), // #afd787
|
||||
normal_yellow: Color::Rgb(215, 175, 95), // #d7af5f
|
||||
normal_blue: Color::Rgb(135, 175, 215), // #87afd7
|
||||
normal_magenta: Color::Rgb(215, 215, 175), // #d7d7af
|
||||
normal_cyan: Color::Rgb(160, 160, 160), // #a0a0a0
|
||||
normal_white: Color::Rgb(238, 238, 238), // #eeeeee
|
||||
|
||||
// Bright colors
|
||||
bright_black: Color::Rgb(48, 48, 48), // #303030
|
||||
bright_red: Color::Rgb(215, 84, 0), // #d75400
|
||||
bright_green: Color::Rgb(175, 215, 135), // #afd787
|
||||
bright_yellow: Color::Rgb(215, 175, 95), // #d7af5f
|
||||
bright_blue: Color::Rgb(135, 175, 215), // #87afd7
|
||||
bright_magenta: Color::Rgb(215, 215, 175), // #d7d7af
|
||||
bright_cyan: Color::Rgb(160, 160, 160), // #a0a0a0
|
||||
bright_white: Color::Rgb(255, 255, 255), // #ffffff
|
||||
|
||||
// Dim colors
|
||||
dim_black: Color::Rgb(0, 0, 0), // #000000
|
||||
dim_red: Color::Rgb(215, 84, 0), // #d75400
|
||||
dim_green: Color::Rgb(175, 215, 135), // #afd787
|
||||
dim_yellow: Color::Rgb(215, 175, 95), // #d7af5f
|
||||
dim_blue: Color::Rgb(135, 175, 215), // #87afd7
|
||||
dim_magenta: Color::Rgb(215, 215, 175), // #d7d7af
|
||||
dim_cyan: Color::Rgb(160, 160, 160), // #a0a0a0
|
||||
dim_white: Color::Rgb(221, 221, 221), // #dddddd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive theming engine for dashboard consistency
|
||||
pub struct Theme;
|
||||
|
||||
impl Theme {
|
||||
/// Get color for status level (btop-style)
|
||||
fn colors() -> &'static TerminalColors {
|
||||
static COLORS: std::sync::OnceLock<TerminalColors> = std::sync::OnceLock::new();
|
||||
COLORS.get_or_init(TerminalColors::default)
|
||||
}
|
||||
|
||||
// Semantic color mapping using the terminal color struct
|
||||
pub fn primary_text() -> Color {
|
||||
Self::colors().normal_white
|
||||
}
|
||||
|
||||
pub fn secondary_text() -> Color {
|
||||
Self::colors().foreground
|
||||
}
|
||||
|
||||
pub fn muted_text() -> Color {
|
||||
Self::colors().dim_foreground
|
||||
}
|
||||
|
||||
pub fn border() -> Color {
|
||||
Self::colors().dim_foreground
|
||||
}
|
||||
|
||||
pub fn border_title() -> Color {
|
||||
Self::colors().bright_white
|
||||
}
|
||||
|
||||
pub fn background() -> Color {
|
||||
Self::colors().background
|
||||
}
|
||||
|
||||
pub fn success() -> Color {
|
||||
Self::colors().normal_green
|
||||
}
|
||||
|
||||
pub fn warning() -> Color {
|
||||
Self::colors().normal_yellow
|
||||
}
|
||||
|
||||
pub fn error() -> Color {
|
||||
Self::colors().normal_red
|
||||
}
|
||||
|
||||
pub fn info() -> Color {
|
||||
Self::colors().normal_cyan
|
||||
}
|
||||
|
||||
pub fn highlight() -> Color {
|
||||
Self::colors().normal_blue
|
||||
}
|
||||
|
||||
/// Get color for status level
|
||||
pub fn status_color(status: Status) -> Color {
|
||||
match status {
|
||||
Status::Ok => Self::success(),
|
||||
@ -20,84 +152,29 @@ impl Theme {
|
||||
Style::default().fg(Self::status_color(status))
|
||||
}
|
||||
|
||||
/// Primary text color (btop bright text)
|
||||
pub fn primary_text() -> Color {
|
||||
Color::Rgb(255, 255, 255) // Pure white
|
||||
}
|
||||
|
||||
/// Secondary text color (btop muted text)
|
||||
pub fn secondary_text() -> Color {
|
||||
Color::Rgb(180, 180, 180) // Light gray
|
||||
}
|
||||
|
||||
/// Muted text color (btop dimmed text)
|
||||
pub fn muted_text() -> Color {
|
||||
Color::Rgb(120, 120, 120) // Medium gray
|
||||
}
|
||||
|
||||
/// Border color (btop muted borders)
|
||||
pub fn border() -> Color {
|
||||
Color::Rgb(100, 100, 100) // Muted gray like btop
|
||||
}
|
||||
|
||||
/// Secondary border color (btop blue)
|
||||
pub fn border_secondary() -> Color {
|
||||
Color::Rgb(100, 149, 237) // Cornflower blue
|
||||
}
|
||||
|
||||
/// Background color (btop true black)
|
||||
pub fn background() -> Color {
|
||||
Color::Black // True black like btop
|
||||
}
|
||||
|
||||
/// Highlight color (btop selection)
|
||||
pub fn highlight() -> Color {
|
||||
Color::Rgb(58, 150, 221) // Bright blue
|
||||
}
|
||||
|
||||
/// Success color (btop green)
|
||||
pub fn success() -> Color {
|
||||
Color::Rgb(40, 167, 69) // Success green
|
||||
}
|
||||
|
||||
/// Warning color (btop orange/yellow)
|
||||
pub fn warning() -> Color {
|
||||
Color::Rgb(255, 193, 7) // Warning amber
|
||||
}
|
||||
|
||||
/// Error color (btop red)
|
||||
pub fn error() -> Color {
|
||||
Color::Rgb(220, 53, 69) // Error red
|
||||
}
|
||||
|
||||
/// Info color (btop blue)
|
||||
pub fn info() -> Color {
|
||||
Color::Rgb(23, 162, 184) // Info cyan-blue
|
||||
}
|
||||
|
||||
/// CPU usage colors (btop CPU gradient)
|
||||
/// CPU usage colors using terminal color struct
|
||||
pub fn cpu_color(percentage: u16) -> Color {
|
||||
match percentage {
|
||||
0..=25 => Color::Rgb(46, 160, 67), // Green
|
||||
26..=50 => Color::Rgb(255, 206, 84), // Yellow
|
||||
51..=75 => Color::Rgb(255, 159, 67), // Orange
|
||||
76..=100 => Color::Rgb(255, 69, 58), // Red
|
||||
_ => Color::Rgb(255, 69, 58), // Red for >100%
|
||||
0..=25 => Self::colors().normal_green, // Low usage
|
||||
26..=50 => Self::colors().normal_yellow, // Medium usage
|
||||
51..=75 => Self::colors().normal_magenta, // High usage
|
||||
76..=100 => Self::colors().normal_red, // Critical usage
|
||||
_ => Self::colors().normal_red, // Over 100%
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory usage colors (btop memory gradient)
|
||||
/// Memory usage colors using terminal color struct
|
||||
pub fn memory_color(percentage: u16) -> Color {
|
||||
match percentage {
|
||||
0..=60 => Color::Rgb(52, 199, 89), // Green
|
||||
61..=80 => Color::Rgb(255, 214, 10), // Yellow
|
||||
81..=95 => Color::Rgb(255, 149, 0), // Orange
|
||||
96..=100 => Color::Rgb(255, 59, 48), // Red
|
||||
_ => Color::Rgb(255, 59, 48), // Red for >100%
|
||||
0..=60 => Self::colors().normal_green, // Low usage
|
||||
61..=80 => Self::colors().normal_yellow, // Medium usage
|
||||
81..=95 => Self::colors().normal_magenta, // High usage
|
||||
96..=100 => Self::colors().normal_red, // Critical usage
|
||||
_ => Self::colors().normal_red, // Over 100%
|
||||
}
|
||||
}
|
||||
|
||||
/// Get gauge color based on percentage (btop-style gradient)
|
||||
/// Get gauge color based on percentage
|
||||
pub fn gauge_color(percentage: u16, warning_threshold: u16, critical_threshold: u16) -> Color {
|
||||
if percentage >= critical_threshold {
|
||||
Self::error()
|
||||
@ -108,27 +185,192 @@ impl Theme {
|
||||
}
|
||||
}
|
||||
|
||||
/// Title style (btop widget titles)
|
||||
pub fn title_style() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::primary_text())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Widget border style (btop default borders)
|
||||
/// Widget border style
|
||||
pub fn widget_border_style() -> Style {
|
||||
Style::default().fg(Self::border())
|
||||
Style::default().fg(Self::border()).bg(Self::background())
|
||||
}
|
||||
|
||||
/// Inactive widget border style
|
||||
pub fn widget_border_inactive_style() -> Style {
|
||||
Style::default().fg(Self::muted_text())
|
||||
Style::default().fg(Self::muted_text()).bg(Self::background())
|
||||
}
|
||||
|
||||
/// Status bar style (btop bottom bar)
|
||||
pub fn status_bar_style() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::secondary_text())
|
||||
.bg(Self::background())
|
||||
/// Title style
|
||||
pub fn title_style() -> Style {
|
||||
Style::default().fg(Self::border_title()).bg(Self::background())
|
||||
}
|
||||
}
|
||||
|
||||
/// Status bar style
|
||||
pub fn status_bar_style() -> Style {
|
||||
Style::default().fg(Self::muted_text()).bg(Self::background())
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout and spacing constants
|
||||
pub struct Layout;
|
||||
|
||||
impl Layout {
|
||||
/// Left panel percentage (system + backup)
|
||||
pub const LEFT_PANEL_WIDTH: u16 = 45;
|
||||
/// Right panel percentage (services)
|
||||
pub const RIGHT_PANEL_WIDTH: u16 = 55;
|
||||
/// System vs backup split (equal)
|
||||
pub const SYSTEM_PANEL_HEIGHT: u16 = 50;
|
||||
pub const BACKUP_PANEL_HEIGHT: u16 = 50;
|
||||
/// System panel CPU section height
|
||||
pub const CPU_SECTION_HEIGHT: u16 = 2;
|
||||
/// System panel memory section height
|
||||
pub const MEMORY_SECTION_HEIGHT: u16 = 3;
|
||||
}
|
||||
|
||||
/// Typography system
|
||||
pub struct Typography;
|
||||
|
||||
/// Component styling system
|
||||
pub struct Components;
|
||||
|
||||
/// Status icons and styling
|
||||
pub struct StatusIcons;
|
||||
|
||||
impl StatusIcons {
|
||||
/// Get status icon symbol
|
||||
pub fn get_icon(status: Status) -> &'static str {
|
||||
match status {
|
||||
Status::Ok => "●",
|
||||
Status::Warning => "◐",
|
||||
Status::Critical => "◯",
|
||||
Status::Unknown => "?",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get style for status-colored text (icon + text in status color)
|
||||
pub fn get_status_style(status: Status) -> Style {
|
||||
let color = match status {
|
||||
Status::Ok => Theme::success(), // Green
|
||||
Status::Warning => Theme::warning(), // Yellow
|
||||
Status::Critical => Theme::error(), // Red
|
||||
Status::Unknown => Theme::muted_text(), // Gray
|
||||
};
|
||||
Style::default().fg(color).bg(Theme::background())
|
||||
}
|
||||
|
||||
/// Format text with status icon (entire line uses status color)
|
||||
pub fn format_with_status(status: Status, text: &str) -> String {
|
||||
let icon = Self::get_icon(status);
|
||||
format!("{} {}", icon, text)
|
||||
}
|
||||
|
||||
/// Create spans with status icon colored and text in foreground color
|
||||
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::Ok => Theme::success(), // Green
|
||||
Status::Warning => Theme::warning(), // Yellow
|
||||
Status::Critical => Theme::error(), // Red
|
||||
Status::Unknown => Theme::muted_text(), // Gray
|
||||
};
|
||||
|
||||
vec![
|
||||
ratatui::text::Span::styled(
|
||||
format!("{} ", icon),
|
||||
Style::default().fg(status_color).bg(Theme::background())
|
||||
),
|
||||
ratatui::text::Span::styled(
|
||||
text.to_string(),
|
||||
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Get style for secondary text with status icon (icon in status color, text in secondary)
|
||||
pub fn get_secondary_with_status_style(_status: Status) -> Style {
|
||||
// For now, use secondary color but we could enhance this later
|
||||
// The icon color will come from the status color in the formatted text
|
||||
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
||||
}
|
||||
}
|
||||
|
||||
impl Components {
|
||||
/// Standard widget block with title using bright foreground for title
|
||||
pub fn widget_block(title: &str) -> Block {
|
||||
Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Theme::border()).bg(Theme::background()))
|
||||
.title_style(Style::default().fg(Theme::border_title()).bg(Theme::background()))
|
||||
}
|
||||
|
||||
/// Status bar style
|
||||
pub fn status_bar() -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::muted_text())
|
||||
.bg(Theme::background())
|
||||
}
|
||||
|
||||
/// CPU usage styling based on percentage
|
||||
pub fn cpu_usage_style(percentage: u16) -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::cpu_color(percentage))
|
||||
.bg(Theme::background())
|
||||
}
|
||||
|
||||
/// Memory usage styling based on percentage
|
||||
pub fn memory_usage_style(percentage: u16) -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::memory_color(percentage))
|
||||
.bg(Theme::background())
|
||||
}
|
||||
|
||||
/// Service status styling
|
||||
pub fn service_status_style(status: Status) -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::status_color(status))
|
||||
.bg(Theme::background())
|
||||
}
|
||||
|
||||
/// Backup item styling
|
||||
pub fn backup_item_style(status: Status) -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::status_color(status))
|
||||
.bg(Theme::background())
|
||||
}
|
||||
}
|
||||
|
||||
impl Typography {
|
||||
/// Main title style (dashboard header)
|
||||
pub fn title() -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::primary_text())
|
||||
.bg(Theme::background())
|
||||
}
|
||||
|
||||
/// Widget title style (panel headers) - bold bright white
|
||||
pub fn widget_title() -> Style {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Theme::background())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Secondary content text
|
||||
pub fn secondary() -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::secondary_text())
|
||||
.bg(Theme::background())
|
||||
}
|
||||
|
||||
/// Muted text (inactive items, placeholders) - now bold bright white for headers
|
||||
pub fn muted() -> Style {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Theme::background())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Status text with dynamic colors
|
||||
pub fn status(status: Status) -> Style {
|
||||
Style::default()
|
||||
.fg(Theme::status_color(status))
|
||||
.bg(Theme::background())
|
||||
}
|
||||
}
|
||||
|
||||
488
dashboard/src/ui/widgets/backup.rs
Normal file
488
dashboard/src/ui/widgets/backup.rs
Normal file
@ -0,0 +1,488 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
|
||||
|
||||
/// Backup widget displaying backup status, services, and repository information
|
||||
pub struct BackupWidget {
|
||||
/// Overall backup status
|
||||
overall_status: Status,
|
||||
/// Last backup duration in seconds
|
||||
duration_seconds: Option<i64>,
|
||||
/// Last backup timestamp
|
||||
last_run_timestamp: Option<i64>,
|
||||
/// Total number of backup services
|
||||
total_services: Option<i64>,
|
||||
/// Total repository size in GB
|
||||
total_repo_size_gb: Option<f32>,
|
||||
/// Total disk space for backups in GB
|
||||
backup_disk_total_gb: Option<f32>,
|
||||
/// Used disk space for backups in GB
|
||||
backup_disk_used_gb: Option<f32>,
|
||||
/// Backup disk product name from SMART data
|
||||
backup_disk_product_name: Option<String>,
|
||||
/// Backup disk serial number from SMART data
|
||||
backup_disk_serial_number: Option<String>,
|
||||
/// Backup disk filesystem label
|
||||
backup_disk_filesystem_label: Option<String>,
|
||||
/// Number of completed services
|
||||
services_completed_count: Option<i64>,
|
||||
/// Number of failed services
|
||||
services_failed_count: Option<i64>,
|
||||
/// Number of disabled services
|
||||
services_disabled_count: Option<i64>,
|
||||
/// All individual service metrics for detailed display
|
||||
service_metrics: Vec<ServiceMetricData>,
|
||||
/// Last update indicator
|
||||
has_data: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServiceMetricData {
|
||||
name: String,
|
||||
status: Status,
|
||||
exit_code: Option<i64>,
|
||||
archive_count: Option<i64>,
|
||||
repo_size_gb: Option<f32>,
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
overall_status: Status::Unknown,
|
||||
duration_seconds: None,
|
||||
last_run_timestamp: None,
|
||||
total_services: None,
|
||||
total_repo_size_gb: None,
|
||||
backup_disk_total_gb: None,
|
||||
backup_disk_used_gb: None,
|
||||
backup_disk_product_name: None,
|
||||
backup_disk_serial_number: None,
|
||||
backup_disk_filesystem_label: None,
|
||||
services_completed_count: None,
|
||||
services_failed_count: None,
|
||||
services_disabled_count: None,
|
||||
service_metrics: Vec::new(),
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format duration for display
|
||||
fn format_duration(&self) -> String {
|
||||
match self.duration_seconds {
|
||||
Some(seconds) => {
|
||||
if seconds >= 3600 {
|
||||
format!("{:.1}h", seconds as f32 / 3600.0)
|
||||
} else if seconds >= 60 {
|
||||
format!("{:.1}m", seconds as f32 / 60.0)
|
||||
} else {
|
||||
format!("{}s", seconds)
|
||||
}
|
||||
}
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format timestamp for display
|
||||
fn format_last_run(&self) -> String {
|
||||
match self.last_run_timestamp {
|
||||
Some(timestamp) => {
|
||||
let duration = chrono::Utc::now().timestamp() - timestamp;
|
||||
if duration < 3600 {
|
||||
format!("{}m ago", duration / 60)
|
||||
} else if duration < 86400 {
|
||||
format!("{}h ago", duration / 3600)
|
||||
} else {
|
||||
format!("{}d ago", duration / 86400)
|
||||
}
|
||||
}
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format service status counts
|
||||
fn format_service_counts(&self) -> String {
|
||||
let completed = self.services_completed_count.unwrap_or(0);
|
||||
let failed = self.services_failed_count.unwrap_or(0);
|
||||
let disabled = self.services_disabled_count.unwrap_or(0);
|
||||
let total = self.total_services.unwrap_or(0);
|
||||
|
||||
if failed > 0 {
|
||||
format!("{}✓ {}✗ {}◯ ({})", completed, failed, disabled, total)
|
||||
} else {
|
||||
format!("{}✓ {}◯ ({})", completed, disabled, total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format disk usage in format "usedGB/totalGB"
|
||||
fn format_repo_size(&self) -> String {
|
||||
match (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
||||
(Some(used_gb), Some(total_gb)) => {
|
||||
let used_str = Self::format_size_with_proper_units(used_gb);
|
||||
let total_str = Self::format_size_with_proper_units(total_gb);
|
||||
format!("{}/{}", used_str, total_str)
|
||||
}
|
||||
(Some(used_gb), None) => {
|
||||
// Fallback to just used size if total not available
|
||||
Self::format_size_with_proper_units(used_gb)
|
||||
}
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format size with proper units (xxxkB/MB/GB/TB)
|
||||
fn format_size_with_proper_units(size_gb: f32) -> String {
|
||||
if size_gb >= 1000.0 {
|
||||
// TB range
|
||||
format!("{:.1}TB", size_gb / 1000.0)
|
||||
} else if size_gb >= 1.0 {
|
||||
// GB range
|
||||
format!("{:.1}GB", size_gb)
|
||||
} else if size_gb >= 0.001 {
|
||||
// MB range (size_gb * 1024 = MB)
|
||||
let size_mb = size_gb * 1024.0;
|
||||
format!("{:.1}MB", size_mb)
|
||||
} else if size_gb >= 0.000001 {
|
||||
// kB range (size_gb * 1024 * 1024 = kB)
|
||||
let size_kb = size_gb * 1024.0 * 1024.0;
|
||||
format!("{:.0}kB", size_kb)
|
||||
} else {
|
||||
// B range (size_gb * 1024^3 = bytes)
|
||||
let size_bytes = size_gb * 1024.0 * 1024.0 * 1024.0;
|
||||
format!("{:.0}B", size_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status indicator symbol
|
||||
fn get_status_symbol(&self) -> &str {
|
||||
match self.overall_status {
|
||||
Status::Ok => "●",
|
||||
Status::Warning => "◐",
|
||||
Status::Critical => "◯",
|
||||
Status::Unknown => "?",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format size in GB to appropriate unit (kB/MB/GB)
|
||||
fn format_size_gb(size_gb: f32) -> String {
|
||||
if size_gb >= 1.0 {
|
||||
format!("{:.1}GB", size_gb)
|
||||
} else if size_gb >= 0.001 {
|
||||
let size_mb = size_gb * 1024.0;
|
||||
format!("{:.1}MB", size_mb)
|
||||
} else if size_gb >= 0.000001 {
|
||||
let size_kb = size_gb * 1024.0 * 1024.0;
|
||||
format!("{:.0}kB", size_kb)
|
||||
} else {
|
||||
format!("{:.0}B", size_gb * 1024.0 * 1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format product name display
|
||||
fn format_product_name(&self) -> String {
|
||||
if let Some(ref product_name) = self.backup_disk_product_name {
|
||||
format!("P/N: {}", product_name)
|
||||
} else {
|
||||
"P/N: Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format serial number display
|
||||
fn format_serial_number(&self) -> String {
|
||||
if let Some(ref serial) = self.backup_disk_serial_number {
|
||||
format!("S/N: {}", serial)
|
||||
} else {
|
||||
"S/N: Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract service name from metric name (e.g., "backup_service_gitea_status" -> "gitea")
|
||||
fn extract_service_name(metric_name: &str) -> Option<String> {
|
||||
if metric_name.starts_with("backup_service_") {
|
||||
let name_part = &metric_name[15..]; // Remove "backup_service_" prefix
|
||||
|
||||
// Try to extract service name by removing known suffixes
|
||||
if let Some(service_name) = name_part.strip_suffix("_status") {
|
||||
Some(service_name.to_string())
|
||||
} else if let Some(service_name) = name_part.strip_suffix("_exit_code") {
|
||||
Some(service_name.to_string())
|
||||
} else if let Some(service_name) = name_part.strip_suffix("_archive_count") {
|
||||
Some(service_name.to_string())
|
||||
} else if let Some(service_name) = name_part.strip_suffix("_repo_size_gb") {
|
||||
Some(service_name.to_string())
|
||||
} else if let Some(service_name) = name_part.strip_suffix("_repo_path") {
|
||||
Some(service_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for BackupWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
debug!("Backup widget updating with {} metrics", metrics.len());
|
||||
for metric in metrics {
|
||||
debug!("Backup metric: {} = {:?} (status: {:?})", metric.name, metric.value, metric.status);
|
||||
}
|
||||
|
||||
// Also debug the service_data after processing
|
||||
debug!("Processing individual service metrics...");
|
||||
|
||||
// Log how many metrics are backup service metrics
|
||||
let service_metric_count = metrics.iter()
|
||||
.filter(|m| m.name.starts_with("backup_service_"))
|
||||
.count();
|
||||
debug!("Found {} backup_service_ metrics out of {} total backup metrics",
|
||||
service_metric_count, metrics.len());
|
||||
|
||||
// Reset service metrics
|
||||
self.service_metrics.clear();
|
||||
let mut service_data: std::collections::HashMap<String, ServiceMetricData> = std::collections::HashMap::new();
|
||||
|
||||
for metric in metrics {
|
||||
match metric.name.as_str() {
|
||||
"backup_overall_status" => {
|
||||
let status_str = metric.value.as_string();
|
||||
self.overall_status = match status_str.as_str() {
|
||||
"ok" => Status::Ok,
|
||||
"warning" => Status::Warning,
|
||||
"critical" => Status::Critical,
|
||||
_ => Status::Unknown,
|
||||
};
|
||||
}
|
||||
"backup_duration_seconds" => {
|
||||
self.duration_seconds = metric.value.as_i64();
|
||||
}
|
||||
"backup_last_run_timestamp" => {
|
||||
self.last_run_timestamp = metric.value.as_i64();
|
||||
}
|
||||
"backup_total_services" => {
|
||||
self.total_services = metric.value.as_i64();
|
||||
}
|
||||
"backup_total_repo_size_gb" => {
|
||||
self.total_repo_size_gb = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_total_gb" => {
|
||||
self.backup_disk_total_gb = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_used_gb" => {
|
||||
self.backup_disk_used_gb = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_product_name" => {
|
||||
self.backup_disk_product_name = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_disk_serial_number" => {
|
||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_disk_filesystem_label" => {
|
||||
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_services_completed_count" => {
|
||||
self.services_completed_count = metric.value.as_i64();
|
||||
}
|
||||
"backup_services_failed_count" => {
|
||||
self.services_failed_count = metric.value.as_i64();
|
||||
}
|
||||
"backup_services_disabled_count" => {
|
||||
self.services_disabled_count = metric.value.as_i64();
|
||||
}
|
||||
_ => {
|
||||
// Handle individual service metrics
|
||||
if let Some(service_name) = Self::extract_service_name(&metric.name) {
|
||||
debug!("Extracted service name '{}' from metric '{}'", service_name, metric.name);
|
||||
let entry = service_data.entry(service_name.clone()).or_insert_with(|| ServiceMetricData {
|
||||
name: service_name,
|
||||
status: Status::Unknown,
|
||||
exit_code: None,
|
||||
archive_count: None,
|
||||
repo_size_gb: None,
|
||||
});
|
||||
|
||||
if metric.name.ends_with("_status") {
|
||||
entry.status = metric.status;
|
||||
debug!("Set status for {}: {:?}", entry.name, entry.status);
|
||||
} else if metric.name.ends_with("_exit_code") {
|
||||
entry.exit_code = metric.value.as_i64();
|
||||
} else if metric.name.ends_with("_archive_count") {
|
||||
entry.archive_count = metric.value.as_i64();
|
||||
debug!("Set archive_count for {}: {:?}", entry.name, entry.archive_count);
|
||||
} else if metric.name.ends_with("_repo_size_gb") {
|
||||
entry.repo_size_gb = metric.value.as_f32();
|
||||
debug!("Set repo_size_gb for {}: {:?}", entry.name, entry.repo_size_gb);
|
||||
}
|
||||
} else {
|
||||
debug!("Could not extract service name from metric: {}", metric.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert service data to sorted vector
|
||||
let mut services: Vec<ServiceMetricData> = service_data.into_values().collect();
|
||||
services.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.service_metrics = services;
|
||||
|
||||
self.has_data = !metrics.is_empty();
|
||||
|
||||
debug!("Backup widget updated: status={:?}, services={}, total_size={:?}GB",
|
||||
self.overall_status, self.service_metrics.len(), self.total_repo_size_gb);
|
||||
|
||||
// Debug individual service data
|
||||
for service in &self.service_metrics {
|
||||
debug!("Service {}: status={:?}, archives={:?}, size={:?}GB",
|
||||
service.name, service.status, service.archive_count, service.repo_size_gb);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
// Split area into header and services list
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(6), // Header with "Latest backup" title, status, P/N, and S/N
|
||||
Constraint::Min(0), // Service list
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Render backup overview
|
||||
self.render_backup_overview(frame, chunks[0]);
|
||||
|
||||
// Render services list
|
||||
self.render_services_list(frame, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
/// Render backup overview section
|
||||
fn render_backup_overview(&self, frame: &mut Frame, area: Rect) {
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // "Latest backup" header
|
||||
Constraint::Length(1), // Status line
|
||||
Constraint::Length(1), // Duration and last run
|
||||
Constraint::Length(1), // Repository size
|
||||
Constraint::Length(1), // Product name
|
||||
Constraint::Length(1), // Serial number
|
||||
Constraint::Min(0), // Remaining space
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// "Latest backup" header
|
||||
let header_para = Paragraph::new("Latest backup:")
|
||||
.style(Typography::widget_title());
|
||||
frame.render_widget(header_para, content_chunks[0]);
|
||||
|
||||
// Status line
|
||||
let status_text = format!("Status: {}",
|
||||
match self.overall_status {
|
||||
Status::Ok => "OK",
|
||||
Status::Warning => "Warning",
|
||||
Status::Critical => "Failed",
|
||||
Status::Unknown => "Unknown",
|
||||
}
|
||||
);
|
||||
let status_spans = StatusIcons::create_status_spans(self.overall_status, &status_text);
|
||||
let status_para = Paragraph::new(ratatui::text::Line::from(status_spans));
|
||||
frame.render_widget(status_para, content_chunks[1]);
|
||||
|
||||
// Duration and last run
|
||||
let time_text = format!("Duration: {} • Last: {}",
|
||||
self.format_duration(),
|
||||
self.format_last_run()
|
||||
);
|
||||
let time_para = Paragraph::new(time_text)
|
||||
.style(Typography::secondary());
|
||||
frame.render_widget(time_para, content_chunks[2]);
|
||||
|
||||
// Repository size
|
||||
let size_text = format!("Disk usage: {}", self.format_repo_size());
|
||||
let size_para = Paragraph::new(size_text)
|
||||
.style(Typography::secondary());
|
||||
frame.render_widget(size_para, content_chunks[3]);
|
||||
|
||||
// Product name
|
||||
let product_text = self.format_product_name();
|
||||
let product_para = Paragraph::new(product_text)
|
||||
.style(Typography::secondary());
|
||||
frame.render_widget(product_para, content_chunks[4]);
|
||||
|
||||
// Serial number
|
||||
let serial_text = self.format_serial_number();
|
||||
let serial_para = Paragraph::new(serial_text)
|
||||
.style(Typography::secondary());
|
||||
frame.render_widget(serial_para, content_chunks[5]);
|
||||
}
|
||||
|
||||
/// Render services list section
|
||||
fn render_services_list(&self, frame: &mut Frame, area: Rect) {
|
||||
if area.height < 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let available_lines = area.height as usize;
|
||||
let services_to_show = self.service_metrics.iter().take(available_lines);
|
||||
|
||||
let mut y_offset = 0;
|
||||
for service in services_to_show {
|
||||
if y_offset >= available_lines {
|
||||
break;
|
||||
}
|
||||
|
||||
let service_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + y_offset as u16,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let service_info = if let (Some(archives), Some(size_gb)) = (service.archive_count, service.repo_size_gb) {
|
||||
let size_str = Self::format_size_with_proper_units(size_gb);
|
||||
format!(" {}archives {}", archives, size_str)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let service_text = format!("{}{}", service.name, service_info);
|
||||
let service_spans = StatusIcons::create_status_spans(service.status, &service_text);
|
||||
let service_para = Paragraph::new(ratatui::text::Line::from(service_spans));
|
||||
|
||||
frame.render_widget(service_para, service_area);
|
||||
y_offset += 1;
|
||||
}
|
||||
|
||||
// If there are more services than we can show, indicate that
|
||||
if self.service_metrics.len() > available_lines {
|
||||
let more_count = self.service_metrics.len() - available_lines;
|
||||
if available_lines > 0 {
|
||||
let last_line_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + (available_lines - 1) as u16,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let more_text = format!("... and {} more services", more_count);
|
||||
let more_para = Paragraph::new(more_text)
|
||||
.style(Typography::muted());
|
||||
|
||||
frame.render_widget(more_para, last_line_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BackupWidget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,13 @@
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
|
||||
|
||||
/// CPU widget displaying load, temperature, and frequency
|
||||
pub struct CpuWidget {
|
||||
@ -40,11 +38,6 @@ impl CpuWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status color for display (btop-style)
|
||||
fn get_status_color(&self) -> Color {
|
||||
Theme::status_color(self.status)
|
||||
}
|
||||
|
||||
/// Format load average for display
|
||||
fn format_load(&self) -> String {
|
||||
match (self.load_1min, self.load_5min, self.load_15min) {
|
||||
@ -55,14 +48,6 @@ impl CpuWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format temperature for display
|
||||
fn format_temperature(&self) -> String {
|
||||
match self.temperature {
|
||||
Some(temp) => format!("{:.1}°C", temp),
|
||||
None => "—°C".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format frequency for display
|
||||
fn format_frequency(&self) -> String {
|
||||
match self.frequency {
|
||||
@ -70,45 +55,7 @@ impl CpuWidget {
|
||||
None => "— MHz".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get load percentage for gauge (based on load_1min)
|
||||
fn get_load_percentage(&self) -> u16 {
|
||||
match self.load_1min {
|
||||
Some(load) => {
|
||||
// Assume 8-core system, so 100% = load of 8.0
|
||||
let percentage = (load / 8.0 * 100.0).min(100.0).max(0.0);
|
||||
percentage as u16
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create btop-style dotted bar pattern (like real btop)
|
||||
fn create_btop_dotted_bar(&self, percentage: u16, width: usize) -> String {
|
||||
let filled = (width * percentage as usize) / 100;
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
// Real btop uses these patterns:
|
||||
// High usage: ████████ (solid blocks)
|
||||
// Medium usage: :::::::: (colons)
|
||||
// Low usage: ........ (dots)
|
||||
// Empty: (spaces)
|
||||
|
||||
let pattern = if percentage >= 75 {
|
||||
"█" // High usage - solid blocks
|
||||
} else if percentage >= 25 {
|
||||
":" // Medium usage - colons like btop
|
||||
} else if percentage > 0 {
|
||||
"." // Low usage - dots like btop
|
||||
} else {
|
||||
" " // No usage - spaces
|
||||
};
|
||||
|
||||
let filled_chars = pattern.repeat(filled);
|
||||
let empty_chars = " ".repeat(empty);
|
||||
|
||||
filled_chars + &empty_chars
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Widget for CpuWidget {
|
||||
@ -168,27 +115,16 @@ impl Widget for CpuWidget {
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
|
||||
let cpu_title = Paragraph::new("CPU:").style(Style::default().fg(Theme::primary_text()).bg(Theme::background()));
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1)]).split(area);
|
||||
let cpu_title = Paragraph::new("CPU:").style(Typography::widget_title());
|
||||
frame.render_widget(cpu_title, content_chunks[0]);
|
||||
let overall_usage = self.get_load_percentage();
|
||||
let cpu_usage_text = format!("Usage: {} {:>3}%", self.create_btop_dotted_bar(overall_usage, 20), overall_usage);
|
||||
let cpu_usage_para = Paragraph::new(cpu_usage_text).style(Style::default().fg(Theme::cpu_color(overall_usage)).bg(Theme::background()));
|
||||
frame.render_widget(cpu_usage_para, content_chunks[1]);
|
||||
let load_freq_text = format!("Load: {} • {}", self.format_load(), self.format_frequency());
|
||||
let load_freq_para = Paragraph::new(load_freq_text).style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
|
||||
frame.render_widget(load_freq_para, content_chunks[2]);
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"CPU"
|
||||
}
|
||||
|
||||
fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
let load_freq_spans = StatusIcons::create_status_spans(self.status, &format!("Load: {} • {}", self.format_load(), self.format_frequency()));
|
||||
let load_freq_para = Paragraph::new(ratatui::text::Line::from(load_freq_spans));
|
||||
frame.render_widget(load_freq_para, content_chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Default for CpuWidget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
|
||||
|
||||
/// Memory widget displaying usage, totals, and swap information
|
||||
pub struct MemoryWidget {
|
||||
@ -54,44 +52,6 @@ impl MemoryWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status color for display (btop-style)
|
||||
fn get_status_color(&self) -> Color {
|
||||
Theme::status_color(self.status)
|
||||
}
|
||||
|
||||
/// Format memory usage for display
|
||||
fn format_memory_usage(&self) -> String {
|
||||
match (self.used_gb, self.total_gb) {
|
||||
(Some(used), Some(total)) => {
|
||||
format!("{:.1}/{:.1} GB", used, total)
|
||||
}
|
||||
_ => "—/— GB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format swap usage for display
|
||||
fn format_swap_usage(&self) -> String {
|
||||
match (self.swap_used_gb, self.swap_total_gb) {
|
||||
(Some(used), Some(total)) => {
|
||||
if total > 0.0 {
|
||||
format!("{:.1}/{:.1} GB", used, total)
|
||||
} else {
|
||||
"No swap".to_string()
|
||||
}
|
||||
}
|
||||
_ => "—/— GB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format /tmp usage for display
|
||||
fn format_tmp_usage(&self) -> String {
|
||||
match (self.tmp_size_mb, self.tmp_total_mb) {
|
||||
(Some(used), Some(total)) => {
|
||||
format!("{:.1}/{:.0} MB", used, total)
|
||||
}
|
||||
_ => "—/— MB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get memory usage percentage for gauge
|
||||
fn get_memory_percentage(&self) -> u16 {
|
||||
@ -109,44 +69,66 @@ impl MemoryWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get swap usage percentage
|
||||
fn get_swap_percentage(&self) -> u16 {
|
||||
match (self.swap_used_gb, self.swap_total_gb) {
|
||||
(Some(used), Some(total)) if total > 0.0 => {
|
||||
let percent = (used / total * 100.0).min(100.0).max(0.0);
|
||||
percent as u16
|
||||
|
||||
/// Format size with proper units (kB/MB/GB)
|
||||
fn format_size_units(size_mb: f32) -> String {
|
||||
if size_mb >= 1024.0 {
|
||||
// Convert to GB
|
||||
let size_gb = size_mb / 1024.0;
|
||||
format!("{:.1}GB", size_gb)
|
||||
} else if size_mb >= 1.0 {
|
||||
// Show as MB
|
||||
format!("{:.0}MB", size_mb)
|
||||
} else if size_mb >= 0.001 {
|
||||
// Convert to kB
|
||||
let size_kb = size_mb * 1024.0;
|
||||
format!("{:.0}kB", size_kb)
|
||||
} else {
|
||||
// Show very small sizes in bytes
|
||||
let size_bytes = size_mb * 1024.0 * 1024.0;
|
||||
format!("{:.0}B", size_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format /tmp usage as "xx% yyykB/MB/GB/zzzGB"
|
||||
fn format_tmp_usage(&self) -> String {
|
||||
match (self.tmp_usage_percent, self.tmp_size_mb, self.tmp_total_mb) {
|
||||
(Some(percent), Some(used_mb), Some(total_mb)) => {
|
||||
let used_str = Self::format_size_units(used_mb);
|
||||
let total_str = Self::format_size_units(total_mb);
|
||||
format!("{:.1}% {}/{}", percent, used_str, total_str)
|
||||
}
|
||||
_ => 0,
|
||||
(Some(percent), Some(used_mb), None) => {
|
||||
let used_str = Self::format_size_units(used_mb);
|
||||
format!("{:.1}% {}", percent, used_str)
|
||||
}
|
||||
(None, Some(used_mb), Some(total_mb)) => {
|
||||
let used_str = Self::format_size_units(used_mb);
|
||||
let total_str = Self::format_size_units(total_mb);
|
||||
format!("{}/{}", used_str, total_str)
|
||||
}
|
||||
(None, Some(used_mb), None) => {
|
||||
Self::format_size_units(used_mb)
|
||||
}
|
||||
_ => "—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tmp status based on usage percentage
|
||||
fn get_tmp_status(&self) -> Status {
|
||||
if let Some(tmp_percent) = self.tmp_usage_percent {
|
||||
if tmp_percent >= 90.0 {
|
||||
Status::Critical
|
||||
} else if tmp_percent >= 70.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
}
|
||||
} else {
|
||||
Status::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Create btop-style dotted bar pattern (same as CPU)
|
||||
fn create_btop_dotted_bar(&self, percentage: u16, width: usize) -> String {
|
||||
let filled = (width * percentage as usize) / 100;
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
// Real btop uses these patterns:
|
||||
// High usage: ████████ (solid blocks)
|
||||
// Medium usage: :::::::: (colons)
|
||||
// Low usage: ........ (dots)
|
||||
// Empty: (spaces)
|
||||
|
||||
let pattern = if percentage >= 75 {
|
||||
"█" // High usage - solid blocks
|
||||
} else if percentage >= 25 {
|
||||
":" // Medium usage - colons like btop
|
||||
} else if percentage > 0 {
|
||||
"." // Low usage - dots like btop
|
||||
} else {
|
||||
" " // No usage - spaces
|
||||
};
|
||||
|
||||
let filled_chars = pattern.repeat(filled);
|
||||
let empty_chars = " ".repeat(empty);
|
||||
|
||||
filled_chars + &empty_chars
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MemoryWidget {
|
||||
@ -231,23 +213,22 @@ impl Widget for MemoryWidget {
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
|
||||
let mem_title = Paragraph::new("Memory:").style(Style::default().fg(Theme::primary_text()).bg(Theme::background()));
|
||||
let mem_title = Paragraph::new("RAM:").style(Typography::widget_title());
|
||||
frame.render_widget(mem_title, content_chunks[0]);
|
||||
let memory_percentage = self.get_memory_percentage();
|
||||
let mem_usage_text = format!("Usage: {} {:>3}%", self.create_btop_dotted_bar(memory_percentage, 20), memory_percentage);
|
||||
let mem_usage_para = Paragraph::new(mem_usage_text).style(Style::default().fg(Theme::memory_color(memory_percentage)).bg(Theme::background()));
|
||||
frame.render_widget(mem_usage_para, content_chunks[1]);
|
||||
let mem_details_text = format!("Used: {} • Total: {}", self.used_gb.map_or("—".to_string(), |v| format!("{:.1}GB", v)), self.total_gb.map_or("—".to_string(), |v| format!("{:.1}GB", v)));
|
||||
let mem_details_para = Paragraph::new(mem_details_text).style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
|
||||
frame.render_widget(mem_details_para, content_chunks[2]);
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"Memory"
|
||||
}
|
||||
|
||||
fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
|
||||
// Format used and total memory with smart units, percentage, and status icon
|
||||
let used_str = self.used_gb.map_or("—".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
|
||||
let total_str = self.total_gb.map_or("—".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
|
||||
let percentage = self.get_memory_percentage();
|
||||
let mem_details_spans = StatusIcons::create_status_spans(self.status, &format!("Used: {}% {}/{}", percentage, used_str, total_str));
|
||||
let mem_details_para = Paragraph::new(ratatui::text::Line::from(mem_details_spans));
|
||||
frame.render_widget(mem_details_para, content_chunks[1]);
|
||||
|
||||
// /tmp usage line with status icon
|
||||
let tmp_status = self.get_tmp_status();
|
||||
let tmp_spans = StatusIcons::create_status_spans(tmp_status, &format!("tmp: {}", self.format_tmp_usage()));
|
||||
let tmp_para = Paragraph::new(ratatui::text::Line::from(tmp_spans));
|
||||
frame.render_widget(tmp_para, content_chunks[2]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,12 @@ use ratatui::{layout::Rect, Frame};
|
||||
pub mod cpu;
|
||||
pub mod memory;
|
||||
pub mod services;
|
||||
pub mod backup;
|
||||
|
||||
pub use cpu::CpuWidget;
|
||||
pub use memory::MemoryWidget;
|
||||
pub use services::ServicesWidget;
|
||||
pub use backup::BackupWidget;
|
||||
|
||||
/// Widget trait for UI components that display metrics
|
||||
pub trait Widget {
|
||||
@ -16,10 +18,4 @@ pub trait Widget {
|
||||
|
||||
/// Render the widget to a terminal frame
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect);
|
||||
|
||||
/// Get widget name for display
|
||||
fn get_name(&self) -> &str;
|
||||
|
||||
/// Check if widget has data to display
|
||||
fn has_data(&self) -> bool;
|
||||
}
|
||||
@ -1,20 +1,22 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Services widget displaying individual systemd service statuses
|
||||
/// Services widget displaying hierarchical systemd service statuses
|
||||
pub struct ServicesWidget {
|
||||
/// Individual service statuses
|
||||
services: HashMap<String, ServiceInfo>,
|
||||
/// Parent services (nginx, docker, etc.)
|
||||
parent_services: HashMap<String, ServiceInfo>,
|
||||
/// Sub-services grouped by parent (nginx -> [gitea, mariehall, ...], docker -> [container1, ...])
|
||||
sub_services: HashMap<String, Vec<(String, ServiceInfo)>>,
|
||||
/// Aggregated status
|
||||
status: Status,
|
||||
/// Last update indicator
|
||||
@ -26,86 +28,183 @@ struct ServiceInfo {
|
||||
status: String,
|
||||
memory_mb: Option<f32>,
|
||||
disk_gb: Option<f32>,
|
||||
latency_ms: Option<f32>,
|
||||
widget_status: Status,
|
||||
}
|
||||
|
||||
impl ServicesWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
services: HashMap::new(),
|
||||
parent_services: HashMap::new(),
|
||||
sub_services: HashMap::new(),
|
||||
status: Status::Unknown,
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status color for display (btop-style)
|
||||
fn get_status_color(&self) -> Color {
|
||||
Theme::status_color(self.status)
|
||||
}
|
||||
|
||||
/// Extract service name from metric name
|
||||
fn extract_service_name(metric_name: &str) -> Option<String> {
|
||||
/// Extract service name and determine if it's a parent or sub-service
|
||||
fn extract_service_info(metric_name: &str) -> Option<(String, Option<String>)> {
|
||||
if metric_name.starts_with("service_") {
|
||||
if let Some(end_pos) = metric_name.rfind("_status")
|
||||
.or_else(|| metric_name.rfind("_memory_mb"))
|
||||
.or_else(|| metric_name.rfind("_disk_gb")) {
|
||||
let service_name = &metric_name[8..end_pos]; // Remove "service_" prefix
|
||||
return Some(service_name.to_string());
|
||||
.or_else(|| metric_name.rfind("_disk_gb"))
|
||||
.or_else(|| metric_name.rfind("_latency_ms")) {
|
||||
let service_part = &metric_name[8..end_pos]; // Remove "service_" prefix
|
||||
|
||||
// Check for sub-services patterns
|
||||
if service_part.starts_with("nginx_") {
|
||||
// nginx sub-services: service_nginx_gitea_latency_ms -> ("nginx", "gitea")
|
||||
let sub_service = service_part.strip_prefix("nginx_").unwrap_or(service_part);
|
||||
return Some(("nginx".to_string(), Some(sub_service.to_string())));
|
||||
} else if service_part.starts_with("docker_") {
|
||||
// docker sub-services: service_docker_container1_status -> ("docker", "container1")
|
||||
let sub_service = service_part.strip_prefix("docker_").unwrap_or(service_part);
|
||||
return Some(("docker".to_string(), Some(sub_service.to_string())));
|
||||
} else {
|
||||
// Regular parent service: service_nginx_status -> ("nginx", None)
|
||||
return Some((service_part.to_string(), None));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Format service info for display
|
||||
fn format_service_info(&self, name: &str, info: &ServiceInfo) -> String {
|
||||
let status_icon = match info.widget_status {
|
||||
Status::Ok => "✅",
|
||||
Status::Warning => "⚠️",
|
||||
Status::Critical => "❌",
|
||||
Status::Unknown => "❓",
|
||||
};
|
||||
/// Format disk size with appropriate units (kB/MB/GB)
|
||||
fn format_disk_size(size_gb: f32) -> String {
|
||||
let size_mb = size_gb * 1024.0; // Convert GB to MB
|
||||
|
||||
let memory_str = if let Some(memory) = info.memory_mb {
|
||||
format!(" Mem:{:.1}MB", memory)
|
||||
if size_mb >= 1024.0 {
|
||||
// Show as GB
|
||||
format!("{:.1}GB", size_gb)
|
||||
} else if size_mb >= 1.0 {
|
||||
// Show as MB
|
||||
format!("{:.0}MB", size_mb)
|
||||
} else if size_mb >= 0.001 {
|
||||
// Convert to kB
|
||||
let size_kb = size_mb * 1024.0;
|
||||
format!("{:.0}kB", size_kb)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let disk_str = if let Some(disk) = info.disk_gb {
|
||||
format!(" Disk:{:.1}GB", disk)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
format!("{} {} ({}){}{}", status_icon, name, info.status, memory_str, disk_str)
|
||||
// Show very small sizes as bytes
|
||||
let size_bytes = size_mb * 1024.0 * 1024.0;
|
||||
format!("{:.0}B", size_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format service info in clean service list format
|
||||
fn format_btop_process_line(&self, name: &str, info: &ServiceInfo, _index: usize) -> String {
|
||||
|
||||
/// Format parent service line - returns text without icon for span formatting
|
||||
fn format_parent_service_line(&self, name: &str, info: &ServiceInfo) -> String {
|
||||
let memory_str = info.memory_mb.map_or("0M".to_string(), |m| format!("{:.0}M", m));
|
||||
let disk_str = info.disk_gb.map_or("0G".to_string(), |d| format!("{:.1}G", d));
|
||||
let disk_str = info.disk_gb.map_or("0".to_string(), |d| Self::format_disk_size(d));
|
||||
|
||||
// Truncate long service names to fit layout
|
||||
let short_name = if name.len() > 23 {
|
||||
format!("{}...", &name[..20])
|
||||
// Truncate long service names to fit layout (account for icon space)
|
||||
let short_name = if name.len() > 22 {
|
||||
format!("{}...", &name[..19])
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Status with color indicator
|
||||
// Parent services always show active/inactive status
|
||||
let status_str = match info.widget_status {
|
||||
Status::Ok => "✅ active",
|
||||
Status::Warning => "⚠️ inactive",
|
||||
Status::Critical => "❌ failed",
|
||||
Status::Unknown => "❓ unknown",
|
||||
Status::Ok => "active".to_string(),
|
||||
Status::Warning => "inactive".to_string(),
|
||||
Status::Critical => "failed".to_string(),
|
||||
Status::Unknown => "unknown".to_string(),
|
||||
};
|
||||
|
||||
format!("{:<25} {:<10} {:<8} {:<8}",
|
||||
format!("{:<24} {:<10} {:<8} {:<8}",
|
||||
short_name,
|
||||
status_str,
|
||||
memory_str,
|
||||
disk_str)
|
||||
}
|
||||
|
||||
/// Format sub-service line (indented, no memory/disk columns) - returns text without icon for span formatting
|
||||
fn format_sub_service_line(&self, name: &str, info: &ServiceInfo) -> String {
|
||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||
let short_name = if name.len() > 18 {
|
||||
format!("{}...", &name[..15])
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Sub-services show latency if available, otherwise status
|
||||
let status_str = if let Some(latency) = info.latency_ms {
|
||||
if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
}
|
||||
} else {
|
||||
match info.widget_status {
|
||||
Status::Ok => "active".to_string(),
|
||||
Status::Warning => "inactive".to_string(),
|
||||
Status::Critical => "failed".to_string(),
|
||||
Status::Unknown => "unknown".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
// Indent sub-services with " ├─ " prefix (no memory/disk columns)
|
||||
format!(" ├─ {:<20} {:<10}",
|
||||
short_name,
|
||||
status_str)
|
||||
}
|
||||
|
||||
/// Create spans for sub-service with icon next to name
|
||||
fn create_sub_service_spans(&self, name: &str, info: &ServiceInfo) -> Vec<ratatui::text::Span<'static>> {
|
||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||
let short_name = if name.len() > 18 {
|
||||
format!("{}...", &name[..15])
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Sub-services show latency if available, otherwise status
|
||||
let status_str = if let Some(latency) = info.latency_ms {
|
||||
if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
}
|
||||
} else {
|
||||
match info.widget_status {
|
||||
Status::Ok => "active".to_string(),
|
||||
Status::Warning => "inactive".to_string(),
|
||||
Status::Critical => "failed".to_string(),
|
||||
Status::Unknown => "unknown".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let status_color = match info.widget_status {
|
||||
Status::Ok => Theme::success(),
|
||||
Status::Warning => Theme::warning(),
|
||||
Status::Critical => Theme::error(),
|
||||
Status::Unknown => Theme::muted_text(),
|
||||
};
|
||||
|
||||
let icon = StatusIcons::get_icon(info.widget_status);
|
||||
|
||||
vec![
|
||||
// Indentation and tree prefix
|
||||
ratatui::text::Span::styled(
|
||||
" ├─ ".to_string(),
|
||||
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
||||
),
|
||||
// Status icon
|
||||
ratatui::text::Span::styled(
|
||||
format!("{} ", icon),
|
||||
Style::default().fg(status_color).bg(Theme::background())
|
||||
),
|
||||
// Service name
|
||||
ratatui::text::Span::styled(
|
||||
format!("{:<18} ", short_name),
|
||||
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
||||
),
|
||||
// Status/latency text
|
||||
ratatui::text::Span::styled(
|
||||
status_str,
|
||||
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ServicesWidget {
|
||||
@ -116,73 +215,180 @@ impl Widget for ServicesWidget {
|
||||
|
||||
// Process individual service metrics
|
||||
for metric in metrics {
|
||||
if let Some(service_name) = Self::extract_service_name(&metric.name) {
|
||||
let service_info = self.services.entry(service_name).or_insert(ServiceInfo {
|
||||
status: "unknown".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
widget_status: Status::Unknown,
|
||||
});
|
||||
|
||||
if metric.name.ends_with("_status") {
|
||||
service_info.status = metric.value.as_string();
|
||||
service_info.widget_status = metric.status;
|
||||
} else if metric.name.ends_with("_memory_mb") {
|
||||
if let Some(memory) = metric.value.as_f32() {
|
||||
service_info.memory_mb = Some(memory);
|
||||
if let Some((parent_service, sub_service)) = Self::extract_service_info(&metric.name) {
|
||||
match sub_service {
|
||||
None => {
|
||||
// Parent service metric
|
||||
let service_info = self.parent_services.entry(parent_service).or_insert(ServiceInfo {
|
||||
status: "unknown".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
widget_status: Status::Unknown,
|
||||
});
|
||||
|
||||
if metric.name.ends_with("_status") {
|
||||
service_info.status = metric.value.as_string();
|
||||
service_info.widget_status = metric.status;
|
||||
} else if metric.name.ends_with("_memory_mb") {
|
||||
if let Some(memory) = metric.value.as_f32() {
|
||||
service_info.memory_mb = Some(memory);
|
||||
}
|
||||
} else if metric.name.ends_with("_disk_gb") {
|
||||
if let Some(disk) = metric.value.as_f32() {
|
||||
service_info.disk_gb = Some(disk);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if metric.name.ends_with("_disk_gb") {
|
||||
if let Some(disk) = metric.value.as_f32() {
|
||||
service_info.disk_gb = Some(disk);
|
||||
Some(sub_name) => {
|
||||
// Sub-service metric
|
||||
let sub_service_list = self.sub_services.entry(parent_service).or_insert_with(Vec::new);
|
||||
|
||||
// Find existing sub-service or create new one
|
||||
let sub_service_info = if let Some(pos) = sub_service_list.iter().position(|(name, _)| name == &sub_name) {
|
||||
&mut sub_service_list[pos].1
|
||||
} else {
|
||||
sub_service_list.push((sub_name.clone(), ServiceInfo {
|
||||
status: "unknown".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
widget_status: Status::Unknown,
|
||||
}));
|
||||
&mut sub_service_list.last_mut().unwrap().1
|
||||
};
|
||||
|
||||
if metric.name.ends_with("_status") {
|
||||
sub_service_info.status = metric.value.as_string();
|
||||
sub_service_info.widget_status = metric.status;
|
||||
} else if metric.name.ends_with("_memory_mb") {
|
||||
if let Some(memory) = metric.value.as_f32() {
|
||||
sub_service_info.memory_mb = Some(memory);
|
||||
}
|
||||
} else if metric.name.ends_with("_disk_gb") {
|
||||
if let Some(disk) = metric.value.as_f32() {
|
||||
sub_service_info.disk_gb = Some(disk);
|
||||
}
|
||||
} else if metric.name.ends_with("_latency_ms") {
|
||||
if let Some(latency) = metric.value.as_f32() {
|
||||
sub_service_info.latency_ms = Some(latency);
|
||||
sub_service_info.widget_status = metric.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status from all services
|
||||
let statuses: Vec<Status> = self.services.values()
|
||||
.map(|info| info.widget_status)
|
||||
.collect();
|
||||
// Aggregate status from all parent and sub-services
|
||||
let mut all_statuses = Vec::new();
|
||||
|
||||
self.status = if statuses.is_empty() {
|
||||
// Add parent service statuses
|
||||
all_statuses.extend(self.parent_services.values().map(|info| info.widget_status));
|
||||
|
||||
// Add sub-service statuses
|
||||
for sub_list in self.sub_services.values() {
|
||||
all_statuses.extend(sub_list.iter().map(|(_, info)| info.widget_status));
|
||||
}
|
||||
|
||||
self.status = if all_statuses.is_empty() {
|
||||
Status::Unknown
|
||||
} else {
|
||||
Status::aggregate(&statuses)
|
||||
Status::aggregate(&all_statuses)
|
||||
};
|
||||
|
||||
self.has_data = !self.services.is_empty();
|
||||
self.has_data = !self.parent_services.is_empty() || !self.sub_services.is_empty();
|
||||
|
||||
debug!("Services widget updated: {} services, status={:?}",
|
||||
self.services.len(), self.status);
|
||||
debug!("Services widget updated: {} parent services, {} sub-service groups, status={:?}",
|
||||
self.parent_services.len(), self.sub_services.len(), self.status);
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let services_block = Block::default().title("services").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
|
||||
let services_block = Components::widget_block("services");
|
||||
let inner_area = services_block.inner(area);
|
||||
frame.render_widget(services_block, area);
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Min(0)]).split(inner_area);
|
||||
let header = format!("{:<25} {:<10} {:<8} {:<8}", "Service:", "Status:", "MemB", "DiskGB");
|
||||
let header_para = Paragraph::new(header).style(Style::default().fg(Theme::muted_text()).bg(Theme::background()));
|
||||
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(inner_area);
|
||||
|
||||
// Header
|
||||
let header = format!("{:<25} {:<10} {:<8} {:<8}", "Service:", "Status:", "RAM:", "Disk:");
|
||||
let header_para = Paragraph::new(header).style(Typography::muted());
|
||||
frame.render_widget(header_para, content_chunks[0]);
|
||||
if self.services.is_empty() { let empty_text = Paragraph::new("No process data").style(Style::default().fg(Theme::muted_text()).bg(Theme::background())); frame.render_widget(empty_text, content_chunks[1]); return; }
|
||||
let mut services: Vec<_> = self.services.iter().collect();
|
||||
services.sort_by(|(_, a), (_, b)| b.memory_mb.unwrap_or(0.0).partial_cmp(&a.memory_mb.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let available_lines = content_chunks[1].height as usize;
|
||||
let service_chunks = Layout::default().direction(Direction::Vertical).constraints(vec![Constraint::Length(1); available_lines.min(services.len())]).split(content_chunks[1]);
|
||||
for (i, (name, info)) in services.iter().take(available_lines).enumerate() {
|
||||
let service_line = self.format_btop_process_line(name, info, i);
|
||||
let color = match info.widget_status { Status::Ok => Theme::primary_text(), Status::Warning => Theme::warning(), Status::Critical => Theme::error(), Status::Unknown => Theme::muted_text(), };
|
||||
let service_para = Paragraph::new(service_line).style(Style::default().fg(color).bg(Theme::background()));
|
||||
frame.render_widget(service_para, service_chunks[i]);
|
||||
|
||||
// Check if we have any services to display
|
||||
if self.parent_services.is_empty() && self.sub_services.is_empty() {
|
||||
let empty_text = Paragraph::new("No process data").style(Typography::muted());
|
||||
frame.render_widget(empty_text, content_chunks[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build hierarchical service list for display
|
||||
let mut display_lines = Vec::new();
|
||||
|
||||
// Sort parent services alphabetically for consistent order
|
||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
// Add parent service line
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None)); // false = not sub-service
|
||||
|
||||
// Add sub-services for this parent (if any)
|
||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||
// Sort sub-services by name for consistent display
|
||||
let mut sorted_subs = sub_list.clone();
|
||||
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (sub_name, sub_info) in sorted_subs {
|
||||
// Store sub-service info for custom span rendering
|
||||
display_lines.push((sub_name.clone(), sub_info.widget_status, true, Some(sub_info.clone()))); // true = sub-service
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render all lines within available space
|
||||
let available_lines = content_chunks[1].height as usize;
|
||||
let lines_to_show = available_lines.min(display_lines.len());
|
||||
|
||||
if lines_to_show > 0 {
|
||||
let service_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); lines_to_show])
|
||||
.split(content_chunks[1]);
|
||||
|
||||
for (i, (line_text, line_status, is_sub, sub_info)) in display_lines.iter().take(lines_to_show).enumerate() {
|
||||
let spans = if *is_sub && sub_info.is_some() {
|
||||
// Use custom sub-service span creation
|
||||
self.create_sub_service_spans(line_text, sub_info.as_ref().unwrap())
|
||||
} else {
|
||||
// Use regular status spans for parent services
|
||||
StatusIcons::create_status_spans(*line_status, line_text)
|
||||
};
|
||||
let service_para = Paragraph::new(ratatui::text::Line::from(spans));
|
||||
frame.render_widget(service_para, service_chunks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Show indicator if there are more services than we can display
|
||||
if display_lines.len() > available_lines {
|
||||
let more_count = display_lines.len() - available_lines;
|
||||
if available_lines > 0 {
|
||||
let last_line_area = Rect {
|
||||
x: content_chunks[1].x,
|
||||
y: content_chunks[1].y + (available_lines - 1) as u16,
|
||||
width: content_chunks[1].width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let more_text = format!("... and {} more services", more_count);
|
||||
let more_para = Paragraph::new(more_text).style(Typography::muted());
|
||||
frame.render_widget(more_para, last_line_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"Services"
|
||||
}
|
||||
|
||||
fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user