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:
Christoffer Martinsson 2025-10-18 18:33:41 +02:00
parent 8a36472a3d
commit 125111ee99
19 changed files with 2788 additions and 1020 deletions

View File

@ -54,16 +54,33 @@ impl Agent {
}
pub async fn run(&mut self, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>) -> Result<()> {
info!("Starting agent main loop");
info!("Starting agent main loop with separated collection and transmission");
// CRITICAL: Collect ALL data immediately at startup before entering the loop
info!("Performing initial FORCE collection of all metrics at startup");
if let Err(e) = self.collect_all_metrics_force().await {
error!("Failed to collect initial metrics: {}", e);
} else {
info!("Initial metric collection completed - all data cached and ready");
}
// Separate intervals for collection and transmission
let mut collection_interval = interval(Duration::from_secs(self.config.collection_interval_seconds));
let mut transmission_interval = interval(Duration::from_secs(1)); // ZMQ broadcast every 1 second
let mut notification_check_interval = interval(Duration::from_secs(30)); // Check notifications every 30s
loop {
tokio::select! {
_ = collection_interval.tick() => {
if let Err(e) = self.collect_and_publish_metrics().await {
error!("Failed to collect and publish metrics: {}", e);
// Only collect and cache metrics, no ZMQ transmission
if let Err(e) = self.collect_metrics_only().await {
error!("Failed to collect metrics: {}", e);
}
}
_ = transmission_interval.tick() => {
// Send all cached metrics via ZMQ every 1 second
if let Err(e) = self.broadcast_all_cached_metrics().await {
error!("Failed to broadcast cached metrics: {}", e);
}
}
_ = notification_check_interval.tick() => {
@ -87,10 +104,29 @@ impl Agent {
Ok(())
}
async fn collect_and_publish_metrics(&mut self) -> Result<()> {
debug!("Starting metric collection cycle");
async fn collect_all_metrics_force(&mut self) -> Result<()> {
info!("Starting FORCE metric collection for startup");
// Collect all metrics from all collectors
// Force collect all metrics from all collectors immediately
let metrics = self.metric_manager.collect_all_metrics_force().await?;
if metrics.is_empty() {
error!("No metrics collected during force collection!");
return Ok(());
}
info!("Force collected and cached {} metrics", metrics.len());
// Check for status changes and send notifications
self.check_status_changes(&metrics).await;
Ok(())
}
async fn collect_metrics_only(&mut self) -> Result<()> {
debug!("Starting metric collection cycle (cache only)");
// Collect all metrics from all collectors and cache them
let metrics = self.metric_manager.collect_all_metrics().await?;
if metrics.is_empty() {
@ -98,16 +134,32 @@ impl Agent {
return Ok(());
}
info!("Collected {} metrics", metrics.len());
debug!("Collected and cached {} metrics", metrics.len());
// Check for status changes and send notifications
self.check_status_changes(&metrics).await;
// Create and send message
let message = MetricMessage::new(self.hostname.clone(), metrics);
Ok(())
}
async fn broadcast_all_cached_metrics(&mut self) -> Result<()> {
debug!("Broadcasting all cached metrics via ZMQ");
// Get all cached metrics from the metric manager
let cached_metrics = self.metric_manager.get_all_cached_metrics().await?;
if cached_metrics.is_empty() {
debug!("No cached metrics to broadcast");
return Ok(());
}
debug!("Broadcasting {} cached metrics", cached_metrics.len());
// Create and send message with all cached data
let message = MetricMessage::new(self.hostname.clone(), cached_metrics);
self.zmq_handler.publish_metrics(&message).await?;
debug!("Metrics published successfully");
debug!("Cached metrics broadcasted successfully");
Ok(())
}
@ -146,7 +198,7 @@ impl Agent {
match command {
AgentCommand::CollectNow => {
info!("Processing CollectNow command");
if let Err(e) = self.collect_and_publish_metrics().await {
if let Err(e) = self.collect_metrics_only().await {
error!("Failed to collect metrics on command: {}", e);
}
}

View File

@ -45,6 +45,11 @@ impl MetricCacheManager {
pub async fn get_all_valid_metrics(&self) -> Vec<Metric> {
self.cache.get_all_valid_metrics().await
}
/// Get all cached metrics (including expired ones) for broadcasting
pub async fn get_all_cached_metrics(&self) -> Vec<Metric> {
self.cache.get_all_cached_metrics().await
}
/// Cache warm-up: collect and cache high-priority metrics
pub async fn warm_cache<F>(&self, collector_fn: F)

View File

@ -113,6 +113,22 @@ impl ConfigurableCache {
valid_metrics
}
/// Get all cached metrics (including expired ones) for broadcasting
pub async fn get_all_cached_metrics(&self) -> Vec<Metric> {
if !self.config.enabled {
return vec![];
}
let cache = self.cache.read().await;
let mut all_metrics = Vec::new();
for cached_metric in cache.values() {
all_metrics.push(cached_metric.metric.clone());
}
all_metrics
}
/// Background cleanup of old entries
async fn cleanup_old_entries(&self, cache: &mut HashMap<String, CachedMetric>) {

View File

@ -0,0 +1,388 @@
use async_trait::async_trait;
use cm_dashboard_shared::{Metric, MetricValue, Status, SharedError};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use tokio::fs;
use super::{Collector, CollectorError, utils};
use tracing::error;
/// Backup collector that reads TOML status files for borgbackup metrics
#[derive(Debug, Clone)]
pub struct BackupCollector {
pub backup_status_file: String,
pub max_age_hours: u64,
}
impl BackupCollector {
pub fn new(backup_status_file: Option<String>, max_age_hours: u64) -> Self {
Self {
backup_status_file: backup_status_file.unwrap_or_else(|| "/var/lib/backup/backup-status.toml".to_string()),
max_age_hours,
}
}
async fn read_backup_status(&self) -> Result<BackupStatusToml, CollectorError> {
let content = fs::read_to_string(&self.backup_status_file)
.await
.map_err(|e| CollectorError::SystemRead {
path: self.backup_status_file.clone(),
error: e.to_string(),
})?;
toml::from_str(&content).map_err(|e| CollectorError::Parse {
value: "backup status TOML".to_string(),
error: e.to_string(),
})
}
fn calculate_backup_status(&self, backup_status: &BackupStatusToml) -> Status {
// Parse the start time to check age - handle both RFC3339 and local timestamp formats
let start_time = match chrono::DateTime::parse_from_rfc3339(&backup_status.start_time) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => {
// Try parsing as naive datetime and assume UTC
match chrono::NaiveDateTime::parse_from_str(&backup_status.start_time, "%Y-%m-%dT%H:%M:%S%.f") {
Ok(naive_dt) => naive_dt.and_utc(),
Err(_) => {
error!("Failed to parse backup timestamp: {}", backup_status.start_time);
return Status::Unknown;
}
}
}
};
let hours_since_backup = Utc::now().signed_duration_since(start_time).num_hours();
// Check overall backup status
match backup_status.status.as_str() {
"success" => {
if hours_since_backup > self.max_age_hours as i64 {
Status::Warning // Backup too old
} else {
Status::Ok
}
},
"failed" => Status::Critical,
"running" => Status::Ok, // Currently running is OK
_ => Status::Unknown,
}
}
fn calculate_service_status(&self, service: &ServiceStatus) -> Status {
match service.status.as_str() {
"completed" => {
if service.exit_code == 0 {
Status::Ok
} else {
Status::Critical
}
},
"failed" => Status::Critical,
"disabled" => Status::Warning, // Service intentionally disabled
"running" => Status::Ok,
_ => Status::Unknown,
}
}
fn bytes_to_gb(bytes: u64) -> f32 {
bytes as f32 / (1024.0 * 1024.0 * 1024.0)
}
}
#[async_trait]
impl Collector for BackupCollector {
fn name(&self) -> &str {
"backup"
}
async fn collect(&self) -> Result<Vec<Metric>, CollectorError> {
let backup_status = self.read_backup_status().await?;
let mut metrics = Vec::new();
let timestamp = chrono::Utc::now().timestamp() as u64;
// Overall backup status
let overall_status = self.calculate_backup_status(&backup_status);
metrics.push(Metric {
name: "backup_overall_status".to_string(),
value: MetricValue::String(match overall_status {
Status::Ok => "ok".to_string(),
Status::Warning => "warning".to_string(),
Status::Critical => "critical".to_string(),
Status::Unknown => "unknown".to_string(),
}),
status: overall_status,
timestamp,
description: Some(format!("Backup: {} at {}", backup_status.status, backup_status.start_time)),
unit: None,
});
// Backup duration
metrics.push(Metric {
name: "backup_duration_seconds".to_string(),
value: MetricValue::Integer(backup_status.duration_seconds),
status: Status::Ok,
timestamp,
description: Some("Duration of last backup run".to_string()),
unit: Some("seconds".to_string()),
});
// Last backup timestamp - use last_updated (when backup finished) instead of start_time
let last_updated_dt_result = chrono::DateTime::parse_from_rfc3339(&backup_status.last_updated)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
// Try parsing as naive datetime and assume UTC
chrono::NaiveDateTime::parse_from_str(&backup_status.last_updated, "%Y-%m-%dT%H:%M:%S%.f")
.map(|naive_dt| naive_dt.and_utc())
});
if let Ok(last_updated_dt) = last_updated_dt_result {
metrics.push(Metric {
name: "backup_last_run_timestamp".to_string(),
value: MetricValue::Integer(last_updated_dt.timestamp()),
status: Status::Ok,
timestamp,
description: Some("Timestamp of last backup completion".to_string()),
unit: Some("unix_timestamp".to_string()),
});
} else {
error!("Failed to parse backup timestamp for last_run_timestamp: {}", backup_status.last_updated);
}
// Individual service metrics
for (service_name, service) in &backup_status.services {
let service_status = self.calculate_service_status(service);
// Service status
metrics.push(Metric {
name: format!("backup_service_{}_status", service_name),
value: MetricValue::String(match service_status {
Status::Ok => "ok".to_string(),
Status::Warning => "warning".to_string(),
Status::Critical => "critical".to_string(),
Status::Unknown => "unknown".to_string(),
}),
status: service_status,
timestamp,
description: Some(format!("Backup service {} status: {}", service_name, service.status)),
unit: None,
});
// Service exit code
metrics.push(Metric {
name: format!("backup_service_{}_exit_code", service_name),
value: MetricValue::Integer(service.exit_code),
status: if service.exit_code == 0 { Status::Ok } else { Status::Critical },
timestamp,
description: Some(format!("Exit code for backup service {}", service_name)),
unit: None,
});
// Repository archive count
metrics.push(Metric {
name: format!("backup_service_{}_archive_count", service_name),
value: MetricValue::Integer(service.archive_count),
status: Status::Ok,
timestamp,
description: Some(format!("Number of archives in {} repository", service_name)),
unit: Some("archives".to_string()),
});
// Repository size in GB
let repo_size_gb = Self::bytes_to_gb(service.repo_size_bytes);
metrics.push(Metric {
name: format!("backup_service_{}_repo_size_gb", service_name),
value: MetricValue::Float(repo_size_gb),
status: Status::Ok,
timestamp,
description: Some(format!("Repository size for {} in GB", service_name)),
unit: Some("GB".to_string()),
});
// Repository path for reference
metrics.push(Metric {
name: format!("backup_service_{}_repo_path", service_name),
value: MetricValue::String(service.repo_path.clone()),
status: Status::Ok,
timestamp,
description: Some(format!("Repository path for {}", service_name)),
unit: None,
});
}
// Total number of services
metrics.push(Metric {
name: "backup_total_services".to_string(),
value: MetricValue::Integer(backup_status.services.len() as i64),
status: Status::Ok,
timestamp,
description: Some("Total number of backup services".to_string()),
unit: Some("services".to_string()),
});
// Calculate total repository size
let total_size_bytes: u64 = backup_status.services.values()
.map(|s| s.repo_size_bytes)
.sum();
let total_size_gb = Self::bytes_to_gb(total_size_bytes);
metrics.push(Metric {
name: "backup_total_repo_size_gb".to_string(),
value: MetricValue::Float(total_size_gb),
status: Status::Ok,
timestamp,
description: Some("Total size of all backup repositories".to_string()),
unit: Some("GB".to_string()),
});
// Disk space metrics for backup directory
if let Some(ref disk_space) = backup_status.disk_space {
metrics.push(Metric {
name: "backup_disk_total_gb".to_string(),
value: MetricValue::Float(disk_space.total_gb as f32),
status: Status::Ok,
timestamp,
description: Some("Total disk space available for backups".to_string()),
unit: Some("GB".to_string()),
});
metrics.push(Metric {
name: "backup_disk_used_gb".to_string(),
value: MetricValue::Float(disk_space.used_gb as f32),
status: Status::Ok,
timestamp,
description: Some("Used disk space on backup drive".to_string()),
unit: Some("GB".to_string()),
});
metrics.push(Metric {
name: "backup_disk_available_gb".to_string(),
value: MetricValue::Float(disk_space.available_gb as f32),
status: Status::Ok,
timestamp,
description: Some("Available disk space on backup drive".to_string()),
unit: Some("GB".to_string()),
});
metrics.push(Metric {
name: "backup_disk_usage_percent".to_string(),
value: MetricValue::Float(disk_space.usage_percent as f32),
status: if disk_space.usage_percent >= 95.0 {
Status::Critical
} else if disk_space.usage_percent >= 85.0 {
Status::Warning
} else {
Status::Ok
},
timestamp,
description: Some("Backup disk usage percentage".to_string()),
unit: Some("percent".to_string()),
});
// Add disk identification metrics if available from disk_space
if let Some(ref product_name) = disk_space.product_name {
metrics.push(Metric {
name: "backup_disk_product_name".to_string(),
value: MetricValue::String(product_name.clone()),
status: Status::Ok,
timestamp,
description: Some("Backup disk product name from SMART data".to_string()),
unit: None,
});
}
if let Some(ref serial_number) = disk_space.serial_number {
metrics.push(Metric {
name: "backup_disk_serial_number".to_string(),
value: MetricValue::String(serial_number.clone()),
status: Status::Ok,
timestamp,
description: Some("Backup disk serial number from SMART data".to_string()),
unit: None,
});
}
}
// Add standalone disk identification metrics from TOML fields
if let Some(ref product_name) = backup_status.disk_product_name {
metrics.push(Metric {
name: "backup_disk_product_name".to_string(),
value: MetricValue::String(product_name.clone()),
status: Status::Ok,
timestamp,
description: Some("Backup disk product name from SMART data".to_string()),
unit: None,
});
}
if let Some(ref serial_number) = backup_status.disk_serial_number {
metrics.push(Metric {
name: "backup_disk_serial_number".to_string(),
value: MetricValue::String(serial_number.clone()),
status: Status::Ok,
timestamp,
description: Some("Backup disk serial number from SMART data".to_string()),
unit: None,
});
}
// Count services by status
let mut status_counts = HashMap::new();
for service in backup_status.services.values() {
*status_counts.entry(service.status.clone()).or_insert(0) += 1;
}
for (status_name, count) in status_counts {
metrics.push(Metric {
name: format!("backup_services_{}_count", status_name),
value: MetricValue::Integer(count),
status: Status::Ok,
timestamp,
description: Some(format!("Number of services with status: {}", status_name)),
unit: Some("services".to_string()),
});
}
Ok(metrics)
}
}
/// TOML structure for backup status file
#[derive(Debug, Clone, Deserialize, Serialize)]
pub 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<DiskSpace>,
pub disk_product_name: Option<String>,
pub disk_serial_number: Option<String>,
pub services: HashMap<String, ServiceStatus>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub 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,
// Optional disk identification fields
pub product_name: Option<String>,
pub serial_number: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServiceStatus {
pub status: String,
pub exit_code: i64,
pub repo_path: String,
pub archive_count: i64,
pub repo_size_bytes: u64,
}

View File

@ -173,152 +173,7 @@ impl CpuCollector {
Ok(None)
}
/// Collect top CPU consuming process using ps command for accurate percentages
async fn collect_top_cpu_process(&self) -> Result<Option<Metric>, CollectorError> {
use std::process::Command;
// Use ps to get current CPU percentages, sorted by CPU usage
let output = Command::new("ps")
.arg("aux")
.arg("--sort=-%cpu")
.arg("--no-headers")
.output()
.map_err(|e| CollectorError::SystemRead {
path: "ps command".to_string(),
error: e.to_string(),
})?;
if !output.status.success() {
return Ok(None);
}
let output_str = String::from_utf8_lossy(&output.stdout);
// Parse lines and find the first non-ps process (to avoid catching our own ps command)
for line in output_str.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
// ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
let pid = parts[1];
let cpu_percent = parts[2];
let full_command = parts[10..].join(" ");
// Skip ps processes to avoid catching our own ps command
if full_command.contains("ps aux") || full_command.starts_with("ps ") {
continue;
}
// Extract just the command name (basename of executable)
let command_name = if let Some(first_part) = parts.get(10) {
// Get just the executable name, not the full path
if let Some(basename) = first_part.split('/').last() {
basename.to_string()
} else {
first_part.to_string()
}
} else {
"unknown".to_string()
};
// Validate CPU percentage is reasonable (not over 100% per core)
if let Ok(cpu_val) = cpu_percent.parse::<f32>() {
if cpu_val > 1000.0 {
// Skip obviously wrong values
continue;
}
}
let process_info = format!("{} (PID {}) {}%", command_name, pid, cpu_percent);
return Ok(Some(Metric::new(
"top_cpu_process".to_string(),
MetricValue::String(process_info),
Status::Ok,
).with_description("Process consuming the most CPU".to_string())));
}
}
Ok(Some(Metric::new(
"top_cpu_process".to_string(),
MetricValue::String("No processes found".to_string()),
Status::Ok,
).with_description("Process consuming the most CPU".to_string())))
}
/// Collect top RAM consuming process using ps command for accurate memory usage
async fn collect_top_ram_process(&self) -> Result<Option<Metric>, CollectorError> {
use std::process::Command;
// Use ps to get current memory usage, sorted by memory
let output = Command::new("ps")
.arg("aux")
.arg("--sort=-%mem")
.arg("--no-headers")
.output()
.map_err(|e| CollectorError::SystemRead {
path: "ps command".to_string(),
error: e.to_string(),
})?;
if !output.status.success() {
return Ok(None);
}
let output_str = String::from_utf8_lossy(&output.stdout);
// Parse lines and find the first non-ps process (to avoid catching our own ps command)
for line in output_str.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
// ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
let pid = parts[1];
let mem_percent = parts[3];
let rss_kb = parts[5]; // RSS in KB
let full_command = parts[10..].join(" ");
// Skip ps processes to avoid catching our own ps command
if full_command.contains("ps aux") || full_command.starts_with("ps ") {
continue;
}
// Extract just the command name (basename of executable)
let command_name = if let Some(first_part) = parts.get(10) {
// Get just the executable name, not the full path
if let Some(basename) = first_part.split('/').last() {
basename.to_string()
} else {
first_part.to_string()
}
} else {
"unknown".to_string()
};
// Convert RSS from KB to MB
if let Ok(rss_kb_val) = rss_kb.parse::<u64>() {
let rss_mb = rss_kb_val as f32 / 1024.0;
// Skip processes with very little memory (likely temporary commands)
if rss_mb < 1.0 {
continue;
}
let process_info = format!("{} (PID {}) {:.1}MB", command_name, pid, rss_mb);
return Ok(Some(Metric::new(
"top_ram_process".to_string(),
MetricValue::String(process_info),
Status::Ok,
).with_description("Process consuming the most RAM".to_string())));
}
}
}
Ok(Some(Metric::new(
"top_ram_process".to_string(),
MetricValue::String("No processes found".to_string()),
Status::Ok,
).with_description("Process consuming the most RAM".to_string())))
}
}
#[async_trait]
@ -347,15 +202,6 @@ impl Collector for CpuCollector {
metrics.push(freq_metric);
}
// Collect top CPU process (optional)
if let Some(top_cpu_metric) = self.collect_top_cpu_process().await? {
metrics.push(top_cpu_metric);
}
// Collect top RAM process (optional)
if let Some(top_ram_metric) = self.collect_top_ram_process().await? {
metrics.push(top_ram_metric);
}
let duration = start.elapsed();
debug!("CPU collection completed in {:?} with {} metrics", duration, metrics.len());

View File

@ -1,12 +1,26 @@
use anyhow::Result;
use async_trait::async_trait;
use cm_dashboard_shared::{Metric, MetricValue, Status};
use std::collections::HashMap;
use std::process::Command;
use std::time::Instant;
use tracing::debug;
use super::{Collector, CollectorError, PerformanceMetrics};
/// Information about a mounted disk
#[derive(Debug, Clone)]
struct MountedDisk {
device: String, // e.g., "/dev/nvme0n1p1"
physical_device: String, // e.g., "/dev/nvme0n1"
mount_point: String, // e.g., "/"
filesystem: String, // e.g., "ext4"
size: String, // e.g., "120G"
used: String, // e.g., "45G"
available: String, // e.g., "75G"
usage_percent: f32, // e.g., 38.5
}
/// Disk usage collector for monitoring filesystem sizes
pub struct DiskCollector {
// Immutable collector for caching compatibility
@ -71,6 +85,142 @@ impl DiskCollector {
Ok((total_bytes, used_bytes))
}
/// Get root filesystem disk usage
fn get_root_filesystem_usage(&self) -> Result<(u64, u64, f32)> {
let (total_bytes, used_bytes) = self.get_filesystem_info("/")?;
let usage_percent = (used_bytes as f64 / total_bytes as f64) * 100.0;
Ok((total_bytes, used_bytes, usage_percent as f32))
}
/// Get all mounted disks with their mount points and underlying devices
fn get_mounted_disks(&self) -> Result<Vec<MountedDisk>> {
let output = Command::new("df")
.arg("-h")
.arg("--output=source,target,fstype,size,used,avail,pcent")
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!("df command failed"));
}
let output_str = String::from_utf8(output.stdout)?;
let mut mounted_disks = Vec::new();
for line in output_str.lines().skip(1) { // Skip header
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() >= 7 {
let source = fields[0];
let target = fields[1];
let fstype = fields[2];
let size = fields[3];
let used = fields[4];
let avail = fields[5];
let pcent_str = fields[6];
// Skip special filesystems
if source.starts_with("/dev/") &&
!fstype.contains("tmpfs") &&
!fstype.contains("devtmpfs") &&
!target.starts_with("/proc") &&
!target.starts_with("/sys") &&
!target.starts_with("/dev") {
// Extract percentage
let usage_percent = pcent_str
.trim_end_matches('%')
.parse::<f32>()
.unwrap_or(0.0);
// Get underlying physical device
let physical_device = self.get_physical_device(source)?;
mounted_disks.push(MountedDisk {
device: source.to_string(),
physical_device,
mount_point: target.to_string(),
filesystem: fstype.to_string(),
size: size.to_string(),
used: used.to_string(),
available: avail.to_string(),
usage_percent,
});
}
}
}
Ok(mounted_disks)
}
/// Get the physical device for a given device (resolves symlinks, gets parent device)
fn get_physical_device(&self, device: &str) -> Result<String> {
// For NVMe: /dev/nvme0n1p1 -> /dev/nvme0n1
if device.contains("nvme") && device.contains("p") {
if let Some(base) = device.split('p').next() {
return Ok(base.to_string());
}
}
// For SATA: /dev/sda1 -> /dev/sda
if device.starts_with("/dev/sd") && device.len() > 8 {
return Ok(device[..8].to_string()); // Keep /dev/sdX
}
// For VirtIO: /dev/vda1 -> /dev/vda
if device.starts_with("/dev/vd") && device.len() > 8 {
return Ok(device[..8].to_string());
}
// If no partition detected, return as-is
Ok(device.to_string())
}
/// Get SMART health for a specific physical device
fn get_smart_health(&self, device: &str) -> (String, f32) {
if let Ok(output) = Command::new("smartctl")
.arg("-H")
.arg(device)
.output()
{
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
let health_status = if output_str.contains("PASSED") {
"PASSED"
} else if output_str.contains("FAILED") {
"FAILED"
} else {
"UNKNOWN"
};
// Try to get temperature
let temperature = if let Ok(temp_output) = Command::new("smartctl")
.arg("-A")
.arg(device)
.output()
{
let temp_str = String::from_utf8_lossy(&temp_output.stdout);
// Look for temperature in SMART attributes
for line in temp_str.lines() {
if line.contains("Temperature") && line.contains("Celsius") {
if let Some(temp_part) = line.split_whitespace().nth(9) {
if let Ok(temp) = temp_part.parse::<f32>() {
return (health_status.to_string(), temp);
}
}
}
}
0.0
} else {
0.0
};
return (health_status.to_string(), temperature);
}
}
("UNKNOWN".to_string(), 0.0)
}
/// Calculate status based on usage percentage
fn calculate_usage_status(&self, used_bytes: u64, total_bytes: u64) -> Status {
if total_bytes == 0 {
@ -88,6 +238,38 @@ impl DiskCollector {
Status::Ok
}
}
/// Parse size string (e.g., "120G", "45M") to GB value
fn parse_size_to_gb(&self, size_str: &str) -> f32 {
let size_str = size_str.trim();
if size_str.is_empty() || size_str == "-" {
return 0.0;
}
// Extract numeric part and unit
let (num_str, unit) = if let Some(last_char) = size_str.chars().last() {
if last_char.is_alphabetic() {
let num_part = &size_str[..size_str.len()-1];
let unit_part = &size_str[size_str.len()-1..];
(num_part, unit_part)
} else {
(size_str, "")
}
} else {
(size_str, "")
};
let number: f32 = num_str.parse().unwrap_or(0.0);
match unit.to_uppercase().as_str() {
"T" | "TB" => number * 1024.0,
"G" | "GB" => number,
"M" | "MB" => number / 1024.0,
"K" | "KB" => number / (1024.0 * 1024.0),
"B" | "" => number / (1024.0 * 1024.0 * 1024.0),
_ => number, // Assume GB if unknown unit
}
}
}
#[async_trait]
@ -98,11 +280,186 @@ impl Collector for DiskCollector {
async fn collect(&self) -> Result<Vec<Metric>, CollectorError> {
let start_time = Instant::now();
debug!("Collecting disk metrics");
debug!("Collecting multi-disk metrics");
let mut metrics = Vec::new();
// Monitor /tmp directory size
// Collect all mounted disks
match self.get_mounted_disks() {
Ok(mounted_disks) => {
debug!("Found {} mounted disks", mounted_disks.len());
// Group disks by physical device to avoid duplicate SMART checks
let mut physical_devices: std::collections::HashMap<String, Vec<&MountedDisk>> = std::collections::HashMap::new();
for disk in &mounted_disks {
physical_devices.entry(disk.physical_device.clone())
.or_insert_with(Vec::new)
.push(disk);
}
// Generate metrics for each mounted disk
for (disk_index, disk) in mounted_disks.iter().enumerate() {
let timestamp = chrono::Utc::now().timestamp() as u64;
// Parse size strings to get actual values for calculations
let size_gb = self.parse_size_to_gb(&disk.size);
let used_gb = self.parse_size_to_gb(&disk.used);
let avail_gb = self.parse_size_to_gb(&disk.available);
// Calculate status based on usage percentage
let status = if disk.usage_percent >= 95.0 {
Status::Critical
} else if disk.usage_percent >= 85.0 {
Status::Warning
} else {
Status::Ok
};
// Device and mount point info
metrics.push(Metric {
name: format!("disk_{}_device", disk_index),
value: MetricValue::String(disk.device.clone()),
unit: None,
description: Some(format!("Device: {}", disk.device)),
status: Status::Ok,
timestamp,
});
metrics.push(Metric {
name: format!("disk_{}_mount_point", disk_index),
value: MetricValue::String(disk.mount_point.clone()),
unit: None,
description: Some(format!("Mount: {}", disk.mount_point)),
status: Status::Ok,
timestamp,
});
metrics.push(Metric {
name: format!("disk_{}_filesystem", disk_index),
value: MetricValue::String(disk.filesystem.clone()),
unit: None,
description: Some(format!("FS: {}", disk.filesystem)),
status: Status::Ok,
timestamp,
});
// Size metrics
metrics.push(Metric {
name: format!("disk_{}_total_gb", disk_index),
value: MetricValue::Float(size_gb),
unit: Some("GB".to_string()),
description: Some(format!("Total: {}", disk.size)),
status: Status::Ok,
timestamp,
});
metrics.push(Metric {
name: format!("disk_{}_used_gb", disk_index),
value: MetricValue::Float(used_gb),
unit: Some("GB".to_string()),
description: Some(format!("Used: {}", disk.used)),
status,
timestamp,
});
metrics.push(Metric {
name: format!("disk_{}_available_gb", disk_index),
value: MetricValue::Float(avail_gb),
unit: Some("GB".to_string()),
description: Some(format!("Available: {}", disk.available)),
status: Status::Ok,
timestamp,
});
metrics.push(Metric {
name: format!("disk_{}_usage_percent", disk_index),
value: MetricValue::Float(disk.usage_percent),
unit: Some("%".to_string()),
description: Some(format!("Usage: {:.1}%", disk.usage_percent)),
status,
timestamp,
});
// Physical device name (for SMART health grouping)
let physical_device_name = disk.physical_device
.strip_prefix("/dev/")
.unwrap_or(&disk.physical_device);
metrics.push(Metric {
name: format!("disk_{}_physical_device", disk_index),
value: MetricValue::String(physical_device_name.to_string()),
unit: None,
description: Some(format!("Physical: {}", physical_device_name)),
status: Status::Ok,
timestamp,
});
}
// Add SMART health metrics for each unique physical device
for (physical_device, disks) in physical_devices {
let (health_status, temperature) = self.get_smart_health(&physical_device);
let device_name = physical_device.strip_prefix("/dev/").unwrap_or(&physical_device);
let timestamp = chrono::Utc::now().timestamp() as u64;
let health_status_enum = match health_status.as_str() {
"PASSED" => Status::Ok,
"FAILED" => Status::Critical,
_ => Status::Unknown,
};
metrics.push(Metric {
name: format!("disk_smart_{}_health", device_name),
value: MetricValue::String(health_status.clone()),
unit: None,
description: Some(format!("SMART Health: {}", health_status)),
status: health_status_enum,
timestamp,
});
if temperature > 0.0 {
let temp_status = if temperature >= 70.0 {
Status::Critical
} else if temperature >= 60.0 {
Status::Warning
} else {
Status::Ok
};
metrics.push(Metric {
name: format!("disk_smart_{}_temperature", device_name),
value: MetricValue::Float(temperature),
unit: Some("°C".to_string()),
description: Some(format!("Temperature: {:.0}°C", temperature)),
status: temp_status,
timestamp,
});
}
}
// Add disk count metric
metrics.push(Metric {
name: "disk_count".to_string(),
value: MetricValue::Integer(mounted_disks.len() as i64),
unit: None,
description: Some(format!("Total mounted disks: {}", mounted_disks.len())),
status: Status::Ok,
timestamp: chrono::Utc::now().timestamp() as u64,
});
}
Err(e) => {
debug!("Failed to get mounted disks: {}", e);
metrics.push(Metric {
name: "disk_count".to_string(),
value: MetricValue::Integer(0),
unit: None,
description: Some(format!("Error: {}", e)),
status: Status::Unknown,
timestamp: chrono::Utc::now().timestamp() as u64,
});
}
}
// Monitor /tmp directory size (keep existing functionality)
match self.get_directory_size("/tmp") {
Ok(tmp_size_bytes) => {
let tmp_size_mb = tmp_size_bytes as f64 / (1024.0 * 1024.0);
@ -161,7 +518,7 @@ impl Collector for DiskCollector {
}
let collection_time = start_time.elapsed();
debug!("Disk collection completed in {:?} with {} metrics",
debug!("Multi-disk collection completed in {:?} with {} metrics",
collection_time, metrics.len());
Ok(metrics)

View File

@ -7,6 +7,7 @@ pub mod cpu;
pub mod memory;
pub mod disk;
pub mod systemd;
pub mod backup;
pub mod error;
pub use error::CollectorError;

View File

@ -25,6 +25,12 @@ struct ServiceCacheState {
last_discovery_time: Option<Instant>,
/// How often to rediscover services (5 minutes)
discovery_interval_seconds: u64,
/// Cached nginx site latency metrics
nginx_site_metrics: Vec<Metric>,
/// Last time nginx sites were checked
last_nginx_check_time: Option<Instant>,
/// How often to check nginx site latency (30 seconds)
nginx_check_interval_seconds: u64,
}
impl SystemdCollector {
@ -35,6 +41,9 @@ impl SystemdCollector {
monitored_services: Vec::new(),
last_discovery_time: None,
discovery_interval_seconds: 300, // 5 minutes
nginx_site_metrics: Vec::new(),
last_nginx_check_time: None,
nginx_check_interval_seconds: 30, // 30 seconds for nginx sites
}),
}
}
@ -71,6 +80,32 @@ impl SystemdCollector {
Ok(state.monitored_services.clone())
}
/// Get nginx site metrics, checking them if cache is expired
fn get_nginx_site_metrics(&self) -> Vec<Metric> {
let mut state = self.state.write().unwrap();
// Check if we need to refresh nginx site metrics
let needs_refresh = match state.last_nginx_check_time {
None => true, // First time
Some(last_time) => {
let elapsed = last_time.elapsed().as_secs();
elapsed >= state.nginx_check_interval_seconds
}
};
if needs_refresh {
// Only check nginx sites if nginx service is active
if state.monitored_services.iter().any(|s| s.contains("nginx")) {
debug!("Refreshing nginx site latency metrics (interval: {}s)", state.nginx_check_interval_seconds);
let fresh_metrics = self.get_nginx_sites();
state.nginx_site_metrics = fresh_metrics;
state.last_nginx_check_time = Some(Instant::now());
}
}
state.nginx_site_metrics.clone()
}
/// Auto-discover interesting services to monitor
fn discover_services(&self) -> Result<Vec<String>> {
let output = Command::new("systemctl")
@ -88,22 +123,86 @@ impl SystemdCollector {
let output_str = String::from_utf8(output.stdout)?;
let mut services = Vec::new();
// Interesting service patterns to monitor
let interesting_patterns = [
"nginx", "apache", "httpd", "gitea", "docker", "mysql", "postgresql",
"redis", "ssh", "sshd", "postfix", "mosquitto", "grafana", "prometheus",
"vaultwarden", "unifi", "immich", "plex", "jellyfin", "transmission",
"syncthing", "nextcloud", "owncloud", "mariadb", "mongodb"
// Skip setup/certificate services that don't need monitoring (from legacy)
let excluded_services = [
"mosquitto-certs",
"immich-setup",
"phpfpm-kryddorten",
"phpfpm-mariehall2",
"acme-haasp.net",
"acme-selfsigned-haasp",
"borgbackup",
"haasp-site-deploy",
"mosquitto-backup",
"nginx-config-reload",
"sshd-keygen",
];
// Define patterns for services we want to monitor (from legacy)
let interesting_services = [
// Web applications
"gitea",
"immich",
"vaultwarden",
"unifi",
"wordpress",
"nginx",
"httpd",
// Databases
"postgresql",
"mysql",
"mariadb",
"redis",
"mongodb",
"mongod",
// Backup and storage
"borg",
"rclone",
// Container runtimes
"docker",
// CI/CD services
"gitea-actions",
"gitea-runner",
"actions-runner",
// Network services
"sshd",
"dnsmasq",
// MQTT and IoT services
"mosquitto",
"mqtt",
// PHP-FPM services
"phpfpm",
// Home automation
"haasp",
// Backup services
"backup",
];
for line in output_str.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() >= 4 && fields[0].ends_with(".service") {
let service_name = fields[0].trim_end_matches(".service");
debug!("Processing service: '{}'", service_name);
// Skip excluded services first
let mut is_excluded = false;
for excluded in &excluded_services {
if service_name.contains(excluded) {
debug!("EXCLUDING service '{}' because it matches pattern '{}'", service_name, excluded);
is_excluded = true;
break;
}
}
if is_excluded {
debug!("Skipping excluded service: '{}'", service_name);
continue;
}
// Check if this service matches our interesting patterns
for pattern in &interesting_patterns {
if service_name.contains(pattern) {
for pattern in &interesting_services {
if service_name.contains(pattern) || pattern.contains(service_name) {
debug!("INCLUDING service '{}' because it matches pattern '{}'", service_name, pattern);
services.push(service_name.to_string());
break;
}
@ -571,140 +670,7 @@ impl SystemdCollector {
Some(estimated_gb)
}
/// Get nginx virtual hosts/sites
fn get_nginx_sites(&self) -> Vec<Metric> {
let mut metrics = Vec::new();
// Check sites-enabled directory
let output = Command::new("ls")
.arg("/etc/nginx/sites-enabled/")
.output();
if let Ok(output) = output {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
let site_name = line.trim();
if !site_name.is_empty() && site_name != "default" {
// Check if site config is valid
let test_output = Command::new("nginx")
.arg("-t")
.arg("-c")
.arg(format!("/etc/nginx/sites-enabled/{}", site_name))
.output();
let status = match test_output {
Ok(out) if out.status.success() => Status::Ok,
_ => Status::Warning,
};
metrics.push(Metric {
name: format!("service_nginx_site_{}_status", site_name),
value: MetricValue::String(if status == Status::Ok { "active".to_string() } else { "error".to_string() }),
unit: None,
description: Some(format!("Nginx site {} configuration status", site_name)),
status,
timestamp: chrono::Utc::now().timestamp() as u64,
});
}
}
}
}
metrics
}
/// Get docker containers
fn get_docker_containers(&self) -> Vec<Metric> {
let mut metrics = Vec::new();
let output = Command::new("docker")
.arg("ps")
.arg("-a")
.arg("--format")
.arg("{{.Names}}\t{{.Status}}\t{{.State}}")
.output();
if let Ok(output) = output {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let container_name = parts[0].trim();
let status_info = parts[1].trim();
let state = parts[2].trim();
let status = match state.to_lowercase().as_str() {
"running" => Status::Ok,
"exited" | "dead" => Status::Warning,
"paused" | "restarting" => Status::Warning,
_ => Status::Critical,
};
metrics.push(Metric {
name: format!("service_docker_container_{}_status", container_name),
value: MetricValue::String(state.to_string()),
unit: None,
description: Some(format!("Docker container {} status: {}", container_name, status_info)),
status,
timestamp: chrono::Utc::now().timestamp() as u64,
});
// Get container memory usage
if state == "running" {
if let Some(memory_mb) = self.get_container_memory(container_name) {
metrics.push(Metric {
name: format!("service_docker_container_{}_memory_mb", container_name),
value: MetricValue::Float(memory_mb),
unit: Some("MB".to_string()),
description: Some(format!("Docker container {} memory usage", container_name)),
status: Status::Ok,
timestamp: chrono::Utc::now().timestamp() as u64,
});
}
}
}
}
}
}
metrics
}
/// Get container memory usage
fn get_container_memory(&self, container_name: &str) -> Option<f32> {
let output = Command::new("docker")
.arg("stats")
.arg("--no-stream")
.arg("--format")
.arg("{{.MemUsage}}")
.arg(container_name)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let output_str = String::from_utf8(output.stdout).ok()?;
let mem_usage = output_str.trim();
// Parse format like "123.4MiB / 4GiB"
if let Some(used_part) = mem_usage.split(" / ").next() {
if used_part.ends_with("MiB") {
let num_str = used_part.trim_end_matches("MiB");
return num_str.parse::<f32>().ok();
} else if used_part.ends_with("GiB") {
let num_str = used_part.trim_end_matches("GiB");
if let Ok(gb) = num_str.parse::<f32>() {
return Some(gb * 1024.0); // Convert to MB
}
}
}
None
}
}
#[async_trait]
@ -770,13 +736,11 @@ impl Collector for SystemdCollector {
// Sub-service metrics for specific services
if service.contains("nginx") && active_status == "active" {
let nginx_sites = self.get_nginx_sites();
metrics.extend(nginx_sites);
metrics.extend(self.get_nginx_site_metrics());
}
if service.contains("docker") && active_status == "active" {
let docker_containers = self.get_docker_containers();
metrics.extend(docker_containers);
metrics.extend(self.get_docker_containers());
}
}
Err(e) => {
@ -795,4 +759,321 @@ impl Collector for SystemdCollector {
fn get_performance_metrics(&self) -> Option<PerformanceMetrics> {
None // Performance tracking handled by cache system
}
}
impl SystemdCollector {
/// Get nginx sites with latency checks
fn get_nginx_sites(&self) -> Vec<Metric> {
let mut metrics = Vec::new();
let timestamp = chrono::Utc::now().timestamp() as u64;
// Discover nginx sites from configuration
let sites = self.discover_nginx_sites();
for (site_name, url) in &sites {
match self.check_site_latency(url) {
Ok(latency_ms) => {
let status = if latency_ms < 500.0 {
Status::Ok
} else if latency_ms < 2000.0 {
Status::Warning
} else {
Status::Critical
};
metrics.push(Metric {
name: format!("service_nginx_{}_latency_ms", site_name),
value: MetricValue::Float(latency_ms),
unit: Some("ms".to_string()),
description: Some(format!("Response time for {}", url)),
status,
timestamp,
});
}
Err(_) => {
// Site is unreachable
metrics.push(Metric {
name: format!("service_nginx_{}_latency_ms", site_name),
value: MetricValue::Float(-1.0), // Use -1 to indicate error
unit: Some("ms".to_string()),
description: Some(format!("Response time for {} (unreachable)", url)),
status: Status::Critical,
timestamp,
});
}
}
}
metrics
}
/// Get docker containers as sub-services
fn get_docker_containers(&self) -> Vec<Metric> {
let mut metrics = Vec::new();
let timestamp = chrono::Utc::now().timestamp() as u64;
// Check if docker is available
let output = Command::new("docker")
.arg("ps")
.arg("--format")
.arg("{{.Names}},{{.Status}}")
.output();
let output = match output {
Ok(out) if out.status.success() => out,
_ => return metrics, // Docker not available or failed
};
let output_str = match String::from_utf8(output.stdout) {
Ok(s) => s,
Err(_) => return metrics,
};
for line in output_str.lines() {
if line.trim().is_empty() {
continue;
}
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2 {
let container_name = parts[0].trim();
let status_str = parts[1].trim();
let status = if status_str.contains("Up") {
Status::Ok
} else if status_str.contains("Exited") {
Status::Warning
} else {
Status::Critical
};
metrics.push(Metric {
name: format!("service_docker_{}_status", container_name),
value: MetricValue::String(status_str.to_string()),
unit: None,
description: Some(format!("Docker container {} status", container_name)),
status,
timestamp,
});
}
}
metrics
}
/// Check site latency using curl GET requests
fn check_site_latency(&self, url: &str) -> Result<f32, Box<dyn std::error::Error>> {
let _start = std::time::Instant::now();
let output = Command::new("curl")
.arg("-X")
.arg("GET") // Explicitly use GET method
.arg("-s")
.arg("-o")
.arg("/dev/null")
.arg("-w")
.arg("%{time_total}")
.arg("--max-time")
.arg("5") // 5 second timeout
.arg("--connect-timeout")
.arg("2") // 2 second connection timeout
.arg("--location") // Follow redirects
.arg("--fail") // Fail on HTTP errors (4xx, 5xx)
.arg(url)
.output()?;
if !output.status.success() {
return Err(format!("Curl GET request failed for {}", url).into());
}
let time_str = String::from_utf8(output.stdout)?;
let time_seconds: f32 = time_str.trim().parse()?;
let time_ms = time_seconds * 1000.0;
Ok(time_ms)
}
/// Discover nginx sites from configuration files (like the old working implementation)
fn discover_nginx_sites(&self) -> Vec<(String, String)> {
use tracing::debug;
// Use the same approach as the old working agent: get nginx config from systemd
let config_content = match self.get_nginx_config_from_systemd() {
Some(content) => content,
None => {
debug!("Could not get nginx config from systemd, trying nginx -T fallback");
match self.get_nginx_config_via_command() {
Some(content) => content,
None => {
debug!("Could not get nginx config via any method");
return Vec::new();
}
}
}
};
// Parse the config content to extract sites
self.parse_nginx_config_for_sites(&config_content)
}
/// Get nginx config from systemd service definition (NixOS compatible)
fn get_nginx_config_from_systemd(&self) -> Option<String> {
use tracing::debug;
let output = std::process::Command::new("systemctl")
.args(["show", "nginx", "--property=ExecStart", "--no-pager"])
.output()
.ok()?;
if !output.status.success() {
debug!("Failed to get nginx ExecStart from systemd");
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
debug!("systemctl show nginx output: {}", stdout);
// Parse ExecStart to extract -c config path
for line in stdout.lines() {
if line.starts_with("ExecStart=") {
debug!("Found ExecStart line: {}", line);
// Handle both traditional and NixOS systemd formats
if let Some(config_path) = self.extract_config_path_from_exec_start(line) {
debug!("Extracted config path: {}", config_path);
// Read the config file
return std::fs::read_to_string(&config_path)
.map_err(|e| debug!("Failed to read config file {}: {}", config_path, e))
.ok();
}
}
}
None
}
/// Extract config path from ExecStart line
fn extract_config_path_from_exec_start(&self, exec_start: &str) -> Option<String> {
use tracing::debug;
// Remove ExecStart= prefix
let exec_part = exec_start.strip_prefix("ExecStart=")?;
debug!("Parsing exec part: {}", exec_part);
// Handle NixOS format: ExecStart={ path=...; argv[]=...nginx -c /config; ... }
if exec_part.contains("argv[]=") {
// Extract the part after argv[]=
let argv_start = exec_part.find("argv[]=")?;
let argv_part = &exec_part[argv_start + 7..]; // Skip "argv[]="
debug!("Found NixOS argv part: {}", argv_part);
// Look for -c flag followed by config path
if let Some(c_pos) = argv_part.find(" -c ") {
let after_c = &argv_part[c_pos + 4..];
// Find the config path (until next space or semicolon)
let config_path = after_c.split([' ', ';']).next()?;
return Some(config_path.to_string());
}
} else {
// Handle traditional format: ExecStart=/path/nginx -c /config
debug!("Parsing traditional format");
if let Some(c_pos) = exec_part.find(" -c ") {
let after_c = &exec_part[c_pos + 4..];
let config_path = after_c.split_whitespace().next()?;
return Some(config_path.to_string());
}
}
None
}
/// Fallback: get nginx config via nginx -T command
fn get_nginx_config_via_command(&self) -> Option<String> {
use tracing::debug;
let output = std::process::Command::new("nginx")
.args(["-T"])
.output()
.ok()?;
if !output.status.success() {
debug!("nginx -T failed");
return None;
}
Some(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Parse nginx config content to extract server names and build site list
fn parse_nginx_config_for_sites(&self, config_content: &str) -> Vec<(String, String)> {
use tracing::debug;
let mut sites = Vec::new();
let lines: Vec<&str> = config_content.lines().collect();
let mut i = 0;
debug!("Parsing nginx config with {} lines", lines.len());
while i < lines.len() {
let line = lines[i].trim();
if line.starts_with("server") && line.contains("{") {
debug!("Found server block at line {}", i);
if let Some(server_name) = self.parse_server_block(&lines, &mut i) {
debug!("Extracted server name: {}", server_name);
let url = format!("https://{}", server_name);
// Use the full domain as the site name for clarity
sites.push((server_name.clone(), url));
}
}
i += 1;
}
debug!("Discovered {} nginx sites total", sites.len());
sites
}
/// Parse a server block to extract the primary server_name
fn parse_server_block(&self, lines: &[&str], start_index: &mut usize) -> Option<String> {
use tracing::debug;
let mut server_names = Vec::new();
let mut has_redirect = false;
let mut i = *start_index + 1;
let mut brace_count = 1;
// Parse until we close the server block
while i < lines.len() && brace_count > 0 {
let trimmed = lines[i].trim();
// Track braces
brace_count += trimmed.matches('{').count();
brace_count -= trimmed.matches('}').count();
// Extract server_name
if trimmed.starts_with("server_name") {
if let Some(names_part) = trimmed.strip_prefix("server_name") {
let names_clean = names_part.trim().trim_end_matches(';');
for name in names_clean.split_whitespace() {
if name != "_" && !name.is_empty() && name.contains('.') && !name.starts_with('$') {
server_names.push(name.to_string());
debug!("Found server_name in block: {}", name);
}
}
}
}
// Check for redirects (skip redirect-only servers)
if trimmed.contains("return") && (trimmed.contains("301") || trimmed.contains("302")) {
has_redirect = true;
}
i += 1;
}
*start_index = i - 1;
// Only return hostnames that are not redirects and have actual content
if !server_names.is_empty() && !has_redirect {
Some(server_names[0].clone())
} else {
None
}
}
}

View File

@ -5,7 +5,7 @@ use std::time::Instant;
use tracing::{info, error, debug};
use crate::config::{CollectorConfig, AgentConfig};
use crate::collectors::{Collector, cpu::CpuCollector, memory::MemoryCollector, disk::DiskCollector, systemd::SystemdCollector, cached_collector::CachedCollector};
use crate::collectors::{Collector, cpu::CpuCollector, memory::MemoryCollector, disk::DiskCollector, systemd::SystemdCollector, backup::BackupCollector, cached_collector::CachedCollector};
use crate::cache::MetricCacheManager;
/// Manages all metric collectors with intelligent caching
@ -51,6 +51,17 @@ impl MetricCollectionManager {
collectors.push(Box::new(systemd_collector));
info!("BENCHMARK: Systemd collector only");
},
Some("backup") => {
// Backup collector only
if config.backup.enabled {
let backup_collector = BackupCollector::new(
config.backup.backup_paths.first().cloned(),
config.backup.max_age_hours
);
collectors.push(Box::new(backup_collector));
info!("BENCHMARK: Backup collector only");
}
},
Some("none") => {
// No collectors - test agent loop only
info!("BENCHMARK: No collectors enabled");
@ -76,6 +87,15 @@ impl MetricCollectionManager {
let systemd_collector = SystemdCollector::new();
collectors.push(Box::new(systemd_collector));
info!("Systemd collector initialized");
if config.backup.enabled {
let backup_collector = BackupCollector::new(
config.backup.backup_paths.first().cloned(),
config.backup.max_age_hours
);
collectors.push(Box::new(backup_collector));
info!("Backup collector initialized");
}
}
}
@ -94,6 +114,40 @@ impl MetricCollectionManager {
})
}
/// Force collection from ALL collectors immediately (used at startup)
pub async fn collect_all_metrics_force(&mut self) -> Result<Vec<Metric>> {
let mut all_metrics = Vec::new();
let now = Instant::now();
info!("Force collecting from ALL {} collectors for startup", self.collectors.len());
// Force collection from every collector regardless of intervals
for collector in &self.collectors {
let collector_name = collector.name();
match collector.collect().await {
Ok(metrics) => {
info!("Force collected {} metrics from {} collector", metrics.len(), collector_name);
// Cache all new metrics
for metric in &metrics {
self.cache_manager.cache_metric(metric.clone()).await;
}
all_metrics.extend(metrics);
self.last_collection_times.insert(collector_name.to_string(), now);
}
Err(e) => {
error!("Collector '{}' failed during force collection: {}", collector_name, e);
// Continue with other collectors even if one fails
}
}
}
info!("Force collection completed: {} total metrics cached", all_metrics.len());
Ok(all_metrics)
}
/// Collect metrics from all collectors with intelligent caching
pub async fn collect_all_metrics(&mut self) -> Result<Vec<Metric>> {
let mut all_metrics = Vec::new();
@ -111,6 +165,7 @@ impl MetricCollectionManager {
// Determine cache interval for this collector type - ALL REALTIME FOR FAST UPDATES
let cache_interval_secs = match collector_name {
"cpu" | "memory" | "disk" | "systemd" => 2, // All realtime for fast updates
"backup" => 10, // Backup metrics every 10 seconds for testing
_ => 2, // All realtime for fast updates
};
@ -168,6 +223,13 @@ impl MetricCollectionManager {
.collect()
}
/// Get all cached metrics from the cache manager
pub async fn get_all_cached_metrics(&self) -> Result<Vec<Metric>> {
let cached_metrics = self.cache_manager.get_all_cached_metrics().await;
debug!("Retrieved {} cached metrics for broadcast", cached_metrics.len());
Ok(cached_metrics)
}
/// Determine which collector handles a specific metric
fn get_collector_for_metric(&self, metric_name: &str) -> String {
if metric_name.starts_with("cpu_") {
@ -178,6 +240,8 @@ impl MetricCollectionManager {
"disk".to_string()
} else if metric_name.starts_with("service_") {
"systemd".to_string()
} else if metric_name.starts_with("backup_") {
"backup".to_string()
} else {
"unknown".to_string()
}

View File

@ -121,10 +121,14 @@ pub mod subscriptions {
/// Backup widget metric subscriptions
pub const BACKUP_WIDGET_METRICS: &[&str] = &[
"backup_status",
"backup_overall_status",
"backup_duration_seconds",
"backup_last_run_timestamp",
"backup_size_gb",
"backup_duration_minutes",
"backup_total_services",
"backup_total_repo_size_gb",
"backup_services_completed_count",
"backup_services_failed_count",
"backup_services_disabled_count",
];
/// Get all metric subscriptions for a widget type

View File

@ -1,121 +0,0 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use anyhow::Result;
/// Input handling utilities for the dashboard
pub struct InputHandler;
impl InputHandler {
/// Check if the event is a quit command (q or Ctrl+C)
pub fn is_quit_event(event: &Event) -> bool {
match event {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
..
}) => true,
Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
..
}) => true,
_ => false,
}
}
/// Check if the event is a refresh command (r)
pub fn is_refresh_event(event: &Event) -> bool {
matches!(event, Event::Key(KeyEvent {
code: KeyCode::Char('r'),
modifiers: KeyModifiers::NONE,
..
}))
}
/// Check if the event is a navigation command (arrow keys)
pub fn get_navigation_direction(event: &Event) -> Option<NavigationDirection> {
match event {
Event::Key(KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
}) => Some(NavigationDirection::Left),
Event::Key(KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
}) => Some(NavigationDirection::Right),
Event::Key(KeyEvent {
code: KeyCode::Up,
modifiers: KeyModifiers::NONE,
..
}) => Some(NavigationDirection::Up),
Event::Key(KeyEvent {
code: KeyCode::Down,
modifiers: KeyModifiers::NONE,
..
}) => Some(NavigationDirection::Down),
_ => None,
}
}
/// Check if the event is an Enter key press
pub fn is_enter_event(event: &Event) -> bool {
matches!(event, Event::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
}))
}
/// Check if the event is an Escape key press
pub fn is_escape_event(event: &Event) -> bool {
matches!(event, Event::Key(KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
..
}))
}
/// Extract character from key event
pub fn get_char(event: &Event) -> Option<char> {
match event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
..
}) => Some(*c),
_ => None,
}
}
}
/// Navigation directions
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationDirection {
Up,
Down,
Left,
Right,
}
impl NavigationDirection {
/// Get the opposite direction
pub fn opposite(&self) -> Self {
match self {
NavigationDirection::Up => NavigationDirection::Down,
NavigationDirection::Down => NavigationDirection::Up,
NavigationDirection::Left => NavigationDirection::Right,
NavigationDirection::Right => NavigationDirection::Left,
}
}
/// Check if this is a horizontal direction
pub fn is_horizontal(&self) -> bool {
matches!(self, NavigationDirection::Left | NavigationDirection::Right)
}
/// Check if this is a vertical direction
pub fn is_vertical(&self) -> bool {
matches!(self, NavigationDirection::Up | NavigationDirection::Down)
}
}

View File

@ -1,71 +0,0 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Layout utilities for consistent dashboard design
pub struct DashboardLayout;
impl DashboardLayout {
/// Create the main dashboard layout (preserving legacy design)
pub fn main_layout(area: Rect) -> [Rect; 3] {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title bar
Constraint::Min(0), // Main content
Constraint::Length(1), // Status bar
])
.split(area);
[chunks[0], chunks[1], chunks[2]]
}
/// Create 2x2 grid layout for widgets (legacy layout)
pub fn content_grid(area: Rect) -> [Rect; 4] {
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(horizontal_chunks[0]);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(horizontal_chunks[1]);
[
left_chunks[0], // Top-left
right_chunks[0], // Top-right
left_chunks[1], // Bottom-left
right_chunks[1], // Bottom-right
]
}
/// Create horizontal split layout
pub fn horizontal_split(area: Rect, left_percentage: u16) -> [Rect; 2] {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(left_percentage),
Constraint::Percentage(100 - left_percentage),
])
.split(area);
[chunks[0], chunks[1]]
}
/// Create vertical split layout
pub fn vertical_split(area: Rect, top_percentage: u16) -> [Rect; 2] {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(top_percentage),
Constraint::Percentage(100 - top_percentage),
])
.split(area);
[chunks[0], chunks[1]]
}
}

View File

@ -1,23 +1,21 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
style::Style,
widgets::{Block, Paragraph},
Frame,
};
use std::time::{Duration, Instant};
use tracing::{debug, info};
use std::time::Instant;
use tracing::info;
pub mod widgets;
pub mod layout;
pub mod theme;
pub mod input;
use widgets::{CpuWidget, MemoryWidget, ServicesWidget, Widget};
use widgets::{CpuWidget, MemoryWidget, ServicesWidget, BackupWidget, Widget};
use crate::metrics::{MetricStore, WidgetType};
use cm_dashboard_shared::Metric;
use theme::Theme;
use cm_dashboard_shared::{Metric, Status};
use theme::{Theme, Layout as ThemeLayout, Typography, Components, StatusIcons};
/// Main TUI application
pub struct TuiApp {
@ -27,6 +25,8 @@ pub struct TuiApp {
memory_widget: MemoryWidget,
/// Services widget
services_widget: ServicesWidget,
/// Backup widget
backup_widget: BackupWidget,
/// Current active host
current_host: Option<String>,
/// Available hosts
@ -45,6 +45,7 @@ impl TuiApp {
cpu_widget: CpuWidget::new(),
memory_widget: MemoryWidget::new(),
services_widget: ServicesWidget::new(),
backup_widget: BackupWidget::new(),
current_host: None,
available_hosts: Vec::new(),
host_index: 0,
@ -66,11 +67,19 @@ impl TuiApp {
// Update Services widget - get all metrics that start with "service_"
let all_metrics = metric_store.get_metrics_for_host(hostname);
let service_metrics: Vec<&Metric> = all_metrics.into_iter()
let service_metrics: Vec<&Metric> = all_metrics.iter()
.filter(|m| m.name.starts_with("service_"))
.copied()
.collect();
self.services_widget.update_from_metrics(&service_metrics);
// Update Backup widget - get all metrics that start with "backup_"
let all_backup_metrics: Vec<&Metric> = all_metrics.iter()
.filter(|m| m.name.starts_with("backup_"))
.copied()
.collect();
self.backup_widget.update_from_metrics(&all_backup_metrics);
self.last_update = Some(Instant::now());
}
}
@ -126,15 +135,6 @@ impl TuiApp {
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
}
/// Check if should quit
pub fn should_quit(&self) -> bool {
self.should_quit
}
/// Get current host
pub fn get_current_host(&self) -> Option<&str> {
self.current_host.as_deref()
}
/// Render the dashboard (real btop-style multi-panel layout)
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
@ -148,32 +148,30 @@ impl TuiApp {
// Create real btop-style layout: multi-panel with borders
// Top section: title bar
// Middle section: split into left (mem + disks) and right (CPU + processes)
// Bottom: status bar
// Bottom section: split into left (mem + disks) and right (CPU + processes)
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Title bar
Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar
])
.split(size);
// New layout: left panels | right services (100% height)
let content_chunks = Layout::default()
let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(45), // Left side: system, backup
Constraint::Percentage(55), // Right side: services (100% height)
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
])
.split(main_chunks[1]);
// Left side: system on top, backup on bottom (equal height)
let left_chunks = Layout::default()
let left_chunks = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50), // System section
Constraint::Percentage(50), // Backup section
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
])
.split(content_chunks[0]);
@ -184,157 +182,256 @@ impl TuiApp {
self.render_system_panel(frame, left_chunks[0], metric_store);
self.render_backup_panel(frame, left_chunks[1]);
self.services_widget.render(frame, content_chunks[1]); // Services takes full right side
// Render status bar
self.render_btop_status(frame, main_chunks[2], metric_store);
}
/// Render btop-style minimal title
fn render_btop_title(&self, frame: &mut Frame, area: Rect) {
let title_text = if let Some(ref host) = self.current_host {
format!("cm-dashboard • {}", host)
} else {
"cm-dashboard • disconnected".to_string()
};
use ratatui::text::{Line, Span};
use ratatui::style::Modifier;
let title = Paragraph::new(title_text)
.style(Style::default()
.fg(Theme::primary_text())
.bg(Theme::background()));
if self.available_hosts.is_empty() {
let title_text = "cm-dashboard • no hosts discovered";
let title = Paragraph::new(title_text)
.style(Typography::title());
frame.render_widget(title, area);
return;
}
// Create spans for each host
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())];
for (i, host) in self.available_hosts.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", Typography::title()));
}
if Some(host) == self.current_host.as_ref() {
// Selected host in bold bright white
spans.push(Span::styled(
host.clone(),
Typography::title().add_modifier(Modifier::BOLD)
));
} else {
// Other hosts in normal style
spans.push(Span::styled(host.clone(), Typography::title()));
}
}
let title_line = Line::from(spans);
let title = Paragraph::new(vec![title_line]);
frame.render_widget(title, area);
}
/// Render title bar (legacy)
fn render_title_bar(&self, frame: &mut Frame, area: Rect) {
let title = if let Some(ref host) = self.current_host {
format!("CM Dashboard • {}", host)
} else {
"CM Dashboard • No Host Connected".to_string()
};
let title_block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(Theme::widget_border_style())
.title_style(Theme::title_style());
frame.render_widget(title_block, area);
}
/// Render btop-style minimal status bar
fn render_btop_status(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
let status_text = if let Some(ref hostname) = self.current_host {
let connected = metric_store.is_host_connected(hostname, Duration::from_secs(30));
let status = if connected { "" } else { "" };
format!("{} [←→] host [q] quit", status)
} else {
"○ waiting for connection...".to_string()
};
let status = Paragraph::new(status_text)
.style(Style::default()
.fg(Theme::muted_text())
.bg(Theme::background()));
frame.render_widget(status, area);
}
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
let system_block = Block::default().title("system").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
let system_block = Components::widget_block("system");
let inner_area = system_block.inner(area);
frame.render_widget(system_block, area);
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(3), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]).split(inner_area);
let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load)
Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp)
Constraint::Min(0) // Storage section
])
.split(inner_area);
self.cpu_widget.render(frame, content_chunks[0]);
self.memory_widget.render(frame, content_chunks[1]);
self.render_top_cpu_process(frame, content_chunks[2], metric_store);
self.render_top_ram_process(frame, content_chunks[3], metric_store);
self.render_storage_section(frame, content_chunks[4]);
self.render_storage_section(frame, content_chunks[2], metric_store);
}
fn render_backup_panel(&self, frame: &mut Frame, area: Rect) {
let backup_block = Block::default().title("backup").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
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);
let backup_text = Paragraph::new("Backup status and metrics").style(Style::default().fg(Theme::muted_text()).bg(Theme::background()));
frame.render_widget(backup_text, inner_area);
self.backup_widget.render(frame, inner_area);
}
fn render_top_cpu_process(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
let top_cpu_text = if let Some(ref hostname) = self.current_host {
if let Some(metric) = metric_store.get_metric(hostname, "top_cpu_process") {
format!("Top CPU: {}", metric.value.as_string())
fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
if area.height < 2 {
return;
}
if let Some(ref hostname) = self.current_host {
// Get disk count to determine how many disks to display
let disk_count = if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") {
count_metric.value.as_i64().unwrap_or(0) as usize
} else {
"Top CPU: awaiting data...".to_string()
0
};
if disk_count == 0 {
// No disks found - show error/waiting message
let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
frame.render_widget(disk_title, content_chunks[0]);
let no_disks_spans = StatusIcons::create_status_spans(Status::Unknown, "No mounted disks detected");
let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans));
frame.render_widget(no_disks_para, content_chunks[1]);
return;
}
} else {
"Top CPU: no host".to_string()
};
let top_cpu_para = Paragraph::new(top_cpu_text).style(Style::default().fg(Theme::warning()).bg(Theme::background()));
frame.render_widget(top_cpu_para, area);
}
fn render_top_ram_process(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
let top_ram_text = if let Some(ref hostname) = self.current_host {
if let Some(metric) = metric_store.get_metric(hostname, "top_ram_process") {
format!("Top RAM: {}", metric.value.as_string())
} else {
"Top RAM: awaiting data...".to_string()
}
} else {
"Top RAM: no host".to_string()
};
let top_ram_para = Paragraph::new(top_ram_text).style(Style::default().fg(Theme::info()).bg(Theme::background()));
frame.render_widget(top_ram_para, area);
}
fn render_storage_section(&self, frame: &mut Frame, area: Rect) {
let storage_text = Paragraph::new("Storage: NVMe health and disk usage").style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
frame.render_widget(storage_text, area);
}
/// Render status bar (legacy)
fn render_status_bar(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
let status_text = if let Some(ref hostname) = self.current_host {
let connected = metric_store.is_host_connected(hostname, Duration::from_secs(30));
let connection_status = if connected { "connected" } else { "disconnected" };
// Group disks by physical device
let mut physical_devices: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
format!(
"Keys: [←→] hosts [r]efresh [q]uit | Status: {} | Hosts: {}/{}",
connection_status,
self.host_index + 1,
self.available_hosts.len()
)
for disk_index in 0..disk_count {
if let Some(physical_device_metric) = metric_store.get_metric(hostname, &format!("disk_{}_physical_device", disk_index)) {
let physical_device = physical_device_metric.value.as_string();
physical_devices.entry(physical_device).or_insert_with(Vec::new).push(disk_index);
}
}
// Calculate how many lines we need
let mut total_lines_needed = 0;
for partitions in physical_devices.values() {
total_lines_needed += 2 + partitions.len(); // title + health + usage_per_partition
}
let available_lines = area.height as usize;
// Create constraints dynamically based on physical devices
let mut constraints = Vec::new();
let mut devices_to_show = Vec::new();
let mut current_line = 0;
for (physical_device, partitions) in &physical_devices {
let lines_for_this_device = 2 + partitions.len();
if current_line + lines_for_this_device <= available_lines {
devices_to_show.push((physical_device.clone(), partitions.clone()));
// Add constraints for this device
constraints.push(Constraint::Length(1)); // Device title
constraints.push(Constraint::Length(1)); // Health line
for _ in 0..partitions.len() {
constraints.push(Constraint::Length(1)); // Usage line per partition
}
current_line += lines_for_this_device;
} else {
break; // Can't fit more devices
}
}
// Add remaining space if any
if constraints.len() < available_lines {
constraints.push(Constraint::Min(0));
}
let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut chunk_index = 0;
// Display each physical device
for (physical_device, partitions) in &devices_to_show {
// Device title
let disk_title_text = format!("Disk {}:", physical_device);
let disk_title_para = Paragraph::new(disk_title_text).style(Typography::widget_title());
frame.render_widget(disk_title_para, content_chunks[chunk_index]);
chunk_index += 1;
// Health status (one per physical device)
let smart_health = metric_store.get_metric(hostname, &format!("disk_smart_{}_health", physical_device))
.map(|m| (m.value.as_string(), m.status))
.unwrap_or_else(|| ("Unknown".to_string(), Status::Unknown));
let smart_temp = metric_store.get_metric(hostname, &format!("disk_smart_{}_temperature", physical_device))
.and_then(|m| m.value.as_f32());
let temp_text = if let Some(temp) = smart_temp {
format!(" {}°C", temp as i32)
} else {
String::new()
};
let health_spans = StatusIcons::create_status_spans(
smart_health.1,
&format!("Health: {}{}", smart_health.0, temp_text)
);
let health_para = Paragraph::new(ratatui::text::Line::from(health_spans));
frame.render_widget(health_para, content_chunks[chunk_index]);
chunk_index += 1;
// Usage lines (one per partition/mount point)
for &disk_index in partitions {
let mount_point = metric_store.get_metric(hostname, &format!("disk_{}_mount_point", disk_index))
.map(|m| m.value.as_string())
.unwrap_or("?".to_string());
let usage_percent = metric_store.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index))
.and_then(|m| m.value.as_f32())
.unwrap_or(0.0);
let used_gb = metric_store.get_metric(hostname, &format!("disk_{}_used_gb", disk_index))
.and_then(|m| m.value.as_f32())
.unwrap_or(0.0);
let total_gb = metric_store.get_metric(hostname, &format!("disk_{}_total_gb", disk_index))
.and_then(|m| m.value.as_f32())
.unwrap_or(0.0);
let usage_status = metric_store.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index))
.map(|m| m.status)
.unwrap_or(Status::Unknown);
// Format mount point for usage line
let mount_display = if mount_point == "/" {
"root".to_string()
} else if mount_point == "/boot" {
"boot".to_string()
} else if mount_point.starts_with("/") {
mount_point[1..].to_string() // Remove leading slash
} else {
mount_point.clone()
};
let usage_spans = StatusIcons::create_status_spans(
usage_status,
&format!("Usage @{}: {:.1}% • {:.1}/{:.1} GB", mount_display, usage_percent, used_gb, total_gb)
);
let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans));
frame.render_widget(usage_para, content_chunks[chunk_index]);
chunk_index += 1;
}
}
// Show truncation indicator if we couldn't display all devices
if devices_to_show.len() < physical_devices.len() {
if let Some(last_chunk) = content_chunks.last() {
let truncated_count = physical_devices.len() - devices_to_show.len();
let truncated_text = format!("... and {} more disk{}",
truncated_count,
if truncated_count == 1 { "" } else { "s" });
let truncated_para = Paragraph::new(truncated_text).style(Typography::muted());
frame.render_widget(truncated_para, *last_chunk);
}
}
} else {
"Keys: [←→] hosts [r]efresh [q]uit | Status: No hosts | Waiting for connections...".to_string()
};
let status_block = Block::default()
.title(status_text)
.style(Theme::status_bar_style());
frame.render_widget(status_block, area);
// No host connected
let content_chunks = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
frame.render_widget(disk_title, content_chunks[0]);
let no_host_spans = StatusIcons::create_status_spans(Status::Unknown, "No host connected");
let no_host_para = Paragraph::new(ratatui::text::Line::from(no_host_spans));
frame.render_widget(no_host_para, content_chunks[1]);
}
}
/// Render placeholder widget
fn render_placeholder(&self, frame: &mut Frame, area: Rect, name: &str) {
let placeholder_block = Block::default()
.title(format!("{} • awaiting implementation", name))
.borders(Borders::ALL)
.style(Theme::widget_border_inactive_style())
.title_style(Style::default().fg(Theme::muted_text()));
frame.render_widget(placeholder_block, area);
}
}
/// Check for input events with timeout
pub fn check_for_input(timeout: Duration) -> Result<Option<Event>> {
if event::poll(timeout)? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}

View File

@ -1,11 +1,143 @@
use ratatui::style::{Color, Style, Modifier};
use ratatui::widgets::{Block, Borders};
use cm_dashboard_shared::Status;
/// Color theme for the dashboard - btop dark theme
/// Complete terminal color palette matching your configuration
pub struct TerminalColors {
// Primary colors
pub foreground: Color,
pub dim_foreground: Color,
pub bright_foreground: Color,
pub background: Color,
// Normal colors
pub normal_black: Color,
pub normal_red: Color,
pub normal_green: Color,
pub normal_yellow: Color,
pub normal_blue: Color,
pub normal_magenta: Color,
pub normal_cyan: Color,
pub normal_white: Color,
// Bright colors
pub bright_black: Color,
pub bright_red: Color,
pub bright_green: Color,
pub bright_yellow: Color,
pub bright_blue: Color,
pub bright_magenta: Color,
pub bright_cyan: Color,
pub bright_white: Color,
// Dim colors
pub dim_black: Color,
pub dim_red: Color,
pub dim_green: Color,
pub dim_yellow: Color,
pub dim_blue: Color,
pub dim_magenta: Color,
pub dim_cyan: Color,
pub dim_white: Color,
}
impl Default for TerminalColors {
fn default() -> Self {
Self {
// Primary colors
foreground: Color::Rgb(198, 198, 198), // #c6c6c6
dim_foreground: Color::Rgb(112, 112, 112), // #707070
bright_foreground: Color::Rgb(255, 255, 255), // #ffffff
background: Color::Rgb(38, 38, 38), // #262626
// Normal colors
normal_black: Color::Rgb(0, 0, 0), // #000000
normal_red: Color::Rgb(215, 84, 0), // #d75400
normal_green: Color::Rgb(175, 215, 135), // #afd787
normal_yellow: Color::Rgb(215, 175, 95), // #d7af5f
normal_blue: Color::Rgb(135, 175, 215), // #87afd7
normal_magenta: Color::Rgb(215, 215, 175), // #d7d7af
normal_cyan: Color::Rgb(160, 160, 160), // #a0a0a0
normal_white: Color::Rgb(238, 238, 238), // #eeeeee
// Bright colors
bright_black: Color::Rgb(48, 48, 48), // #303030
bright_red: Color::Rgb(215, 84, 0), // #d75400
bright_green: Color::Rgb(175, 215, 135), // #afd787
bright_yellow: Color::Rgb(215, 175, 95), // #d7af5f
bright_blue: Color::Rgb(135, 175, 215), // #87afd7
bright_magenta: Color::Rgb(215, 215, 175), // #d7d7af
bright_cyan: Color::Rgb(160, 160, 160), // #a0a0a0
bright_white: Color::Rgb(255, 255, 255), // #ffffff
// Dim colors
dim_black: Color::Rgb(0, 0, 0), // #000000
dim_red: Color::Rgb(215, 84, 0), // #d75400
dim_green: Color::Rgb(175, 215, 135), // #afd787
dim_yellow: Color::Rgb(215, 175, 95), // #d7af5f
dim_blue: Color::Rgb(135, 175, 215), // #87afd7
dim_magenta: Color::Rgb(215, 215, 175), // #d7d7af
dim_cyan: Color::Rgb(160, 160, 160), // #a0a0a0
dim_white: Color::Rgb(221, 221, 221), // #dddddd
}
}
}
/// Comprehensive theming engine for dashboard consistency
pub struct Theme;
impl Theme {
/// Get color for status level (btop-style)
fn colors() -> &'static TerminalColors {
static COLORS: std::sync::OnceLock<TerminalColors> = std::sync::OnceLock::new();
COLORS.get_or_init(TerminalColors::default)
}
// Semantic color mapping using the terminal color struct
pub fn primary_text() -> Color {
Self::colors().normal_white
}
pub fn secondary_text() -> Color {
Self::colors().foreground
}
pub fn muted_text() -> Color {
Self::colors().dim_foreground
}
pub fn border() -> Color {
Self::colors().dim_foreground
}
pub fn border_title() -> Color {
Self::colors().bright_white
}
pub fn background() -> Color {
Self::colors().background
}
pub fn success() -> Color {
Self::colors().normal_green
}
pub fn warning() -> Color {
Self::colors().normal_yellow
}
pub fn error() -> Color {
Self::colors().normal_red
}
pub fn info() -> Color {
Self::colors().normal_cyan
}
pub fn highlight() -> Color {
Self::colors().normal_blue
}
/// Get color for status level
pub fn status_color(status: Status) -> Color {
match status {
Status::Ok => Self::success(),
@ -20,84 +152,29 @@ impl Theme {
Style::default().fg(Self::status_color(status))
}
/// Primary text color (btop bright text)
pub fn primary_text() -> Color {
Color::Rgb(255, 255, 255) // Pure white
}
/// Secondary text color (btop muted text)
pub fn secondary_text() -> Color {
Color::Rgb(180, 180, 180) // Light gray
}
/// Muted text color (btop dimmed text)
pub fn muted_text() -> Color {
Color::Rgb(120, 120, 120) // Medium gray
}
/// Border color (btop muted borders)
pub fn border() -> Color {
Color::Rgb(100, 100, 100) // Muted gray like btop
}
/// Secondary border color (btop blue)
pub fn border_secondary() -> Color {
Color::Rgb(100, 149, 237) // Cornflower blue
}
/// Background color (btop true black)
pub fn background() -> Color {
Color::Black // True black like btop
}
/// Highlight color (btop selection)
pub fn highlight() -> Color {
Color::Rgb(58, 150, 221) // Bright blue
}
/// Success color (btop green)
pub fn success() -> Color {
Color::Rgb(40, 167, 69) // Success green
}
/// Warning color (btop orange/yellow)
pub fn warning() -> Color {
Color::Rgb(255, 193, 7) // Warning amber
}
/// Error color (btop red)
pub fn error() -> Color {
Color::Rgb(220, 53, 69) // Error red
}
/// Info color (btop blue)
pub fn info() -> Color {
Color::Rgb(23, 162, 184) // Info cyan-blue
}
/// CPU usage colors (btop CPU gradient)
/// CPU usage colors using terminal color struct
pub fn cpu_color(percentage: u16) -> Color {
match percentage {
0..=25 => Color::Rgb(46, 160, 67), // Green
26..=50 => Color::Rgb(255, 206, 84), // Yellow
51..=75 => Color::Rgb(255, 159, 67), // Orange
76..=100 => Color::Rgb(255, 69, 58), // Red
_ => Color::Rgb(255, 69, 58), // Red for >100%
0..=25 => Self::colors().normal_green, // Low usage
26..=50 => Self::colors().normal_yellow, // Medium usage
51..=75 => Self::colors().normal_magenta, // High usage
76..=100 => Self::colors().normal_red, // Critical usage
_ => Self::colors().normal_red, // Over 100%
}
}
/// Memory usage colors (btop memory gradient)
/// Memory usage colors using terminal color struct
pub fn memory_color(percentage: u16) -> Color {
match percentage {
0..=60 => Color::Rgb(52, 199, 89), // Green
61..=80 => Color::Rgb(255, 214, 10), // Yellow
81..=95 => Color::Rgb(255, 149, 0), // Orange
96..=100 => Color::Rgb(255, 59, 48), // Red
_ => Color::Rgb(255, 59, 48), // Red for >100%
0..=60 => Self::colors().normal_green, // Low usage
61..=80 => Self::colors().normal_yellow, // Medium usage
81..=95 => Self::colors().normal_magenta, // High usage
96..=100 => Self::colors().normal_red, // Critical usage
_ => Self::colors().normal_red, // Over 100%
}
}
/// Get gauge color based on percentage (btop-style gradient)
/// Get gauge color based on percentage
pub fn gauge_color(percentage: u16, warning_threshold: u16, critical_threshold: u16) -> Color {
if percentage >= critical_threshold {
Self::error()
@ -108,27 +185,192 @@ impl Theme {
}
}
/// Title style (btop widget titles)
pub fn title_style() -> Style {
Style::default()
.fg(Self::primary_text())
.add_modifier(Modifier::BOLD)
}
/// Widget border style (btop default borders)
/// Widget border style
pub fn widget_border_style() -> Style {
Style::default().fg(Self::border())
Style::default().fg(Self::border()).bg(Self::background())
}
/// Inactive widget border style
pub fn widget_border_inactive_style() -> Style {
Style::default().fg(Self::muted_text())
Style::default().fg(Self::muted_text()).bg(Self::background())
}
/// Status bar style (btop bottom bar)
pub fn status_bar_style() -> Style {
Style::default()
.fg(Self::secondary_text())
.bg(Self::background())
/// Title style
pub fn title_style() -> Style {
Style::default().fg(Self::border_title()).bg(Self::background())
}
}
/// Status bar style
pub fn status_bar_style() -> Style {
Style::default().fg(Self::muted_text()).bg(Self::background())
}
}
/// Layout and spacing constants
pub struct Layout;
impl Layout {
/// Left panel percentage (system + backup)
pub const LEFT_PANEL_WIDTH: u16 = 45;
/// Right panel percentage (services)
pub const RIGHT_PANEL_WIDTH: u16 = 55;
/// System vs backup split (equal)
pub const SYSTEM_PANEL_HEIGHT: u16 = 50;
pub const BACKUP_PANEL_HEIGHT: u16 = 50;
/// System panel CPU section height
pub const CPU_SECTION_HEIGHT: u16 = 2;
/// System panel memory section height
pub const MEMORY_SECTION_HEIGHT: u16 = 3;
}
/// Typography system
pub struct Typography;
/// Component styling system
pub struct Components;
/// Status icons and styling
pub struct StatusIcons;
impl StatusIcons {
/// Get status icon symbol
pub fn get_icon(status: Status) -> &'static str {
match status {
Status::Ok => "",
Status::Warning => "",
Status::Critical => "",
Status::Unknown => "?",
}
}
/// Get style for status-colored text (icon + text in status color)
pub fn get_status_style(status: Status) -> Style {
let color = match status {
Status::Ok => Theme::success(), // Green
Status::Warning => Theme::warning(), // Yellow
Status::Critical => Theme::error(), // Red
Status::Unknown => Theme::muted_text(), // Gray
};
Style::default().fg(color).bg(Theme::background())
}
/// Format text with status icon (entire line uses status color)
pub fn format_with_status(status: Status, text: &str) -> String {
let icon = Self::get_icon(status);
format!("{} {}", icon, text)
}
/// Create spans with status icon colored and text in foreground color
pub fn create_status_spans(status: Status, text: &str) -> Vec<ratatui::text::Span<'static>> {
let icon = Self::get_icon(status);
let status_color = match status {
Status::Ok => Theme::success(), // Green
Status::Warning => Theme::warning(), // Yellow
Status::Critical => Theme::error(), // Red
Status::Unknown => Theme::muted_text(), // Gray
};
vec![
ratatui::text::Span::styled(
format!("{} ", icon),
Style::default().fg(status_color).bg(Theme::background())
),
ratatui::text::Span::styled(
text.to_string(),
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
),
]
}
/// Get style for secondary text with status icon (icon in status color, text in secondary)
pub fn get_secondary_with_status_style(_status: Status) -> Style {
// For now, use secondary color but we could enhance this later
// The icon color will come from the status color in the formatted text
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
}
}
impl Components {
/// Standard widget block with title using bright foreground for title
pub fn widget_block(title: &str) -> Block {
Block::default()
.title(title)
.borders(Borders::ALL)
.style(Style::default().fg(Theme::border()).bg(Theme::background()))
.title_style(Style::default().fg(Theme::border_title()).bg(Theme::background()))
}
/// Status bar style
pub fn status_bar() -> Style {
Style::default()
.fg(Theme::muted_text())
.bg(Theme::background())
}
/// CPU usage styling based on percentage
pub fn cpu_usage_style(percentage: u16) -> Style {
Style::default()
.fg(Theme::cpu_color(percentage))
.bg(Theme::background())
}
/// Memory usage styling based on percentage
pub fn memory_usage_style(percentage: u16) -> Style {
Style::default()
.fg(Theme::memory_color(percentage))
.bg(Theme::background())
}
/// Service status styling
pub fn service_status_style(status: Status) -> Style {
Style::default()
.fg(Theme::status_color(status))
.bg(Theme::background())
}
/// Backup item styling
pub fn backup_item_style(status: Status) -> Style {
Style::default()
.fg(Theme::status_color(status))
.bg(Theme::background())
}
}
impl Typography {
/// Main title style (dashboard header)
pub fn title() -> Style {
Style::default()
.fg(Theme::primary_text())
.bg(Theme::background())
}
/// Widget title style (panel headers) - bold bright white
pub fn widget_title() -> Style {
Style::default()
.fg(Color::White)
.bg(Theme::background())
.add_modifier(Modifier::BOLD)
}
/// Secondary content text
pub fn secondary() -> Style {
Style::default()
.fg(Theme::secondary_text())
.bg(Theme::background())
}
/// Muted text (inactive items, placeholders) - now bold bright white for headers
pub fn muted() -> Style {
Style::default()
.fg(Color::White)
.bg(Theme::background())
.add_modifier(Modifier::BOLD)
}
/// Status text with dynamic colors
pub fn status(status: Status) -> Style {
Style::default()
.fg(Theme::status_color(status))
.bg(Theme::background())
}
}

View 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()
}
}

View File

@ -1,15 +1,13 @@
use cm_dashboard_shared::{Metric, MetricValue, Status};
use cm_dashboard_shared::{Metric, Status};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Gauge, Paragraph},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use tracing::debug;
use super::Widget;
use crate::ui::theme::Theme;
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
/// CPU widget displaying load, temperature, and frequency
pub struct CpuWidget {
@ -40,11 +38,6 @@ impl CpuWidget {
}
}
/// Get status color for display (btop-style)
fn get_status_color(&self) -> Color {
Theme::status_color(self.status)
}
/// Format load average for display
fn format_load(&self) -> String {
match (self.load_1min, self.load_5min, self.load_15min) {
@ -55,14 +48,6 @@ impl CpuWidget {
}
}
/// Format temperature for display
fn format_temperature(&self) -> String {
match self.temperature {
Some(temp) => format!("{:.1}°C", temp),
None => "—°C".to_string(),
}
}
/// Format frequency for display
fn format_frequency(&self) -> String {
match self.frequency {
@ -70,45 +55,7 @@ impl CpuWidget {
None => "— MHz".to_string(),
}
}
/// Get load percentage for gauge (based on load_1min)
fn get_load_percentage(&self) -> u16 {
match self.load_1min {
Some(load) => {
// Assume 8-core system, so 100% = load of 8.0
let percentage = (load / 8.0 * 100.0).min(100.0).max(0.0);
percentage as u16
}
None => 0,
}
}
/// Create btop-style dotted bar pattern (like real btop)
fn create_btop_dotted_bar(&self, percentage: u16, width: usize) -> String {
let filled = (width * percentage as usize) / 100;
let empty = width.saturating_sub(filled);
// Real btop uses these patterns:
// High usage: ████████ (solid blocks)
// Medium usage: :::::::: (colons)
// Low usage: ........ (dots)
// Empty: (spaces)
let pattern = if percentage >= 75 {
"" // High usage - solid blocks
} else if percentage >= 25 {
":" // Medium usage - colons like btop
} else if percentage > 0 {
"." // Low usage - dots like btop
} else {
" " // No usage - spaces
};
let filled_chars = pattern.repeat(filled);
let empty_chars = " ".repeat(empty);
filled_chars + &empty_chars
}
}
impl Widget for CpuWidget {
@ -168,27 +115,16 @@ impl Widget for CpuWidget {
}
fn render(&mut self, frame: &mut Frame, area: Rect) {
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
let cpu_title = Paragraph::new("CPU:").style(Style::default().fg(Theme::primary_text()).bg(Theme::background()));
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1)]).split(area);
let cpu_title = Paragraph::new("CPU:").style(Typography::widget_title());
frame.render_widget(cpu_title, content_chunks[0]);
let overall_usage = self.get_load_percentage();
let cpu_usage_text = format!("Usage: {} {:>3}%", self.create_btop_dotted_bar(overall_usage, 20), overall_usage);
let cpu_usage_para = Paragraph::new(cpu_usage_text).style(Style::default().fg(Theme::cpu_color(overall_usage)).bg(Theme::background()));
frame.render_widget(cpu_usage_para, content_chunks[1]);
let load_freq_text = format!("Load: {}{}", self.format_load(), self.format_frequency());
let load_freq_para = Paragraph::new(load_freq_text).style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
frame.render_widget(load_freq_para, content_chunks[2]);
}
fn get_name(&self) -> &str {
"CPU"
}
fn has_data(&self) -> bool {
self.has_data
let load_freq_spans = StatusIcons::create_status_spans(self.status, &format!("Load: {}{}", self.format_load(), self.format_frequency()));
let load_freq_para = Paragraph::new(ratatui::text::Line::from(load_freq_spans));
frame.render_widget(load_freq_para, content_chunks[1]);
}
}
impl Default for CpuWidget {
fn default() -> Self {
Self::new()

View File

@ -1,15 +1,13 @@
use cm_dashboard_shared::{Metric, MetricValue, Status};
use cm_dashboard_shared::{Metric, Status};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Gauge, Paragraph},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use tracing::debug;
use super::Widget;
use crate::ui::theme::Theme;
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
/// Memory widget displaying usage, totals, and swap information
pub struct MemoryWidget {
@ -54,44 +52,6 @@ impl MemoryWidget {
}
}
/// Get status color for display (btop-style)
fn get_status_color(&self) -> Color {
Theme::status_color(self.status)
}
/// Format memory usage for display
fn format_memory_usage(&self) -> String {
match (self.used_gb, self.total_gb) {
(Some(used), Some(total)) => {
format!("{:.1}/{:.1} GB", used, total)
}
_ => "—/— GB".to_string(),
}
}
/// Format swap usage for display
fn format_swap_usage(&self) -> String {
match (self.swap_used_gb, self.swap_total_gb) {
(Some(used), Some(total)) => {
if total > 0.0 {
format!("{:.1}/{:.1} GB", used, total)
} else {
"No swap".to_string()
}
}
_ => "—/— GB".to_string(),
}
}
/// Format /tmp usage for display
fn format_tmp_usage(&self) -> String {
match (self.tmp_size_mb, self.tmp_total_mb) {
(Some(used), Some(total)) => {
format!("{:.1}/{:.0} MB", used, total)
}
_ => "—/— MB".to_string(),
}
}
/// Get memory usage percentage for gauge
fn get_memory_percentage(&self) -> u16 {
@ -109,44 +69,66 @@ impl MemoryWidget {
}
}
}
/// Get swap usage percentage
fn get_swap_percentage(&self) -> u16 {
match (self.swap_used_gb, self.swap_total_gb) {
(Some(used), Some(total)) if total > 0.0 => {
let percent = (used / total * 100.0).min(100.0).max(0.0);
percent as u16
/// Format size with proper units (kB/MB/GB)
fn format_size_units(size_mb: f32) -> String {
if size_mb >= 1024.0 {
// Convert to GB
let size_gb = size_mb / 1024.0;
format!("{:.1}GB", size_gb)
} else if size_mb >= 1.0 {
// Show as MB
format!("{:.0}MB", size_mb)
} else if size_mb >= 0.001 {
// Convert to kB
let size_kb = size_mb * 1024.0;
format!("{:.0}kB", size_kb)
} else {
// Show very small sizes in bytes
let size_bytes = size_mb * 1024.0 * 1024.0;
format!("{:.0}B", size_bytes)
}
}
/// Format /tmp usage as "xx% yyykB/MB/GB/zzzGB"
fn format_tmp_usage(&self) -> String {
match (self.tmp_usage_percent, self.tmp_size_mb, self.tmp_total_mb) {
(Some(percent), Some(used_mb), Some(total_mb)) => {
let used_str = Self::format_size_units(used_mb);
let total_str = Self::format_size_units(total_mb);
format!("{:.1}% {}/{}", percent, used_str, total_str)
}
_ => 0,
(Some(percent), Some(used_mb), None) => {
let used_str = Self::format_size_units(used_mb);
format!("{:.1}% {}", percent, used_str)
}
(None, Some(used_mb), Some(total_mb)) => {
let used_str = Self::format_size_units(used_mb);
let total_str = Self::format_size_units(total_mb);
format!("{}/{}", used_str, total_str)
}
(None, Some(used_mb), None) => {
Self::format_size_units(used_mb)
}
_ => "".to_string()
}
}
/// Get tmp status based on usage percentage
fn get_tmp_status(&self) -> Status {
if let Some(tmp_percent) = self.tmp_usage_percent {
if tmp_percent >= 90.0 {
Status::Critical
} else if tmp_percent >= 70.0 {
Status::Warning
} else {
Status::Ok
}
} else {
Status::Unknown
}
}
/// Create btop-style dotted bar pattern (same as CPU)
fn create_btop_dotted_bar(&self, percentage: u16, width: usize) -> String {
let filled = (width * percentage as usize) / 100;
let empty = width.saturating_sub(filled);
// Real btop uses these patterns:
// High usage: ████████ (solid blocks)
// Medium usage: :::::::: (colons)
// Low usage: ........ (dots)
// Empty: (spaces)
let pattern = if percentage >= 75 {
"" // High usage - solid blocks
} else if percentage >= 25 {
":" // Medium usage - colons like btop
} else if percentage > 0 {
"." // Low usage - dots like btop
} else {
" " // No usage - spaces
};
let filled_chars = pattern.repeat(filled);
let empty_chars = " ".repeat(empty);
filled_chars + &empty_chars
}
}
impl Widget for MemoryWidget {
@ -231,23 +213,22 @@ impl Widget for MemoryWidget {
fn render(&mut self, frame: &mut Frame, area: Rect) {
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
let mem_title = Paragraph::new("Memory:").style(Style::default().fg(Theme::primary_text()).bg(Theme::background()));
let mem_title = Paragraph::new("RAM:").style(Typography::widget_title());
frame.render_widget(mem_title, content_chunks[0]);
let memory_percentage = self.get_memory_percentage();
let mem_usage_text = format!("Usage: {} {:>3}%", self.create_btop_dotted_bar(memory_percentage, 20), memory_percentage);
let mem_usage_para = Paragraph::new(mem_usage_text).style(Style::default().fg(Theme::memory_color(memory_percentage)).bg(Theme::background()));
frame.render_widget(mem_usage_para, content_chunks[1]);
let mem_details_text = format!("Used: {} • Total: {}", self.used_gb.map_or("".to_string(), |v| format!("{:.1}GB", v)), self.total_gb.map_or("".to_string(), |v| format!("{:.1}GB", v)));
let mem_details_para = Paragraph::new(mem_details_text).style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
frame.render_widget(mem_details_para, content_chunks[2]);
}
fn get_name(&self) -> &str {
"Memory"
}
fn has_data(&self) -> bool {
self.has_data
// Format used and total memory with smart units, percentage, and status icon
let used_str = self.used_gb.map_or("".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
let total_str = self.total_gb.map_or("".to_string(), |v| Self::format_size_units(v * 1024.0)); // Convert GB to MB for formatting
let percentage = self.get_memory_percentage();
let mem_details_spans = StatusIcons::create_status_spans(self.status, &format!("Used: {}% {}/{}", percentage, used_str, total_str));
let mem_details_para = Paragraph::new(ratatui::text::Line::from(mem_details_spans));
frame.render_widget(mem_details_para, content_chunks[1]);
// /tmp usage line with status icon
let tmp_status = self.get_tmp_status();
let tmp_spans = StatusIcons::create_status_spans(tmp_status, &format!("tmp: {}", self.format_tmp_usage()));
let tmp_para = Paragraph::new(ratatui::text::Line::from(tmp_spans));
frame.render_widget(tmp_para, content_chunks[2]);
}
}

View File

@ -4,10 +4,12 @@ use ratatui::{layout::Rect, Frame};
pub mod cpu;
pub mod memory;
pub mod services;
pub mod backup;
pub use cpu::CpuWidget;
pub use memory::MemoryWidget;
pub use services::ServicesWidget;
pub use backup::BackupWidget;
/// Widget trait for UI components that display metrics
pub trait Widget {
@ -16,10 +18,4 @@ pub trait Widget {
/// Render the widget to a terminal frame
fn render(&mut self, frame: &mut Frame, area: Rect);
/// Get widget name for display
fn get_name(&self) -> &str;
/// Check if widget has data to display
fn has_data(&self) -> bool;
}

View File

@ -1,20 +1,22 @@
use cm_dashboard_shared::{Metric, Status};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
widgets::Paragraph,
Frame,
};
use std::collections::HashMap;
use tracing::debug;
use super::Widget;
use crate::ui::theme::Theme;
use crate::ui::theme::{Theme, Typography, Components, StatusIcons};
use ratatui::style::Style;
/// Services widget displaying individual systemd service statuses
/// Services widget displaying hierarchical systemd service statuses
pub struct ServicesWidget {
/// Individual service statuses
services: HashMap<String, ServiceInfo>,
/// Parent services (nginx, docker, etc.)
parent_services: HashMap<String, ServiceInfo>,
/// Sub-services grouped by parent (nginx -> [gitea, mariehall, ...], docker -> [container1, ...])
sub_services: HashMap<String, Vec<(String, ServiceInfo)>>,
/// Aggregated status
status: Status,
/// Last update indicator
@ -26,86 +28,183 @@ struct ServiceInfo {
status: String,
memory_mb: Option<f32>,
disk_gb: Option<f32>,
latency_ms: Option<f32>,
widget_status: Status,
}
impl ServicesWidget {
pub fn new() -> Self {
Self {
services: HashMap::new(),
parent_services: HashMap::new(),
sub_services: HashMap::new(),
status: Status::Unknown,
has_data: false,
}
}
/// Get status color for display (btop-style)
fn get_status_color(&self) -> Color {
Theme::status_color(self.status)
}
/// Extract service name from metric name
fn extract_service_name(metric_name: &str) -> Option<String> {
/// Extract service name and determine if it's a parent or sub-service
fn extract_service_info(metric_name: &str) -> Option<(String, Option<String>)> {
if metric_name.starts_with("service_") {
if let Some(end_pos) = metric_name.rfind("_status")
.or_else(|| metric_name.rfind("_memory_mb"))
.or_else(|| metric_name.rfind("_disk_gb")) {
let service_name = &metric_name[8..end_pos]; // Remove "service_" prefix
return Some(service_name.to_string());
.or_else(|| metric_name.rfind("_disk_gb"))
.or_else(|| metric_name.rfind("_latency_ms")) {
let service_part = &metric_name[8..end_pos]; // Remove "service_" prefix
// Check for sub-services patterns
if service_part.starts_with("nginx_") {
// nginx sub-services: service_nginx_gitea_latency_ms -> ("nginx", "gitea")
let sub_service = service_part.strip_prefix("nginx_").unwrap_or(service_part);
return Some(("nginx".to_string(), Some(sub_service.to_string())));
} else if service_part.starts_with("docker_") {
// docker sub-services: service_docker_container1_status -> ("docker", "container1")
let sub_service = service_part.strip_prefix("docker_").unwrap_or(service_part);
return Some(("docker".to_string(), Some(sub_service.to_string())));
} else {
// Regular parent service: service_nginx_status -> ("nginx", None)
return Some((service_part.to_string(), None));
}
}
}
None
}
/// Format service info for display
fn format_service_info(&self, name: &str, info: &ServiceInfo) -> String {
let status_icon = match info.widget_status {
Status::Ok => "",
Status::Warning => "⚠️",
Status::Critical => "",
Status::Unknown => "",
};
/// Format disk size with appropriate units (kB/MB/GB)
fn format_disk_size(size_gb: f32) -> String {
let size_mb = size_gb * 1024.0; // Convert GB to MB
let memory_str = if let Some(memory) = info.memory_mb {
format!(" Mem:{:.1}MB", memory)
if size_mb >= 1024.0 {
// Show as GB
format!("{:.1}GB", size_gb)
} else if size_mb >= 1.0 {
// Show as MB
format!("{:.0}MB", size_mb)
} else if size_mb >= 0.001 {
// Convert to kB
let size_kb = size_mb * 1024.0;
format!("{:.0}kB", size_kb)
} else {
"".to_string()
};
let disk_str = if let Some(disk) = info.disk_gb {
format!(" Disk:{:.1}GB", disk)
} else {
"".to_string()
};
format!("{} {} ({}){}{}", status_icon, name, info.status, memory_str, disk_str)
// Show very small sizes as bytes
let size_bytes = size_mb * 1024.0 * 1024.0;
format!("{:.0}B", size_bytes)
}
}
/// Format service info in clean service list format
fn format_btop_process_line(&self, name: &str, info: &ServiceInfo, _index: usize) -> String {
/// Format parent service line - returns text without icon for span formatting
fn format_parent_service_line(&self, name: &str, info: &ServiceInfo) -> String {
let memory_str = info.memory_mb.map_or("0M".to_string(), |m| format!("{:.0}M", m));
let disk_str = info.disk_gb.map_or("0G".to_string(), |d| format!("{:.1}G", d));
let disk_str = info.disk_gb.map_or("0".to_string(), |d| Self::format_disk_size(d));
// Truncate long service names to fit layout
let short_name = if name.len() > 23 {
format!("{}...", &name[..20])
// Truncate long service names to fit layout (account for icon space)
let short_name = if name.len() > 22 {
format!("{}...", &name[..19])
} else {
name.to_string()
};
// Status with color indicator
// Parent services always show active/inactive status
let status_str = match info.widget_status {
Status::Ok => "active",
Status::Warning => "⚠️ inactive",
Status::Critical => "failed",
Status::Unknown => "unknown",
Status::Ok => "active".to_string(),
Status::Warning => "inactive".to_string(),
Status::Critical => "failed".to_string(),
Status::Unknown => "unknown".to_string(),
};
format!("{:<25} {:<10} {:<8} {:<8}",
format!("{:<24} {:<10} {:<8} {:<8}",
short_name,
status_str,
memory_str,
disk_str)
}
/// Format sub-service line (indented, no memory/disk columns) - returns text without icon for span formatting
fn format_sub_service_line(&self, name: &str, info: &ServiceInfo) -> String {
// Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 {
format!("{}...", &name[..15])
} else {
name.to_string()
};
// Sub-services show latency if available, otherwise status
let status_str = if let Some(latency) = info.latency_ms {
if latency < 0.0 {
"timeout".to_string()
} else {
format!("{:.0}ms", latency)
}
} else {
match info.widget_status {
Status::Ok => "active".to_string(),
Status::Warning => "inactive".to_string(),
Status::Critical => "failed".to_string(),
Status::Unknown => "unknown".to_string(),
}
};
// Indent sub-services with " ├─ " prefix (no memory/disk columns)
format!(" ├─ {:<20} {:<10}",
short_name,
status_str)
}
/// Create spans for sub-service with icon next to name
fn create_sub_service_spans(&self, name: &str, info: &ServiceInfo) -> Vec<ratatui::text::Span<'static>> {
// Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 {
format!("{}...", &name[..15])
} else {
name.to_string()
};
// Sub-services show latency if available, otherwise status
let status_str = if let Some(latency) = info.latency_ms {
if latency < 0.0 {
"timeout".to_string()
} else {
format!("{:.0}ms", latency)
}
} else {
match info.widget_status {
Status::Ok => "active".to_string(),
Status::Warning => "inactive".to_string(),
Status::Critical => "failed".to_string(),
Status::Unknown => "unknown".to_string(),
}
};
let status_color = match info.widget_status {
Status::Ok => Theme::success(),
Status::Warning => Theme::warning(),
Status::Critical => Theme::error(),
Status::Unknown => Theme::muted_text(),
};
let icon = StatusIcons::get_icon(info.widget_status);
vec![
// Indentation and tree prefix
ratatui::text::Span::styled(
" ├─ ".to_string(),
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
),
// Status icon
ratatui::text::Span::styled(
format!("{} ", icon),
Style::default().fg(status_color).bg(Theme::background())
),
// Service name
ratatui::text::Span::styled(
format!("{:<18} ", short_name),
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
),
// Status/latency text
ratatui::text::Span::styled(
status_str,
Style::default().fg(Theme::secondary_text()).bg(Theme::background())
),
]
}
}
impl Widget for ServicesWidget {
@ -116,73 +215,180 @@ impl Widget for ServicesWidget {
// Process individual service metrics
for metric in metrics {
if let Some(service_name) = Self::extract_service_name(&metric.name) {
let service_info = self.services.entry(service_name).or_insert(ServiceInfo {
status: "unknown".to_string(),
memory_mb: None,
disk_gb: None,
widget_status: Status::Unknown,
});
if metric.name.ends_with("_status") {
service_info.status = metric.value.as_string();
service_info.widget_status = metric.status;
} else if metric.name.ends_with("_memory_mb") {
if let Some(memory) = metric.value.as_f32() {
service_info.memory_mb = Some(memory);
if let Some((parent_service, sub_service)) = Self::extract_service_info(&metric.name) {
match sub_service {
None => {
// Parent service metric
let service_info = self.parent_services.entry(parent_service).or_insert(ServiceInfo {
status: "unknown".to_string(),
memory_mb: None,
disk_gb: None,
latency_ms: None,
widget_status: Status::Unknown,
});
if metric.name.ends_with("_status") {
service_info.status = metric.value.as_string();
service_info.widget_status = metric.status;
} else if metric.name.ends_with("_memory_mb") {
if let Some(memory) = metric.value.as_f32() {
service_info.memory_mb = Some(memory);
}
} else if metric.name.ends_with("_disk_gb") {
if let Some(disk) = metric.value.as_f32() {
service_info.disk_gb = Some(disk);
}
}
}
} else if metric.name.ends_with("_disk_gb") {
if let Some(disk) = metric.value.as_f32() {
service_info.disk_gb = Some(disk);
Some(sub_name) => {
// Sub-service metric
let sub_service_list = self.sub_services.entry(parent_service).or_insert_with(Vec::new);
// Find existing sub-service or create new one
let sub_service_info = if let Some(pos) = sub_service_list.iter().position(|(name, _)| name == &sub_name) {
&mut sub_service_list[pos].1
} else {
sub_service_list.push((sub_name.clone(), ServiceInfo {
status: "unknown".to_string(),
memory_mb: None,
disk_gb: None,
latency_ms: None,
widget_status: Status::Unknown,
}));
&mut sub_service_list.last_mut().unwrap().1
};
if metric.name.ends_with("_status") {
sub_service_info.status = metric.value.as_string();
sub_service_info.widget_status = metric.status;
} else if metric.name.ends_with("_memory_mb") {
if let Some(memory) = metric.value.as_f32() {
sub_service_info.memory_mb = Some(memory);
}
} else if metric.name.ends_with("_disk_gb") {
if let Some(disk) = metric.value.as_f32() {
sub_service_info.disk_gb = Some(disk);
}
} else if metric.name.ends_with("_latency_ms") {
if let Some(latency) = metric.value.as_f32() {
sub_service_info.latency_ms = Some(latency);
sub_service_info.widget_status = metric.status;
}
}
}
}
}
}
// Aggregate status from all services
let statuses: Vec<Status> = self.services.values()
.map(|info| info.widget_status)
.collect();
// Aggregate status from all parent and sub-services
let mut all_statuses = Vec::new();
self.status = if statuses.is_empty() {
// Add parent service statuses
all_statuses.extend(self.parent_services.values().map(|info| info.widget_status));
// Add sub-service statuses
for sub_list in self.sub_services.values() {
all_statuses.extend(sub_list.iter().map(|(_, info)| info.widget_status));
}
self.status = if all_statuses.is_empty() {
Status::Unknown
} else {
Status::aggregate(&statuses)
Status::aggregate(&all_statuses)
};
self.has_data = !self.services.is_empty();
self.has_data = !self.parent_services.is_empty() || !self.sub_services.is_empty();
debug!("Services widget updated: {} services, status={:?}",
self.services.len(), self.status);
debug!("Services widget updated: {} parent services, {} sub-service groups, status={:?}",
self.parent_services.len(), self.sub_services.len(), self.status);
}
fn render(&mut self, frame: &mut Frame, area: Rect) {
let services_block = Block::default().title("services").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
let services_block = Components::widget_block("services");
let inner_area = services_block.inner(area);
frame.render_widget(services_block, area);
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Min(0)]).split(inner_area);
let header = format!("{:<25} {:<10} {:<8} {:<8}", "Service:", "Status:", "MemB", "DiskGB");
let header_para = Paragraph::new(header).style(Style::default().fg(Theme::muted_text()).bg(Theme::background()));
let content_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(inner_area);
// Header
let header = format!("{:<25} {:<10} {:<8} {:<8}", "Service:", "Status:", "RAM:", "Disk:");
let header_para = Paragraph::new(header).style(Typography::muted());
frame.render_widget(header_para, content_chunks[0]);
if self.services.is_empty() { let empty_text = Paragraph::new("No process data").style(Style::default().fg(Theme::muted_text()).bg(Theme::background())); frame.render_widget(empty_text, content_chunks[1]); return; }
let mut services: Vec<_> = self.services.iter().collect();
services.sort_by(|(_, a), (_, b)| b.memory_mb.unwrap_or(0.0).partial_cmp(&a.memory_mb.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));
let available_lines = content_chunks[1].height as usize;
let service_chunks = Layout::default().direction(Direction::Vertical).constraints(vec![Constraint::Length(1); available_lines.min(services.len())]).split(content_chunks[1]);
for (i, (name, info)) in services.iter().take(available_lines).enumerate() {
let service_line = self.format_btop_process_line(name, info, i);
let color = match info.widget_status { Status::Ok => Theme::primary_text(), Status::Warning => Theme::warning(), Status::Critical => Theme::error(), Status::Unknown => Theme::muted_text(), };
let service_para = Paragraph::new(service_line).style(Style::default().fg(color).bg(Theme::background()));
frame.render_widget(service_para, service_chunks[i]);
// Check if we have any services to display
if self.parent_services.is_empty() && self.sub_services.is_empty() {
let empty_text = Paragraph::new("No process data").style(Typography::muted());
frame.render_widget(empty_text, content_chunks[1]);
return;
}
// Build hierarchical service list for display
let mut display_lines = Vec::new();
// Sort parent services alphabetically for consistent order
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
for (parent_name, parent_info) in parent_services {
// Add parent service line
let parent_line = self.format_parent_service_line(parent_name, parent_info);
display_lines.push((parent_line, parent_info.widget_status, false, None)); // false = not sub-service
// Add sub-services for this parent (if any)
if let Some(sub_list) = self.sub_services.get(parent_name) {
// Sort sub-services by name for consistent display
let mut sorted_subs = sub_list.clone();
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
for (sub_name, sub_info) in sorted_subs {
// Store sub-service info for custom span rendering
display_lines.push((sub_name.clone(), sub_info.widget_status, true, Some(sub_info.clone()))); // true = sub-service
}
}
}
// Render all lines within available space
let available_lines = content_chunks[1].height as usize;
let lines_to_show = available_lines.min(display_lines.len());
if lines_to_show > 0 {
let service_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); lines_to_show])
.split(content_chunks[1]);
for (i, (line_text, line_status, is_sub, sub_info)) in display_lines.iter().take(lines_to_show).enumerate() {
let spans = if *is_sub && sub_info.is_some() {
// Use custom sub-service span creation
self.create_sub_service_spans(line_text, sub_info.as_ref().unwrap())
} else {
// Use regular status spans for parent services
StatusIcons::create_status_spans(*line_status, line_text)
};
let service_para = Paragraph::new(ratatui::text::Line::from(spans));
frame.render_widget(service_para, service_chunks[i]);
}
}
// Show indicator if there are more services than we can display
if display_lines.len() > available_lines {
let more_count = display_lines.len() - available_lines;
if available_lines > 0 {
let last_line_area = Rect {
x: content_chunks[1].x,
y: content_chunks[1].y + (available_lines - 1) as u16,
width: content_chunks[1].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);
}
}
}
fn get_name(&self) -> &str {
"Services"
}
fn has_data(&self) -> bool {
self.has_data
}
}