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:
parent
c99e0bd8ee
commit
39fc9cd22f
87
CLAUDE.md
87
CLAUDE.md
@ -6,52 +6,65 @@ A high-performance Rust-based TUI dashboard for monitoring CMTEC infrastructure.
|
|||||||
|
|
||||||
## Implementation Strategy
|
## Implementation Strategy
|
||||||
|
|
||||||
### Next Phase: Systemd Collector Optimization (Based on TODO.md)
|
### Current Implementation Status
|
||||||
|
|
||||||
**Current Status**: Reverted to working baseline (commit 245e546) after optimization broke service discovery.
|
**Systemd Collector Optimization - COMPLETED** ✅
|
||||||
|
|
||||||
**Planned Implementation Steps** (step-by-step to avoid breaking functionality):
|
All phases successfully implemented:
|
||||||
|
- ✅ **Phase 1**: Exact name filtering implemented
|
||||||
|
- ✅ **Phase 2**: User service collection removed
|
||||||
|
- ✅ **Phase 3**: Wildcard pattern support added
|
||||||
|
- ✅ **Phase 4**: Status caching and systemctl call optimization completed
|
||||||
|
- ❌ **Phase 5**: Skipped (would increase systemctl calls vs current caching)
|
||||||
|
|
||||||
**Phase 1: Exact Name Filtering**
|
**Performance Results:**
|
||||||
- Replace `contains()` matching with exact name matching for service filters
|
- Reduced from ~21 systemctl calls to 1 call every 10 seconds (configurable)
|
||||||
- Change `service_name.contains(pattern) || pattern.contains(service_name)` to `service_name == pattern`
|
- Fixed RwLock deadlock issues
|
||||||
- Test: Ensure cmbox remains visible with exact service names in config
|
- Removed hardcoded discovery intervals
|
||||||
- Commit and test after each change
|
|
||||||
|
|
||||||
**Phase 2: Remove User Service Collection**
|
### Next Priority: System Panel Enhancement (Based on TODO.md)
|
||||||
- Remove all `sudo -u` systemctl commands for user services
|
|
||||||
- Remove user_unit_files_output and user_units_output logic
|
|
||||||
- Keep only system service discovery via `systemctl list-units --type=service`
|
|
||||||
- Test: Verify system services still discovered correctly
|
|
||||||
|
|
||||||
**Phase 3: Add Wildcard Support**
|
**Target Layout:**
|
||||||
- Implement glob pattern matching for service filters
|
```
|
||||||
- Support patterns like "nginx*" to match "nginx", "nginx-config-reload", etc.
|
NixOS:
|
||||||
- Use fnmatch or similar for wildcard expansion
|
Version: xxxxxxxxxx
|
||||||
- Test: Verify patterns work as expected
|
Active users: cm, simon
|
||||||
|
CPU:
|
||||||
|
● Load: 0.02 0.31 0.86 • 3000 MHz
|
||||||
|
RAM:
|
||||||
|
● Usage: 33% 2.6GB/7.6GB
|
||||||
|
● /tmp: 0% 0B/2.0GB
|
||||||
|
Storage:
|
||||||
|
● root (Single):
|
||||||
|
├─ ● nvme0n1 Temp: 40C Wear: 4%
|
||||||
|
└─ ● 8% 75.0GB/906.2GB
|
||||||
|
```
|
||||||
|
|
||||||
**Phase 4: Optimize systemctl Calls**
|
**Implementation Tasks:**
|
||||||
- Cache service status information during discovery
|
1. **NixOS Version Display**
|
||||||
- Eliminate redundant `systemctl is-active` and `systemctl show` calls per service
|
- Collect system version information
|
||||||
- Parse status from `systemctl list-units` output directly
|
- Show timestamp/version for latest nixos rebuild
|
||||||
- Test: Ensure performance improvement without functionality loss
|
|
||||||
|
|
||||||
**Phase 5: Include-Only Discovery**
|
2. **Active Users Display**
|
||||||
- Remove auto-discovery of all services
|
- Implement user session detection
|
||||||
- Only check services explicitly listed in service_name_filters
|
- Show currently logged in/active users
|
||||||
- Skip systemctl discovery entirely, use configured list directly
|
|
||||||
- Test: Verify only configured services are monitored
|
|
||||||
|
|
||||||
**Critical Requirements:**
|
3. **System Widget Layout Update**
|
||||||
- Each phase must be tested independently
|
- Update dashboard to match new layout specification
|
||||||
- cmbox must remain visible in dashboard after each change
|
- Integrate NixOS version and user information
|
||||||
- No functionality regressions allowed
|
|
||||||
- Commit each phase separately with descriptive messages
|
|
||||||
|
|
||||||
**Rollback Strategy:**
|
### Future Priorities
|
||||||
- If any phase breaks functionality, immediately revert that specific commit
|
|
||||||
- Do not attempt to "fix forward" - revert and redesign the problematic step
|
**Keyboard Navigation (Dashboard):**
|
||||||
- Each phase should be atomic and independently revertible
|
- Change host switching to "Shift-Tab"
|
||||||
|
- Add panel navigation with "Tab"
|
||||||
|
- Add scrolling support for overflow content
|
||||||
|
|
||||||
|
**Remote Execution (Agent/Dashboard):**
|
||||||
|
- Dynamic statusbar with context shortcuts
|
||||||
|
- Remote nixos rebuild commands
|
||||||
|
- Service start/stop/restart controls
|
||||||
|
- Backup trigger functionality
|
||||||
|
|
||||||
## Core Architecture Principles - CRITICAL
|
## Core Architecture Principles - CRITICAL
|
||||||
|
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@ -16,13 +16,13 @@ NixOS:
|
|||||||
Version: xxxxxxxxxx
|
Version: xxxxxxxxxx
|
||||||
Active users: cm, simon
|
Active users: cm, simon
|
||||||
CPU:
|
CPU:
|
||||||
● Load: 0.02 0.31 0.86 • 3000.2 MHz
|
● Load: 0.02 0.31 0.86 • 3000 MHz
|
||||||
RAM:
|
RAM:
|
||||||
● Usage: 33% 2.6GB/7.6GB
|
● Usage: 33% 2.6GB/7.6GB
|
||||||
● /tmp: 0% 0B/2.0GB
|
● /tmp: 0% 0B/2.0GB
|
||||||
Storage:
|
Storage:
|
||||||
● root (Single):
|
● root (Single):
|
||||||
├─ ● nvme0n1 Temp: 40C Wear: 4%
|
├─ ● nvme0n1 T: 40C • W: 4%
|
||||||
└─ ● 8% 75.0GB/906.2GB
|
└─ ● 8% 75.0GB/906.2GB
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ pub mod cpu;
|
|||||||
pub mod disk;
|
pub mod disk;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
pub mod nixos;
|
||||||
pub mod systemd;
|
pub mod systemd;
|
||||||
|
|
||||||
pub use error::CollectorError;
|
pub use error::CollectorError;
|
||||||
|
|||||||
163
agent/src/collectors/nixos.rs
Normal file
163
agent/src/collectors/nixos.rs
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use cm_dashboard_shared::{Metric, MetricValue, Status, StatusTracker};
|
||||||
|
use std::process::Command;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use super::{Collector, CollectorError};
|
||||||
|
use crate::config::NixOSConfig;
|
||||||
|
|
||||||
|
/// NixOS system information collector
|
||||||
|
///
|
||||||
|
/// Collects NixOS-specific system information including:
|
||||||
|
/// - NixOS version and build information
|
||||||
|
/// - Currently active/logged in users
|
||||||
|
pub struct NixOSCollector {
|
||||||
|
config: NixOSConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NixOSCollector {
|
||||||
|
pub fn new(config: NixOSConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get NixOS version information
|
||||||
|
fn get_nixos_version(&self) -> Result<(String, Option<String>), Box<dyn std::error::Error>> {
|
||||||
|
// Try nixos-version command first
|
||||||
|
if let Ok(output) = Command::new("nixos-version").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let version_line = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let version = version_line.trim().to_string();
|
||||||
|
|
||||||
|
// Extract build date if present (format: "24.05.20241023.abcdef (Vicuna)")
|
||||||
|
let build_date = if version.contains('.') {
|
||||||
|
let parts: Vec<&str> = version.split('.').collect();
|
||||||
|
if parts.len() >= 3 && parts[2].len() >= 8 {
|
||||||
|
Some(parts[2][..8].to_string()) // Extract YYYYMMDD
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok((version, build_date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to /etc/os-release
|
||||||
|
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.starts_with("VERSION_ID=") {
|
||||||
|
let version = line
|
||||||
|
.strip_prefix("VERSION_ID=")
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
return Ok((version, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Could not determine NixOS version".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get currently active users
|
||||||
|
fn get_active_users(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let output = Command::new("who").output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err("who command failed".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let who_output = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut users = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for line in who_output.lines() {
|
||||||
|
if let Some(username) = line.split_whitespace().next() {
|
||||||
|
if !username.is_empty() {
|
||||||
|
users.insert(username.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(users.into_iter().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Collector for NixOSCollector {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"nixos"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect(&self, _status_tracker: &mut StatusTracker) -> Result<Vec<Metric>, CollectorError> {
|
||||||
|
debug!("Collecting NixOS system information");
|
||||||
|
let mut metrics = Vec::new();
|
||||||
|
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||||
|
|
||||||
|
// Collect NixOS version information
|
||||||
|
match self.get_nixos_version() {
|
||||||
|
Ok((version, build_date)) => {
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "system_nixos_version".to_string(),
|
||||||
|
value: MetricValue::String(version),
|
||||||
|
unit: None,
|
||||||
|
description: Some("NixOS version".to_string()),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(date) = build_date {
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "system_nixos_build_date".to_string(),
|
||||||
|
value: MetricValue::String(date),
|
||||||
|
unit: None,
|
||||||
|
description: Some("NixOS build date".to_string()),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to get NixOS version: {}", e);
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "system_nixos_version".to_string(),
|
||||||
|
value: MetricValue::String("unknown".to_string()),
|
||||||
|
unit: None,
|
||||||
|
description: Some("NixOS version (failed to detect)".to_string()),
|
||||||
|
status: Status::Unknown,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect active users
|
||||||
|
match self.get_active_users() {
|
||||||
|
Ok(users) => {
|
||||||
|
let users_str = users.join(", ");
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "system_active_users".to_string(),
|
||||||
|
value: MetricValue::String(users_str),
|
||||||
|
unit: None,
|
||||||
|
description: Some("Currently active users".to_string()),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to get active users: {}", e);
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "system_active_users".to_string(),
|
||||||
|
value: MetricValue::String("unknown".to_string()),
|
||||||
|
unit: None,
|
||||||
|
description: Some("Active users (failed to detect)".to_string()),
|
||||||
|
status: Status::Unknown,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Collected {} NixOS metrics", metrics.len());
|
||||||
|
Ok(metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ pub struct CollectorConfig {
|
|||||||
pub smart: SmartConfig,
|
pub smart: SmartConfig,
|
||||||
pub backup: BackupConfig,
|
pub backup: BackupConfig,
|
||||||
pub network: NetworkConfig,
|
pub network: NetworkConfig,
|
||||||
|
pub nixos: NixOSConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CPU collector configuration
|
/// CPU collector configuration
|
||||||
@ -113,6 +114,13 @@ pub struct SmartConfig {
|
|||||||
pub wear_critical_percent: f32,
|
pub wear_critical_percent: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NixOS collector configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NixOSConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub interval_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Backup collector configuration
|
/// Backup collector configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BackupConfig {
|
pub struct BackupConfig {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use tracing::{debug, error, info};
|
|||||||
use crate::cache::MetricCacheManager;
|
use crate::cache::MetricCacheManager;
|
||||||
use crate::collectors::{
|
use crate::collectors::{
|
||||||
backup::BackupCollector, cpu::CpuCollector, disk::DiskCollector, memory::MemoryCollector,
|
backup::BackupCollector, cpu::CpuCollector, disk::DiskCollector, memory::MemoryCollector,
|
||||||
systemd::SystemdCollector, Collector,
|
nixos::NixOSCollector, systemd::SystemdCollector, Collector,
|
||||||
};
|
};
|
||||||
use crate::config::{AgentConfig, CollectorConfig};
|
use crate::config::{AgentConfig, CollectorConfig};
|
||||||
|
|
||||||
@ -100,6 +100,12 @@ impl MetricCollectionManager {
|
|||||||
collectors.push(Box::new(backup_collector));
|
collectors.push(Box::new(backup_collector));
|
||||||
info!("Backup collector initialized");
|
info!("Backup collector initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.nixos.enabled {
|
||||||
|
let nixos_collector = NixOSCollector::new(config.nixos.clone());
|
||||||
|
collectors.push(Box::new(nixos_collector));
|
||||||
|
info!("NixOS collector initialized");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,15 +16,13 @@ pub mod widgets;
|
|||||||
use crate::metrics::MetricStore;
|
use crate::metrics::MetricStore;
|
||||||
use cm_dashboard_shared::{Metric, Status};
|
use cm_dashboard_shared::{Metric, Status};
|
||||||
use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography};
|
use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography};
|
||||||
use widgets::{BackupWidget, CpuWidget, MemoryWidget, ServicesWidget, Widget};
|
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
||||||
|
|
||||||
/// Widget states for a specific host
|
/// Widget states for a specific host
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HostWidgets {
|
pub struct HostWidgets {
|
||||||
/// CPU widget state
|
/// System widget state (includes CPU, Memory, NixOS info, Storage)
|
||||||
pub cpu_widget: CpuWidget,
|
pub system_widget: SystemWidget,
|
||||||
/// Memory widget state
|
|
||||||
pub memory_widget: MemoryWidget,
|
|
||||||
/// Services widget state
|
/// Services widget state
|
||||||
pub services_widget: ServicesWidget,
|
pub services_widget: ServicesWidget,
|
||||||
/// Backup widget state
|
/// Backup widget state
|
||||||
@ -36,8 +34,7 @@ pub struct HostWidgets {
|
|||||||
impl HostWidgets {
|
impl HostWidgets {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cpu_widget: CpuWidget::new(),
|
system_widget: SystemWidget::new(),
|
||||||
memory_widget: MemoryWidget::new(),
|
|
||||||
services_widget: ServicesWidget::new(),
|
services_widget: ServicesWidget::new(),
|
||||||
backup_widget: BackupWidget::new(),
|
backup_widget: BackupWidget::new(),
|
||||||
last_update: None,
|
last_update: None,
|
||||||
@ -115,10 +112,27 @@ impl TuiApp {
|
|||||||
// Now get host widgets and update them
|
// Now get host widgets and update them
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
|
|
||||||
host_widgets.cpu_widget.update_from_metrics(&cpu_metrics);
|
// Collect all system metrics (CPU, memory, NixOS, disk/storage)
|
||||||
host_widgets
|
let mut system_metrics = cpu_metrics;
|
||||||
.memory_widget
|
system_metrics.extend(memory_metrics);
|
||||||
.update_from_metrics(&memory_metrics);
|
|
||||||
|
// Add NixOS metrics
|
||||||
|
let nixos_metrics: Vec<&Metric> = all_metrics
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.name.starts_with("system_nixos") || m.name.starts_with("system_active"))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
system_metrics.extend(nixos_metrics);
|
||||||
|
|
||||||
|
// Add disk/storage metrics
|
||||||
|
let disk_metrics: Vec<&Metric> = all_metrics
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.name.starts_with("disk_"))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
system_metrics.extend(disk_metrics);
|
||||||
|
|
||||||
|
host_widgets.system_widget.update_from_metrics(&system_metrics);
|
||||||
host_widgets
|
host_widgets
|
||||||
.services_widget
|
.services_widget
|
||||||
.update_from_metrics(&service_metrics);
|
.update_from_metrics(&service_metrics);
|
||||||
@ -396,22 +410,11 @@ impl TuiApp {
|
|||||||
let system_block = Components::widget_block("system");
|
let system_block = Components::widget_block("system");
|
||||||
let inner_area = system_block.inner(area);
|
let inner_area = system_block.inner(area);
|
||||||
frame.render_widget(system_block, area);
|
frame.render_widget(system_block, area);
|
||||||
let content_chunks = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load)
|
|
||||||
Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp)
|
|
||||||
Constraint::Min(0), // Storage section
|
|
||||||
])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
// Get current host widgets, create if none exist
|
// Get current host widgets, create if none exist
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
host_widgets.cpu_widget.render(frame, content_chunks[0]);
|
host_widgets.system_widget.render(frame, inner_area);
|
||||||
host_widgets.memory_widget.render(frame, content_chunks[1]);
|
|
||||||
}
|
}
|
||||||
self.render_storage_section(frame, content_chunks[2], metric_store);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
||||||
|
|||||||
@ -5,11 +5,13 @@ pub mod backup;
|
|||||||
pub mod cpu;
|
pub mod cpu;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
pub mod system;
|
||||||
|
|
||||||
pub use backup::BackupWidget;
|
pub use backup::BackupWidget;
|
||||||
pub use cpu::CpuWidget;
|
pub use cpu::CpuWidget;
|
||||||
pub use memory::MemoryWidget;
|
pub use memory::MemoryWidget;
|
||||||
pub use services::ServicesWidget;
|
pub use services::ServicesWidget;
|
||||||
|
pub use system::SystemWidget;
|
||||||
|
|
||||||
/// Widget trait for UI components that display metrics
|
/// Widget trait for UI components that display metrics
|
||||||
pub trait Widget {
|
pub trait Widget {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user