Some checks failed
Build and Release / build-and-release (push) Failing after 1m32s
Extend NixOS collector to gather network interfaces using ip command JSON output. Display all interfaces with IPv4 and IPv6 addresses in Network section above CPU metrics. Filters out loopback and link-local addresses. Version bump to 0.1.161
751 lines
29 KiB
Rust
751 lines
29 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_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,
|
|
network_interfaces: Vec::new(),
|
|
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 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_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()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// Render network section for display
|
|
fn render_network(&self) -> Vec<Line<'_>> {
|
|
let mut lines = Vec::new();
|
|
|
|
if self.network_interfaces.is_empty() {
|
|
return lines;
|
|
}
|
|
|
|
for (i, interface) in self.network_interfaces.iter().enumerate() {
|
|
let is_last = i == self.network_interfaces.len() - 1;
|
|
let tree_symbol = if is_last { " └─ " } else { " ├─ " };
|
|
|
|
// Show interface name
|
|
let mut interface_text = format!("{}: ", interface.name);
|
|
|
|
// Add IPv4 addresses
|
|
if !interface.ipv4_addresses.is_empty() {
|
|
interface_text.push_str(&interface.ipv4_addresses.join(", "));
|
|
}
|
|
|
|
// Add IPv6 addresses
|
|
if !interface.ipv6_addresses.is_empty() {
|
|
if !interface.ipv4_addresses.is_empty() {
|
|
interface_text.push_str(", ");
|
|
}
|
|
interface_text.push_str(&interface.ipv6_addresses.join(", "));
|
|
}
|
|
|
|
let mut spans = vec![
|
|
Span::styled(tree_symbol, Typography::tree()),
|
|
];
|
|
spans.extend(StatusIcons::create_status_spans(Status::Ok, &interface_text));
|
|
lines.push(Line::from(spans));
|
|
}
|
|
|
|
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())
|
|
]));
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
} |