diff --git a/CLAUDE.md b/CLAUDE.md index b4e92fc..5b2e3aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,12 @@ Storage: ● nvme0n1 T: 25C W: 4% ├─ ● /: 55% 250.5GB/456.4GB └─ ● /boot: 26% 0.3GB/1.0GB + +Backup: +● WD-WCC7K1234567 T: 32°C W: 12% + ├─ Last: 2h ago (12.3GB) + ├─ Next: in 22h + └─ ● Usage: 45% 678GB/1.5TB ``` ## Important Communication Guidelines diff --git a/Cargo.lock b/Cargo.lock index c38efdc..ac43b28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cm-dashboard" -version = "0.1.147" +version = "0.1.148" dependencies = [ "anyhow", "chrono", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "cm-dashboard-agent" -version = "0.1.147" +version = "0.1.148" dependencies = [ "anyhow", "async-trait", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "cm-dashboard-shared" -version = "0.1.147" +version = "0.1.148" dependencies = [ "chrono", "serde", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index fae63e5..6ee5787 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-agent" -version = "0.1.148" +version = "0.1.149" edition = "2021" [dependencies] diff --git a/agent/src/collectors/backup.rs b/agent/src/collectors/backup.rs index 8006111..8ef5a6a 100644 --- a/agent/src/collectors/backup.rs +++ b/agent/src/collectors/backup.rs @@ -1,13 +1,15 @@ use async_trait::async_trait; -use cm_dashboard_shared::{AgentData, BackupData}; +use chrono; +use cm_dashboard_shared::{AgentData, BackupData, BackupDiskData}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::path::Path; use tracing::debug; use super::{Collector, CollectorError}; -/// Backup collector that reads backup status from JSON files with structured data output +/// 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, @@ -16,12 +18,12 @@ pub struct BackupCollector { impl BackupCollector { pub fn new() -> Self { Self { - status_file_path: "/var/lib/backup/status.json".to_string(), + status_file_path: "/var/lib/backup/backup-status.toml".to_string(), } } - /// Read backup status from JSON file - async fn read_backup_status(&self) -> Result, CollectorError> { + /// 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); @@ -33,24 +35,66 @@ impl BackupCollector { error: e.to_string(), })?; - let status: BackupStatus = serde_json::from_str(&content) + let status: BackupStatusToml = toml::from_str(&content) .map_err(|e| CollectorError::Parse { value: content.clone(), - error: format!("Failed to parse backup status JSON: {}", e), + error: format!("Failed to parse backup status TOML: {}", e), })?; Ok(Some(status)) } - /// Convert BackupStatus to BackupData and populate AgentData + /// 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? { + // Parse start_time to get last_run timestamp + let last_run = if let Ok(parsed_time) = chrono::DateTime::parse_from_str(&backup_status.start_time, "%Y-%m-%d %H:%M:%S UTC") { + Some(parsed_time.timestamp() as u64) + } else { + None + }; + + // Calculate next_scheduled (if needed - may need to be provided by backup script) + let next_scheduled = None; // TODO: Extract from backup script if available + + // 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, - last_run: Some(backup_status.last_run), - next_scheduled: Some(backup_status.next_scheduled), - total_size_gb: Some(backup_status.total_size_gb), - repository_health: Some(backup_status.repository_health), + last_run, + next_scheduled, + 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 }; agent_data.backup = backup_data; @@ -62,6 +106,8 @@ impl BackupCollector { next_scheduled: None, total_size_gb: None, repository_health: None, + repository_disk: None, + last_backup_size_gb: None, }; } @@ -77,12 +123,38 @@ impl Collector for BackupCollector { } } -/// Backup status structure from JSON file +/// TOML structure for backup status file #[derive(Debug, Clone, Serialize, Deserialize)] -struct BackupStatus { - pub status: String, // "completed", "running", "failed", etc. - pub last_run: u64, // Unix timestamp - pub next_scheduled: u64, // Unix timestamp - pub total_size_gb: f32, // Total backup size in GB - pub repository_health: String, // "ok", "warning", "error" +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, } \ No newline at end of file diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 98b1502..28b0afa 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard" -version = "0.1.148" +version = "0.1.149" edition = "2021" [dependencies] diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 4a7f6c0..6104cde 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -18,7 +18,7 @@ use crate::config::DashboardConfig; use crate::metrics::MetricStore; use cm_dashboard_shared::Status; use theme::{Components, Layout as ThemeLayout, Theme, Typography}; -use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget}; +use widgets::{ServicesWidget, SystemWidget, Widget}; @@ -32,8 +32,6 @@ pub struct HostWidgets { pub system_widget: SystemWidget, /// Services widget state pub services_widget: ServicesWidget, - /// Backup widget state - pub backup_widget: BackupWidget, /// Last update time for this host pub last_update: Option, } @@ -43,7 +41,6 @@ impl HostWidgets { Self { system_widget: SystemWidget::new(), services_widget: ServicesWidget::new(), - backup_widget: BackupWidget::new(), last_update: None, } } @@ -112,7 +109,6 @@ impl TuiApp { // Update all widgets with structured data directly host_widgets.system_widget.update_from_agent_data(agent_data); host_widgets.services_widget.update_from_agent_data(agent_data); - host_widgets.backup_widget.update_from_agent_data(agent_data); host_widgets.last_update = Some(Instant::now()); } @@ -469,40 +465,17 @@ impl TuiApp { return; } - // Check if backup panel should be shown - let show_backup = if let Some(hostname) = self.current_host.clone() { - let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.backup_widget.has_data() - } else { - false - }; - - // Left side: dynamic layout based on backup data availability - let left_chunks = if show_backup { - // Show both system and backup panels - ratatui::layout::Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section - Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section - ]) - .split(content_chunks[0]) - } else { - // Show only system panel (full height) - ratatui::layout::Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)]) // System section takes full height - .split(content_chunks[0]) - }; + // Left side: system panel only (full height) + let left_chunks = ratatui::layout::Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(100)]) // System section takes full height + .split(content_chunks[0]); // Render title bar self.render_btop_title(frame, main_chunks[0], metric_store); - // Render new panel layout + // Render system panel self.render_system_panel(frame, left_chunks[0], metric_store); - if show_backup && left_chunks.len() > 1 { - self.render_backup_panel(frame, left_chunks[1]); - } // Render services widget for current host if let Some(hostname) = self.current_host.clone() { @@ -669,17 +642,6 @@ impl TuiApp { } } - 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); - - // Get current host widgets for backup widget - if let Some(hostname) = self.current_host.clone() { - let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.backup_widget.render(frame, inner_area); - } - } /// Render offline host message with wake-up option fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) { diff --git a/dashboard/src/ui/widgets/backup.rs b/dashboard/src/ui/widgets/backup.rs deleted file mode 100644 index b30a5f9..0000000 --- a/dashboard/src/ui/widgets/backup.rs +++ /dev/null @@ -1,418 +0,0 @@ -use cm_dashboard_shared::{Metric, Status}; -use super::Widget; -use ratatui::{ - layout::Rect, - widgets::Paragraph, - Frame, -}; -use tracing::debug; - -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 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 wear percentage from SMART data - backup_disk_wear_percent: 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, - 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_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_wear_percent: 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 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) - } - } - - - - /// Extract service name from metric name (e.g., "backup_service_gitea_status" -> "gitea") - #[allow(dead_code)] - 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("_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_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) { - self.has_data = true; - - let backup = &agent_data.backup; - self.overall_status = Status::Ok; - - if let Some(size) = backup.total_size_gb { - self.total_repo_size_gb = Some(size); - } - - if let Some(last_run) = backup.last_run { - self.last_run_timestamp = Some(last_run as i64); - } - } -} - -impl BackupWidget { - #[allow(dead_code)] - 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_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_wear_percent" => { - self.backup_disk_wear_percent = metric.value.as_f32(); - } - _ => { - // 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, - 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("_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; - - // Only show backup panel if we have meaningful backup data - self.has_data = !metrics.is_empty() && ( - self.last_run_timestamp.is_some() || - self.total_repo_size_gb.is_some() || - !self.service_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 - ); - } - } - -} - -impl BackupWidget { - /// Render backup widget - pub fn render(&mut self, frame: &mut Frame, area: Rect) { - 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)); - - // Collect sub-items to determine tree structure - let mut sub_items = Vec::new(); - - if let Some(serial) = &self.backup_disk_serial_number { - sub_items.push(format!("S/N: {}", serial)); - } - - if let Some(wear) = self.backup_disk_wear_percent { - sub_items.push(format!("Wear: {:.0}%", wear)); - } - - 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); - sub_items.push(format!("Usage: {}/{}", used_str, total_str)); - } - - // Render sub-items with proper tree structure - let num_items = sub_items.len(); - for (i, item) in sub_items.into_iter().enumerate() { - let is_last = i == num_items - 1; - let tree_char = if is_last { " └─ " } else { " ├─ " }; - lines.push(ratatui::text::Line::from(vec![ - ratatui::text::Span::styled(tree_char, Typography::tree()), - ratatui::text::Span::styled(item, 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; - - // Show only what fits, with "X more below" if needed - if total_lines > available_height { - let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below" - let mut visible_lines: Vec<_> = lines - .into_iter() - .take(lines_for_content) - .collect(); - - let hidden_below = total_lines.saturating_sub(lines_for_content); - if hidden_below > 0 { - let more_line = ratatui::text::Line::from(vec![ - ratatui::text::Span::styled(format!("... {} more below", hidden_below), Typography::muted()) - ]); - visible_lines.push(more_line); - } - - 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() - } -} diff --git a/dashboard/src/ui/widgets/mod.rs b/dashboard/src/ui/widgets/mod.rs index e8653bc..550add0 100644 --- a/dashboard/src/ui/widgets/mod.rs +++ b/dashboard/src/ui/widgets/mod.rs @@ -1,12 +1,10 @@ use cm_dashboard_shared::AgentData; -pub mod backup; pub mod cpu; pub mod memory; pub mod services; pub mod system; -pub use backup::BackupWidget; pub use services::ServicesWidget; pub use system::SystemWidget; diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index abf74cb..744e9ed 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -37,6 +37,18 @@ pub struct SystemWidget { // Storage metrics (collected from disk metrics) storage_pools: Vec, + // Backup metrics + backup_status: String, + backup_last_run: Option, + backup_next_scheduled: 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, + // Overall status has_data: bool, } @@ -91,6 +103,16 @@ impl SystemWidget { tmp_status: Status::Unknown, tmpfs_mounts: Vec::new(), storage_pools: Vec::new(), + backup_status: "unknown".to_string(), + backup_last_run: None, + backup_next_scheduled: 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, has_data: false, } } @@ -170,6 +192,29 @@ impl Widget for SystemWidget { // Convert storage data to internal format self.update_storage_from_agent_data(agent_data); + + // Extract backup data + let backup = &agent_data.backup; + self.backup_status = backup.status.clone(); + self.backup_last_run = backup.last_run; + self.backup_next_scheduled = backup.next_scheduled; + 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; + } } } @@ -352,6 +397,116 @@ fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec Vec> { + let mut lines = Vec::new(); + + // First line: serial number with temperature and wear + if let Some(serial) = &self.backup_disk_serial { + let mut details = Vec::new(); + if let Some(temp) = self.backup_disk_temperature { + details.push(format!("T: {}°C", temp as i32)); + } + if let Some(wear) = self.backup_disk_wear_percent { + details.push(format!("W: {}%", wear as i32)); + } + + let disk_text = if !details.is_empty() { + format!("{} {}", serial, details.join(" ")) + } else { + serial.clone() + }; + + 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); + lines.push(Line::from(disk_spans)); + + // Last backup time + if let Some(last_run) = self.backup_last_run { + let time_ago = self.format_time_ago(last_run); + let last_text = if let Some(size) = self.backup_last_size_gb { + format!("Last: {} ({:.1}GB)", time_ago, size) + } else { + format!("Last: {}", time_ago) + }; + + lines.push(Line::from(vec![ + Span::styled(" ├─ ", Typography::tree()), + Span::styled(last_text, Typography::secondary()) + ])); + } + + // Next backup time + if let Some(next_scheduled) = self.backup_next_scheduled { + let next_text = format!("Next: {}", self.format_time_until(next_scheduled)); + lines.push(Line::from(vec![ + Span::styled(" ├─ ", Typography::tree()), + Span::styled(next_text, Typography::secondary()) + ])); + } + + // 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)); + } + } + + lines + } + + /// Format time ago from timestamp + fn format_time_ago(&self, timestamp: u64) -> String { + let now = chrono::Utc::now().timestamp() as u64; + let seconds_ago = now.saturating_sub(timestamp); + + let hours = seconds_ago / 3600; + let minutes = (seconds_ago % 3600) / 60; + + if hours > 0 { + format!("{}h ago", hours) + } else if minutes > 0 { + format!("{}m ago", minutes) + } else { + "now".to_string() + } + } + + /// Format time until from future timestamp + fn format_time_until(&self, timestamp: u64) -> String { + let now = chrono::Utc::now().timestamp() as u64; + if timestamp <= now { + return "overdue".to_string(); + } + + let seconds_until = timestamp - now; + let hours = seconds_until / 3600; + let minutes = (seconds_until % 3600) / 60; + + if hours > 0 { + format!("in {}h", hours) + } else if minutes > 0 { + format!("in {}m", minutes) + } else { + "soon".to_string() + } + } + /// Render system widget pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, config: Option<&crate::config::DashboardConfig>) { let mut lines = Vec::new(); @@ -445,6 +600,19 @@ impl SystemWidget { let storage_lines = self.render_storage(); lines.extend(storage_lines); + // Backup section (if available) + if self.backup_status != "unavailable" && self.backup_status != "unknown" { + lines.push(Line::from(vec![ + Span::styled("", Typography::secondary()) // Empty line for spacing + ])); + lines.push(Line::from(vec![ + Span::styled("Backup:", Typography::widget_title()) + ])); + + let backup_lines = self.render_backup(); + lines.extend(backup_lines); + } + // Apply scroll offset let total_lines = lines.len(); let available_height = area.height as usize; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index faaafb5..2bc1aea 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-dashboard-shared" -version = "0.1.148" +version = "0.1.149" edition = "2021" [dependencies] diff --git a/shared/src/agent_data.rs b/shared/src/agent_data.rs index 181c2ea..11248fc 100644 --- a/shared/src/agent_data.rs +++ b/shared/src/agent_data.rs @@ -142,6 +142,19 @@ pub struct BackupData { pub next_scheduled: Option, pub total_size_gb: Option, pub repository_health: Option, + pub repository_disk: Option, + pub last_backup_size_gb: Option, +} + +/// 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 wear_percent: Option, + pub temperature_celsius: Option, } impl AgentData { @@ -184,6 +197,8 @@ impl AgentData { next_scheduled: None, total_size_gb: None, repository_health: None, + repository_disk: None, + last_backup_size_gb: None, }, } }