From 125111ee99628d8c0f775e41a5c8aa4b2a982e30 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 18 Oct 2025 18:33:41 +0200 Subject: [PATCH] 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 --- agent/src/agent.rs | 74 +++- agent/src/cache/manager.rs | 5 + agent/src/cache/mod.rs | 16 + agent/src/collectors/backup.rs | 388 ++++++++++++++++++ agent/src/collectors/cpu.rs | 154 -------- agent/src/collectors/disk.rs | 363 ++++++++++++++++- agent/src/collectors/mod.rs | 1 + agent/src/collectors/systemd.rs | 571 ++++++++++++++++++++------- agent/src/metrics/mod.rs | 66 +++- dashboard/src/metrics/mod.rs | 10 +- dashboard/src/ui/input.rs | 121 ------ dashboard/src/ui/layout.rs | 71 ---- dashboard/src/ui/mod.rs | 405 +++++++++++-------- dashboard/src/ui/theme.rs | 414 +++++++++++++++---- dashboard/src/ui/widgets/backup.rs | 488 +++++++++++++++++++++++ dashboard/src/ui/widgets/cpu.rs | 84 +--- dashboard/src/ui/widgets/memory.rs | 167 ++++---- dashboard/src/ui/widgets/mod.rs | 8 +- dashboard/src/ui/widgets/services.rs | 402 ++++++++++++++----- 19 files changed, 2788 insertions(+), 1020 deletions(-) create mode 100644 agent/src/collectors/backup.rs delete mode 100644 dashboard/src/ui/input.rs delete mode 100644 dashboard/src/ui/layout.rs create mode 100644 dashboard/src/ui/widgets/backup.rs diff --git a/agent/src/agent.rs b/agent/src/agent.rs index 5cb3bc4..0575c4e 100644 --- a/agent/src/agent.rs +++ b/agent/src/agent.rs @@ -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); } } diff --git a/agent/src/cache/manager.rs b/agent/src/cache/manager.rs index 74e31aa..517d4f8 100644 --- a/agent/src/cache/manager.rs +++ b/agent/src/cache/manager.rs @@ -45,6 +45,11 @@ impl MetricCacheManager { pub async fn get_all_valid_metrics(&self) -> Vec { 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 { + self.cache.get_all_cached_metrics().await + } /// Cache warm-up: collect and cache high-priority metrics pub async fn warm_cache(&self, collector_fn: F) diff --git a/agent/src/cache/mod.rs b/agent/src/cache/mod.rs index 113606f..488f862 100644 --- a/agent/src/cache/mod.rs +++ b/agent/src/cache/mod.rs @@ -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 { + 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) { diff --git a/agent/src/collectors/backup.rs b/agent/src/collectors/backup.rs new file mode 100644 index 0000000..5e3e85f --- /dev/null +++ b/agent/src/collectors/backup.rs @@ -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, 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 { + 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, 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, + pub disk_product_name: Option, + pub disk_serial_number: Option, + pub services: HashMap, +} + +#[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, + pub serial_number: Option, +} + +#[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, +} \ No newline at end of file diff --git a/agent/src/collectors/cpu.rs b/agent/src/collectors/cpu.rs index 4772c12..8da4ecf 100644 --- a/agent/src/collectors/cpu.rs +++ b/agent/src/collectors/cpu.rs @@ -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, 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::() { - 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, 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::() { - 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()); diff --git a/agent/src/collectors/disk.rs b/agent/src/collectors/disk.rs index 3e839f6..114d9e3 100644 --- a/agent/src/collectors/disk.rs +++ b/agent/src/collectors/disk.rs @@ -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> { + 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::() + .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 { + // 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::() { + 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, 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> = 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) diff --git a/agent/src/collectors/mod.rs b/agent/src/collectors/mod.rs index 00024e4..d486601 100644 --- a/agent/src/collectors/mod.rs +++ b/agent/src/collectors/mod.rs @@ -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; diff --git a/agent/src/collectors/systemd.rs b/agent/src/collectors/systemd.rs index 4e350a1..ea60c20 100644 --- a/agent/src/collectors/systemd.rs +++ b/agent/src/collectors/systemd.rs @@ -25,6 +25,12 @@ struct ServiceCacheState { last_discovery_time: Option, /// How often to rediscover services (5 minutes) discovery_interval_seconds: u64, + /// Cached nginx site latency metrics + nginx_site_metrics: Vec, + /// Last time nginx sites were checked + last_nginx_check_time: Option, + /// 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 { + 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> { 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 { - 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 { - 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 { - 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::().ok(); - } else if used_part.ends_with("GiB") { - let num_str = used_part.trim_end_matches("GiB"); - if let Ok(gb) = num_str.parse::() { - 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 { None // Performance tracking handled by cache system } +} + +impl SystemdCollector { + /// Get nginx sites with latency checks + fn get_nginx_sites(&self) -> Vec { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 + } + } } \ No newline at end of file diff --git a/agent/src/metrics/mod.rs b/agent/src/metrics/mod.rs index a6d2d0b..56283e5 100644 --- a/agent/src/metrics/mod.rs +++ b/agent/src/metrics/mod.rs @@ -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> { + 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> { 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> { + 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() } diff --git a/dashboard/src/metrics/mod.rs b/dashboard/src/metrics/mod.rs index 855bf2d..9cd30cd 100644 --- a/dashboard/src/metrics/mod.rs +++ b/dashboard/src/metrics/mod.rs @@ -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 diff --git a/dashboard/src/ui/input.rs b/dashboard/src/ui/input.rs deleted file mode 100644 index fe67499..0000000 --- a/dashboard/src/ui/input.rs +++ /dev/null @@ -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 { - 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 { - 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) - } -} \ No newline at end of file diff --git a/dashboard/src/ui/layout.rs b/dashboard/src/ui/layout.rs deleted file mode 100644 index be86697..0000000 --- a/dashboard/src/ui/layout.rs +++ /dev/null @@ -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]] - } -} \ No newline at end of file diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 7fa3e2b..c8b2986 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -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, /// 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> = 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> { - if event::poll(timeout)? { - Ok(Some(event::read()?)) - } else { - Ok(None) - } -} \ No newline at end of file diff --git a/dashboard/src/ui/theme.rs b/dashboard/src/ui/theme.rs index 963b499..efffb12 100644 --- a/dashboard/src/ui/theme.rs +++ b/dashboard/src/ui/theme.rs @@ -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 = 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()) } -} \ No newline at end of file + + /// 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> { + 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()) + } +} diff --git a/dashboard/src/ui/widgets/backup.rs b/dashboard/src/ui/widgets/backup.rs new file mode 100644 index 0000000..184d885 --- /dev/null +++ b/dashboard/src/ui/widgets/backup.rs @@ -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, + /// Last backup timestamp + last_run_timestamp: Option, + /// Total number of backup services + total_services: Option, + /// Total repository size in GB + total_repo_size_gb: Option, + /// Total disk space for backups in GB + backup_disk_total_gb: Option, + /// Used disk space for backups in GB + backup_disk_used_gb: Option, + /// Backup disk product name from SMART data + backup_disk_product_name: Option, + /// Backup disk serial number from SMART data + backup_disk_serial_number: Option, + /// Backup disk filesystem label + backup_disk_filesystem_label: Option, + /// Number of completed services + services_completed_count: Option, + /// Number of failed services + services_failed_count: Option, + /// Number of disabled services + services_disabled_count: Option, + /// All individual service metrics for detailed display + service_metrics: Vec, + /// Last update indicator + has_data: bool, +} + +#[derive(Debug, Clone)] +struct ServiceMetricData { + name: String, + status: Status, + exit_code: Option, + archive_count: Option, + repo_size_gb: Option, +} + +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 { + 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 = 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 = 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() + } +} \ No newline at end of file diff --git a/dashboard/src/ui/widgets/cpu.rs b/dashboard/src/ui/widgets/cpu.rs index fb7e3d8..c5bf48b 100644 --- a/dashboard/src/ui/widgets/cpu.rs +++ b/dashboard/src/ui/widgets/cpu.rs @@ -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() diff --git a/dashboard/src/ui/widgets/memory.rs b/dashboard/src/ui/widgets/memory.rs index a14b301..bd6de4a 100644 --- a/dashboard/src/ui/widgets/memory.rs +++ b/dashboard/src/ui/widgets/memory.rs @@ -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]); } } diff --git a/dashboard/src/ui/widgets/mod.rs b/dashboard/src/ui/widgets/mod.rs index c9776a2..a157009 100644 --- a/dashboard/src/ui/widgets/mod.rs +++ b/dashboard/src/ui/widgets/mod.rs @@ -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; } \ No newline at end of file diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index cd97f93..57c87f6 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -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, + /// Parent services (nginx, docker, etc.) + parent_services: HashMap, + /// Sub-services grouped by parent (nginx -> [gitea, mariehall, ...], docker -> [container1, ...]) + sub_services: HashMap>, /// Aggregated status status: Status, /// Last update indicator @@ -26,86 +28,183 @@ struct ServiceInfo { status: String, memory_mb: Option, disk_gb: Option, + latency_ms: Option, 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 { + /// Extract service name and determine if it's a parent or sub-service + fn extract_service_info(metric_name: &str) -> Option<(String, Option)> { 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> { + // 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 = 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 } }