Implement comprehensive backup monitoring and fix timestamp issues
- Add BackupCollector for reading TOML status files with disk space metrics - Implement BackupWidget with disk usage display and service status details - Fix backup script disk space parsing by adding missing capture_output=True - Update backup widget to show actual disk usage instead of repository size - Fix timestamp parsing to use backup completion time instead of start time - Resolve timezone issues by using UTC timestamps in backup script - Add disk identification metrics (product name, serial number) to backup status - Enhance UI layout with proper backup monitoring integration
This commit is contained in:
488
dashboard/src/ui/widgets/backup.rs
Normal file
488
dashboard/src/ui/widgets/backup.rs
Normal file
@@ -0,0 +1,488 @@
|
||||
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::{Theme, Typography, Components, StatusIcons};
|
||||
|
||||
/// Backup widget displaying backup status, services, and repository information
|
||||
pub struct BackupWidget {
|
||||
/// Overall backup status
|
||||
overall_status: Status,
|
||||
/// Last backup duration in seconds
|
||||
duration_seconds: Option<i64>,
|
||||
/// Last backup timestamp
|
||||
last_run_timestamp: Option<i64>,
|
||||
/// Total number of backup services
|
||||
total_services: Option<i64>,
|
||||
/// Total repository size in GB
|
||||
total_repo_size_gb: Option<f32>,
|
||||
/// Total disk space for backups in GB
|
||||
backup_disk_total_gb: Option<f32>,
|
||||
/// Used disk space for backups in GB
|
||||
backup_disk_used_gb: Option<f32>,
|
||||
/// Backup disk product name from SMART data
|
||||
backup_disk_product_name: Option<String>,
|
||||
/// Backup disk serial number from SMART data
|
||||
backup_disk_serial_number: Option<String>,
|
||||
/// Backup disk filesystem label
|
||||
backup_disk_filesystem_label: Option<String>,
|
||||
/// Number of completed services
|
||||
services_completed_count: Option<i64>,
|
||||
/// Number of failed services
|
||||
services_failed_count: Option<i64>,
|
||||
/// Number of disabled services
|
||||
services_disabled_count: Option<i64>,
|
||||
/// All individual service metrics for detailed display
|
||||
service_metrics: Vec<ServiceMetricData>,
|
||||
/// Last update indicator
|
||||
has_data: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServiceMetricData {
|
||||
name: String,
|
||||
status: Status,
|
||||
exit_code: Option<i64>,
|
||||
archive_count: Option<i64>,
|
||||
repo_size_gb: Option<f32>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 service status counts
|
||||
fn format_service_counts(&self) -> String {
|
||||
let completed = self.services_completed_count.unwrap_or(0);
|
||||
let failed = self.services_failed_count.unwrap_or(0);
|
||||
let disabled = self.services_disabled_count.unwrap_or(0);
|
||||
let total = self.total_services.unwrap_or(0);
|
||||
|
||||
if failed > 0 {
|
||||
format!("{}✓ {}✗ {}◯ ({})", completed, failed, disabled, total)
|
||||
} else {
|
||||
format!("{}✓ {}◯ ({})", completed, disabled, total)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status indicator symbol
|
||||
fn get_status_symbol(&self) -> &str {
|
||||
match self.overall_status {
|
||||
Status::Ok => "●",
|
||||
Status::Warning => "◐",
|
||||
Status::Critical => "◯",
|
||||
Status::Unknown => "?",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format size in GB to appropriate unit (kB/MB/GB)
|
||||
fn format_size_gb(size_gb: f32) -> String {
|
||||
if size_gb >= 1.0 {
|
||||
format!("{:.1}GB", size_gb)
|
||||
} else if size_gb >= 0.001 {
|
||||
let size_mb = size_gb * 1024.0;
|
||||
format!("{:.1}MB", size_mb)
|
||||
} else if size_gb >= 0.000001 {
|
||||
let size_kb = size_gb * 1024.0 * 1024.0;
|
||||
format!("{:.0}kB", size_kb)
|
||||
} else {
|
||||
format!("{:.0}B", size_gb * 1024.0 * 1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<String, ServiceMetricData> = 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<ServiceMetricData> = 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user