Service Selection Features: - Add selection cursor for Services panel with visual highlighting - Up/Down arrows move service selection instead of scrolling - Track selected service for future action implementation - Selection state maintained per host Panel Navigation Improvements: - Fix panel switching to only cycle through visible panels - Dynamic panel list based on backup data availability - Smart recovery when focused panel becomes invisible - No more navigation to hidden backup panel Backup Panel Scrolling Fix: - Fix backup panel scroll to show actual repository content - Replace static overflow indicator with proper scroll behavior - Add scroll position indicators (above/below) - Show all repositories when scrolling instead of truncated list Navigation now works correctly with actual UI layout and provides proper service selection for future action implementation.
487 lines
19 KiB
Rust
487 lines
19 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 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) {
|
|
self.render_with_scroll(frame, area, 0);
|
|
}
|
|
}
|
|
|
|
impl BackupWidget {
|
|
/// Render with scroll offset support
|
|
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) {
|
|
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));
|
|
|
|
// Serial number as sub-item
|
|
if let Some(serial) = &self.backup_disk_serial_number {
|
|
lines.push(ratatui::text::Line::from(vec![
|
|
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
|
|
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::styled(" └─ ", Typography::tree()),
|
|
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())
|
|
]));
|
|
|
|
// 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;
|
|
|
|
// Calculate scroll boundaries
|
|
let max_scroll = if total_lines > available_height {
|
|
total_lines - available_height
|
|
} else {
|
|
total_lines.saturating_sub(1)
|
|
};
|
|
let effective_scroll = scroll_offset.min(max_scroll);
|
|
|
|
// Apply scrolling if needed
|
|
if scroll_offset > 0 || total_lines > available_height {
|
|
let mut visible_lines: Vec<_> = lines
|
|
.into_iter()
|
|
.skip(effective_scroll)
|
|
.take(available_height)
|
|
.collect();
|
|
|
|
// Add scroll indicator if there are hidden lines
|
|
if total_lines > available_height {
|
|
let hidden_above = effective_scroll;
|
|
let hidden_below = total_lines.saturating_sub(effective_scroll + available_height);
|
|
|
|
if (hidden_above > 0 || hidden_below > 0) && !visible_lines.is_empty() {
|
|
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
|
format!("... {} above, {} below", hidden_above, hidden_below)
|
|
} else if hidden_above > 0 {
|
|
format!("... {} more above", hidden_above)
|
|
} else {
|
|
format!("... {} more below", hidden_below)
|
|
};
|
|
|
|
// Replace last line with scroll indicator
|
|
visible_lines.pop();
|
|
visible_lines.push(ratatui::text::Line::from(vec![
|
|
ratatui::text::Span::styled(scroll_text, Typography::muted())
|
|
]));
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|