use cm_dashboard_shared::{Metric, Status}; use ratatui::{ 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 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) { self.render_with_scroll(frame, area, 0); } } impl BackupWidget { /// Render with scroll offset support pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) { let mut lines = Vec::new(); // Latest backup section lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled("Latest backup:", Typography::widget_title()) ])); // Timestamp with status icon let timestamp_text = if let Some(timestamp) = self.last_run_timestamp { self.format_timestamp(timestamp) } else { "Unknown".to_string() }; let timestamp_spans = StatusIcons::create_status_spans( self.overall_status, ×tamp_text ); lines.push(ratatui::text::Line::from(timestamp_spans)); // Duration as sub-item if let Some(duration) = self.duration_seconds { let duration_text = self.format_duration(duration); lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled(" └─ ", Typography::tree()), ratatui::text::Span::styled(format!("Duration: {}", duration_text), Typography::secondary()) ])); } // Disk section lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled("Disk:", Typography::widget_title()) ])); // Disk product name with status if let Some(product) = &self.backup_disk_product_name { let disk_spans = StatusIcons::create_status_spans( Status::Ok, // Assuming disk is OK if we have data product ); lines.push(ratatui::text::Line::from(disk_spans)); // Serial number as sub-item if let Some(serial) = &self.backup_disk_serial_number { lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled(" ├─ ", Typography::tree()), ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary()) ])); } // Usage as sub-item if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) { let used_str = Self::format_size_with_proper_units(used); let total_str = Self::format_size_with_proper_units(total); lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled(" └─ ", Typography::tree()), ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary()) ])); } } // Repos section lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled("Repos:", Typography::widget_title()) ])); // Add all repository lines (no truncation here - scroll will handle display) for service in &self.service_metrics { 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); let repo_text = format!("{} ({}) {}", service.name, archives, size_str); let repo_spans = StatusIcons::create_status_spans(service.status, &repo_text); lines.push(ratatui::text::Line::from(repo_spans)); } } // Apply scroll offset let total_lines = lines.len(); let available_height = area.height as usize; // Calculate scroll boundaries let max_scroll = if total_lines > available_height { total_lines - available_height } else { total_lines.saturating_sub(1) }; let effective_scroll = scroll_offset.min(max_scroll); // Apply scrolling if needed if scroll_offset > 0 || total_lines > available_height { let mut visible_lines: Vec<_> = lines .into_iter() .skip(effective_scroll) .take(available_height) .collect(); // Add scroll indicator if there are hidden lines if total_lines > available_height { let hidden_above = effective_scroll; let hidden_below = total_lines.saturating_sub(effective_scroll + available_height); if (hidden_above > 0 || hidden_below > 0) && !visible_lines.is_empty() { let scroll_text = if hidden_above > 0 && hidden_below > 0 { format!("... {} above, {} below", hidden_above, hidden_below) } else if hidden_above > 0 { format!("... {} more above", hidden_above) } else { format!("... {} more below", hidden_below) }; // Replace last line with scroll indicator visible_lines.pop(); visible_lines.push(ratatui::text::Line::from(vec![ ratatui::text::Span::styled(scroll_text, Typography::muted()) ])); } } let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines)); frame.render_widget(paragraph, area); } else { let paragraph = Paragraph::new(ratatui::text::Text::from(lines)); frame.render_widget(paragraph, area); } } } impl BackupWidget { /// Format timestamp for display fn format_timestamp(&self, timestamp: i64) -> String { let datetime = chrono::DateTime::from_timestamp(timestamp, 0) .unwrap_or_else(|| chrono::Utc::now()); datetime.format("%Y-%m-%d %H:%M:%S").to_string() } /// Format duration in seconds to human readable format fn format_duration(&self, duration_seconds: i64) -> String { let minutes = duration_seconds / 60; let seconds = duration_seconds % 60; if minutes > 0 { format!("{}.{}m", minutes, seconds / 6) // Show 1 decimal for minutes } else { format!("{}s", seconds) } } } impl Default for BackupWidget { fn default() -> Self { Self::new() } }