From da37e28b6a2761999ccae027b9291748d050af22 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Mon, 24 Nov 2025 23:55:35 +0100 Subject: [PATCH] Integrate backup metrics into system widget with enhanced disk monitoring Replace standalone backup widget with compact backup section in system widget displaying disk serial, temperature, wear level, timing, and usage information. Changes: - Remove standalone backup widget and integrate into system widget - Update backup collector to read TOML format from backup script - Add BackupDiskData structure with serial, usage, temperature, wear fields - Implement compact backup display matching specification format - Add time formatting utilities for backup timing display - Update backup data extraction from TOML with disk space parsing Version bump to v0.1.149 --- CLAUDE.md | 6 + Cargo.lock | 6 +- agent/Cargo.toml | 2 +- agent/src/collectors/backup.rs | 110 ++++++-- dashboard/Cargo.toml | 2 +- dashboard/src/ui/mod.rs | 52 +--- dashboard/src/ui/widgets/backup.rs | 418 ----------------------------- dashboard/src/ui/widgets/mod.rs | 2 - dashboard/src/ui/widgets/system.rs | 168 ++++++++++++ shared/Cargo.toml | 2 +- shared/src/agent_data.rs | 15 ++ 11 files changed, 293 insertions(+), 490 deletions(-) delete mode 100644 dashboard/src/ui/widgets/backup.rs 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, }, } }