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::{StatusIcons, Typography}; /// Backup widget displaying backup status, services, and repository information #[derive(Clone)] 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, } } /// Check if the backup widget has any data to display pub fn has_data(&self) -> bool { self.has_data } /// 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 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) } } /// 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() } }