All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
Bump version across all workspace crates for next release including agent, dashboard, and shared components.
453 lines
17 KiB
Rust
453 lines
17 KiB
Rust
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<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 wear percentage from SMART data
|
|
backup_disk_wear_percent: Option<f32>,
|
|
/// 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_wear_percent: 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 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")
|
|
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_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 {
|
|
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_wear_percent" => {
|
|
self.backup_disk_wear_percent = metric.value.as_f32();
|
|
}
|
|
"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;
|
|
|
|
// 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()
|
|
}
|
|
}
|