Backup widget: - Restructure to match new layout specification - Add section headers: Latest backup, Disk, Repos - Show timestamp with status icon and duration as sub-item - Display disk info with product name, S/N, and usage in tree structure - List repositories with archive count and size - Remove old render methods and unused imports System widget: - Hide (Single) storage type label for cleaner display
449 lines
17 KiB
Rust
449 lines
17 KiB
Rust
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<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,
|
|
}
|
|
}
|
|
|
|
/// Check if the backup widget has any data to display
|
|
pub fn has_data(&self) -> bool {
|
|
self.has_data
|
|
}
|
|
|
|
|
|
/// Format duration for display
|
|
fn format_duration(&self) -> String {
|
|
match self.duration_seconds {
|
|
Some(seconds) => {
|
|
if seconds >= 3600 {
|
|
format!("{:.1}h", seconds as f32 / 3600.0)
|
|
} else if seconds >= 60 {
|
|
format!("{:.1}m", seconds as f32 / 60.0)
|
|
} else {
|
|
format!("{}s", seconds)
|
|
}
|
|
}
|
|
None => "—".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Format timestamp for display
|
|
fn format_last_run(&self) -> String {
|
|
match self.last_run_timestamp {
|
|
Some(timestamp) => {
|
|
let duration = chrono::Utc::now().timestamp() - timestamp;
|
|
if duration < 3600 {
|
|
format!("{}m ago", duration / 60)
|
|
} else if duration < 86400 {
|
|
format!("{}h ago", duration / 3600)
|
|
} else {
|
|
format!("{}d ago", duration / 86400)
|
|
}
|
|
}
|
|
None => "—".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Format disk usage in format "usedGB/totalGB"
|
|
fn format_repo_size(&self) -> String {
|
|
match (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
|
(Some(used_gb), Some(total_gb)) => {
|
|
let used_str = Self::format_size_with_proper_units(used_gb);
|
|
let total_str = Self::format_size_with_proper_units(total_gb);
|
|
format!("{}/{}", used_str, total_str)
|
|
}
|
|
(Some(used_gb), None) => {
|
|
// Fallback to just used size if total not available
|
|
Self::format_size_with_proper_units(used_gb)
|
|
}
|
|
_ => "—".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Format size with proper units (xxxkB/MB/GB/TB)
|
|
fn format_size_with_proper_units(size_gb: f32) -> String {
|
|
if size_gb >= 1000.0 {
|
|
// TB range
|
|
format!("{:.1}TB", size_gb / 1000.0)
|
|
} else if size_gb >= 1.0 {
|
|
// GB range
|
|
format!("{:.1}GB", size_gb)
|
|
} else if size_gb >= 0.001 {
|
|
// MB range (size_gb * 1024 = MB)
|
|
let size_mb = size_gb * 1024.0;
|
|
format!("{:.1}MB", size_mb)
|
|
} else if size_gb >= 0.000001 {
|
|
// kB range (size_gb * 1024 * 1024 = kB)
|
|
let size_kb = size_gb * 1024.0 * 1024.0;
|
|
format!("{:.0}kB", size_kb)
|
|
} else {
|
|
// B range (size_gb * 1024^3 = bytes)
|
|
let size_bytes = size_gb * 1024.0 * 1024.0 * 1024.0;
|
|
format!("{:.0}B", size_bytes)
|
|
}
|
|
}
|
|
|
|
/// Format product name display
|
|
fn format_product_name(&self) -> String {
|
|
if let Some(ref product_name) = self.backup_disk_product_name {
|
|
format!("P/N: {}", product_name)
|
|
} else {
|
|
"P/N: Unknown".to_string()
|
|
}
|
|
}
|
|
|
|
/// Format serial number display
|
|
fn format_serial_number(&self) -> String {
|
|
if let Some(ref serial) = self.backup_disk_serial_number {
|
|
format!("S/N: {}", serial)
|
|
} else {
|
|
"S/N: Unknown".to_string()
|
|
}
|
|
}
|
|
|
|
/// Extract service name from metric name (e.g., "backup_service_gitea_status" -> "gitea")
|
|
fn extract_service_name(metric_name: &str) -> Option<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) {
|
|
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::raw(" └─ "),
|
|
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::raw(" ├─ "),
|
|
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::raw(" └─ "),
|
|
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())
|
|
]));
|
|
|
|
// Repository list
|
|
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));
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|