Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 156d707377 | |||
| dc1a2e3a0f | |||
| 5d6b8e6253 | |||
| 0cba083305 | |||
| a6be7a4788 | |||
| 2384f7f9b9 | |||
| cd5ef65d3d | |||
| 7bf9ca6201 | |||
| f587b42797 | |||
| 7ae464e172 | |||
| 980c9a20a2 | |||
| 448a38dede |
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.86"
|
version = "0.1.97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -301,7 +301,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.86"
|
version = "0.1.97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -324,7 +324,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.86"
|
version = "0.1.97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.86"
|
version = "0.1.98"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ impl BackupCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
||||||
|
// Check if we're in maintenance mode
|
||||||
|
if std::fs::metadata("/tmp/cm-maintenance").is_ok() {
|
||||||
|
// Return special maintenance mode status
|
||||||
|
let maintenance_status = BackupStatusToml {
|
||||||
|
backup_name: "maintenance".to_string(),
|
||||||
|
start_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
current_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
duration_seconds: 0,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
last_updated: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
disk_space: None,
|
||||||
|
disk_product_name: None,
|
||||||
|
disk_serial_number: None,
|
||||||
|
disk_wear_percent: None,
|
||||||
|
services: HashMap::new(),
|
||||||
|
};
|
||||||
|
return Ok(Some(maintenance_status));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if backup status file exists
|
// Check if backup status file exists
|
||||||
if !std::path::Path::new(&self.backup_status_file).exists() {
|
if !std::path::Path::new(&self.backup_status_file).exists() {
|
||||||
return Ok(None); // File doesn't exist, but this is not an error
|
return Ok(None); // File doesn't exist, but this is not an error
|
||||||
@@ -79,7 +98,9 @@ impl BackupCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"failed" => Status::Critical,
|
"failed" => Status::Critical,
|
||||||
|
"warning" => Status::Warning, // Backup completed with warnings
|
||||||
"running" => Status::Ok, // Currently running is OK
|
"running" => Status::Ok, // Currently running is OK
|
||||||
|
"pending" => Status::Pending, // Maintenance mode or backup starting
|
||||||
_ => Status::Unknown,
|
_ => Status::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,6 +400,25 @@ impl Collector for BackupCollector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(wear_percent) = backup_status.disk_wear_percent {
|
||||||
|
let wear_status = if wear_percent >= 90.0 {
|
||||||
|
Status::Critical
|
||||||
|
} else if wear_percent >= 75.0 {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "backup_disk_wear_percent".to_string(),
|
||||||
|
value: MetricValue::Float(wear_percent),
|
||||||
|
status: wear_status,
|
||||||
|
timestamp,
|
||||||
|
description: Some("Backup disk wear percentage from SMART data".to_string()),
|
||||||
|
unit: Some("percent".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Count services by status
|
// Count services by status
|
||||||
let mut status_counts = HashMap::new();
|
let mut status_counts = HashMap::new();
|
||||||
for service in backup_status.services.values() {
|
for service in backup_status.services.values() {
|
||||||
@@ -412,6 +452,7 @@ pub struct BackupStatusToml {
|
|||||||
pub disk_space: Option<DiskSpace>,
|
pub disk_space: Option<DiskSpace>,
|
||||||
pub disk_product_name: Option<String>,
|
pub disk_product_name: Option<String>,
|
||||||
pub disk_serial_number: Option<String>,
|
pub disk_serial_number: Option<String>,
|
||||||
|
pub disk_wear_percent: Option<f32>,
|
||||||
pub services: HashMap<String, ServiceStatus>,
|
pub services: HashMap<String, ServiceStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.86"
|
version = "0.1.98"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ pub struct SystemConfig {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SshConfig {
|
pub struct SshConfig {
|
||||||
pub rebuild_user: String,
|
pub rebuild_user: String,
|
||||||
pub rebuild_alias: String,
|
pub rebuild_cmd: String,
|
||||||
pub backup_alias: String,
|
pub service_manage_cmd: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service log file configuration per host
|
/// Service log file configuration per host
|
||||||
|
|||||||
@@ -220,12 +220,12 @@ impl TuiApp {
|
|||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
// Create command that shows logo, rebuilds, and waits for user input
|
// Create command that shows logo, rebuilds, and waits for user input
|
||||||
let logo_and_rebuild = format!(
|
let logo_and_rebuild = format!(
|
||||||
"echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic {}\" && echo 'Press any key to close...' && read -n 1 -s",
|
"echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
||||||
hostname,
|
hostname,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
self.config.ssh.rebuild_alias
|
self.config.ssh.rebuild_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
std::process::Command::new("tmux")
|
std::process::Command::new("tmux")
|
||||||
@@ -244,12 +244,12 @@ impl TuiApp {
|
|||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
// Create command that shows logo, runs backup, and waits for user input
|
// Create command that shows logo, runs backup, and waits for user input
|
||||||
let logo_and_backup = format!(
|
let logo_and_backup = format!(
|
||||||
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic {}\" && echo 'Press any key to close...' && read -n 1 -s",
|
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
||||||
hostname,
|
hostname,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
self.config.ssh.backup_alias
|
format!("{} start borgbackup", self.config.ssh.service_manage_cmd)
|
||||||
);
|
);
|
||||||
|
|
||||||
std::process::Command::new("tmux")
|
std::process::Command::new("tmux")
|
||||||
@@ -267,14 +267,12 @@ impl TuiApp {
|
|||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let service_start_command = format!(
|
let service_start_command = format!(
|
||||||
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"sudo systemctl start {} & sudo journalctl -fu {} --since=\\\"1 second ago\\\" --no-pager & while ! systemctl is-active {} >/dev/null 2>&1; do sleep 0.5; done; pkill -f 'journalctl -fu {}'\" && echo 'Press any key to close...' && read -n 1 -s",
|
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"",
|
||||||
service_name,
|
service_name,
|
||||||
hostname,
|
hostname,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
service_name,
|
self.config.ssh.service_manage_cmd,
|
||||||
service_name,
|
|
||||||
service_name,
|
|
||||||
service_name
|
service_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,14 +291,12 @@ impl TuiApp {
|
|||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let service_stop_command = format!(
|
let service_stop_command = format!(
|
||||||
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"sudo systemctl stop {} & sudo journalctl -fu {} --since=\\\"1 second ago\\\" --no-pager & while systemctl is-active {} >/dev/null 2>&1; do sleep 0.5; done; pkill -f 'journalctl -fu {}'\" && echo 'Press any key to close...' && read -n 1 -s",
|
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"",
|
||||||
service_name,
|
service_name,
|
||||||
hostname,
|
hostname,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
service_name,
|
self.config.ssh.service_manage_cmd,
|
||||||
service_name,
|
|
||||||
service_name,
|
|
||||||
service_name
|
service_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -314,16 +310,15 @@ impl TuiApp {
|
|||||||
.ok(); // Ignore errors, tmux will handle them
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('J') => {
|
KeyCode::Char('L') => {
|
||||||
// Show service logs via journalctl in tmux split window
|
// Show service logs via service-manage script in tmux split window
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let journalctl_command = format!(
|
let logs_command = format!(
|
||||||
"echo 'Viewing logs for service: {} on {}' && ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'",
|
"ssh -tt {}@{} '{} logs {}'",
|
||||||
service_name,
|
|
||||||
hostname,
|
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip,
|
connection_ip,
|
||||||
|
self.config.ssh.service_manage_cmd,
|
||||||
service_name
|
service_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -332,39 +327,11 @@ impl TuiApp {
|
|||||||
.arg("-v")
|
.arg("-v")
|
||||||
.arg("-p")
|
.arg("-p")
|
||||||
.arg("30")
|
.arg("30")
|
||||||
.arg(&journalctl_command)
|
.arg(&logs_command)
|
||||||
.spawn()
|
.spawn()
|
||||||
.ok(); // Ignore errors, tmux will handle them
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('L') => {
|
|
||||||
// Show custom service log file in tmux split window
|
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
||||||
// Check if this service has a custom log file configured
|
|
||||||
if let Some(host_logs) = self.config.service_logs.get(&hostname) {
|
|
||||||
if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) {
|
|
||||||
let connection_ip = self.get_connection_ip(&hostname);
|
|
||||||
let tail_command = format!(
|
|
||||||
"echo 'Viewing custom logs for service: {} on {}' && ssh -tt {}@{} 'sudo tail -n 50 -f {}'",
|
|
||||||
service_name,
|
|
||||||
hostname,
|
|
||||||
self.config.ssh.rebuild_user,
|
|
||||||
connection_ip,
|
|
||||||
log_config.log_file_path
|
|
||||||
);
|
|
||||||
|
|
||||||
std::process::Command::new("tmux")
|
|
||||||
.arg("split-window")
|
|
||||||
.arg("-v")
|
|
||||||
.arg("-p")
|
|
||||||
.arg("30")
|
|
||||||
.arg(&tail_command)
|
|
||||||
.spawn()
|
|
||||||
.ok(); // Ignore errors, tmux will handle them
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('w') => {
|
KeyCode::Char('w') => {
|
||||||
// Wake on LAN for offline hosts
|
// Wake on LAN for offline hosts
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
@@ -397,7 +364,7 @@ impl TuiApp {
|
|||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
let connection_ip = self.get_connection_ip(&hostname);
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let ssh_command = format!(
|
let ssh_command = format!(
|
||||||
"echo 'Opening SSH terminal to: {}' && ssh -tt {}@{} && echo 'Press any key to close...' && read -n 1 -s",
|
"echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}",
|
||||||
hostname,
|
hostname,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
connection_ip
|
connection_ip
|
||||||
@@ -622,12 +589,13 @@ impl TuiApp {
|
|||||||
// Split the title bar into left and right sections
|
// Split the title bar into left and right sections
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Length(15), Constraint::Min(0)])
|
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Left side: "cm-dashboard" text
|
// Left side: "cm-dashboard" text with version
|
||||||
|
let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
||||||
let left_span = Span::styled(
|
let left_span = Span::styled(
|
||||||
" cm-dashboard",
|
&title_text,
|
||||||
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
||||||
);
|
);
|
||||||
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
||||||
@@ -699,35 +667,27 @@ impl TuiApp {
|
|||||||
return host_summary_metric.status;
|
return host_summary_metric.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to old aggregation logic with proper Pending handling
|
// Rewritten status aggregation - only Critical, Warning, or OK for top bar
|
||||||
let mut has_critical = false;
|
let mut has_critical = false;
|
||||||
let mut has_warning = false;
|
let mut has_warning = false;
|
||||||
let mut has_pending = false;
|
|
||||||
let mut ok_count = 0;
|
|
||||||
|
|
||||||
for metric in &metrics {
|
for metric in &metrics {
|
||||||
match metric.status {
|
match metric.status {
|
||||||
Status::Critical => has_critical = true,
|
Status::Critical => has_critical = true,
|
||||||
Status::Warning => has_warning = true,
|
Status::Warning => has_warning = true,
|
||||||
Status::Pending => has_pending = true,
|
// Treat all other statuses as OK for top bar aggregation
|
||||||
Status::Ok => ok_count += 1,
|
Status::Ok | Status::Pending | Status::Inactive | Status::Unknown => {},
|
||||||
Status::Inactive => ok_count += 1, // Treat inactive as OK for aggregation
|
Status::Offline => {}, // Ignore offline
|
||||||
Status::Unknown => {}, // Ignore unknown for aggregation
|
|
||||||
Status::Offline => {}, // Ignore offline for aggregation
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority order: Critical > Warning > Pending > Ok > Unknown
|
// Only return Critical, Warning, or OK - no other statuses
|
||||||
if has_critical {
|
if has_critical {
|
||||||
Status::Critical
|
Status::Critical
|
||||||
} else if has_warning {
|
} else if has_warning {
|
||||||
Status::Warning
|
Status::Warning
|
||||||
} else if has_pending {
|
|
||||||
Status::Pending
|
|
||||||
} else if ok_count > 0 {
|
|
||||||
Status::Ok
|
|
||||||
} else {
|
} else {
|
||||||
Status::Unknown
|
Status::Ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,9 +711,10 @@ impl TuiApp {
|
|||||||
shortcuts.push("Tab: Host".to_string());
|
shortcuts.push("Tab: Host".to_string());
|
||||||
shortcuts.push("↑↓/jk: Select".to_string());
|
shortcuts.push("↑↓/jk: Select".to_string());
|
||||||
shortcuts.push("r: Rebuild".to_string());
|
shortcuts.push("r: Rebuild".to_string());
|
||||||
|
shortcuts.push("B: Backup".to_string());
|
||||||
shortcuts.push("s/S: Start/Stop".to_string());
|
shortcuts.push("s/S: Start/Stop".to_string());
|
||||||
shortcuts.push("J: Logs".to_string());
|
shortcuts.push("L: Logs".to_string());
|
||||||
shortcuts.push("L: Custom".to_string());
|
shortcuts.push("t: Terminal".to_string());
|
||||||
shortcuts.push("w: Wake".to_string());
|
shortcuts.push("w: Wake".to_string());
|
||||||
|
|
||||||
// Always show quit
|
// Always show quit
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub struct BackupWidget {
|
|||||||
backup_disk_product_name: Option<String>,
|
backup_disk_product_name: Option<String>,
|
||||||
/// Backup disk serial number from SMART data
|
/// Backup disk serial number from SMART data
|
||||||
backup_disk_serial_number: Option<String>,
|
backup_disk_serial_number: Option<String>,
|
||||||
|
/// Backup disk wear percentage from SMART data
|
||||||
|
backup_disk_wear_percent: Option<f32>,
|
||||||
/// Backup disk filesystem label
|
/// Backup disk filesystem label
|
||||||
backup_disk_filesystem_label: Option<String>,
|
backup_disk_filesystem_label: Option<String>,
|
||||||
/// Number of completed services
|
/// Number of completed services
|
||||||
@@ -65,6 +67,7 @@ impl BackupWidget {
|
|||||||
backup_disk_used_gb: None,
|
backup_disk_used_gb: None,
|
||||||
backup_disk_product_name: None,
|
backup_disk_product_name: None,
|
||||||
backup_disk_serial_number: None,
|
backup_disk_serial_number: None,
|
||||||
|
backup_disk_wear_percent: None,
|
||||||
backup_disk_filesystem_label: None,
|
backup_disk_filesystem_label: None,
|
||||||
services_completed_count: None,
|
services_completed_count: None,
|
||||||
services_failed_count: None,
|
services_failed_count: None,
|
||||||
@@ -197,6 +200,9 @@ impl Widget for BackupWidget {
|
|||||||
"backup_disk_serial_number" => {
|
"backup_disk_serial_number" => {
|
||||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
self.backup_disk_serial_number = Some(metric.value.as_string());
|
||||||
}
|
}
|
||||||
|
"backup_disk_wear_percent" => {
|
||||||
|
self.backup_disk_wear_percent = metric.value.as_f32();
|
||||||
|
}
|
||||||
"backup_disk_filesystem_label" => {
|
"backup_disk_filesystem_label" => {
|
||||||
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
||||||
}
|
}
|
||||||
@@ -328,21 +334,31 @@ impl BackupWidget {
|
|||||||
);
|
);
|
||||||
lines.push(ratatui::text::Line::from(disk_spans));
|
lines.push(ratatui::text::Line::from(disk_spans));
|
||||||
|
|
||||||
// Serial number as sub-item
|
// Collect sub-items to determine tree structure
|
||||||
|
let mut sub_items = Vec::new();
|
||||||
|
|
||||||
if let Some(serial) = &self.backup_disk_serial_number {
|
if let Some(serial) = &self.backup_disk_serial_number {
|
||||||
lines.push(ratatui::text::Line::from(vec![
|
sub_items.push(format!("S/N: {}", serial));
|
||||||
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
|
|
||||||
ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary())
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage as sub-item
|
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) {
|
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 used_str = Self::format_size_with_proper_units(used);
|
||||||
let total_str = Self::format_size_with_proper_units(total);
|
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![
|
lines.push(ratatui::text::Line::from(vec![
|
||||||
ratatui::text::Span::styled(" └─ ", Typography::tree()),
|
ratatui::text::Span::styled(tree_char, Typography::tree()),
|
||||||
ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary())
|
ratatui::text::Span::styled(item, Typography::secondary())
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,36 +209,13 @@ impl ServicesWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get currently selected service name (for actions)
|
/// Get currently selected service name (for actions)
|
||||||
|
/// Only returns parent service names since only parent services can be selected
|
||||||
pub fn get_selected_service(&self) -> Option<String> {
|
pub fn get_selected_service(&self) -> Option<String> {
|
||||||
// Build the same display list to find the selected service
|
// Only parent services can be selected, so just get the parent service at selected_index
|
||||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new();
|
|
||||||
|
|
||||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||||
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||||
|
|
||||||
for (parent_name, parent_info) in parent_services {
|
|
||||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
|
||||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone()));
|
|
||||||
|
|
||||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
||||||
let mut sorted_subs = sub_list.clone();
|
|
||||||
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
||||||
|
|
||||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
|
||||||
let is_last_sub = i == sorted_subs.len() - 1;
|
|
||||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
|
||||||
display_lines.push((
|
|
||||||
sub_name.clone(),
|
|
||||||
sub_info.widget_status,
|
|
||||||
true,
|
|
||||||
Some((sub_info.clone(), is_last_sub)),
|
|
||||||
full_sub_name,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
display_lines.get(self.selected_index).map(|(_, _, _, _, raw_name)| raw_name.clone())
|
parent_services.get(self.selected_index).map(|(name, _)| name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total count of selectable services (parent services only, not sub-services)
|
/// Get total count of selectable services (parent services only, not sub-services)
|
||||||
|
|||||||
@@ -513,48 +513,9 @@ impl SystemWidget {
|
|||||||
Span::styled("Storage:", Typography::widget_title())
|
Span::styled("Storage:", Typography::widget_title())
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Storage items with overflow handling
|
// Storage items - let main overflow logic handle truncation
|
||||||
let storage_lines = self.render_storage();
|
let storage_lines = self.render_storage();
|
||||||
let remaining_space = area.height.saturating_sub(lines.len() as u16);
|
lines.extend(storage_lines);
|
||||||
|
|
||||||
if storage_lines.len() <= remaining_space as usize {
|
|
||||||
// All storage lines fit
|
|
||||||
lines.extend(storage_lines);
|
|
||||||
} else if remaining_space >= 2 {
|
|
||||||
// Show what we can and add overflow indicator
|
|
||||||
let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow
|
|
||||||
lines.extend(storage_lines.iter().take(lines_to_show).cloned());
|
|
||||||
|
|
||||||
// Count hidden pools
|
|
||||||
let mut hidden_pools = 0;
|
|
||||||
let mut current_pool = String::new();
|
|
||||||
for (i, line) in storage_lines.iter().enumerate() {
|
|
||||||
if i >= lines_to_show {
|
|
||||||
// Check if this line represents a new pool (no indentation)
|
|
||||||
if let Some(first_span) = line.spans.first() {
|
|
||||||
let text = first_span.content.as_ref();
|
|
||||||
if !text.starts_with(" ") && text.contains(':') {
|
|
||||||
let pool_name = text.split(':').next().unwrap_or("").trim();
|
|
||||||
if pool_name != current_pool {
|
|
||||||
hidden_pools += 1;
|
|
||||||
current_pool = pool_name.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hidden_pools > 0 {
|
|
||||||
let overflow_text = format!(
|
|
||||||
"... and {} more pool{}",
|
|
||||||
hidden_pools,
|
|
||||||
if hidden_pools == 1 { "" } else { "s" }
|
|
||||||
);
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(overflow_text, Typography::muted())
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply scroll offset
|
// Apply scroll offset
|
||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.86"
|
version = "0.1.98"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ impl MetricValue {
|
|||||||
/// Health status for metrics
|
/// Health status for metrics
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum Status {
|
pub enum Status {
|
||||||
Inactive, // Lowest priority - treated as good
|
Inactive, // Lowest priority
|
||||||
Ok, // Second lowest - also good
|
Unknown, //
|
||||||
Unknown,
|
Offline, //
|
||||||
Offline,
|
Pending, //
|
||||||
Pending,
|
Ok, // 5th place - good status has higher priority than unknown states
|
||||||
Warning,
|
Warning, //
|
||||||
Critical,
|
Critical, // Highest priority
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Status {
|
impl Status {
|
||||||
|
|||||||
Reference in New Issue
Block a user