Implement unified system widget with NixOS info, CPU, RAM, and Storage
- Create NixOS collector for version and active users detection - Add SystemWidget combining all system information in TODO.md layout - Replace separate CPU/Memory widgets with unified system display - Add tree structure for storage with drive temperature/wear info - Support NixOS version, active users, load averages, memory usage - Follow exact decimal formatting from specification
This commit is contained in:
438
dashboard/src/ui/widgets/system.rs
Normal file
438
dashboard/src/ui/widgets/system.rs
Normal file
@@ -0,0 +1,438 @@
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::Widget;
|
||||
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_version: Option<String>,
|
||||
nixos_build_date: Option<String>,
|
||||
active_users: 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,
|
||||
|
||||
// Storage metrics (collected from disk metrics)
|
||||
storage_pools: Vec<StoragePool>,
|
||||
|
||||
// Overall status
|
||||
has_data: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StoragePool {
|
||||
name: String,
|
||||
pool_type: String, // "Single", "Raid0", etc.
|
||||
drives: Vec<StorageDrive>,
|
||||
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,
|
||||
}
|
||||
|
||||
impl SystemWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nixos_version: None,
|
||||
nixos_build_date: None,
|
||||
active_users: 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,
|
||||
storage_pools: 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 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format /tmp usage
|
||||
fn format_tmp_usage(&self) -> String {
|
||||
match (self.tmp_usage_percent, self.tmp_used_gb, self.tmp_total_gb) {
|
||||
(Some(pct), Some(used), Some(total)) => {
|
||||
let used_str = if used < 0.1 {
|
||||
format!("{:.0}B", used * 1024.0) // Show as MB if very small
|
||||
} else {
|
||||
format!("{:.1}GB", used)
|
||||
};
|
||||
format!("{:.0}% {}/{:.1}GB", pct, used_str, total)
|
||||
}
|
||||
_ => "—% —GB/—GB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse storage metrics into pools and drives
|
||||
fn update_storage_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
let mut pools: std::collections::HashMap<String, StoragePool> = std::collections::HashMap::new();
|
||||
|
||||
for metric in metrics {
|
||||
if metric.name.starts_with("disk_") {
|
||||
if let Some(pool_name) = self.extract_pool_name(&metric.name) {
|
||||
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
|
||||
name: pool_name.clone(),
|
||||
pool_type: "Single".to_string(), // Default, could be enhanced
|
||||
drives: Vec::new(),
|
||||
usage_percent: None,
|
||||
used_gb: None,
|
||||
total_gb: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
|
||||
// Parse different metric types
|
||||
if metric.name.contains("_usage_percent") {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
pool.usage_percent = Some(usage);
|
||||
pool.status = metric.status.clone();
|
||||
}
|
||||
} else if metric.name.contains("_used_gb") {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
pool.used_gb = Some(used);
|
||||
}
|
||||
} else if metric.name.contains("_total_gb") {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
pool.total_gb = Some(total);
|
||||
}
|
||||
} else if metric.name.contains("_temperature") {
|
||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||
// Find existing drive or create new one
|
||||
let drive_exists = pool.drives.iter().any(|d| d.name == drive_name);
|
||||
if !drive_exists {
|
||||
pool.drives.push(StorageDrive {
|
||||
name: drive_name.clone(),
|
||||
temperature: None,
|
||||
wear_percent: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||
if let MetricValue::Float(temp) = metric.value {
|
||||
drive.temperature = Some(temp);
|
||||
drive.status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if metric.name.contains("_wear_percent") {
|
||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||
// Find existing drive or create new one
|
||||
let drive_exists = pool.drives.iter().any(|d| d.name == drive_name);
|
||||
if !drive_exists {
|
||||
pool.drives.push(StorageDrive {
|
||||
name: drive_name.clone(),
|
||||
temperature: None,
|
||||
wear_percent: None,
|
||||
status: Status::Unknown,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||
if let MetricValue::Float(wear) = metric.value {
|
||||
drive.wear_percent = Some(wear);
|
||||
drive.status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.storage_pools = pools.into_values().collect();
|
||||
}
|
||||
|
||||
/// Extract pool name from disk metric name
|
||||
fn extract_pool_name(&self, metric_name: &str) -> Option<String> {
|
||||
if let Some(captures) = metric_name.strip_prefix("disk_") {
|
||||
if let Some(pos) = captures.find('_') {
|
||||
return Some(captures[..pos].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract drive name from disk metric name
|
||||
fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
|
||||
// Pattern: disk_pool_drive_metric
|
||||
let parts: Vec<&str> = metric_name.split('_').collect();
|
||||
if parts.len() >= 3 && parts[0] == "disk" {
|
||||
return Some(parts[2].to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Render storage section with tree structure
|
||||
fn render_storage(&self) -> Vec<Line> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Storage:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
for pool in &self.storage_pools {
|
||||
// Pool header line
|
||||
let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
||||
(Some(pct), Some(used), Some(total)) => {
|
||||
format!("{:.0}% {:.1}GB/{:.1}GB", pct, used, total)
|
||||
}
|
||||
_ => "—% —GB/—GB".to_string(),
|
||||
};
|
||||
|
||||
let pool_spans = StatusIcons::create_status_spans(
|
||||
pool.status.clone(),
|
||||
&format!("{} ({}):", pool.name, pool.pool_type)
|
||||
);
|
||||
lines.push(Line::from(pool_spans));
|
||||
|
||||
// Drive lines with tree structure
|
||||
for (i, drive) in pool.drives.iter().enumerate() {
|
||||
let tree_symbol = if i == pool.drives.len() - 1 { "└─" } else { "├─" };
|
||||
|
||||
let mut drive_info = Vec::new();
|
||||
if let Some(temp) = drive.temperature {
|
||||
drive_info.push(format!("T: {:.0}C", temp));
|
||||
}
|
||||
if let Some(wear) = drive.wear_percent {
|
||||
drive_info.push(format!("W: {:.0}%", wear));
|
||||
}
|
||||
let drive_text = if drive_info.is_empty() {
|
||||
drive.name.clone()
|
||||
} else {
|
||||
format!("{} {}", drive.name, drive_info.join(" • "))
|
||||
};
|
||||
|
||||
let mut drive_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::raw(tree_symbol),
|
||||
Span::raw(" "),
|
||||
];
|
||||
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
||||
lines.push(Line::from(drive_spans));
|
||||
}
|
||||
|
||||
// Usage line
|
||||
if pool.usage_percent.is_some() {
|
||||
let tree_symbol = "└─";
|
||||
let mut usage_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::raw(tree_symbol),
|
||||
Span::raw(" "),
|
||||
];
|
||||
usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text));
|
||||
lines.push(Line::from(usage_spans));
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for SystemWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
self.has_data = !metrics.is_empty();
|
||||
|
||||
for metric in metrics {
|
||||
match metric.name.as_str() {
|
||||
// NixOS metrics
|
||||
"system_nixos_version" => {
|
||||
if let MetricValue::String(version) = &metric.value {
|
||||
self.nixos_version = Some(version.clone());
|
||||
}
|
||||
}
|
||||
"system_nixos_build_date" => {
|
||||
if let MetricValue::String(date) = &metric.value {
|
||||
self.nixos_build_date = Some(date.clone());
|
||||
}
|
||||
}
|
||||
"system_active_users" => {
|
||||
if let MetricValue::String(users) = &metric.value {
|
||||
self.active_users = Some(users.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// CPU metrics
|
||||
"cpu_load_1min" => {
|
||||
if let MetricValue::Float(load) = metric.value {
|
||||
self.cpu_load_1min = Some(load);
|
||||
self.cpu_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"cpu_load_5min" => {
|
||||
if let MetricValue::Float(load) = metric.value {
|
||||
self.cpu_load_5min = Some(load);
|
||||
}
|
||||
}
|
||||
"cpu_load_15min" => {
|
||||
if let MetricValue::Float(load) = metric.value {
|
||||
self.cpu_load_15min = Some(load);
|
||||
}
|
||||
}
|
||||
"cpu_frequency_mhz" => {
|
||||
if let MetricValue::Float(freq) = metric.value {
|
||||
self.cpu_frequency = Some(freq);
|
||||
}
|
||||
}
|
||||
|
||||
// Memory metrics
|
||||
"memory_usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
self.memory_usage_percent = Some(usage);
|
||||
self.memory_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"memory_used_gb" => {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
self.memory_used_gb = Some(used);
|
||||
}
|
||||
}
|
||||
"memory_total_gb" => {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
self.memory_total_gb = Some(total);
|
||||
}
|
||||
}
|
||||
"disk_tmp_usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
self.tmp_usage_percent = Some(usage);
|
||||
}
|
||||
}
|
||||
"disk_tmp_used_gb" => {
|
||||
if let MetricValue::Float(used) = metric.value {
|
||||
self.tmp_used_gb = Some(used);
|
||||
}
|
||||
}
|
||||
"disk_tmp_total_gb" => {
|
||||
if let MetricValue::Float(total) = metric.value {
|
||||
self.tmp_total_gb = Some(total);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Update storage from all disk metrics
|
||||
self.update_storage_from_metrics(metrics);
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// NixOS section
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("NixOS:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
let version_text = self.nixos_version.as_deref().unwrap_or("unknown");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("Version: {}", version_text), Typography::secondary())
|
||||
]));
|
||||
|
||||
let users_text = self.active_users.as_deref().unwrap_or("unknown");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("Active users: {}", users_text), Typography::secondary())
|
||||
]));
|
||||
|
||||
// CPU section
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("CPU:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
let load_text = self.format_cpu_load();
|
||||
let freq_text = self.format_cpu_frequency();
|
||||
let cpu_spans = StatusIcons::create_status_spans(
|
||||
self.cpu_status.clone(),
|
||||
&format!("Load: {} • {}", load_text, freq_text)
|
||||
);
|
||||
lines.push(Line::from(cpu_spans));
|
||||
|
||||
// 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));
|
||||
|
||||
let tmp_text = self.format_tmp_usage();
|
||||
let tmp_spans = StatusIcons::create_status_spans(
|
||||
self.memory_status.clone(),
|
||||
&format!("/tmp: {}", tmp_text)
|
||||
);
|
||||
lines.push(Line::from(tmp_spans));
|
||||
|
||||
// Storage section with tree structure
|
||||
lines.extend(self.render_storage());
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(lines))
|
||||
.block(Block::default().borders(Borders::ALL).title("System"));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user