From afb8d68e03f583d7c5244512022d39b269c2e024 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 29 Nov 2025 16:44:50 +0100 Subject: [PATCH] Implement multi-disk backup support - Update BackupData structure to support multiple backup disks - Scan /var/lib/backup/status/ directory for all status files - Calculate status icons for backup and disk usage - Aggregate repository status from all disks - Update dashboard to display all backup disks with per-disk status - Display repository list with count and aggregated status --- CLAUDE.md | 11 +- Cargo.lock | 6 +- agent/src/collectors/backup.rs | 203 ++++++++++++++++++++--------- dashboard/src/ui/widgets/system.rs | 131 ++++++++----------- shared/src/agent_data.rs | 29 ++--- 5 files changed, 219 insertions(+), 161 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f2619a8..676240a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,9 +327,16 @@ Storage: ├─ ● Data_2: GGA04461 T: 28°C └─ ● Parity: WDZS8RY0 T: 29°C Backup: +● Repo: 4 + ├─ getea + ├─ vaultwarden + ├─ mysql + └─ immich +● W800639Y W: 2% + ├─ ● Backup: 2025-11-29T04:00:01.324623 + └─ ● Usage: 8% 70GB/916GB ● WD-WCC7K1234567 T: 32°C W: 12% - ├─ Last: 2h ago (12.3GB) - ├─ Next: in 22h + ├─ ● Backup: 2025-11-29T04:00:01.324623 └─ ● Usage: 45% 678GB/1.5TB ``` diff --git a/Cargo.lock b/Cargo.lock index 4bb0c94..02da80d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.211" +version = "0.1.212" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.211" +version = "0.1.212" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.211" +version = "0.1.212" dependencies = [ "chrono", "serde", diff --git a/agent/src/collectors/backup.rs b/agent/src/collectors/backup.rs index 94ec73c..b423738 100644 --- a/agent/src/collectors/backup.rs +++ b/agent/src/collectors/backup.rs @@ -1,36 +1,66 @@ use async_trait::async_trait; -use cm_dashboard_shared::{AgentData, BackupData, BackupDiskData}; +use cm_dashboard_shared::{AgentData, BackupData, BackupDiskData, Status}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::Path; -use tracing::debug; +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 { - /// Path to backup status file - status_file_path: String, + /// Directory containing backup status files + status_dir: String, } impl BackupCollector { pub fn new() -> Self { Self { - status_file_path: "/var/lib/backup/backup-status.toml".to_string(), + status_dir: "/var/lib/backup/status".to_string(), } } - /// Read backup status from TOML file - async fn read_backup_status(&self) -> Result, CollectorError> { - if !Path::new(&self.status_file_path).exists() { - debug!("Backup status file not found: {}", self.status_file_path); - return Ok(None); + /// 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 content = fs::read_to_string(&self.status_file_path) + 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: self.status_file_path.clone(), + path: path.to_string_lossy().to_string(), error: e.to_string(), })?; @@ -40,66 +70,109 @@ impl BackupCollector { error: format!("Failed to parse backup status TOML: {}", e), })?; - Ok(Some(status)) + 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> { - if let Some(backup_status) = self.read_backup_status().await? { - // Use raw start_time string from TOML + let status_files = self.scan_status_files().await?; - // Extract disk information - let repository_disk = if let Some(disk_space) = &backup_status.disk_space { - Some(BackupDiskData { - serial: backup_status.disk_serial_number.clone().unwrap_or_else(|| "Unknown".to_string()), - usage_percent: disk_space.usage_percent as f32, - used_gb: disk_space.used_gb as f32, - total_gb: disk_space.total_gb as f32, - wear_percent: backup_status.disk_wear_percent, - temperature_celsius: None, // Not available in current TOML - }) - } else if let Some(serial) = &backup_status.disk_serial_number { - // Fallback: create minimal disk info if we have serial but no disk_space - Some(BackupDiskData { - serial: serial.clone(), - usage_percent: 0.0, - used_gb: 0.0, - total_gb: 0.0, - wear_percent: backup_status.disk_wear_percent, - temperature_celsius: None, - }) - } else { - None - }; - - // Calculate total repository size from services - let total_size_gb = backup_status.services - .values() - .map(|service| service.repo_size_bytes as f32 / (1024.0 * 1024.0 * 1024.0)) - .sum::(); - - let backup_data = BackupData { - status: backup_status.status, - total_size_gb: Some(total_size_gb), - repository_health: Some("ok".to_string()), // Derive from status if needed - repository_disk, - last_backup_size_gb: None, // Not available in current TOML format - start_time_raw: Some(backup_status.start_time), - }; - - agent_data.backup = backup_data; - } else { - // No backup status available - set default values + if status_files.is_empty() { + debug!("No backup status files found"); agent_data.backup = BackupData { - status: "unavailable".to_string(), - total_size_gb: None, - repository_health: None, - repository_disk: None, - last_backup_size_gb: None, - start_time_raw: None, + 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(); + + // 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, + }; + + 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(()) } } diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index 65d0995..d37e6df 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -45,15 +45,9 @@ pub struct SystemWidget { storage_pools: Vec, // Backup metrics - backup_status: String, - backup_start_time_raw: Option, - backup_disk_serial: Option, - backup_disk_usage_percent: Option, - backup_disk_used_gb: Option, - backup_disk_total_gb: Option, - backup_disk_wear_percent: Option, - backup_disk_temperature: Option, - backup_last_size_gb: Option, + backup_repositories: Vec, + backup_repository_status: Status, + backup_disks: Vec, // Overall status has_data: bool, @@ -114,15 +108,9 @@ impl SystemWidget { tmp_status: Status::Unknown, tmpfs_mounts: Vec::new(), storage_pools: Vec::new(), - backup_status: "unknown".to_string(), - backup_start_time_raw: None, - backup_disk_serial: None, - backup_disk_usage_percent: None, - backup_disk_used_gb: None, - backup_disk_total_gb: None, - backup_disk_wear_percent: None, - backup_disk_temperature: None, - backup_last_size_gb: None, + backup_repositories: Vec::new(), + backup_repository_status: Status::Unknown, + backup_disks: Vec::new(), has_data: false, } } @@ -221,25 +209,9 @@ impl Widget for SystemWidget { // Extract backup data let backup = &agent_data.backup; - self.backup_status = backup.status.clone(); - self.backup_start_time_raw = backup.start_time_raw.clone(); - self.backup_last_size_gb = backup.last_backup_size_gb; - - if let Some(disk) = &backup.repository_disk { - self.backup_disk_serial = Some(disk.serial.clone()); - self.backup_disk_usage_percent = Some(disk.usage_percent); - self.backup_disk_used_gb = Some(disk.used_gb); - self.backup_disk_total_gb = Some(disk.total_gb); - self.backup_disk_wear_percent = disk.wear_percent; - self.backup_disk_temperature = disk.temperature_celsius; - } else { - self.backup_disk_serial = None; - self.backup_disk_usage_percent = None; - self.backup_disk_used_gb = None; - self.backup_disk_total_gb = None; - self.backup_disk_wear_percent = None; - self.backup_disk_temperature = None; - } + self.backup_repositories = backup.repositories.clone(); + self.backup_repository_status = backup.repository_status; + self.backup_disks = backup.disks.clone(); } } @@ -539,14 +511,32 @@ impl SystemWidget { fn render_backup(&self) -> Vec> { let mut lines = Vec::new(); - // First line: serial number with temperature and wear - if let Some(serial) = &self.backup_disk_serial { - let truncated_serial = truncate_serial(serial); + // First section: Repository status and list + if !self.backup_repositories.is_empty() { + let repo_text = format!("Repo: {}", self.backup_repositories.len()); + let repo_spans = StatusIcons::create_status_spans(self.backup_repository_status, &repo_text); + lines.push(Line::from(repo_spans)); + + // List all repositories + let repo_count = self.backup_repositories.len(); + for (idx, repo) in self.backup_repositories.iter().enumerate() { + let tree_char = if idx == repo_count - 1 { "└─" } else { "├─" }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", tree_char), Typography::tree()), + Span::styled(repo, Typography::secondary()), + ])); + } + } + + // Second section: Per-disk backup information + for disk in &self.backup_disks { + let truncated_serial = truncate_serial(&disk.serial); let mut details = Vec::new(); - if let Some(temp) = self.backup_disk_temperature { + + if let Some(temp) = disk.temperature_celsius { details.push(format!("T: {}°C", temp as i32)); } - if let Some(wear) = self.backup_disk_wear_percent { + if let Some(wear) = disk.wear_percent { details.push(format!("W: {}%", wear as i32)); } @@ -556,44 +546,33 @@ impl SystemWidget { truncated_serial }; - let backup_status = match self.backup_status.as_str() { - "completed" | "success" => Status::Ok, - "running" => Status::Pending, - "failed" => Status::Critical, - _ => Status::Unknown, - }; - - let disk_spans = StatusIcons::create_status_spans(backup_status, &disk_text); + // Overall disk status (worst of backup and usage) + let disk_status = disk.backup_status.max(disk.usage_status); + let disk_spans = StatusIcons::create_status_spans(disk_status, &disk_text); lines.push(Line::from(disk_spans)); - // Show backup time from TOML if available - if let Some(start_time) = &self.backup_start_time_raw { - let time_text = if let Some(size) = self.backup_last_size_gb { - format!("Time: {} ({:.1}GB)", start_time, size) - } else { - format!("Time: {}", start_time) - }; - - lines.push(Line::from(vec![ + // Show backup time with status + if let Some(backup_time) = &disk.last_backup_time { + let time_text = format!("Backup: {}", backup_time); + let mut time_spans = vec![ Span::styled(" ├─ ", Typography::tree()), - Span::styled(time_text, Typography::secondary()) - ])); + ]; + time_spans.extend(StatusIcons::create_status_spans(disk.backup_status, &time_text)); + lines.push(Line::from(time_spans)); } - // Usage information - if let (Some(used), Some(total), Some(usage_percent)) = ( - self.backup_disk_used_gb, - self.backup_disk_total_gb, - self.backup_disk_usage_percent - ) { - let usage_text = format!("Usage: {:.0}% {:.0}GB/{:.0}GB", usage_percent, used, total); - let usage_spans = StatusIcons::create_status_spans(Status::Ok, &usage_text); - let mut full_spans = vec![ - Span::styled(" └─ ", Typography::tree()), - ]; - full_spans.extend(usage_spans); - lines.push(Line::from(full_spans)); - } + // Show usage with status + let usage_text = format!( + "Usage: {:.0}% {:.0}GB/{:.0}GB", + disk.disk_usage_percent, + disk.disk_used_gb, + disk.disk_total_gb + ); + let mut usage_spans = vec![ + Span::styled(" └─ ", Typography::tree()), + ]; + usage_spans.extend(StatusIcons::create_status_spans(disk.usage_status, &usage_text)); + lines.push(Line::from(usage_spans)); } lines @@ -901,7 +880,7 @@ impl SystemWidget { lines.extend(storage_lines); // Backup section (if available) - if self.backup_status != "unavailable" && self.backup_status != "unknown" { + if !self.backup_repositories.is_empty() || !self.backup_disks.is_empty() { lines.push(Line::from(vec![ Span::styled("Backup:", Typography::widget_title()) ])); diff --git a/shared/src/agent_data.rs b/shared/src/agent_data.rs index c4e5160..5ea686d 100644 --- a/shared/src/agent_data.rs +++ b/shared/src/agent_data.rs @@ -176,23 +176,25 @@ pub struct SubServiceMetric { /// Backup system data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackupData { - pub status: String, - pub total_size_gb: Option, - pub repository_health: Option, - pub repository_disk: Option, - pub last_backup_size_gb: Option, - pub start_time_raw: Option, + pub repositories: Vec, + pub repository_status: Status, + pub disks: Vec, } /// Backup repository disk information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackupDiskData { pub serial: String, - pub usage_percent: f32, - pub used_gb: f32, - pub total_gb: f32, + pub product_name: Option, pub wear_percent: Option, pub temperature_celsius: Option, + pub last_backup_time: Option, + pub backup_status: Status, + pub disk_usage_percent: f32, + pub disk_used_gb: f32, + pub disk_total_gb: f32, + pub usage_status: Status, + pub services: Vec, } impl AgentData { @@ -233,12 +235,9 @@ impl AgentData { }, services: Vec::new(), backup: BackupData { - status: "unknown".to_string(), - total_size_gb: None, - repository_health: None, - repository_disk: None, - last_backup_size_gb: None, - start_time_raw: None, + repositories: Vec::new(), + repository_status: Status::Unknown, + disks: Vec::new(), }, } }