Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b11db990c | |||
| 67b59e9551 | |||
| da37e28b6a | |||
| d89b3ac881 | |||
| 7f26991609 | |||
| 75ec190b93 | |||
| eb892096d9 |
@@ -325,6 +325,12 @@ Storage:
|
||||
● nvme0n1 T: 25C W: 4%
|
||||
├─ ● /: 55% 250.5GB/456.4GB
|
||||
└─ ● /boot: 26% 0.3GB/1.0GB
|
||||
|
||||
Backup:
|
||||
● WD-WCC7K1234567 T: 32°C W: 12%
|
||||
├─ Last: 2h ago (12.3GB)
|
||||
├─ Next: in 22h
|
||||
└─ ● Usage: 45% 678GB/1.5TB
|
||||
```
|
||||
|
||||
## Important Communication Guidelines
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.142"
|
||||
version = "0.1.150"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -301,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.142"
|
||||
version = "0.1.150"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -324,7 +324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.142"
|
||||
version = "0.1.150"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.144"
|
||||
version = "0.1.151"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use cm_dashboard_shared::{AgentData, BackupData};
|
||||
use chrono::{NaiveDateTime, DateTime};
|
||||
use cm_dashboard_shared::{AgentData, BackupData, BackupDiskData};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tracing::debug;
|
||||
|
||||
use super::{Collector, CollectorError};
|
||||
|
||||
/// Backup collector that reads backup status from JSON files with structured data output
|
||||
/// Backup collector that reads backup status from TOML files with structured data output
|
||||
pub struct BackupCollector {
|
||||
/// Path to backup status file
|
||||
status_file_path: String,
|
||||
@@ -16,12 +18,12 @@ pub struct BackupCollector {
|
||||
impl BackupCollector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status_file_path: "/var/lib/backup/status.json".to_string(),
|
||||
status_file_path: "/var/lib/backup/backup-status.toml".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read backup status from JSON file
|
||||
async fn read_backup_status(&self) -> Result<Option<BackupStatus>, CollectorError> {
|
||||
/// Read backup status from TOML file
|
||||
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
||||
if !Path::new(&self.status_file_path).exists() {
|
||||
debug!("Backup status file not found: {}", self.status_file_path);
|
||||
return Ok(None);
|
||||
@@ -33,24 +35,57 @@ impl BackupCollector {
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
|
||||
let status: BackupStatus = serde_json::from_str(&content)
|
||||
let status: BackupStatusToml = toml::from_str(&content)
|
||||
.map_err(|e| CollectorError::Parse {
|
||||
value: content.clone(),
|
||||
error: format!("Failed to parse backup status JSON: {}", e),
|
||||
error: format!("Failed to parse backup status TOML: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(Some(status))
|
||||
}
|
||||
|
||||
/// Convert BackupStatus to BackupData and populate AgentData
|
||||
/// Convert BackupStatusToml to BackupData and populate AgentData
|
||||
async fn populate_backup_data(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
if let Some(backup_status) = self.read_backup_status().await? {
|
||||
// Use raw start_time string from TOML
|
||||
|
||||
// Extract disk information
|
||||
let repository_disk = if let Some(disk_space) = &backup_status.disk_space {
|
||||
Some(BackupDiskData {
|
||||
serial: backup_status.disk_serial_number.clone().unwrap_or_else(|| "Unknown".to_string()),
|
||||
usage_percent: disk_space.usage_percent as f32,
|
||||
used_gb: disk_space.used_gb as f32,
|
||||
total_gb: disk_space.total_gb as f32,
|
||||
wear_percent: backup_status.disk_wear_percent,
|
||||
temperature_celsius: None, // Not available in current TOML
|
||||
})
|
||||
} else if let Some(serial) = &backup_status.disk_serial_number {
|
||||
// Fallback: create minimal disk info if we have serial but no disk_space
|
||||
Some(BackupDiskData {
|
||||
serial: serial.clone(),
|
||||
usage_percent: 0.0,
|
||||
used_gb: 0.0,
|
||||
total_gb: 0.0,
|
||||
wear_percent: backup_status.disk_wear_percent,
|
||||
temperature_celsius: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Calculate total repository size from services
|
||||
let total_size_gb = backup_status.services
|
||||
.values()
|
||||
.map(|service| service.repo_size_bytes as f32 / (1024.0 * 1024.0 * 1024.0))
|
||||
.sum::<f32>();
|
||||
|
||||
let backup_data = BackupData {
|
||||
status: backup_status.status,
|
||||
last_run: Some(backup_status.last_run),
|
||||
next_scheduled: Some(backup_status.next_scheduled),
|
||||
total_size_gb: Some(backup_status.total_size_gb),
|
||||
repository_health: Some(backup_status.repository_health),
|
||||
total_size_gb: Some(total_size_gb),
|
||||
repository_health: Some("ok".to_string()), // Derive from status if needed
|
||||
repository_disk,
|
||||
last_backup_size_gb: None, // Not available in current TOML format
|
||||
start_time_raw: Some(backup_status.start_time),
|
||||
};
|
||||
|
||||
agent_data.backup = backup_data;
|
||||
@@ -58,10 +93,11 @@ impl BackupCollector {
|
||||
// No backup status available - set default values
|
||||
agent_data.backup = BackupData {
|
||||
status: "unavailable".to_string(),
|
||||
last_run: None,
|
||||
next_scheduled: None,
|
||||
total_size_gb: None,
|
||||
repository_health: None,
|
||||
repository_disk: None,
|
||||
last_backup_size_gb: None,
|
||||
start_time_raw: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,12 +113,38 @@ impl Collector for BackupCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backup status structure from JSON file
|
||||
/// TOML structure for backup status file
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BackupStatus {
|
||||
pub status: String, // "completed", "running", "failed", etc.
|
||||
pub last_run: u64, // Unix timestamp
|
||||
pub next_scheduled: u64, // Unix timestamp
|
||||
pub total_size_gb: f32, // Total backup size in GB
|
||||
pub repository_health: String, // "ok", "warning", "error"
|
||||
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 disk_wear_percent: Option<f32>,
|
||||
pub services: HashMap<String, ServiceStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ServiceStatus {
|
||||
pub status: String,
|
||||
pub exit_code: i64,
|
||||
pub repo_path: String,
|
||||
pub archive_count: i64,
|
||||
pub repo_size_bytes: u64,
|
||||
}
|
||||
@@ -50,6 +50,7 @@ struct MergerfsPool {
|
||||
#[derive(Debug, Clone)]
|
||||
struct PoolDrive {
|
||||
name: String, // Drive name
|
||||
mount_point: String, // e.g., "/mnt/disk1"
|
||||
temperature_celsius: Option<f32>, // Drive temperature
|
||||
}
|
||||
|
||||
@@ -198,16 +199,80 @@ impl DiskCollector {
|
||||
}
|
||||
|
||||
/// Detect MergerFS pools from mount data
|
||||
fn detect_mergerfs_pools(&self, _filesystem_usage: &HashMap<String, (u64, u64)>) -> anyhow::Result<Vec<MergerfsPool>> {
|
||||
let pools = Vec::new();
|
||||
fn detect_mergerfs_pools(&self, filesystem_usage: &HashMap<String, (u64, u64)>) -> anyhow::Result<Vec<MergerfsPool>> {
|
||||
let mounts_content = std::fs::read_to_string("/proc/mounts")
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read /proc/mounts: {}", e))?;
|
||||
let mut pools = Vec::new();
|
||||
|
||||
// For now, return empty pools - full mergerfs detection would require parsing /proc/mounts for fuse.mergerfs
|
||||
// This ensures we don't break existing functionality
|
||||
for line in mounts_content.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 && parts[2] == "fuse.mergerfs" {
|
||||
let mount_point = parts[1].to_string();
|
||||
let device_sources = parts[0]; // e.g., "/mnt/disk1:/mnt/disk2"
|
||||
|
||||
// Get pool usage
|
||||
let (total_bytes, used_bytes) = filesystem_usage.get(&mount_point)
|
||||
.copied()
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
// Extract pool name from mount point (e.g., "/srv/media" -> "srv_media")
|
||||
let pool_name = if mount_point == "/" {
|
||||
"root".to_string()
|
||||
} else {
|
||||
mount_point.trim_start_matches('/').replace('/', "_")
|
||||
};
|
||||
|
||||
if pool_name.is_empty() {
|
||||
debug!("Skipping mergerfs pool with empty name: {}", mount_point);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse member paths - handle both full paths and numeric references
|
||||
let raw_paths: Vec<String> = device_sources
|
||||
.split(':')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// Convert numeric references to actual mount points if needed
|
||||
let member_paths = if raw_paths.iter().any(|path| !path.starts_with('/')) {
|
||||
// Handle numeric format like "1:2" by finding corresponding /mnt/disk* paths
|
||||
self.resolve_numeric_mergerfs_paths(&raw_paths)?
|
||||
} else {
|
||||
// Already full paths
|
||||
raw_paths
|
||||
};
|
||||
|
||||
// For SnapRAID setups, include parity drives that are related to this pool's data drives
|
||||
let mut all_member_paths = member_paths.clone();
|
||||
let related_parity_paths = self.discover_related_parity_drives(&member_paths)?;
|
||||
all_member_paths.extend(related_parity_paths);
|
||||
|
||||
// Categorize as data vs parity drives
|
||||
let (data_drives, parity_drives) = match self.categorize_pool_drives(&all_member_paths) {
|
||||
Ok(drives) => drives,
|
||||
Err(e) => {
|
||||
debug!("Failed to categorize drives for pool {}: {}. Skipping.", mount_point, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
pools.push(MergerfsPool {
|
||||
name: pool_name,
|
||||
mount_point,
|
||||
total_bytes,
|
||||
used_bytes,
|
||||
data_drives,
|
||||
parity_drives,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Found {} mergerfs pools", pools.len());
|
||||
Ok(pools)
|
||||
}
|
||||
|
||||
/// Group filesystems by physical drive (excluding mergerfs members)
|
||||
/// Group filesystems by physical drive (excluding mergerfs members) - exact old logic
|
||||
fn group_by_physical_drive(
|
||||
&self,
|
||||
mount_devices: &HashMap<String, String>,
|
||||
@@ -216,14 +281,14 @@ impl DiskCollector {
|
||||
) -> anyhow::Result<Vec<PhysicalDrive>> {
|
||||
let mut drive_groups: HashMap<String, Vec<Filesystem>> = HashMap::new();
|
||||
|
||||
// Get all mergerfs member paths to exclude them
|
||||
// Get all mergerfs member paths to exclude them - exactly like old code
|
||||
let mut mergerfs_members = std::collections::HashSet::new();
|
||||
for pool in mergerfs_pools {
|
||||
for drive in &pool.data_drives {
|
||||
mergerfs_members.insert(drive.name.clone());
|
||||
mergerfs_members.insert(drive.mount_point.clone());
|
||||
}
|
||||
for drive in &pool.parity_drives {
|
||||
mergerfs_members.insert(drive.name.clone());
|
||||
mergerfs_members.insert(drive.mount_point.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,28 +509,23 @@ impl DiskCollector {
|
||||
}
|
||||
|
||||
/// Populate pools data into AgentData
|
||||
fn populate_pools_data(&self, mergerfs_pools: &[MergerfsPool], _smart_data: &HashMap<String, SmartData>, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
fn populate_pools_data(&self, mergerfs_pools: &[MergerfsPool], smart_data: &HashMap<String, SmartData>, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
for pool in mergerfs_pools {
|
||||
// Calculate pool health based on member drive health
|
||||
let (pool_health, data_drive_data, parity_drive_data) = self.calculate_pool_health(pool, smart_data);
|
||||
|
||||
let pool_data = PoolData {
|
||||
name: pool.name.clone(),
|
||||
mount: pool.mount_point.clone(),
|
||||
pool_type: "mergerfs".to_string(),
|
||||
health: "healthy".to_string(), // TODO: Calculate based on member drives
|
||||
usage_percent: (pool.used_bytes as f32 / pool.total_bytes as f32) * 100.0,
|
||||
pool_type: format!("mergerfs ({}+{})", pool.data_drives.len(), pool.parity_drives.len()),
|
||||
health: pool_health,
|
||||
usage_percent: if pool.total_bytes > 0 {
|
||||
(pool.used_bytes as f32 / pool.total_bytes as f32) * 100.0
|
||||
} else { 0.0 },
|
||||
used_gb: pool.used_bytes as f32 / (1024.0 * 1024.0 * 1024.0),
|
||||
total_gb: pool.total_bytes as f32 / (1024.0 * 1024.0 * 1024.0),
|
||||
data_drives: pool.data_drives.iter().map(|d| cm_dashboard_shared::PoolDriveData {
|
||||
name: d.name.clone(),
|
||||
temperature_celsius: d.temperature_celsius,
|
||||
health: "unknown".to_string(),
|
||||
wear_percent: None,
|
||||
}).collect(),
|
||||
parity_drives: pool.parity_drives.iter().map(|d| cm_dashboard_shared::PoolDriveData {
|
||||
name: d.name.clone(),
|
||||
temperature_celsius: d.temperature_celsius,
|
||||
health: "unknown".to_string(),
|
||||
wear_percent: None,
|
||||
}).collect(),
|
||||
data_drives: data_drive_data,
|
||||
parity_drives: parity_drive_data,
|
||||
};
|
||||
|
||||
agent_data.system.storage.pools.push(pool_data);
|
||||
@@ -474,6 +534,55 @@ impl DiskCollector {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate pool health based on member drive status
|
||||
fn calculate_pool_health(&self, pool: &MergerfsPool, smart_data: &HashMap<String, SmartData>) -> (String, Vec<cm_dashboard_shared::PoolDriveData>, Vec<cm_dashboard_shared::PoolDriveData>) {
|
||||
let mut failed_data = 0;
|
||||
let mut failed_parity = 0;
|
||||
|
||||
// Process data drives
|
||||
let data_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.data_drives.iter().map(|d| {
|
||||
let smart = smart_data.get(&d.name);
|
||||
let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string());
|
||||
|
||||
if health == "FAILED" {
|
||||
failed_data += 1;
|
||||
}
|
||||
|
||||
cm_dashboard_shared::PoolDriveData {
|
||||
name: d.name.clone(),
|
||||
temperature_celsius: smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius),
|
||||
health,
|
||||
wear_percent: smart.and_then(|s| s.wear_percent),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Process parity drives
|
||||
let parity_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.parity_drives.iter().map(|d| {
|
||||
let smart = smart_data.get(&d.name);
|
||||
let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string());
|
||||
|
||||
if health == "FAILED" {
|
||||
failed_parity += 1;
|
||||
}
|
||||
|
||||
cm_dashboard_shared::PoolDriveData {
|
||||
name: d.name.clone(),
|
||||
temperature_celsius: smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius),
|
||||
health,
|
||||
wear_percent: smart.and_then(|s| s.wear_percent),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Calculate overall pool health
|
||||
let pool_health = match (failed_data, failed_parity) {
|
||||
(0, 0) => "healthy".to_string(),
|
||||
(1, 0) | (0, 1) => "degraded".to_string(), // One failure is degraded but recoverable
|
||||
_ => "critical".to_string(), // Multiple failures are critical
|
||||
};
|
||||
|
||||
(pool_health, data_drive_data, parity_drive_data)
|
||||
}
|
||||
|
||||
/// Calculate filesystem usage status
|
||||
fn calculate_filesystem_usage_status(&self, usage_percent: f32) -> Status {
|
||||
// Use standard filesystem warning/critical thresholds
|
||||
@@ -499,6 +608,134 @@ impl DiskCollector {
|
||||
_ => Status::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover parity drives that are related to the given data drives
|
||||
fn discover_related_parity_drives(&self, data_drives: &[String]) -> anyhow::Result<Vec<String>> {
|
||||
let mount_devices = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.get_mount_devices())
|
||||
}).map_err(|e| anyhow::anyhow!("Failed to get mount devices: {}", e))?;
|
||||
|
||||
let mut related_parity = Vec::new();
|
||||
|
||||
// Find parity drives that share the same parent directory as the data drives
|
||||
for data_path in data_drives {
|
||||
if let Some(parent_dir) = self.get_parent_directory(data_path) {
|
||||
// Look for parity drives in the same parent directory
|
||||
for (mount_point, _device) in &mount_devices {
|
||||
if mount_point.contains("parity") && mount_point.starts_with(&parent_dir) {
|
||||
if !related_parity.contains(mount_point) {
|
||||
related_parity.push(mount_point.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(related_parity)
|
||||
}
|
||||
|
||||
/// Get parent directory of a mount path (e.g., "/mnt/disk1" -> "/mnt")
|
||||
fn get_parent_directory(&self, path: &str) -> Option<String> {
|
||||
if let Some(last_slash) = path.rfind('/') {
|
||||
if last_slash > 0 {
|
||||
return Some(path[..last_slash].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Categorize pool member drives as data vs parity
|
||||
fn categorize_pool_drives(&self, member_paths: &[String]) -> anyhow::Result<(Vec<PoolDrive>, Vec<PoolDrive>)> {
|
||||
let mut data_drives = Vec::new();
|
||||
let mut parity_drives = Vec::new();
|
||||
|
||||
for path in member_paths {
|
||||
let drive_info = self.get_drive_info_for_path(path)?;
|
||||
|
||||
// Heuristic: if path contains "parity", it's parity
|
||||
if path.to_lowercase().contains("parity") {
|
||||
parity_drives.push(drive_info);
|
||||
} else {
|
||||
data_drives.push(drive_info);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((data_drives, parity_drives))
|
||||
}
|
||||
|
||||
/// Get drive information for a mount path
|
||||
fn get_drive_info_for_path(&self, path: &str) -> anyhow::Result<PoolDrive> {
|
||||
// Use lsblk to find the backing device
|
||||
let output = Command::new("lsblk")
|
||||
.args(&["-rn", "-o", "NAME,MOUNTPOINT"])
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run lsblk: {}", e))?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
let mut device = String::new();
|
||||
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 && parts[1] == path {
|
||||
device = parts[0].to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if device.is_empty() {
|
||||
return Err(anyhow::anyhow!("Could not find device for path {}", path));
|
||||
}
|
||||
|
||||
// Extract base device name (e.g., "sda1" -> "sda")
|
||||
let base_device = self.extract_base_device(&format!("/dev/{}", device));
|
||||
|
||||
// Get temperature from SMART data if available
|
||||
let temperature = if let Ok(smart_data) = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.get_smart_data(&base_device))
|
||||
}) {
|
||||
smart_data.temperature_celsius
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(PoolDrive {
|
||||
name: base_device,
|
||||
mount_point: path.to_string(),
|
||||
temperature_celsius: temperature,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve numeric mergerfs references like "1:2" to actual mount paths
|
||||
fn resolve_numeric_mergerfs_paths(&self, numeric_refs: &[String]) -> anyhow::Result<Vec<String>> {
|
||||
let mut resolved_paths = Vec::new();
|
||||
|
||||
// Get all mount points that look like /mnt/disk* or /mnt/parity*
|
||||
let mount_devices = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.get_mount_devices())
|
||||
}).map_err(|e| anyhow::anyhow!("Failed to get mount devices: {}", e))?;
|
||||
|
||||
let mut disk_mounts: Vec<String> = mount_devices.keys()
|
||||
.filter(|path| path.starts_with("/mnt/disk") || path.starts_with("/mnt/parity"))
|
||||
.cloned()
|
||||
.collect();
|
||||
disk_mounts.sort(); // Ensure consistent ordering
|
||||
|
||||
for num_ref in numeric_refs {
|
||||
if let Ok(index) = num_ref.parse::<usize>() {
|
||||
// Convert 1-based index to 0-based
|
||||
if index > 0 && index <= disk_mounts.len() {
|
||||
resolved_paths.push(disk_mounts[index - 1].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't resolve, return the original paths
|
||||
if resolved_paths.is_empty() {
|
||||
resolved_paths = numeric_refs.to_vec();
|
||||
}
|
||||
|
||||
Ok(resolved_paths)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use cm_dashboard_shared::{AgentData, ServiceData, Status};
|
||||
use cm_dashboard_shared::{AgentData, ServiceData, SubServiceData, SubServiceMetric, Status};
|
||||
use std::process::Command;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Instant;
|
||||
@@ -24,6 +24,8 @@ struct ServiceCacheState {
|
||||
last_collection: Option<Instant>,
|
||||
/// Cached service data
|
||||
services: Vec<ServiceInfo>,
|
||||
/// Cached complete service data with sub-services
|
||||
cached_service_data: Vec<ServiceData>,
|
||||
/// Interesting services to monitor (cached after discovery)
|
||||
monitored_services: Vec<String>,
|
||||
/// Cached service status information from discovery
|
||||
@@ -32,6 +34,12 @@ struct ServiceCacheState {
|
||||
last_discovery_time: Option<Instant>,
|
||||
/// How often to rediscover services (from config)
|
||||
discovery_interval_seconds: u64,
|
||||
/// Cached nginx site latency metrics
|
||||
nginx_site_metrics: Vec<(String, f32)>,
|
||||
/// Last time nginx sites were checked
|
||||
last_nginx_check_time: Option<Instant>,
|
||||
/// How often to check nginx site latency (configurable)
|
||||
nginx_check_interval_seconds: u64,
|
||||
}
|
||||
|
||||
/// Cached service status information from systemctl list-units
|
||||
@@ -56,10 +64,14 @@ impl SystemdCollector {
|
||||
let state = ServiceCacheState {
|
||||
last_collection: None,
|
||||
services: Vec::new(),
|
||||
cached_service_data: Vec::new(),
|
||||
monitored_services: Vec::new(),
|
||||
service_status_cache: std::collections::HashMap::new(),
|
||||
last_discovery_time: None,
|
||||
discovery_interval_seconds: config.interval_seconds,
|
||||
nginx_site_metrics: Vec::new(),
|
||||
last_nginx_check_time: None,
|
||||
nginx_check_interval_seconds: config.nginx_check_interval_seconds,
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -84,12 +96,55 @@ impl SystemdCollector {
|
||||
|
||||
// Collect service data for each monitored service
|
||||
let mut services = Vec::new();
|
||||
let mut complete_service_data = Vec::new();
|
||||
for service_name in &monitored_services {
|
||||
match self.get_service_status(service_name) {
|
||||
Ok((active_status, _detailed_info)) => {
|
||||
let memory_mb = self.get_service_memory_usage(service_name).await.unwrap_or(0.0);
|
||||
let disk_gb = self.get_service_disk_usage(service_name).await.unwrap_or(0.0);
|
||||
|
||||
let mut sub_services = Vec::new();
|
||||
|
||||
// Sub-service metrics for specific services (always include cached results)
|
||||
if service_name.contains("nginx") && active_status == "active" {
|
||||
let nginx_sites = self.get_nginx_site_metrics();
|
||||
for (site_name, latency_ms) in nginx_sites {
|
||||
let site_status = if latency_ms >= 0.0 && latency_ms < self.config.nginx_latency_critical_ms {
|
||||
"active"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
|
||||
let mut metrics = Vec::new();
|
||||
metrics.push(SubServiceMetric {
|
||||
label: "latency_ms".to_string(),
|
||||
value: latency_ms,
|
||||
unit: Some("ms".to_string()),
|
||||
});
|
||||
|
||||
sub_services.push(SubServiceData {
|
||||
name: site_name.clone(),
|
||||
service_status: self.calculate_service_status(&site_name, &site_status),
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if service_name.contains("docker") && active_status == "active" {
|
||||
let docker_containers = self.get_docker_containers();
|
||||
for (container_name, container_status) in docker_containers {
|
||||
// For now, docker containers have no additional metrics
|
||||
// Future: could add memory_mb, cpu_percent, restart_count, etc.
|
||||
let metrics = Vec::new();
|
||||
|
||||
sub_services.push(SubServiceData {
|
||||
name: container_name.clone(),
|
||||
service_status: self.calculate_service_status(&container_name, &container_status),
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let service_info = ServiceInfo {
|
||||
name: service_name.clone(),
|
||||
status: active_status.clone(),
|
||||
@@ -98,36 +153,19 @@ impl SystemdCollector {
|
||||
};
|
||||
services.push(service_info);
|
||||
|
||||
// Sub-service metrics for specific services
|
||||
if service_name.contains("nginx") && active_status == "active" {
|
||||
let nginx_sites = self.get_nginx_sites();
|
||||
for (site_name, latency_ms) in nginx_sites {
|
||||
let site_status = if latency_ms >= 0.0 && latency_ms < self.config.nginx_latency_critical_ms {
|
||||
"active"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
|
||||
services.push(ServiceInfo {
|
||||
name: site_name,
|
||||
status: site_status.to_string(),
|
||||
memory_mb: 0.0,
|
||||
disk_gb: latency_ms / 1000.0, // Store latency in disk_gb field as workaround
|
||||
});
|
||||
}
|
||||
}
|
||||
// Create complete service data
|
||||
let service_data = ServiceData {
|
||||
name: service_name.clone(),
|
||||
memory_mb,
|
||||
disk_gb,
|
||||
user_stopped: false, // TODO: Integrate with service tracker
|
||||
service_status: self.calculate_service_status(service_name, &active_status),
|
||||
sub_services,
|
||||
};
|
||||
|
||||
if service_name.contains("docker") && active_status == "active" {
|
||||
let docker_containers = self.get_docker_containers();
|
||||
for (container_name, container_status) in docker_containers {
|
||||
services.push(ServiceInfo {
|
||||
name: container_name,
|
||||
status: container_status,
|
||||
memory_mb: 0.0,
|
||||
disk_gb: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Add to AgentData and cache
|
||||
agent_data.services.push(service_data.clone());
|
||||
complete_service_data.push(service_data);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to get status for service {}: {}", service_name, e);
|
||||
@@ -139,19 +177,8 @@ impl SystemdCollector {
|
||||
{
|
||||
let mut state = self.state.write().unwrap();
|
||||
state.last_collection = Some(start_time);
|
||||
state.services = services.clone();
|
||||
}
|
||||
|
||||
// Populate AgentData with service information
|
||||
for service in services {
|
||||
agent_data.services.push(ServiceData {
|
||||
name: service.name.clone(),
|
||||
status: service.status.clone(),
|
||||
memory_mb: service.memory_mb,
|
||||
disk_gb: service.disk_gb,
|
||||
user_stopped: false, // TODO: Integrate with service tracker
|
||||
service_status: self.calculate_service_status(&service.name, &service.status),
|
||||
});
|
||||
state.services = services;
|
||||
state.cached_service_data = complete_service_data;
|
||||
}
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
@@ -198,6 +225,31 @@ impl SystemdCollector {
|
||||
Ok(state.monitored_services.clone())
|
||||
}
|
||||
|
||||
/// Get nginx site metrics, checking them if cache is expired (like old working version)
|
||||
fn get_nginx_site_metrics(&self) -> Vec<(String, f32)> {
|
||||
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")) {
|
||||
let fresh_metrics = self.get_nginx_sites_internal();
|
||||
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_internal(&self) -> Result<(Vec<String>, std::collections::HashMap<String, ServiceStatusInfo>)> {
|
||||
// First: Get all service unit files
|
||||
@@ -400,8 +452,8 @@ impl SystemdCollector {
|
||||
|
||||
/// Get size of a directory in GB
|
||||
fn get_directory_size(&self, path: &str) -> Option<f32> {
|
||||
let output = Command::new("du")
|
||||
.args(&["-sb", path])
|
||||
let output = Command::new("sudo")
|
||||
.args(&["du", "-sb", path])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
@@ -431,6 +483,25 @@ impl SystemdCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get service memory usage (if available)
|
||||
fn get_service_memory(&self, service: &str) -> Option<f32> {
|
||||
let output = Command::new("systemctl")
|
||||
.args(&["show", &format!("{}.service", service), "--property=MemoryCurrent"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let output_str = String::from_utf8(output.stdout).ok()?;
|
||||
for line in output_str.lines() {
|
||||
if line.starts_with("MemoryCurrent=") {
|
||||
let memory_str = line.strip_prefix("MemoryCurrent=")?;
|
||||
if let Ok(memory_bytes) = memory_str.parse::<u64>() {
|
||||
return Some(memory_bytes as f32 / (1024.0 * 1024.0)); // Convert to MB
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate service status, taking user-stopped services into account
|
||||
fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status {
|
||||
match active_status.to_lowercase().as_str() {
|
||||
@@ -498,21 +569,32 @@ impl SystemdCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get nginx sites with latency checks
|
||||
fn get_nginx_sites(&self) -> Vec<(String, f32)> {
|
||||
/// Get cached complete service data with sub-services if available and fresh
|
||||
fn get_cached_complete_services(&self) -> Option<Vec<ServiceData>> {
|
||||
if !self.should_update_cache() {
|
||||
let state = self.state.read().unwrap();
|
||||
Some(state.cached_service_data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get nginx sites with latency checks (internal - no caching)
|
||||
fn get_nginx_sites_internal(&self) -> Vec<(String, f32)> {
|
||||
let mut sites = Vec::new();
|
||||
|
||||
// Discover nginx sites from configuration
|
||||
let discovered_sites = self.discover_nginx_sites();
|
||||
|
||||
// Always add all discovered sites, even if checks fail (like old version)
|
||||
for (site_name, url) in &discovered_sites {
|
||||
match self.check_site_latency(url) {
|
||||
Ok(latency_ms) => {
|
||||
sites.push((format!("nginx_{}", site_name), latency_ms));
|
||||
sites.push((site_name.clone(), latency_ms));
|
||||
}
|
||||
Err(_) => {
|
||||
// Site is unreachable - use -1.0 to indicate error
|
||||
sites.push((format!("nginx_{}", site_name), -1.0));
|
||||
// Site is unreachable - use -1.0 to indicate error (like old version)
|
||||
sites.push((site_name.clone(), -1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,24 +604,38 @@ impl SystemdCollector {
|
||||
|
||||
/// Discover nginx sites from configuration
|
||||
fn discover_nginx_sites(&self) -> Vec<(String, String)> {
|
||||
let mut sites = Vec::new();
|
||||
|
||||
// Try to get nginx config from systemd service definition (NixOS compatible)
|
||||
if let Some(config_content) = self.get_nginx_config_from_systemd() {
|
||||
sites.extend(self.parse_nginx_config_for_sites(&config_content));
|
||||
}
|
||||
|
||||
// Fallback: try standard nginx config paths
|
||||
if sites.is_empty() {
|
||||
for config_path in ["/etc/nginx/nginx.conf", "/usr/local/nginx/conf/nginx.conf"] {
|
||||
if let Ok(config_content) = std::fs::read_to_string(config_path) {
|
||||
sites.extend(self.parse_nginx_config_for_sites(&config_content));
|
||||
break;
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Fallback: get nginx config via nginx -T command
|
||||
fn get_nginx_config_via_command(&self) -> Option<String> {
|
||||
let output = Command::new("nginx")
|
||||
.args(&["-T"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
debug!("nginx -T failed");
|
||||
return None;
|
||||
}
|
||||
|
||||
sites
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Get nginx config from systemd service definition (NixOS compatible)
|
||||
@@ -572,89 +668,138 @@ impl SystemdCollector {
|
||||
}
|
||||
|
||||
/// Extract config path from ExecStart line
|
||||
fn extract_config_path_from_exec_start(&self, exec_start_line: &str) -> Option<String> {
|
||||
// Handle both traditional and NixOS systemd formats
|
||||
let parts: Vec<&str> = exec_start_line.split_whitespace().collect();
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if part == &"-c" && i + 1 < parts.len() {
|
||||
return Some(parts[i + 1].to_string());
|
||||
fn extract_config_path_from_exec_start(&self, exec_start: &str) -> Option<String> {
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Parse nginx config content to extract sites
|
||||
/// 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)> {
|
||||
let mut sites = Vec::new();
|
||||
let mut current_server_name: Option<String> = None;
|
||||
let mut current_listen_port: Option<u16> = None;
|
||||
let mut in_server_block = false;
|
||||
let lines: Vec<&str> = config_content.lines().collect();
|
||||
let mut i = 0;
|
||||
|
||||
for line in config_content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with("server {") {
|
||||
in_server_block = true;
|
||||
current_server_name = None;
|
||||
current_listen_port = None;
|
||||
} else if line == "}" && in_server_block {
|
||||
// End of server block - create site entry if we have both name and port
|
||||
if let (Some(name), Some(port)) = (¤t_server_name, ¤t_listen_port) {
|
||||
let url = format!("http://{}:{}", name, port);
|
||||
sites.push((name.clone(), url));
|
||||
debug!("Parsing nginx config with {} lines", lines.len());
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if line.starts_with("server") && line.contains("{") {
|
||||
if let Some(server_name) = self.parse_server_block(&lines, &mut i) {
|
||||
let url = format!("https://{}", server_name);
|
||||
sites.push((server_name.clone(), url));
|
||||
}
|
||||
in_server_block = false;
|
||||
} else if in_server_block {
|
||||
if line.starts_with("server_name ") {
|
||||
if let Some(name) = line.split_whitespace().nth(1) {
|
||||
current_server_name = Some(name.trim_end_matches(';').to_string());
|
||||
}
|
||||
} else if line.starts_with("listen ") {
|
||||
if let Some(listen_spec) = line.split_whitespace().nth(1) {
|
||||
let port_str = listen_spec.trim_end_matches(';').split(':').last().unwrap_or(listen_spec);
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
current_listen_port = Some(port);
|
||||
}
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
sites
|
||||
*start_index = i - 1;
|
||||
|
||||
if !server_names.is_empty() && !has_redirect {
|
||||
return Some(server_names[0].clone());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check site latency via HTTP request
|
||||
/// Check site latency using HTTP GET requests
|
||||
fn check_site_latency(&self, url: &str) -> Result<f32, Box<dyn std::error::Error>> {
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// Use curl for HTTP request with timeout
|
||||
let output = Command::new("curl")
|
||||
.args(&[
|
||||
"-s",
|
||||
"-o", "/dev/null",
|
||||
"-w", "%{http_code}",
|
||||
"--max-time", &self.config.http_timeout_seconds.to_string(),
|
||||
"--connect-timeout", &self.config.http_connect_timeout_seconds.to_string(),
|
||||
url
|
||||
])
|
||||
.output()?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let latency_ms = elapsed.as_secs_f32() * 1000.0;
|
||||
// Create HTTP client with timeouts from configuration
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(self.config.http_timeout_seconds))
|
||||
.connect_timeout(Duration::from_secs(self.config.http_connect_timeout_seconds))
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.build()?;
|
||||
|
||||
if output.status.success() {
|
||||
let http_code = String::from_utf8_lossy(&output.stdout);
|
||||
if http_code.starts_with("2") || http_code.starts_with("3") {
|
||||
Ok(latency_ms)
|
||||
} else {
|
||||
Err(format!("HTTP error: {}", http_code).into())
|
||||
}
|
||||
// Make GET request and measure latency
|
||||
let response = client.get(url).send()?;
|
||||
let latency = start.elapsed().as_millis() as f32;
|
||||
|
||||
// Check if response is successful (2xx or 3xx status codes)
|
||||
if response.status().is_success() || response.status().is_redirection() {
|
||||
Ok(latency)
|
||||
} else {
|
||||
Err("HTTP request failed".into())
|
||||
Err(format!(
|
||||
"HTTP request failed for {} with status: {}",
|
||||
url,
|
||||
response.status()
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,23 +807,40 @@ impl SystemdCollector {
|
||||
fn get_docker_containers(&self) -> Vec<(String, String)> {
|
||||
let mut containers = Vec::new();
|
||||
|
||||
// Check if docker is available
|
||||
let output = Command::new("docker")
|
||||
.args(&["ps", "--format", "{{.Names}}:{{.Status}}"])
|
||||
.args(&["ps", "--format", "{{.Names}},{{.Status}}"])
|
||||
.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() {
|
||||
if let Some((name, status)) = line.split_once(':') {
|
||||
let container_status = if status.contains("Up") {
|
||||
"active"
|
||||
} else {
|
||||
"inactive"
|
||||
};
|
||||
containers.push((format!("docker_{}", name), container_status.to_string()));
|
||||
}
|
||||
}
|
||||
let output = match output {
|
||||
Ok(out) if out.status.success() => out,
|
||||
_ => return containers, // Docker not available or failed
|
||||
};
|
||||
|
||||
let output_str = match String::from_utf8(output.stdout) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return containers,
|
||||
};
|
||||
|
||||
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 container_status = if status_str.contains("Up") {
|
||||
"active"
|
||||
} else if status_str.contains("Exited") {
|
||||
"warning" // Match original: Exited → Warning, not inactive
|
||||
} else {
|
||||
"failed" // Other states → failed
|
||||
};
|
||||
|
||||
containers.push((format!("docker_{}", container_name), container_status.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,18 +851,10 @@ impl SystemdCollector {
|
||||
#[async_trait]
|
||||
impl Collector for SystemdCollector {
|
||||
async fn collect_structured(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
|
||||
// Use cached data if available and fresh
|
||||
if let Some(cached_services) = self.get_cached_services() {
|
||||
debug!("Using cached systemd services data");
|
||||
for service in cached_services {
|
||||
agent_data.services.push(ServiceData {
|
||||
name: service.name.clone(),
|
||||
status: service.status.clone(),
|
||||
memory_mb: service.memory_mb,
|
||||
disk_gb: service.disk_gb,
|
||||
user_stopped: false, // TODO: Integrate with service tracker
|
||||
service_status: self.calculate_service_status(&service.name, &service.status),
|
||||
});
|
||||
// Use cached complete data if available and fresh
|
||||
if let Some(cached_complete_services) = self.get_cached_complete_services() {
|
||||
for service_data in cached_complete_services {
|
||||
agent_data.services.push(service_data);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.144"
|
||||
version = "0.1.151"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::config::DashboardConfig;
|
||||
use crate::metrics::MetricStore;
|
||||
use cm_dashboard_shared::Status;
|
||||
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
|
||||
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
||||
use widgets::{ServicesWidget, SystemWidget, Widget};
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ pub struct HostWidgets {
|
||||
pub system_widget: SystemWidget,
|
||||
/// Services widget state
|
||||
pub services_widget: ServicesWidget,
|
||||
/// Backup widget state
|
||||
pub backup_widget: BackupWidget,
|
||||
/// Last update time for this host
|
||||
pub last_update: Option<Instant>,
|
||||
}
|
||||
@@ -43,7 +41,6 @@ impl HostWidgets {
|
||||
Self {
|
||||
system_widget: SystemWidget::new(),
|
||||
services_widget: ServicesWidget::new(),
|
||||
backup_widget: BackupWidget::new(),
|
||||
last_update: None,
|
||||
}
|
||||
}
|
||||
@@ -112,7 +109,6 @@ impl TuiApp {
|
||||
// Update all widgets with structured data directly
|
||||
host_widgets.system_widget.update_from_agent_data(agent_data);
|
||||
host_widgets.services_widget.update_from_agent_data(agent_data);
|
||||
host_widgets.backup_widget.update_from_agent_data(agent_data);
|
||||
|
||||
host_widgets.last_update = Some(Instant::now());
|
||||
}
|
||||
@@ -469,40 +465,17 @@ impl TuiApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if backup panel should be shown
|
||||
let show_backup = if let Some(hostname) = self.current_host.clone() {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.backup_widget.has_data()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Left side: dynamic layout based on backup data availability
|
||||
let left_chunks = if show_backup {
|
||||
// Show both system and backup panels
|
||||
ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
|
||||
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
|
||||
])
|
||||
.split(content_chunks[0])
|
||||
} else {
|
||||
// Show only system panel (full height)
|
||||
ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(100)]) // System section takes full height
|
||||
.split(content_chunks[0])
|
||||
};
|
||||
// Left side: system panel only (full height)
|
||||
let left_chunks = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(100)]) // System section takes full height
|
||||
.split(content_chunks[0]);
|
||||
|
||||
// Render title bar
|
||||
self.render_btop_title(frame, main_chunks[0], metric_store);
|
||||
|
||||
// Render new panel layout
|
||||
// Render system panel
|
||||
self.render_system_panel(frame, left_chunks[0], metric_store);
|
||||
if show_backup && left_chunks.len() > 1 {
|
||||
self.render_backup_panel(frame, left_chunks[1]);
|
||||
}
|
||||
|
||||
// Render services widget for current host
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
@@ -669,17 +642,6 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Get current host widgets for backup widget
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.backup_widget.render(frame, inner_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render offline host message with wake-up option
|
||||
fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) {
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use super::Widget;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ui::theme::{StatusIcons, Typography};
|
||||
|
||||
/// Backup widget displaying backup status, services, and repository information
|
||||
#[derive(Clone)]
|
||||
pub struct BackupWidget {
|
||||
/// Overall backup status
|
||||
overall_status: Status,
|
||||
/// Last backup duration in seconds
|
||||
duration_seconds: Option<i64>,
|
||||
/// Last backup timestamp
|
||||
last_run_timestamp: Option<i64>,
|
||||
/// Total repository size in GB
|
||||
total_repo_size_gb: Option<f32>,
|
||||
/// Total disk space for backups in GB
|
||||
backup_disk_total_gb: Option<f32>,
|
||||
/// Used disk space for backups in GB
|
||||
backup_disk_used_gb: Option<f32>,
|
||||
/// Backup disk product name from SMART data
|
||||
backup_disk_product_name: Option<String>,
|
||||
/// Backup disk serial number from SMART data
|
||||
backup_disk_serial_number: Option<String>,
|
||||
/// Backup disk wear percentage from SMART data
|
||||
backup_disk_wear_percent: Option<f32>,
|
||||
/// 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,
|
||||
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_repo_size_gb: None,
|
||||
backup_disk_total_gb: None,
|
||||
backup_disk_used_gb: None,
|
||||
backup_disk_product_name: None,
|
||||
backup_disk_serial_number: None,
|
||||
backup_disk_wear_percent: None,
|
||||
service_metrics: Vec::new(),
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the backup widget has any data to display
|
||||
pub fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Format size with proper units (xxxkB/MB/GB/TB)
|
||||
fn format_size_with_proper_units(size_gb: f32) -> String {
|
||||
if size_gb >= 1000.0 {
|
||||
// TB range
|
||||
format!("{:.1}TB", size_gb / 1000.0)
|
||||
} else if size_gb >= 1.0 {
|
||||
// GB range
|
||||
format!("{:.1}GB", size_gb)
|
||||
} else if size_gb >= 0.001 {
|
||||
// MB range (size_gb * 1024 = MB)
|
||||
let size_mb = size_gb * 1024.0;
|
||||
format!("{:.1}MB", size_mb)
|
||||
} else if size_gb >= 0.000001 {
|
||||
// kB range (size_gb * 1024 * 1024 = kB)
|
||||
let size_kb = size_gb * 1024.0 * 1024.0;
|
||||
format!("{:.0}kB", size_kb)
|
||||
} else {
|
||||
// B range (size_gb * 1024^3 = bytes)
|
||||
let size_bytes = size_gb * 1024.0 * 1024.0 * 1024.0;
|
||||
format!("{:.0}B", size_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Extract service name from metric name (e.g., "backup_service_gitea_status" -> "gitea")
|
||||
#[allow(dead_code)]
|
||||
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("_archive_count") {
|
||||
Some(service_name.to_string())
|
||||
} else if let Some(service_name) = name_part.strip_suffix("_repo_size_gb") {
|
||||
Some(service_name.to_string())
|
||||
} else if let Some(service_name) = name_part.strip_suffix("_repo_path") {
|
||||
Some(service_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for BackupWidget {
|
||||
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||
self.has_data = true;
|
||||
|
||||
let backup = &agent_data.backup;
|
||||
self.overall_status = Status::Ok;
|
||||
|
||||
if let Some(size) = backup.total_size_gb {
|
||||
self.total_repo_size_gb = Some(size);
|
||||
}
|
||||
|
||||
if let Some(last_run) = backup.last_run {
|
||||
self.last_run_timestamp = Some(last_run as i64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
#[allow(dead_code)]
|
||||
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_repo_size_gb" => {
|
||||
self.total_repo_size_gb = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_total_gb" => {
|
||||
self.backup_disk_total_gb = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_used_gb" => {
|
||||
self.backup_disk_used_gb = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_product_name" => {
|
||||
self.backup_disk_product_name = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_disk_serial_number" => {
|
||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_disk_wear_percent" => {
|
||||
self.backup_disk_wear_percent = metric.value.as_f32();
|
||||
}
|
||||
_ => {
|
||||
// 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,
|
||||
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("_archive_count") {
|
||||
entry.archive_count = metric.value.as_i64();
|
||||
debug!(
|
||||
"Set archive_count for {}: {:?}",
|
||||
entry.name, entry.archive_count
|
||||
);
|
||||
} else if metric.name.ends_with("_repo_size_gb") {
|
||||
entry.repo_size_gb = metric.value.as_f32();
|
||||
debug!(
|
||||
"Set repo_size_gb for {}: {:?}",
|
||||
entry.name, entry.repo_size_gb
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"Could not extract service name from metric: {}",
|
||||
metric.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert service data to sorted vector
|
||||
let mut services: Vec<ServiceMetricData> = service_data.into_values().collect();
|
||||
services.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.service_metrics = services;
|
||||
|
||||
// Only show backup panel if we have meaningful backup data
|
||||
self.has_data = !metrics.is_empty() && (
|
||||
self.last_run_timestamp.is_some() ||
|
||||
self.total_repo_size_gb.is_some() ||
|
||||
!self.service_metrics.is_empty()
|
||||
);
|
||||
|
||||
debug!(
|
||||
"Backup widget updated: status={:?}, services={}, total_size={:?}GB",
|
||||
self.overall_status,
|
||||
self.service_metrics.len(),
|
||||
self.total_repo_size_gb
|
||||
);
|
||||
|
||||
// Debug individual service data
|
||||
for service in &self.service_metrics {
|
||||
debug!(
|
||||
"Service {}: status={:?}, archives={:?}, size={:?}GB",
|
||||
service.name, service.status, service.archive_count, service.repo_size_gb
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
/// Render backup widget
|
||||
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Latest backup section
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled("Latest backup:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
// Timestamp with status icon
|
||||
let timestamp_text = if let Some(timestamp) = self.last_run_timestamp {
|
||||
self.format_timestamp(timestamp)
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
let timestamp_spans = StatusIcons::create_status_spans(
|
||||
self.overall_status,
|
||||
×tamp_text
|
||||
);
|
||||
lines.push(ratatui::text::Line::from(timestamp_spans));
|
||||
|
||||
// Duration as sub-item
|
||||
if let Some(duration) = self.duration_seconds {
|
||||
let duration_text = self.format_duration(duration);
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(" └─ ", Typography::tree()),
|
||||
ratatui::text::Span::styled(format!("Duration: {}", duration_text), Typography::secondary())
|
||||
]));
|
||||
}
|
||||
|
||||
// Disk section
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled("Disk:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
// Disk product name with status
|
||||
if let Some(product) = &self.backup_disk_product_name {
|
||||
let disk_spans = StatusIcons::create_status_spans(
|
||||
Status::Ok, // Assuming disk is OK if we have data
|
||||
product
|
||||
);
|
||||
lines.push(ratatui::text::Line::from(disk_spans));
|
||||
|
||||
// Collect sub-items to determine tree structure
|
||||
let mut sub_items = Vec::new();
|
||||
|
||||
if let Some(serial) = &self.backup_disk_serial_number {
|
||||
sub_items.push(format!("S/N: {}", serial));
|
||||
}
|
||||
|
||||
if let Some(wear) = self.backup_disk_wear_percent {
|
||||
sub_items.push(format!("Wear: {:.0}%", wear));
|
||||
}
|
||||
|
||||
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
||||
let used_str = Self::format_size_with_proper_units(used);
|
||||
let total_str = Self::format_size_with_proper_units(total);
|
||||
sub_items.push(format!("Usage: {}/{}", used_str, total_str));
|
||||
}
|
||||
|
||||
// Render sub-items with proper tree structure
|
||||
let num_items = sub_items.len();
|
||||
for (i, item) in sub_items.into_iter().enumerate() {
|
||||
let is_last = i == num_items - 1;
|
||||
let tree_char = if is_last { " └─ " } else { " ├─ " };
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(tree_char, Typography::tree()),
|
||||
ratatui::text::Span::styled(item, Typography::secondary())
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
// Repos section
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled("Repos:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
// Add all repository lines (no truncation here - scroll will handle display)
|
||||
for service in &self.service_metrics {
|
||||
if let (Some(archives), Some(size_gb)) = (service.archive_count, service.repo_size_gb) {
|
||||
let size_str = Self::format_size_with_proper_units(size_gb);
|
||||
let repo_text = format!("{} ({}) {}", service.name, archives, size_str);
|
||||
let repo_spans = StatusIcons::create_status_spans(service.status, &repo_text);
|
||||
lines.push(ratatui::text::Line::from(repo_spans));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply scroll offset
|
||||
let total_lines = lines.len();
|
||||
let available_height = area.height as usize;
|
||||
|
||||
// Show only what fits, with "X more below" if needed
|
||||
if total_lines > available_height {
|
||||
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
|
||||
let mut visible_lines: Vec<_> = lines
|
||||
.into_iter()
|
||||
.take(lines_for_content)
|
||||
.collect();
|
||||
|
||||
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||
if hidden_below > 0 {
|
||||
let more_line = ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
||||
]);
|
||||
visible_lines.push(more_line);
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines));
|
||||
frame.render_widget(paragraph, area);
|
||||
} else {
|
||||
let paragraph = Paragraph::new(ratatui::text::Text::from(lines));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
/// Format timestamp for display
|
||||
fn format_timestamp(&self, timestamp: i64) -> String {
|
||||
let datetime = chrono::DateTime::from_timestamp(timestamp, 0)
|
||||
.unwrap_or_else(|| chrono::Utc::now());
|
||||
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Format duration in seconds to human readable format
|
||||
fn format_duration(&self, duration_seconds: i64) -> String {
|
||||
let minutes = duration_seconds / 60;
|
||||
let seconds = duration_seconds % 60;
|
||||
|
||||
if minutes > 0 {
|
||||
format!("{}.{}m", minutes, seconds / 6) // Show 1 decimal for minutes
|
||||
} else {
|
||||
format!("{}s", seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BackupWidget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
use cm_dashboard_shared::AgentData;
|
||||
|
||||
pub mod backup;
|
||||
pub mod cpu;
|
||||
pub mod memory;
|
||||
pub mod services;
|
||||
pub mod system;
|
||||
|
||||
pub use backup::BackupWidget;
|
||||
pub use services::ServicesWidget;
|
||||
pub use system::SystemWidget;
|
||||
|
||||
|
||||
@@ -28,10 +28,9 @@ pub struct ServicesWidget {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServiceInfo {
|
||||
status: String,
|
||||
memory_mb: Option<f32>,
|
||||
disk_gb: Option<f32>,
|
||||
latency_ms: Option<f32>,
|
||||
metrics: Vec<(String, f32, Option<String>)>, // (label, value, unit)
|
||||
widget_status: Status,
|
||||
}
|
||||
|
||||
@@ -113,10 +112,15 @@ impl ServicesWidget {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Parent services always show actual systemctl status
|
||||
// Convert Status enum to display text
|
||||
let status_str = match info.widget_status {
|
||||
Status::Pending => "pending".to_string(),
|
||||
_ => info.status.clone(), // Use actual status from agent (active/inactive/failed)
|
||||
Status::Ok => "active",
|
||||
Status::Inactive => "inactive",
|
||||
Status::Critical => "failed",
|
||||
Status::Pending => "pending",
|
||||
Status::Warning => "warning",
|
||||
Status::Unknown => "unknown",
|
||||
Status::Offline => "offline",
|
||||
};
|
||||
|
||||
format!(
|
||||
@@ -153,15 +157,25 @@ impl ServicesWidget {
|
||||
Status::Offline => Theme::muted_text(),
|
||||
};
|
||||
|
||||
// For sub-services, prefer latency if available
|
||||
let status_str = if let Some(latency) = info.latency_ms {
|
||||
if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
// Display metrics or status for sub-services
|
||||
let status_str = if !info.metrics.is_empty() {
|
||||
// Show first metric with label and unit
|
||||
let (label, value, unit) = &info.metrics[0];
|
||||
match unit {
|
||||
Some(u) => format!("{}: {:.1} {}", label, value, u),
|
||||
None => format!("{}: {:.1}", label, value),
|
||||
}
|
||||
} else {
|
||||
info.status.clone()
|
||||
// Convert Status enum to display text for sub-services
|
||||
match info.widget_status {
|
||||
Status::Ok => "active",
|
||||
Status::Inactive => "inactive",
|
||||
Status::Critical => "failed",
|
||||
Status::Pending => "pending",
|
||||
Status::Warning => "warning",
|
||||
Status::Unknown => "unknown",
|
||||
Status::Offline => "offline",
|
||||
}.to_string()
|
||||
};
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
|
||||
@@ -262,18 +276,48 @@ impl Widget for ServicesWidget {
|
||||
self.sub_services.clear();
|
||||
|
||||
for service in &agent_data.services {
|
||||
let service_info = ServiceInfo {
|
||||
status: service.status.clone(),
|
||||
// Store parent service
|
||||
let parent_info = ServiceInfo {
|
||||
memory_mb: Some(service.memory_mb),
|
||||
disk_gb: Some(service.disk_gb),
|
||||
latency_ms: None,
|
||||
widget_status: Status::Ok,
|
||||
metrics: Vec::new(), // Parent services don't have custom metrics
|
||||
widget_status: service.service_status,
|
||||
};
|
||||
self.parent_services.insert(service.name.clone(), parent_info);
|
||||
|
||||
self.parent_services.insert(service.name.clone(), service_info);
|
||||
// Process sub-services if any
|
||||
if !service.sub_services.is_empty() {
|
||||
let mut sub_list = Vec::new();
|
||||
for sub_service in &service.sub_services {
|
||||
// Convert metrics to display format
|
||||
let metrics: Vec<(String, f32, Option<String>)> = sub_service.metrics.iter()
|
||||
.map(|m| (m.label.clone(), m.value, m.unit.clone()))
|
||||
.collect();
|
||||
|
||||
let sub_info = ServiceInfo {
|
||||
memory_mb: None, // Not used for sub-services
|
||||
disk_gb: None, // Not used for sub-services
|
||||
metrics,
|
||||
widget_status: sub_service.service_status,
|
||||
};
|
||||
sub_list.push((sub_service.name.clone(), sub_info));
|
||||
}
|
||||
self.sub_services.insert(service.name.clone(), sub_list);
|
||||
}
|
||||
}
|
||||
|
||||
self.status = Status::Ok;
|
||||
// Aggregate status from all services
|
||||
let mut all_statuses = Vec::new();
|
||||
all_statuses.extend(self.parent_services.values().map(|info| info.widget_status));
|
||||
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(&all_statuses)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,15 +338,13 @@ impl ServicesWidget {
|
||||
self.parent_services
|
||||
.entry(parent_service)
|
||||
.or_insert(ServiceInfo {
|
||||
status: "unknown".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
metrics: Vec::new(),
|
||||
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() {
|
||||
@@ -331,10 +373,9 @@ impl ServicesWidget {
|
||||
sub_service_list.push((
|
||||
sub_name.clone(),
|
||||
ServiceInfo {
|
||||
status: "unknown".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
metrics: Vec::new(),
|
||||
widget_status: Status::Unknown,
|
||||
},
|
||||
));
|
||||
@@ -342,7 +383,6 @@ impl ServicesWidget {
|
||||
};
|
||||
|
||||
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() {
|
||||
@@ -352,11 +392,6 @@ impl ServicesWidget {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,17 @@ pub struct SystemWidget {
|
||||
// Storage metrics (collected from disk metrics)
|
||||
storage_pools: Vec<StoragePool>,
|
||||
|
||||
// Backup metrics
|
||||
backup_status: String,
|
||||
backup_start_time_raw: Option<String>,
|
||||
backup_disk_serial: Option<String>,
|
||||
backup_disk_usage_percent: Option<f32>,
|
||||
backup_disk_used_gb: Option<f32>,
|
||||
backup_disk_total_gb: Option<f32>,
|
||||
backup_disk_wear_percent: Option<f32>,
|
||||
backup_disk_temperature: Option<f32>,
|
||||
backup_last_size_gb: Option<f32>,
|
||||
|
||||
// Overall status
|
||||
has_data: bool,
|
||||
}
|
||||
@@ -91,6 +102,15 @@ impl SystemWidget {
|
||||
tmp_status: Status::Unknown,
|
||||
tmpfs_mounts: Vec::new(),
|
||||
storage_pools: Vec::new(),
|
||||
backup_status: "unknown".to_string(),
|
||||
backup_start_time_raw: None,
|
||||
backup_disk_serial: None,
|
||||
backup_disk_usage_percent: None,
|
||||
backup_disk_used_gb: None,
|
||||
backup_disk_total_gb: None,
|
||||
backup_disk_wear_percent: None,
|
||||
backup_disk_temperature: None,
|
||||
backup_last_size_gb: None,
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
@@ -170,6 +190,28 @@ impl Widget for SystemWidget {
|
||||
|
||||
// Convert storage data to internal format
|
||||
self.update_storage_from_agent_data(agent_data);
|
||||
|
||||
// Extract backup data
|
||||
let backup = &agent_data.backup;
|
||||
self.backup_status = backup.status.clone();
|
||||
self.backup_start_time_raw = backup.start_time_raw.clone();
|
||||
self.backup_last_size_gb = backup.last_backup_size_gb;
|
||||
|
||||
if let Some(disk) = &backup.repository_disk {
|
||||
self.backup_disk_serial = Some(disk.serial.clone());
|
||||
self.backup_disk_usage_percent = Some(disk.usage_percent);
|
||||
self.backup_disk_used_gb = Some(disk.used_gb);
|
||||
self.backup_disk_total_gb = Some(disk.total_gb);
|
||||
self.backup_disk_wear_percent = disk.wear_percent;
|
||||
self.backup_disk_temperature = disk.temperature_celsius;
|
||||
} else {
|
||||
self.backup_disk_serial = None;
|
||||
self.backup_disk_usage_percent = None;
|
||||
self.backup_disk_used_gb = None;
|
||||
self.backup_disk_total_gb = None;
|
||||
self.backup_disk_wear_percent = None;
|
||||
self.backup_disk_temperature = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +394,106 @@ fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'
|
||||
}
|
||||
|
||||
impl SystemWidget {
|
||||
/// Render backup section for display
|
||||
fn render_backup(&self) -> Vec<Line<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// First line: serial number with temperature and wear
|
||||
if let Some(serial) = &self.backup_disk_serial {
|
||||
let mut details = Vec::new();
|
||||
if let Some(temp) = self.backup_disk_temperature {
|
||||
details.push(format!("T: {}°C", temp as i32));
|
||||
}
|
||||
if let Some(wear) = self.backup_disk_wear_percent {
|
||||
details.push(format!("W: {}%", wear as i32));
|
||||
}
|
||||
|
||||
let disk_text = if !details.is_empty() {
|
||||
format!("{} {}", serial, details.join(" "))
|
||||
} else {
|
||||
serial.clone()
|
||||
};
|
||||
|
||||
let backup_status = match self.backup_status.as_str() {
|
||||
"completed" | "success" => Status::Ok,
|
||||
"running" => Status::Pending,
|
||||
"failed" => Status::Critical,
|
||||
_ => Status::Unknown,
|
||||
};
|
||||
|
||||
let disk_spans = StatusIcons::create_status_spans(backup_status, &disk_text);
|
||||
lines.push(Line::from(disk_spans));
|
||||
|
||||
// Show backup time from TOML if available
|
||||
if let Some(start_time) = &self.backup_start_time_raw {
|
||||
let time_text = if let Some(size) = self.backup_last_size_gb {
|
||||
format!("Time: {} ({:.1}GB)", start_time, size)
|
||||
} else {
|
||||
format!("Time: {}", start_time)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ├─ ", Typography::tree()),
|
||||
Span::styled(time_text, Typography::secondary())
|
||||
]));
|
||||
}
|
||||
|
||||
// Usage information
|
||||
if let (Some(used), Some(total), Some(usage_percent)) = (
|
||||
self.backup_disk_used_gb,
|
||||
self.backup_disk_total_gb,
|
||||
self.backup_disk_usage_percent
|
||||
) {
|
||||
let usage_text = format!("Usage: {:.0}% {:.0}GB/{:.0}GB", usage_percent, used, total);
|
||||
let usage_spans = StatusIcons::create_status_spans(Status::Ok, &usage_text);
|
||||
let mut full_spans = vec![
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
];
|
||||
full_spans.extend(usage_spans);
|
||||
lines.push(Line::from(full_spans));
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Format time ago from timestamp
|
||||
fn format_time_ago(&self, timestamp: u64) -> String {
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
let seconds_ago = now.saturating_sub(timestamp);
|
||||
|
||||
let hours = seconds_ago / 3600;
|
||||
let minutes = (seconds_ago % 3600) / 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}h ago", hours)
|
||||
} else if minutes > 0 {
|
||||
format!("{}m ago", minutes)
|
||||
} else {
|
||||
"now".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format time until from future timestamp
|
||||
fn format_time_until(&self, timestamp: u64) -> String {
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
if timestamp <= now {
|
||||
return "overdue".to_string();
|
||||
}
|
||||
|
||||
let seconds_until = timestamp - now;
|
||||
let hours = seconds_until / 3600;
|
||||
let minutes = (seconds_until % 3600) / 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("in {}h", hours)
|
||||
} else if minutes > 0 {
|
||||
format!("in {}m", minutes)
|
||||
} else {
|
||||
"soon".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render system widget
|
||||
pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, config: Option<&crate::config::DashboardConfig>) {
|
||||
let mut lines = Vec::new();
|
||||
@@ -445,6 +587,16 @@ impl SystemWidget {
|
||||
let storage_lines = self.render_storage();
|
||||
lines.extend(storage_lines);
|
||||
|
||||
// Backup section (if available)
|
||||
if self.backup_status != "unavailable" && self.backup_status != "unknown" {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Backup:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
let backup_lines = self.render_backup();
|
||||
lines.extend(backup_lines);
|
||||
}
|
||||
|
||||
// Apply scroll offset
|
||||
let total_lines = lines.len();
|
||||
let available_height = area.height as usize;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.144"
|
||||
version = "0.1.151"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -111,21 +111,49 @@ pub struct PoolDriveData {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceData {
|
||||
pub name: String,
|
||||
pub status: String, // "active", "inactive", "failed"
|
||||
pub memory_mb: f32,
|
||||
pub disk_gb: f32,
|
||||
pub user_stopped: bool,
|
||||
pub service_status: Status,
|
||||
pub sub_services: Vec<SubServiceData>,
|
||||
}
|
||||
|
||||
/// Sub-service data (nginx sites, docker containers, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubServiceData {
|
||||
pub name: String,
|
||||
pub service_status: Status,
|
||||
pub metrics: Vec<SubServiceMetric>,
|
||||
}
|
||||
|
||||
/// Individual metric for a sub-service
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubServiceMetric {
|
||||
pub label: String,
|
||||
pub value: f32,
|
||||
pub unit: Option<String>,
|
||||
}
|
||||
|
||||
/// Backup system data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupData {
|
||||
pub status: String,
|
||||
pub last_run: Option<u64>,
|
||||
pub next_scheduled: Option<u64>,
|
||||
pub total_size_gb: Option<f32>,
|
||||
pub repository_health: Option<String>,
|
||||
pub repository_disk: Option<BackupDiskData>,
|
||||
pub last_backup_size_gb: Option<f32>,
|
||||
pub start_time_raw: Option<String>,
|
||||
}
|
||||
|
||||
/// Backup repository disk information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupDiskData {
|
||||
pub serial: String,
|
||||
pub usage_percent: f32,
|
||||
pub used_gb: f32,
|
||||
pub total_gb: f32,
|
||||
pub wear_percent: Option<f32>,
|
||||
pub temperature_celsius: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentData {
|
||||
@@ -164,10 +192,11 @@ impl AgentData {
|
||||
services: Vec::new(),
|
||||
backup: BackupData {
|
||||
status: "unknown".to_string(),
|
||||
last_run: None,
|
||||
next_scheduled: None,
|
||||
total_size_gb: None,
|
||||
repository_health: None,
|
||||
repository_disk: None,
|
||||
last_backup_size_gb: None,
|
||||
start_time_raw: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user