All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
- Fix physical drive serial number display in dashboard - Improve pool health calculation for arrays with multiple disks - Support proper tree symbols for multiple parity drives - Read git commit hash from /var/lib/cm-dashboard/git-commit for Build display
793 lines
30 KiB
Rust
793 lines
30 KiB
Rust
use cm_dashboard_shared::Status;
|
|
use ratatui::{
|
|
layout::Rect,
|
|
text::{Line, Span, Text},
|
|
widgets::Paragraph,
|
|
Frame,
|
|
};
|
|
|
|
use crate::ui::theme::{StatusIcons, Typography};
|
|
|
|
/// System widget displaying NixOS info, CPU, RAM, and Storage in unified layout
|
|
#[derive(Clone)]
|
|
pub struct SystemWidget {
|
|
// NixOS information
|
|
nixos_build: Option<String>,
|
|
agent_hash: Option<String>,
|
|
|
|
// CPU metrics
|
|
cpu_load_1min: Option<f32>,
|
|
cpu_load_5min: Option<f32>,
|
|
cpu_load_15min: Option<f32>,
|
|
cpu_frequency: Option<f32>,
|
|
cpu_status: Status,
|
|
|
|
// Memory metrics
|
|
memory_usage_percent: Option<f32>,
|
|
memory_used_gb: Option<f32>,
|
|
memory_total_gb: Option<f32>,
|
|
tmp_usage_percent: Option<f32>,
|
|
tmp_used_gb: Option<f32>,
|
|
tmp_total_gb: Option<f32>,
|
|
memory_status: Status,
|
|
tmp_status: Status,
|
|
/// All tmpfs mounts (for auto-discovery support)
|
|
tmpfs_mounts: Vec<cm_dashboard_shared::TmpfsData>,
|
|
|
|
// 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,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct StoragePool {
|
|
name: String,
|
|
mount_point: String,
|
|
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
|
|
drives: Vec<StorageDrive>, // For physical drives
|
|
data_drives: Vec<StorageDrive>, // For MergerFS pools
|
|
parity_drives: Vec<StorageDrive>, // For MergerFS pools
|
|
filesystems: Vec<FileSystem>, // For physical drive pools: individual filesystem children
|
|
usage_percent: Option<f32>,
|
|
used_gb: Option<f32>,
|
|
total_gb: Option<f32>,
|
|
status: Status,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct StorageDrive {
|
|
name: String,
|
|
temperature: Option<f32>,
|
|
wear_percent: Option<f32>,
|
|
status: Status,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FileSystem {
|
|
mount_point: String,
|
|
usage_percent: Option<f32>,
|
|
used_gb: Option<f32>,
|
|
total_gb: Option<f32>,
|
|
status: Status,
|
|
}
|
|
|
|
impl SystemWidget {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
nixos_build: None,
|
|
agent_hash: None,
|
|
cpu_load_1min: None,
|
|
cpu_load_5min: None,
|
|
cpu_load_15min: None,
|
|
cpu_frequency: None,
|
|
cpu_status: Status::Unknown,
|
|
memory_usage_percent: None,
|
|
memory_used_gb: None,
|
|
memory_total_gb: None,
|
|
tmp_usage_percent: None,
|
|
tmp_used_gb: None,
|
|
tmp_total_gb: None,
|
|
memory_status: Status::Unknown,
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// Format CPU load averages
|
|
fn format_cpu_load(&self) -> String {
|
|
match (self.cpu_load_1min, self.cpu_load_5min, self.cpu_load_15min) {
|
|
(Some(l1), Some(l5), Some(l15)) => {
|
|
format!("{:.2} {:.2} {:.2}", l1, l5, l15)
|
|
}
|
|
_ => "— — —".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Format CPU frequency
|
|
fn format_cpu_frequency(&self) -> String {
|
|
match self.cpu_frequency {
|
|
Some(freq) => format!("{:.0} MHz", freq),
|
|
None => "— MHz".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Format memory usage
|
|
fn format_memory_usage(&self) -> String {
|
|
match (self.memory_usage_percent, self.memory_used_gb, self.memory_total_gb) {
|
|
(Some(pct), Some(used), Some(total)) => {
|
|
format!("{:.0}% {:.1}GB/{:.1}GB", pct, used, total)
|
|
}
|
|
_ => "—% —GB/—GB".to_string(),
|
|
}
|
|
}
|
|
|
|
|
|
/// Get the current agent hash for rebuild completion detection
|
|
pub fn _get_agent_hash(&self) -> Option<&String> {
|
|
self.agent_hash.as_ref()
|
|
}
|
|
}
|
|
|
|
use super::Widget;
|
|
|
|
impl Widget for SystemWidget {
|
|
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
|
self.has_data = true;
|
|
|
|
// Extract agent version
|
|
self.agent_hash = Some(agent_data.agent_version.clone());
|
|
|
|
// Extract build version
|
|
self.nixos_build = agent_data.build_version.clone();
|
|
|
|
// Extract CPU data directly
|
|
let cpu = &agent_data.system.cpu;
|
|
self.cpu_load_1min = Some(cpu.load_1min);
|
|
self.cpu_load_5min = Some(cpu.load_5min);
|
|
self.cpu_load_15min = Some(cpu.load_15min);
|
|
self.cpu_frequency = Some(cpu.frequency_mhz);
|
|
self.cpu_status = Status::Ok;
|
|
|
|
// Extract memory data directly
|
|
let memory = &agent_data.system.memory;
|
|
self.memory_usage_percent = Some(memory.usage_percent);
|
|
self.memory_used_gb = Some(memory.used_gb);
|
|
self.memory_total_gb = Some(memory.total_gb);
|
|
self.memory_status = Status::Ok;
|
|
|
|
// Store all tmpfs mounts for display
|
|
self.tmpfs_mounts = memory.tmpfs.clone();
|
|
|
|
// Extract tmpfs data (maintain backward compatibility for /tmp)
|
|
if let Some(tmp_data) = memory.tmpfs.iter().find(|t| t.mount == "/tmp") {
|
|
self.tmp_usage_percent = Some(tmp_data.usage_percent);
|
|
self.tmp_used_gb = Some(tmp_data.used_gb);
|
|
self.tmp_total_gb = Some(tmp_data.total_gb);
|
|
self.tmp_status = Status::Ok;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SystemWidget {
|
|
/// Convert structured storage data to internal format
|
|
fn update_storage_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
|
let mut pools: std::collections::HashMap<String, StoragePool> = std::collections::HashMap::new();
|
|
|
|
// Convert drives
|
|
for drive in &agent_data.system.storage.drives {
|
|
let mut pool = StoragePool {
|
|
name: drive.name.clone(),
|
|
mount_point: drive.name.clone(),
|
|
pool_type: "drive".to_string(),
|
|
drives: Vec::new(),
|
|
data_drives: Vec::new(),
|
|
parity_drives: Vec::new(),
|
|
filesystems: Vec::new(),
|
|
usage_percent: None,
|
|
used_gb: None,
|
|
total_gb: None,
|
|
status: Status::Ok,
|
|
};
|
|
|
|
// Add drive info
|
|
let display_name = drive.serial_number.as_ref()
|
|
.map(|s| truncate_serial(s))
|
|
.unwrap_or(drive.name.clone());
|
|
let storage_drive = StorageDrive {
|
|
name: display_name,
|
|
temperature: drive.temperature_celsius,
|
|
wear_percent: drive.wear_percent,
|
|
status: Status::Ok,
|
|
};
|
|
pool.drives.push(storage_drive);
|
|
|
|
// Calculate totals from filesystems
|
|
let total_used: f32 = drive.filesystems.iter().map(|fs| fs.used_gb).sum();
|
|
let total_size: f32 = drive.filesystems.iter().map(|fs| fs.total_gb).sum();
|
|
let average_usage = if total_size > 0.0 { (total_used / total_size) * 100.0 } else { 0.0 };
|
|
|
|
pool.usage_percent = Some(average_usage);
|
|
pool.used_gb = Some(total_used);
|
|
pool.total_gb = Some(total_size);
|
|
|
|
// Add filesystems
|
|
for fs in &drive.filesystems {
|
|
let filesystem = FileSystem {
|
|
mount_point: fs.mount.clone(),
|
|
usage_percent: Some(fs.usage_percent),
|
|
used_gb: Some(fs.used_gb),
|
|
total_gb: Some(fs.total_gb),
|
|
status: Status::Ok,
|
|
};
|
|
pool.filesystems.push(filesystem);
|
|
}
|
|
|
|
pools.insert(drive.name.clone(), pool);
|
|
}
|
|
|
|
// Convert pools (MergerFS, RAID, etc.)
|
|
for pool in &agent_data.system.storage.pools {
|
|
// Use agent-calculated status (combined health and usage status)
|
|
let pool_status = if pool.health_status == Status::Critical || pool.usage_status == Status::Critical {
|
|
Status::Critical
|
|
} else if pool.health_status == Status::Warning || pool.usage_status == Status::Warning {
|
|
Status::Warning
|
|
} else if pool.health_status == Status::Ok && pool.usage_status == Status::Ok {
|
|
Status::Ok
|
|
} else {
|
|
Status::Unknown
|
|
};
|
|
|
|
let mut storage_pool = StoragePool {
|
|
name: pool.name.clone(),
|
|
mount_point: pool.mount.clone(),
|
|
pool_type: pool.pool_type.clone(),
|
|
drives: Vec::new(),
|
|
data_drives: Vec::new(),
|
|
parity_drives: Vec::new(),
|
|
filesystems: Vec::new(),
|
|
usage_percent: Some(pool.usage_percent),
|
|
used_gb: Some(pool.used_gb),
|
|
total_gb: Some(pool.total_gb),
|
|
status: pool_status,
|
|
};
|
|
|
|
// Add data drives - use agent-calculated status
|
|
for drive in &pool.data_drives {
|
|
// Use combined health and temperature status
|
|
let drive_status = if drive.health_status == Status::Critical || drive.temperature_status == Status::Critical {
|
|
Status::Critical
|
|
} else if drive.health_status == Status::Warning || drive.temperature_status == Status::Warning {
|
|
Status::Warning
|
|
} else if drive.health_status == Status::Ok && drive.temperature_status == Status::Ok {
|
|
Status::Ok
|
|
} else {
|
|
Status::Unknown
|
|
};
|
|
|
|
let display_name = drive.serial_number.as_ref()
|
|
.map(|s| truncate_serial(s))
|
|
.unwrap_or(drive.name.clone());
|
|
let storage_drive = StorageDrive {
|
|
name: display_name,
|
|
temperature: drive.temperature_celsius,
|
|
wear_percent: drive.wear_percent,
|
|
status: drive_status,
|
|
};
|
|
storage_pool.data_drives.push(storage_drive);
|
|
}
|
|
|
|
// Add parity drives - use agent-calculated status
|
|
for drive in &pool.parity_drives {
|
|
// Use combined health and temperature status
|
|
let drive_status = if drive.health_status == Status::Critical || drive.temperature_status == Status::Critical {
|
|
Status::Critical
|
|
} else if drive.health_status == Status::Warning || drive.temperature_status == Status::Warning {
|
|
Status::Warning
|
|
} else if drive.health_status == Status::Ok && drive.temperature_status == Status::Ok {
|
|
Status::Ok
|
|
} else {
|
|
Status::Unknown
|
|
};
|
|
|
|
let display_name = drive.serial_number.as_ref()
|
|
.map(|s| truncate_serial(s))
|
|
.unwrap_or(drive.name.clone());
|
|
let storage_drive = StorageDrive {
|
|
name: display_name,
|
|
temperature: drive.temperature_celsius,
|
|
wear_percent: drive.wear_percent,
|
|
status: drive_status,
|
|
};
|
|
storage_pool.parity_drives.push(storage_drive);
|
|
}
|
|
|
|
pools.insert(pool.name.clone(), storage_pool);
|
|
}
|
|
|
|
// Store pools
|
|
let mut pool_list: Vec<StoragePool> = pools.into_values().collect();
|
|
pool_list.sort_by(|a, b| a.name.cmp(&b.name));
|
|
self.storage_pools = pool_list;
|
|
}
|
|
|
|
/// Render storage section with enhanced tree structure
|
|
fn render_storage(&self) -> Vec<Line<'_>> {
|
|
let mut lines = Vec::new();
|
|
|
|
for pool in &self.storage_pools {
|
|
// Pool header line with type and health
|
|
let pool_label = if pool.pool_type == "drive" {
|
|
// For physical drives, show the drive name with temperature and wear percentage if available
|
|
// Physical drives only have one drive entry
|
|
if let Some(drive) = pool.drives.first() {
|
|
let mut drive_details = Vec::new();
|
|
if let Some(temp) = drive.temperature {
|
|
drive_details.push(format!("T: {}°C", temp as i32));
|
|
}
|
|
if let Some(wear) = drive.wear_percent {
|
|
drive_details.push(format!("W: {}%", wear as i32));
|
|
}
|
|
|
|
if !drive_details.is_empty() {
|
|
format!("{} {}", drive.name, drive_details.join(" "))
|
|
} else {
|
|
drive.name.clone()
|
|
}
|
|
} else {
|
|
pool.name.clone()
|
|
}
|
|
} else {
|
|
// For mergerfs pools, show pool type with mount point
|
|
format!("mergerfs {}:", pool.mount_point)
|
|
};
|
|
|
|
let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label);
|
|
lines.push(Line::from(pool_spans));
|
|
|
|
// Show individual filesystems for physical drives (matching CLAUDE.md format)
|
|
if pool.pool_type == "drive" {
|
|
// Show filesystem entries like: ├─ ● /: 55% 250.5GB/456.4GB
|
|
for (i, filesystem) in pool.filesystems.iter().enumerate() {
|
|
let is_last = i == pool.filesystems.len() - 1;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
|
|
let fs_text = format!("{}: {:.0}% {:.1}GB/{:.1}GB",
|
|
filesystem.mount_point,
|
|
filesystem.usage_percent.unwrap_or(0.0),
|
|
filesystem.used_gb.unwrap_or(0.0),
|
|
filesystem.total_gb.unwrap_or(0.0));
|
|
|
|
let mut fs_spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
];
|
|
fs_spans.extend(StatusIcons::create_status_spans(
|
|
filesystem.status.clone(),
|
|
&fs_text
|
|
));
|
|
lines.push(Line::from(fs_spans));
|
|
}
|
|
} else {
|
|
// For mergerfs pools, show structure matching CLAUDE.md format:
|
|
// ● mergerfs (2+1):
|
|
// ├─ Total: ● 63% 2355.2GB/3686.4GB
|
|
// ├─ Data Disks:
|
|
// │ ├─ ● sdb T: 24°C W: 5%
|
|
// │ └─ ● sdd T: 27°C W: 5%
|
|
// ├─ Parity: ● sdc T: 24°C W: 5%
|
|
// └─ Mount: /srv/media
|
|
|
|
// Pool total usage
|
|
let total_text = format!("{:.0}% {:.1}GB/{:.1}GB",
|
|
pool.usage_percent.unwrap_or(0.0),
|
|
pool.used_gb.unwrap_or(0.0),
|
|
pool.total_gb.unwrap_or(0.0)
|
|
);
|
|
let mut total_spans = vec![
|
|
Span::styled(" ├─ ", Typography::tree()),
|
|
];
|
|
total_spans.extend(StatusIcons::create_status_spans(Status::Ok, &total_text));
|
|
lines.push(Line::from(total_spans));
|
|
|
|
// Data drives - at same level as parity
|
|
let has_parity = !pool.parity_drives.is_empty();
|
|
for (i, drive) in pool.data_drives.iter().enumerate() {
|
|
let is_last_data = i == pool.data_drives.len() - 1;
|
|
let mut drive_details = Vec::new();
|
|
if let Some(temp) = drive.temperature {
|
|
drive_details.push(format!("T: {}°C", temp as i32));
|
|
}
|
|
if let Some(wear) = drive.wear_percent {
|
|
drive_details.push(format!("W: {}%", wear as i32));
|
|
}
|
|
|
|
let drive_text = if !drive_details.is_empty() {
|
|
format!("Data_{}: {} {}", i + 1, drive.name, drive_details.join(" "))
|
|
} else {
|
|
format!("Data_{}: {}", i + 1, drive.name)
|
|
};
|
|
|
|
// Last data drive uses └─ if there's no parity, otherwise ├─
|
|
let tree_symbol = if is_last_data && !has_parity { " └─ " } else { " ├─ " };
|
|
let mut data_spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
];
|
|
data_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
|
lines.push(Line::from(data_spans));
|
|
}
|
|
|
|
// Parity drives - last item(s)
|
|
if !pool.parity_drives.is_empty() {
|
|
for (i, drive) in pool.parity_drives.iter().enumerate() {
|
|
let is_last = i == pool.parity_drives.len() - 1;
|
|
let mut drive_details = Vec::new();
|
|
if let Some(temp) = drive.temperature {
|
|
drive_details.push(format!("T: {}°C", temp as i32));
|
|
}
|
|
if let Some(wear) = drive.wear_percent {
|
|
drive_details.push(format!("W: {}%", wear as i32));
|
|
}
|
|
|
|
let drive_text = if !drive_details.is_empty() {
|
|
format!("Parity: {} {}", drive.name, drive_details.join(" "))
|
|
} else {
|
|
format!("Parity: {}", drive.name)
|
|
};
|
|
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
let mut parity_spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
];
|
|
parity_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
|
lines.push(Line::from(parity_spans));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
lines
|
|
}
|
|
}
|
|
|
|
/// Truncate serial number to last 8 characters
|
|
fn truncate_serial(serial: &str) -> String {
|
|
let len = serial.len();
|
|
if len > 8 {
|
|
serial[len - 8..].to_string()
|
|
} else {
|
|
serial.to_string()
|
|
}
|
|
}
|
|
|
|
/// Helper function to render a drive in a MergerFS pool
|
|
fn render_mergerfs_drive<'a>(drive: &StorageDrive, tree_symbol: &'a str, lines: &mut Vec<Line<'a>>) {
|
|
let mut drive_details = Vec::new();
|
|
if let Some(temp) = drive.temperature {
|
|
drive_details.push(format!("T: {}°C", temp as i32));
|
|
}
|
|
if let Some(wear) = drive.wear_percent {
|
|
drive_details.push(format!("W: {}%", wear as i32));
|
|
}
|
|
|
|
let drive_text = if !drive_details.is_empty() {
|
|
format!("{} {}", drive.name, drive_details.join(" "))
|
|
} else {
|
|
drive.name.clone()
|
|
};
|
|
|
|
let mut drive_spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
];
|
|
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
|
lines.push(Line::from(drive_spans));
|
|
}
|
|
|
|
/// Helper function to render a drive in a storage pool
|
|
fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'_>>) {
|
|
let tree_symbol = if is_last { " └─" } else { " ├─" };
|
|
|
|
let mut drive_details = Vec::new();
|
|
if let Some(temp) = drive.temperature {
|
|
drive_details.push(format!("T: {}°C", temp as i32));
|
|
}
|
|
if let Some(wear) = drive.wear_percent {
|
|
drive_details.push(format!("W: {}%", wear as i32));
|
|
}
|
|
|
|
let drive_text = if !drive_details.is_empty() {
|
|
format!("● {} {}", drive.name, drive_details.join(" "))
|
|
} else {
|
|
format!("● {}", drive.name)
|
|
};
|
|
|
|
let mut drive_spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
Span::raw(" "),
|
|
];
|
|
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
|
lines.push(Line::from(drive_spans));
|
|
}
|
|
|
|
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 truncated_serial = truncate_serial(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!("{} {}", truncated_serial, details.join(" "))
|
|
} else {
|
|
truncated_serial
|
|
};
|
|
|
|
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();
|
|
|
|
// NixOS section
|
|
lines.push(Line::from(vec![
|
|
Span::styled(format!("NixOS {}:", hostname), Typography::widget_title())
|
|
]));
|
|
|
|
let build_text = self.nixos_build.as_deref().unwrap_or("unknown");
|
|
lines.push(Line::from(vec![
|
|
Span::styled(format!("Build: {}", build_text), Typography::secondary())
|
|
]));
|
|
|
|
let agent_version_text = self.agent_hash.as_deref().unwrap_or("unknown");
|
|
lines.push(Line::from(vec![
|
|
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
|
|
]));
|
|
|
|
// Display detected connection IP
|
|
if let Some(config) = config {
|
|
if let Some(host_details) = config.hosts.get(hostname) {
|
|
let detected_ip = host_details.get_connection_ip(hostname);
|
|
lines.push(Line::from(vec![
|
|
Span::styled(format!("IP: {}", detected_ip), Typography::secondary())
|
|
]));
|
|
}
|
|
}
|
|
|
|
|
|
// CPU section
|
|
lines.push(Line::from(vec![
|
|
Span::styled("CPU:", Typography::widget_title())
|
|
]));
|
|
|
|
let load_text = self.format_cpu_load();
|
|
let cpu_spans = StatusIcons::create_status_spans(
|
|
self.cpu_status.clone(),
|
|
&format!("Load: {}", load_text)
|
|
);
|
|
lines.push(Line::from(cpu_spans));
|
|
|
|
let freq_text = self.format_cpu_frequency();
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" └─ ", Typography::tree()),
|
|
Span::styled(format!("Freq: {}", freq_text), Typography::secondary())
|
|
]));
|
|
|
|
// RAM section
|
|
lines.push(Line::from(vec![
|
|
Span::styled("RAM:", Typography::widget_title())
|
|
]));
|
|
|
|
let memory_text = self.format_memory_usage();
|
|
let memory_spans = StatusIcons::create_status_spans(
|
|
self.memory_status.clone(),
|
|
&format!("Usage: {}", memory_text)
|
|
);
|
|
lines.push(Line::from(memory_spans));
|
|
|
|
// Display all tmpfs mounts
|
|
for (i, tmpfs) in self.tmpfs_mounts.iter().enumerate() {
|
|
let is_last = i == self.tmpfs_mounts.len() - 1;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
|
|
let usage_text = if tmpfs.total_gb > 0.0 {
|
|
format!("{:.0}% {:.1}GB/{:.1}GB",
|
|
tmpfs.usage_percent,
|
|
tmpfs.used_gb,
|
|
tmpfs.total_gb)
|
|
} else {
|
|
"— —/—".to_string()
|
|
};
|
|
|
|
let mut tmpfs_spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
];
|
|
tmpfs_spans.extend(StatusIcons::create_status_spans(
|
|
Status::Ok, // TODO: Calculate status based on usage_percent
|
|
&format!("{}: {}", tmpfs.mount, usage_text)
|
|
));
|
|
lines.push(Line::from(tmpfs_spans));
|
|
}
|
|
|
|
// Storage section
|
|
lines.push(Line::from(vec![
|
|
Span::styled("Storage:", Typography::widget_title())
|
|
]));
|
|
|
|
// Storage items - let main overflow logic handle truncation
|
|
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;
|
|
|
|
// 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<Line> = 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 = Line::from(vec![
|
|
Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
|
]);
|
|
visible_lines.push(more_line);
|
|
}
|
|
|
|
let paragraph = Paragraph::new(Text::from(visible_lines));
|
|
frame.render_widget(paragraph, area);
|
|
} else {
|
|
// All content fits and no scroll offset, render normally
|
|
let paragraph = Paragraph::new(Text::from(lines));
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
}
|
|
} |