All checks were successful
Build and Release / build-and-release (push) Successful in 1m23s
- Remove ZMQ stats display from system widget - Remove update_zmq_stats method - Remove zmq_packets_received and zmq_last_packet_age fields - Clean up display to only show essential information
933 lines
36 KiB
Rust
933 lines
36 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, Network, CPU, RAM, and Storage in unified layout
|
|
#[derive(Clone)]
|
|
pub struct SystemWidget {
|
|
// NixOS information
|
|
nixos_build: Option<String>,
|
|
agent_hash: Option<String>,
|
|
|
|
// Network interfaces
|
|
network_interfaces: Vec<cm_dashboard_shared::NetworkInterfaceData>,
|
|
|
|
// CPU metrics
|
|
cpu_load_1min: Option<f32>,
|
|
cpu_load_5min: Option<f32>,
|
|
cpu_load_15min: Option<f32>,
|
|
cpu_cstates: Vec<cm_dashboard_shared::CStateInfo>,
|
|
cpu_model_name: Option<String>,
|
|
cpu_core_count: Option<u32>,
|
|
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_repositories: Vec<String>,
|
|
backup_repository_status: Status,
|
|
backup_disks: Vec<cm_dashboard_shared::BackupDiskData>,
|
|
|
|
// 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,
|
|
network_interfaces: Vec::new(),
|
|
cpu_load_1min: None,
|
|
cpu_load_5min: None,
|
|
cpu_load_15min: None,
|
|
cpu_cstates: Vec::new(),
|
|
cpu_model_name: None,
|
|
cpu_core_count: 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_repositories: Vec::new(),
|
|
backup_repository_status: Status::Unknown,
|
|
backup_disks: Vec::new(),
|
|
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 C-states (idle depth) with percentages
|
|
fn format_cpu_cstate(&self) -> String {
|
|
if self.cpu_cstates.is_empty() {
|
|
return "—".to_string();
|
|
}
|
|
|
|
// Format top 3 C-states with percentages: "C10:79% C8:10% C6:8%"
|
|
// Agent already sends clean names (C3, C10, etc.)
|
|
self.cpu_cstates
|
|
.iter()
|
|
.map(|cs| format!("{}:{:.0}%", cs.name, cs.percent))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// 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 network interfaces
|
|
self.network_interfaces = agent_data.system.network.interfaces.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_cstates = cpu.cstates.clone();
|
|
self.cpu_model_name = cpu.model_name.clone();
|
|
self.cpu_core_count = cpu.core_count;
|
|
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_repositories = backup.repositories.clone();
|
|
self.backup_repository_status = backup.repository_status;
|
|
self.backup_disks = backup.disks.clone();
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
impl SystemWidget {
|
|
/// Render backup section for display
|
|
fn render_backup(&self) -> Vec<Line<'_>> {
|
|
let mut lines = Vec::new();
|
|
|
|
// First section: Repository status and list
|
|
if !self.backup_repositories.is_empty() {
|
|
let repo_text = format!("Repo: {}", self.backup_repositories.len());
|
|
let repo_spans = StatusIcons::create_status_spans(self.backup_repository_status, &repo_text);
|
|
lines.push(Line::from(repo_spans));
|
|
|
|
// List all repositories (sorted for consistent display)
|
|
let mut sorted_repos = self.backup_repositories.clone();
|
|
sorted_repos.sort();
|
|
let repo_count = sorted_repos.len();
|
|
for (idx, repo) in sorted_repos.iter().enumerate() {
|
|
let tree_char = if idx == repo_count - 1 { "└─" } else { "├─" };
|
|
lines.push(Line::from(vec![
|
|
Span::styled(format!(" {} ", tree_char), Typography::tree()),
|
|
Span::styled(repo.clone(), Typography::secondary()),
|
|
]));
|
|
}
|
|
}
|
|
|
|
// Second section: Per-disk backup information (sorted by serial for consistent display)
|
|
let mut sorted_disks = self.backup_disks.clone();
|
|
sorted_disks.sort_by(|a, b| a.serial.cmp(&b.serial));
|
|
for disk in &sorted_disks {
|
|
let truncated_serial = truncate_serial(&disk.serial);
|
|
let mut details = Vec::new();
|
|
|
|
if let Some(temp) = disk.temperature_celsius {
|
|
details.push(format!("T: {}°C", temp as i32));
|
|
}
|
|
if let Some(wear) = 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
|
|
};
|
|
|
|
// Overall disk status (worst of backup and usage)
|
|
let disk_status = disk.backup_status.max(disk.usage_status);
|
|
let disk_spans = StatusIcons::create_status_spans(disk_status, &disk_text);
|
|
lines.push(Line::from(disk_spans));
|
|
|
|
// Show backup time with status
|
|
if let Some(backup_time) = &disk.last_backup_time {
|
|
let time_text = format!("Backup: {}", backup_time);
|
|
let mut time_spans = vec![
|
|
Span::styled(" ├─ ", Typography::tree()),
|
|
];
|
|
time_spans.extend(StatusIcons::create_status_spans(disk.backup_status, &time_text));
|
|
lines.push(Line::from(time_spans));
|
|
}
|
|
|
|
// Show usage with status and archive count
|
|
let archive_display = if disk.archives_min == disk.archives_max {
|
|
format!("{}", disk.archives_min)
|
|
} else {
|
|
format!("{}-{}", disk.archives_min, disk.archives_max)
|
|
};
|
|
|
|
let usage_text = format!(
|
|
"Usage: ({}) {:.0}% {:.0}GB/{:.0}GB",
|
|
archive_display,
|
|
disk.disk_usage_percent,
|
|
disk.disk_used_gb,
|
|
disk.disk_total_gb
|
|
);
|
|
let mut usage_spans = vec![
|
|
Span::styled(" └─ ", Typography::tree()),
|
|
];
|
|
usage_spans.extend(StatusIcons::create_status_spans(disk.usage_status, &usage_text));
|
|
lines.push(Line::from(usage_spans));
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
/// Compress IPv4 addresses from same subnet
|
|
/// Example: "192.168.30.1, 192.168.30.100" -> "192.168.30.1, 100"
|
|
fn compress_ipv4_addresses(addresses: &[String]) -> String {
|
|
if addresses.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
if addresses.len() == 1 {
|
|
return addresses[0].clone();
|
|
}
|
|
|
|
let mut result = Vec::new();
|
|
let mut last_prefix = String::new();
|
|
|
|
for addr in addresses {
|
|
let parts: Vec<&str> = addr.split('.').collect();
|
|
if parts.len() == 4 {
|
|
let prefix = format!("{}.{}.{}", parts[0], parts[1], parts[2]);
|
|
|
|
if prefix == last_prefix {
|
|
// Same subnet, show only last octet
|
|
result.push(parts[3].to_string());
|
|
} else {
|
|
// Different subnet, show full IP
|
|
result.push(addr.clone());
|
|
last_prefix = prefix;
|
|
}
|
|
} else {
|
|
// Invalid IP format, show as-is
|
|
result.push(addr.clone());
|
|
}
|
|
}
|
|
|
|
result.join(", ")
|
|
}
|
|
|
|
/// Render network section for display with physical/virtual grouping
|
|
fn render_network(&self) -> Vec<Line<'_>> {
|
|
let mut lines = Vec::new();
|
|
|
|
if self.network_interfaces.is_empty() {
|
|
return lines;
|
|
}
|
|
|
|
// Separate physical and virtual interfaces
|
|
let physical: Vec<_> = self.network_interfaces.iter().filter(|i| i.is_physical).collect();
|
|
let virtual_interfaces: Vec<_> = self.network_interfaces.iter().filter(|i| !i.is_physical).collect();
|
|
|
|
// Find standalone virtual interfaces (those without a parent)
|
|
let mut standalone_virtual: Vec<_> = virtual_interfaces.iter()
|
|
.filter(|i| i.parent_interface.is_none())
|
|
.collect();
|
|
|
|
// Sort standalone virtual: VLANs first (by VLAN ID), then others alphabetically
|
|
standalone_virtual.sort_by(|a, b| {
|
|
match (a.vlan_id, b.vlan_id) {
|
|
(Some(vlan_a), Some(vlan_b)) => vlan_a.cmp(&vlan_b),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => a.name.cmp(&b.name),
|
|
}
|
|
});
|
|
|
|
// Render physical interfaces with their children
|
|
for (phy_idx, interface) in physical.iter().enumerate() {
|
|
let is_last_physical = phy_idx == physical.len() - 1 && standalone_virtual.is_empty();
|
|
|
|
// Physical interface header with status icon
|
|
let mut header_spans = vec![];
|
|
header_spans.extend(StatusIcons::create_status_spans(
|
|
interface.link_status.clone(),
|
|
&format!("{}:", interface.name)
|
|
));
|
|
lines.push(Line::from(header_spans));
|
|
|
|
// Find child interfaces for this physical interface
|
|
let mut children: Vec<_> = virtual_interfaces.iter()
|
|
.filter(|vi| {
|
|
if let Some(parent) = &vi.parent_interface {
|
|
parent == &interface.name
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Sort children: VLANs first (by VLAN ID), then others alphabetically
|
|
children.sort_by(|a, b| {
|
|
match (a.vlan_id, b.vlan_id) {
|
|
(Some(vlan_a), Some(vlan_b)) => vlan_a.cmp(&vlan_b),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => a.name.cmp(&b.name),
|
|
}
|
|
});
|
|
|
|
// Count total items under this physical interface (IPs + children)
|
|
let ip_count = interface.ipv4_addresses.len() + interface.ipv6_addresses.len();
|
|
let total_children = ip_count + children.len();
|
|
let mut child_index = 0;
|
|
|
|
// IPv4 addresses on the physical interface itself
|
|
for ipv4 in &interface.ipv4_addresses {
|
|
child_index += 1;
|
|
let is_last = child_index == total_children && is_last_physical;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
lines.push(Line::from(vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
Span::styled(format!("ip: {}", ipv4), Typography::secondary()),
|
|
]));
|
|
}
|
|
|
|
// IPv6 addresses on the physical interface itself
|
|
for ipv6 in &interface.ipv6_addresses {
|
|
child_index += 1;
|
|
let is_last = child_index == total_children && is_last_physical;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
lines.push(Line::from(vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
Span::styled(format!("ip: {}", ipv6), Typography::secondary()),
|
|
]));
|
|
}
|
|
|
|
// Child virtual interfaces (VLANs, etc.)
|
|
for child in children {
|
|
child_index += 1;
|
|
let is_last = child_index == total_children && is_last_physical;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
|
|
let ip_text = if !child.ipv4_addresses.is_empty() {
|
|
Self::compress_ipv4_addresses(&child.ipv4_addresses)
|
|
} else if !child.ipv6_addresses.is_empty() {
|
|
child.ipv6_addresses.join(", ")
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Format: "name (vlan X): IP" or "name: IP"
|
|
let child_text = if let Some(vlan_id) = child.vlan_id {
|
|
if !ip_text.is_empty() {
|
|
format!("{} (vlan {}): {}", child.name, vlan_id, ip_text)
|
|
} else {
|
|
format!("{} (vlan {}):", child.name, vlan_id)
|
|
}
|
|
} else {
|
|
if !ip_text.is_empty() {
|
|
format!("{}: {}", child.name, ip_text)
|
|
} else {
|
|
format!("{}:", child.name)
|
|
}
|
|
};
|
|
|
|
lines.push(Line::from(vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
Span::styled(child_text, Typography::secondary()),
|
|
]));
|
|
}
|
|
}
|
|
|
|
// Render standalone virtual interfaces (those without a parent)
|
|
for (virt_idx, interface) in standalone_virtual.iter().enumerate() {
|
|
let is_last = virt_idx == standalone_virtual.len() - 1;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
|
|
// Virtual interface with IPs
|
|
let ip_text = if !interface.ipv4_addresses.is_empty() {
|
|
Self::compress_ipv4_addresses(&interface.ipv4_addresses)
|
|
} else if !interface.ipv6_addresses.is_empty() {
|
|
interface.ipv6_addresses.join(", ")
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Format: "name (vlan X): IP" or "name: IP"
|
|
let interface_text = if let Some(vlan_id) = interface.vlan_id {
|
|
if !ip_text.is_empty() {
|
|
format!("{} (vlan {}): {}", interface.name, vlan_id, ip_text)
|
|
} else {
|
|
format!("{} (vlan {}):", interface.name, vlan_id)
|
|
}
|
|
} else {
|
|
if !ip_text.is_empty() {
|
|
format!("{}: {}", interface.name, ip_text)
|
|
} else {
|
|
format!("{}:", interface.name)
|
|
}
|
|
};
|
|
|
|
lines.push(Line::from(vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
Span::styled(interface_text, Typography::secondary()),
|
|
]));
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
/// 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())
|
|
]));
|
|
|
|
// 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 cstate_text = self.format_cpu_cstate();
|
|
let has_cpu_info = self.cpu_model_name.is_some() || self.cpu_core_count.is_some();
|
|
let cstate_tree = if has_cpu_info { " ├─ " } else { " └─ " };
|
|
lines.push(Line::from(vec![
|
|
Span::styled(cstate_tree, Typography::tree()),
|
|
Span::styled(format!("C-state: {}", cstate_text), Typography::secondary())
|
|
]));
|
|
|
|
// CPU model and core count (if available)
|
|
if let (Some(model), Some(cores)) = (&self.cpu_model_name, self.cpu_core_count) {
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" └─ ", Typography::tree()),
|
|
Span::styled(format!("{} ({} cores)", model, cores), Typography::secondary())
|
|
]));
|
|
} else if let Some(model) = &self.cpu_model_name {
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" └─ ", Typography::tree()),
|
|
Span::styled(model.clone(), Typography::secondary())
|
|
]));
|
|
} else if let Some(cores) = self.cpu_core_count {
|
|
lines.push(Line::from(vec![
|
|
Span::styled(" └─ ", Typography::tree()),
|
|
Span::styled(format!("{} cores", cores), 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));
|
|
}
|
|
|
|
// Network section
|
|
if !self.network_interfaces.is_empty() {
|
|
lines.push(Line::from(vec![
|
|
Span::styled("Network:", Typography::widget_title())
|
|
]));
|
|
|
|
let network_lines = self.render_network();
|
|
lines.extend(network_lines);
|
|
}
|
|
|
|
// 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_repositories.is_empty() || !self.backup_disks.is_empty() {
|
|
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);
|
|
}
|
|
}
|
|
} |