use async_trait::async_trait; use cm_dashboard_shared::{AgentData, BackupData, BackupDiskData, Status}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use tracing::{debug, warn}; use super::{Collector, CollectorError}; /// Backup collector that reads backup status from TOML files with structured data output pub struct BackupCollector { /// Directory containing backup status files status_dir: String, } impl BackupCollector { pub fn new() -> Self { Self { status_dir: "/var/lib/backup/status".to_string(), } } /// Scan directory for all backup status files async fn scan_status_files(&self) -> Result, CollectorError> { let status_path = Path::new(&self.status_dir); if !status_path.exists() { debug!("Backup status directory not found: {}", self.status_dir); return Ok(Vec::new()); } let mut status_files = Vec::new(); match fs::read_dir(status_path) { Ok(entries) => { for entry in entries { if let Ok(entry) = entry { let path = entry.path(); if path.is_file() { if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { if filename.starts_with("backup-status-") && filename.ends_with(".toml") { status_files.push(path); } } } } } } Err(e) => { warn!("Failed to read backup status directory: {}", e); return Ok(Vec::new()); } } Ok(status_files) } /// Read a single backup status file async fn read_status_file(&self, path: &Path) -> Result { let content = fs::read_to_string(path) .map_err(|e| CollectorError::SystemRead { path: path.to_string_lossy().to_string(), error: e.to_string(), })?; let status: BackupStatusToml = toml::from_str(&content) .map_err(|e| CollectorError::Parse { value: content.clone(), error: format!("Failed to parse backup status TOML: {}", e), })?; Ok(status) } /// Calculate backup status from TOML status field fn calculate_backup_status(status_str: &str) -> Status { match status_str.to_lowercase().as_str() { "success" => Status::Ok, "warning" => Status::Warning, "failed" | "error" => Status::Critical, _ => Status::Unknown, } } /// Calculate usage status from disk usage percentage fn calculate_usage_status(usage_percent: f32) -> Status { if usage_percent < 80.0 { Status::Ok } else if usage_percent < 90.0 { Status::Warning } else { Status::Critical } } /// Convert BackupStatusToml to BackupData and populate AgentData async fn populate_backup_data(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> { let status_files = self.scan_status_files().await?; if status_files.is_empty() { debug!("No backup status files found"); agent_data.backup = BackupData { repositories: Vec::new(), repository_status: Status::Unknown, disks: Vec::new(), }; return Ok(()); } let mut all_repositories = HashSet::new(); let mut disks = Vec::new(); let mut worst_status = Status::Ok; for status_file in status_files { match self.read_status_file(&status_file).await { Ok(backup_status) => { // Collect all service names for service_name in backup_status.services.keys() { all_repositories.insert(service_name.clone()); } // Calculate backup status let backup_status_enum = Self::calculate_backup_status(&backup_status.status); // Calculate usage status from disk space let (usage_percent, used_gb, total_gb, usage_status) = if let Some(disk_space) = &backup_status.disk_space { let usage_pct = disk_space.usage_percent as f32; ( usage_pct, disk_space.used_gb as f32, disk_space.total_gb as f32, Self::calculate_usage_status(usage_pct), ) } else { (0.0, 0.0, 0.0, Status::Unknown) }; // Update worst status worst_status = worst_status.max(backup_status_enum).max(usage_status); // Build service list for this disk let services: Vec = backup_status.services.keys().cloned().collect(); // Get min and max archive counts to detect inconsistencies let archives_min: i64 = backup_status.services.values() .map(|service| service.archive_count) .min() .unwrap_or(0); let archives_max: i64 = backup_status.services.values() .map(|service| service.archive_count) .max() .unwrap_or(0); // Create disk data let disk_data = BackupDiskData { serial: backup_status.disk_serial_number.unwrap_or_else(|| "Unknown".to_string()), product_name: backup_status.disk_product_name, wear_percent: backup_status.disk_wear_percent, temperature_celsius: None, // Not available in current TOML last_backup_time: Some(backup_status.start_time), backup_status: backup_status_enum, disk_usage_percent: usage_percent, disk_used_gb: used_gb, disk_total_gb: total_gb, usage_status, services, archives_min, archives_max, }; disks.push(disk_data); } Err(e) => { warn!("Failed to read backup status file {:?}: {}", status_file, e); } } } let repositories: Vec = all_repositories.into_iter().collect(); agent_data.backup = BackupData { repositories, repository_status: worst_status, disks, }; Ok(()) } } #[async_trait] impl Collector for BackupCollector { async fn collect_structured(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> { debug!("Collecting backup status"); self.populate_backup_data(agent_data).await } } /// TOML structure for backup status file #[derive(Debug, Clone, Serialize, Deserialize)] 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 disk_wear_percent: Option, pub services: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] 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, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ServiceStatus { pub status: String, pub exit_code: i64, pub repo_path: String, pub archive_count: i64, pub repo_size_bytes: u64, }