Compare commits

..

20 Commits

Author SHA1 Message Date
748a9f3a3b Move Network section below RAM in system widget
All checks were successful
Build and Release / build-and-release (push) Successful in 1m11s
Reordered display sections in system widget:
- Network section now appears after RAM and tmpfs mounts
- Improves logical grouping by placing network info between memory and storage
- Updated to version 0.1.168
2025-11-26 23:23:56 +01:00
5c6b11c794 Filter out network interfaces without IP addresses
All checks were successful
Build and Release / build-and-release (push) Successful in 1m9s
Remove interfaces like ifb0, dummy devices that have no IPs. Only show interfaces with at least one IPv4 or IPv6 address.

Version bump to 0.1.167
2025-11-26 19:19:21 +01:00
9f0aa5f806 Update network display format to match CLAUDE.md specification
All checks were successful
Build and Release / build-and-release (push) Successful in 1m38s
Nest IP addresses under physical interface names. Show physical interfaces with status icon on header line. Virtual interfaces show inline with compressed IPs.

Format:
● eno1:
  ├─ ip: 192.168.30.105
  └─ tailscale0: 100.125.108.16

Version bump to 0.1.166
2025-11-26 19:13:28 +01:00
fc247bd0ad Create dedicated network collector with physical/virtual interface grouping
All checks were successful
Build and Release / build-and-release (push) Successful in 1m43s
Move network collection from NixOS collector to dedicated NetworkCollector. Add link status detection for physical interfaces (up/down). Group interfaces by physical/virtual, show status icons for physical NICs only. Down interfaces show as Inactive instead of Critical.

Version bump to 0.1.165
2025-11-26 19:02:50 +01:00
00fe8c28ab Remove status icon from network interface display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Network interfaces now display without status icons since there's no meaningful status to show. Just shows interface name and IP addresses with subnet compression.

Version bump to 0.1.164
2025-11-26 18:15:01 +01:00
fbbb4a4cfb Add subnet compression for IP address display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m8s
Compress IPv4 addresses from same subnet to save space. Shows first IP in full (192.168.30.1) and subsequent IPs in same subnet with only last octet (100, 142).

Version bump to 0.1.163
2025-11-26 18:10:08 +01:00
53e1d8bbce Version bump to 0.1.162
All checks were successful
Build and Release / build-and-release (push) Successful in 1m44s
2025-11-26 18:01:31 +01:00
1b9fecea98 Fix nixosbox file path in release workflow
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
Correct path from hosts/services/cm-dashboard.nix to services/cm-dashboard.nix
2025-11-26 17:55:28 +01:00
b7ffeaced5 Add network interface collection and display
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
2025-11-26 17:41:35 +01:00
3858309a5d Fix Docker container detection with sudo permissions
Some checks failed
Build and Release / build-and-release (push) Failing after 1m19s
Update systemd collector to use sudo for docker ps command to resolve
permission issues when cm-agent user lacks docker group membership.
This ensures Docker containers are properly discovered and displayed
as sub-services under the docker service.

Version: 0.1.160
2025-11-25 12:40:27 +01:00
df104bf940 Remove debug prints and unused code
All checks were successful
Build and Release / build-and-release (push) Successful in 1m19s
- Remove all debug println statements
- Remove unused service_tracker module
- Remove unused struct fields and methods
- Remove empty placeholder files (cpu.rs, memory.rs, defaults.rs)
- Fix all compiler warnings
- Clean build with zero warnings

Version bump to 0.1.159
2025-11-25 12:19:04 +01:00
d5ce36ee18 Add support for additional SMART attributes
All checks were successful
Build and Release / build-and-release (push) Successful in 1m30s
- Support Temperature_Case attribute for Intel SSDs
- Support Media_Wearout_Indicator attribute for wear percentage
- Parse wear value from column 3 (VALUE) for Media_Wearout_Indicator
- Fixes temperature and wear display for Intel PHLA847000FL512DGN drives
2025-11-25 11:53:08 +01:00
4f80701671 Fix NVMe serial display and improve pool health logic
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
- Fix physical drive serial number display in dashboard
- Improve pool health calculation for arrays with multiple disks
- Support proper tree symbols for multiple parity drives
- Read git commit hash from /var/lib/cm-dashboard/git-commit for Build display
2025-11-25 11:44:20 +01:00
267654fda4 Improve NVMe serial parsing and restructure MergerFS display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m25s
- Fix NVMe serial number parsing to handle whitespace variations
- Move mount point to MergerFS header, remove drive count
- Restructure data drives to same level as parity with Data_1, Data_2 labels
- Remove "Total:" label from pool usage line
- Update parity to use closing tree symbol as last item
2025-11-25 11:28:54 +01:00
dc1105eefe Display disk serial numbers instead of device names
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
- Add serial_number field to DriveData structure
- Collect serial numbers from SMART data for all drives
- Display truncated serial numbers (last 8 chars) in dashboard
- Fix parity drive label to show status icon before "Parity:"
- Fix mount point label styling to match other labels
2025-11-25 11:06:54 +01:00
c9d12793ef Replace device names with serial numbers in MergerFS pool display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m19s
Updates disk collector and dashboard to show drive serial numbers
instead of device names (sdX) for MergerFS data/parity drives.
Agent extracts serial numbers from SMART data and dashboard
displays them when available, falling back to device names.
2025-11-25 10:30:37 +01:00
8f80015273 Fix dashboard storage pool label styling
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Replace non-existent Typography::primary() with Typography::secondary() for
MergerFS pool labels following existing UI patterns.
2025-11-25 10:16:26 +01:00
7a95a9d762 Add MergerFS pool display to dashboard matching CLAUDE.md format
All checks were successful
Build and Release / build-and-release (push) Successful in 2m32s
Updated the dashboard system widget to properly display MergerFS storage
pools in the exact format described in CLAUDE.md:

- Pool header showing "mergerfs (2+1):" format
- Total usage line: "├─ Total: ● 63% 2355.2GB/3686.4GB"
- Data Disks section with tree structure
- Individual drive entries: "│  ├─ ● sdb T: 24°C W: 5%"
- Parity drives section: "├─ Parity: ● sdc T: 24°C W: 5%"
- Mount point footer: "└─ Mount: /srv/media"

The dashboard now processes both data_drives and parity_drives arrays from
the agent data correctly and renders the complete MergerFS pool hierarchy
with proper status indicators, temperatures, and wear levels.

Storage display now matches the enhanced tree structure format specified
in documentation with correct Unicode tree characters and spacing.
2025-11-25 09:12:13 +01:00
7b11db990c Restore complete MergerFS and SnapRAID functionality to disk collector
All checks were successful
Build and Release / build-and-release (push) Successful in 1m17s
Updated the disk collector to include all missing functionality from the
previous string-based implementation while working with the new structured
JSON data architecture:

- MergerFS pool discovery from /proc/mounts parsing
- SnapRAID parity drive detection via mount path heuristics
- Drive categorization (data vs parity) based on path analysis
- Numeric mergerfs reference resolution (1:2 -> /mnt/disk paths)
- Pool health calculation based on member drive SMART status
- Complete SMART data integration for temperatures and wear levels
- Proper exclusion of pool member drives from physical drive grouping

The implementation replicates the exact logic from the old code while
adapting to structured AgentData output format. All mergerfs and snapraid
monitoring capabilities are fully restored.
2025-11-25 08:37:32 +01:00
67b59e9551 Simplify backup timestamp display with raw TOML format and remove spacing
All checks were successful
Build and Release / build-and-release (push) Successful in 1m41s
Replace timestamp parsing with direct display of start_time from backup TOML file to ensure timestamp always appears regardless of format. Remove empty line spacing above backup section for compact layout.

Changes:
- Remove parsed timestamp fields and use raw start_time string from TOML
- Display backup time directly from TOML file without parsing
- Remove blank line above backup section for tighter layout
- Simplify BackupData structure by removing last_run and next_scheduled fields

Version bump to v0.1.150
2025-11-25 00:08:36 +01:00
25 changed files with 913 additions and 1566 deletions

View File

@@ -113,13 +113,13 @@ jobs:
NIX_HASH="sha256-$(python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('$NEW_HASH')).decode())")"
# Update the NixOS configuration
sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" hosts/services/cm-dashboard.nix
sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" hosts/services/cm-dashboard.nix
sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" services/cm-dashboard.nix
sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" services/cm-dashboard.nix
# Commit and push changes
git config user.name "Gitea Actions"
git config user.email "actions@gitea.cmtec.se"
git add hosts/services/cm-dashboard.nix
git add services/cm-dashboard.nix
git commit -m "Auto-update cm-dashboard to $VERSION
- Update version to $VERSION with automated release

116
CLAUDE.md
View File

@@ -304,28 +304,28 @@ exclude_fs_types = ["tmpfs", "devtmpfs", "sysfs", "proc"]
### Display Format
```
Network:
● eno1:
├─ ip: 192.168.30.105
└─ tailscale0: 100.125.108.16
● eno2:
└─ ip: 192.168.32.105
CPU:
● Load: 0.23 0.21 0.13
└─ Freq: 1048 MHz
RAM:
● Usage: 25% 5.8GB/23.3GB
├─ ● /tmp: 2% 0.5GB/2GB
└─ ● /var/tmp: 0% 0GB/1.0GB
Storage:
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
● nvme0n1 T: 25C W: 4%
844B9A25 T: 25C W: 4%
├─ ● /: 55% 250.5GB/456.4GB
└─ ● /boot: 26% 0.3GB/1.0GB
● mergerfs /srv/media:
├─ ● 63% 2355.2GB/3686.4GB
├─ ● Data_1: WDZQ8H8D T: 28°C
├─ ● Data_2: GGA04461 T: 28°C
└─ ● Parity: WDZS8RY0 T: 29°C
Backup:
● WD-WCC7K1234567 T: 32°C W: 12%
├─ Last: 2h ago (12.3GB)
@@ -361,98 +361,6 @@ Keep responses concise and focused. Avoid extensive implementation summaries unl
- ✅ "Restructure storage widget with improved layout"
- ✅ "Update CPU thresholds to production values"
## Completed Architecture Migration (v0.1.131)
## ✅ COMPLETE MONITORING SYSTEM RESTORATION (v0.1.141)
**🎉 SUCCESS: All Issues Fixed - Complete Functional Monitoring System**
### ✅ Completed Implementation (v0.1.141)
**All Major Issues Resolved:**
```
✅ Data Collection: Agent collects structured data correctly
✅ Storage Display: Perfect format with correct mount points and temperature/wear
✅ Status Evaluation: All metrics properly evaluated against thresholds
✅ Notifications: Working email alerts on status changes
✅ Thresholds: All collectors using configured thresholds for status calculation
✅ Build Information: NixOS version displayed correctly
✅ Mount Point Consistency: Stable, sorted display order
```
### ✅ All Phases Completed Successfully
#### ✅ Phase 1: Storage Display - COMPLETED
- ✅ Use `lsblk` instead of `findmnt` (eliminated `/nix/store` bind mount issue)
- ✅ Add `sudo smartctl` for permissions (SMART data collection working)
- ✅ Fix NVMe SMART parsing (`Temperature:` and `Percentage Used:` fields)
- ✅ Consistent filesystem/tmpfs sorting (no more random order swapping)
-**VERIFIED**: Dashboard shows `● nvme0n1 T: 28°C W: 1%` correctly
#### ✅ Phase 2: Status Evaluation System - COMPLETED
-**CPU Status**: Load averages and temperature evaluated against `HysteresisThresholds`
-**Memory Status**: Usage percentage evaluated against thresholds
-**Storage Status**: Drive temperature, health, and filesystem usage evaluated
-**Service Status**: Service states properly tracked and evaluated
-**Status Fields**: All AgentData structures include status information
-**Threshold Integration**: All collectors use their configured thresholds
#### ✅ Phase 3: Notification System - COMPLETED
-**Status Change Detection**: Agent tracks status between collection cycles
-**Email Notifications**: Alerts sent on degradation (OK→Warning/Critical, Warning→Critical)
-**Notification Content**: Detailed alerts with metric values and timestamps
-**NotificationManager Integration**: Fully restored and operational
-**Maintenance Mode**: `/tmp/cm-maintenance` file support maintained
#### ✅ Phase 4: Integration & Testing - COMPLETED
-**AgentData Status Fields**: All structured data includes status evaluation
-**Status Processing**: Agent applies thresholds at collection time
-**End-to-End Flow**: Collection → Evaluation → Notification → Display
-**Dynamic Versioning**: Agent version from `CARGO_PKG_VERSION`
-**Build Information**: NixOS generation display restored
### ✅ Final Architecture - WORKING
**Complete Operational Flow:**
```
Collectors → AgentData (with Status) → NotificationManager → Email Alerts
↘ ↗
ZMQ → Dashboard → Perfect Display
```
**Operational Components:**
1.**Collectors**: Populate AgentData with metrics AND status evaluation
2.**Status Evaluation**: `HysteresisThresholds.evaluate()` applied per collector
3.**Notifications**: Email alerts on status change detection
4.**Display**: Correct mount points, temperature, wear, and build information
### ✅ Success Criteria - ALL MET
**Display Requirements:**
- ✅ Dashboard shows `● nvme0n1 T: 28°C W: 1%` format perfectly
- ✅ Mount points show `/` and `/boot` (not `root`/`boot`)
- ✅ Build information shows actual NixOS version (not "unknown")
- ✅ Consistent sorting eliminates random order changes
**Monitoring Requirements:**
- ✅ High CPU load triggers Warning/Critical status and email alert
- ✅ High memory usage triggers Warning/Critical status and email alert
- ✅ High disk temperature triggers Warning/Critical status and email alert
- ✅ Failed services trigger Warning/Critical status and email alert
- ✅ Maintenance mode suppresses notifications as expected
### 🚀 Production Ready
**CM Dashboard v0.1.141 is a complete, functional infrastructure monitoring system:**
- **Real-time Monitoring**: All system components with 1-second intervals
- **Intelligent Alerting**: Email notifications on threshold violations
- **Perfect Display**: Accurate mount points, temperatures, and system information
- **Status-Aware**: All metrics evaluated against configurable thresholds
- **Production Ready**: Full monitoring capabilities restored
**The monitoring system is fully operational and ready for production use.**
## Implementation Rules
1. **Agent Status Authority**: Agent calculates status for each metric using thresholds

6
Cargo.lock generated
View File

@@ -279,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cm-dashboard"
version = "0.1.148"
version = "0.1.168"
dependencies = [
"anyhow",
"chrono",
@@ -301,7 +301,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-agent"
version = "0.1.148"
version = "0.1.168"
dependencies = [
"anyhow",
"async-trait",
@@ -324,7 +324,7 @@ dependencies = [
[[package]]
name = "cm-dashboard-shared"
version = "0.1.148"
version = "0.1.168"
dependencies = [
"chrono",
"serde",

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-agent"
version = "0.1.149"
version = "0.1.168"
edition = "2021"
[dependencies]

View File

@@ -12,11 +12,11 @@ use crate::collectors::{
cpu::CpuCollector,
disk::DiskCollector,
memory::MemoryCollector,
network::NetworkCollector,
nixos::NixOSCollector,
systemd::SystemdCollector,
};
use crate::notifications::NotificationManager;
use crate::service_tracker::UserStoppedServiceTracker;
use cm_dashboard_shared::AgentData;
pub struct Agent {
@@ -25,7 +25,6 @@ pub struct Agent {
zmq_handler: ZmqHandler,
collectors: Vec<Box<dyn Collector>>,
notification_manager: NotificationManager,
service_tracker: UserStoppedServiceTracker,
previous_status: Option<SystemStatus>,
}
@@ -79,7 +78,11 @@ impl Agent {
if config.collectors.backup.enabled {
collectors.push(Box::new(BackupCollector::new()));
}
if config.collectors.network.enabled {
collectors.push(Box::new(NetworkCollector::new(config.collectors.network.clone())));
}
if config.collectors.nixos.enabled {
collectors.push(Box::new(NixOSCollector::new(config.collectors.nixos.clone())));
}
@@ -90,17 +93,12 @@ impl Agent {
let notification_manager = NotificationManager::new(&config.notifications, &hostname)?;
info!("Notification manager initialized");
// Initialize service tracker
let service_tracker = UserStoppedServiceTracker::new();
info!("Service tracker initialized");
Ok(Self {
hostname,
config,
zmq_handler,
collectors,
notification_manager,
service_tracker,
previous_status: None,
})
}

View File

@@ -1,5 +1,4 @@
use async_trait::async_trait;
use chrono;
use cm_dashboard_shared::{AgentData, BackupData, BackupDiskData};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -47,15 +46,7 @@ impl BackupCollector {
/// Convert BackupStatusToml to BackupData and populate AgentData
async fn populate_backup_data(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
if let Some(backup_status) = self.read_backup_status().await? {
// Parse start_time to get last_run timestamp
let last_run = if let Ok(parsed_time) = chrono::DateTime::parse_from_str(&backup_status.start_time, "%Y-%m-%d %H:%M:%S UTC") {
Some(parsed_time.timestamp() as u64)
} else {
None
};
// Calculate next_scheduled (if needed - may need to be provided by backup script)
let next_scheduled = None; // TODO: Extract from backup script if available
// Use raw start_time string from TOML
// Extract disk information
let repository_disk = if let Some(disk_space) = &backup_status.disk_space {
@@ -89,12 +80,11 @@ impl BackupCollector {
let backup_data = BackupData {
status: backup_status.status,
last_run,
next_scheduled,
total_size_gb: Some(total_size_gb),
repository_health: Some("ok".to_string()), // Derive from status if needed
repository_disk,
last_backup_size_gb: None, // Not available in current TOML format
start_time_raw: Some(backup_status.start_time),
};
agent_data.backup = backup_data;
@@ -102,12 +92,11 @@ impl BackupCollector {
// No backup status available - set default values
agent_data.backup = BackupData {
status: "unavailable".to_string(),
last_run: None,
next_scheduled: None,
total_size_gb: None,
repository_health: None,
repository_disk: None,
last_backup_size_gb: None,
start_time_raw: None,
};
}

View File

@@ -19,10 +19,8 @@ pub struct DiskCollector {
/// A physical drive with its filesystems
#[derive(Debug, Clone)]
struct PhysicalDrive {
name: String, // e.g., "nvme0n1", "sda"
name: String, // e.g., "nvme0n1", "sda"
health: String, // SMART health status
temperature_celsius: Option<f32>, // Drive temperature
wear_percent: Option<f32>, // SSD wear level
filesystems: Vec<Filesystem>, // mounted filesystems on this drive
}
@@ -50,6 +48,7 @@ struct MergerfsPool {
#[derive(Debug, Clone)]
struct PoolDrive {
name: String, // Drive name
mount_point: String, // e.g., "/mnt/disk1"
temperature_celsius: Option<f32>, // Drive temperature
}
@@ -75,11 +74,17 @@ impl DiskCollector {
let mount_devices = self.get_mount_devices().await?;
// Step 2: Get filesystem usage for each mount point using df
let filesystem_usage = self.get_filesystem_usage(&mount_devices).map_err(|e| CollectorError::Parse {
let mut filesystem_usage = self.get_filesystem_usage(&mount_devices).map_err(|e| CollectorError::Parse {
value: "filesystem usage".to_string(),
error: format!("Failed to get filesystem usage: {}", e),
})?;
// Step 2.5: Add MergerFS mount points that weren't in lsblk output
self.add_mergerfs_filesystem_usage(&mut filesystem_usage).map_err(|e| CollectorError::Parse {
value: "mergerfs filesystem usage".to_string(),
error: format!("Failed to get mergerfs filesystem usage: {}", e),
})?;
// Step 3: Detect MergerFS pools
let mergerfs_pools = self.detect_mergerfs_pools(&filesystem_usage).map_err(|e| CollectorError::Parse {
value: "mergerfs pools".to_string(),
@@ -155,6 +160,30 @@ impl DiskCollector {
Ok(filesystem_usage)
}
/// Add filesystem usage for MergerFS mount points that aren't in lsblk
fn add_mergerfs_filesystem_usage(&self, filesystem_usage: &mut HashMap<String, (u64, u64)>) -> anyhow::Result<()> {
let mounts_content = std::fs::read_to_string("/proc/mounts")
.map_err(|e| anyhow::anyhow!("Failed to read /proc/mounts: {}", e))?;
for line in mounts_content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 && parts[2] == "fuse.mergerfs" {
let mount_point = parts[1].to_string();
// Only add if we don't already have usage data for this mount point
if !filesystem_usage.contains_key(&mount_point) {
if let Ok((total, used)) = self.get_filesystem_info(&mount_point) {
debug!("Added MergerFS filesystem usage for {}: {}GB total, {}GB used",
mount_point, total as f32 / (1024.0 * 1024.0 * 1024.0), used as f32 / (1024.0 * 1024.0 * 1024.0));
filesystem_usage.insert(mount_point, (total, used));
}
}
}
}
Ok(())
}
/// Get filesystem info for a single mount point
fn get_filesystem_info(&self, mount_point: &str) -> Result<(u64, u64), CollectorError> {
let output = Command::new("df")
@@ -198,16 +227,80 @@ impl DiskCollector {
}
/// Detect MergerFS pools from mount data
fn detect_mergerfs_pools(&self, _filesystem_usage: &HashMap<String, (u64, u64)>) -> anyhow::Result<Vec<MergerfsPool>> {
let pools = Vec::new();
fn detect_mergerfs_pools(&self, filesystem_usage: &HashMap<String, (u64, u64)>) -> anyhow::Result<Vec<MergerfsPool>> {
let mounts_content = std::fs::read_to_string("/proc/mounts")
.map_err(|e| anyhow::anyhow!("Failed to read /proc/mounts: {}", e))?;
let mut pools = Vec::new();
// For now, return empty pools - full mergerfs detection would require parsing /proc/mounts for fuse.mergerfs
// This ensures we don't break existing functionality
for line in mounts_content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 && parts[2] == "fuse.mergerfs" {
let mount_point = parts[1].to_string();
let device_sources = parts[0]; // e.g., "/mnt/disk1:/mnt/disk2"
// Get pool usage
let (total_bytes, used_bytes) = filesystem_usage.get(&mount_point)
.copied()
.unwrap_or((0, 0));
// Extract pool name from mount point (e.g., "/srv/media" -> "srv_media")
let pool_name = if mount_point == "/" {
"root".to_string()
} else {
mount_point.trim_start_matches('/').replace('/', "_")
};
if pool_name.is_empty() {
debug!("Skipping mergerfs pool with empty name: {}", mount_point);
continue;
}
// Parse member paths - handle both full paths and numeric references
let raw_paths: Vec<String> = device_sources
.split(':')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Convert numeric references to actual mount points if needed
let member_paths = if raw_paths.iter().any(|path| !path.starts_with('/')) {
// Handle numeric format like "1:2" by finding corresponding /mnt/disk* paths
self.resolve_numeric_mergerfs_paths(&raw_paths)?
} else {
// Already full paths
raw_paths
};
// For SnapRAID setups, include parity drives that are related to this pool's data drives
let mut all_member_paths = member_paths.clone();
let related_parity_paths = self.discover_related_parity_drives(&member_paths)?;
all_member_paths.extend(related_parity_paths);
// Categorize as data vs parity drives
let (data_drives, parity_drives) = match self.categorize_pool_drives(&all_member_paths) {
Ok(drives) => drives,
Err(e) => {
debug!("Failed to categorize drives for pool {}: {}. Skipping.", mount_point, e);
continue;
}
};
pools.push(MergerfsPool {
name: pool_name,
mount_point,
total_bytes,
used_bytes,
data_drives,
parity_drives,
});
}
}
debug!("Found {} mergerfs pools", pools.len());
Ok(pools)
}
/// Group filesystems by physical drive (excluding mergerfs members)
/// Group filesystems by physical drive (excluding mergerfs members) - exact old logic
fn group_by_physical_drive(
&self,
mount_devices: &HashMap<String, String>,
@@ -216,14 +309,14 @@ impl DiskCollector {
) -> anyhow::Result<Vec<PhysicalDrive>> {
let mut drive_groups: HashMap<String, Vec<Filesystem>> = HashMap::new();
// Get all mergerfs member paths to exclude them
// Get all mergerfs member paths to exclude them - exactly like old code
let mut mergerfs_members = std::collections::HashSet::new();
for pool in mergerfs_pools {
for drive in &pool.data_drives {
mergerfs_members.insert(drive.name.clone());
mergerfs_members.insert(drive.mount_point.clone());
}
for drive in &pool.parity_drives {
mergerfs_members.insert(drive.name.clone());
mergerfs_members.insert(drive.mount_point.clone());
}
}
@@ -256,8 +349,6 @@ impl DiskCollector {
let physical_drive = PhysicalDrive {
name: drive_name,
health: "UNKNOWN".to_string(), // Will be updated with SMART data
temperature_celsius: None,
wear_percent: None,
filesystems,
};
physical_drives.push(physical_drive);
@@ -342,12 +433,14 @@ impl DiskCollector {
// Return unknown data rather than failing completely
return Ok(SmartData {
health: "UNKNOWN".to_string(),
serial_number: None,
temperature_celsius: None,
wear_percent: None,
});
}
let mut health = "UNKNOWN".to_string();
let mut serial_number = None;
let mut temperature = None;
let mut wear_percent = None;
@@ -360,8 +453,21 @@ impl DiskCollector {
}
}
// Serial number parsing (both SATA and NVMe)
if line.contains("Serial Number:") {
if let Some(serial_part) = line.split("Serial Number:").nth(1) {
let serial_str = serial_part.trim();
if !serial_str.is_empty() {
// Take first whitespace-separated token
if let Some(serial) = serial_str.split_whitespace().next() {
serial_number = Some(serial.to_string());
}
}
}
}
// Temperature parsing for different drive types
if line.contains("Temperature_Celsius") || line.contains("Airflow_Temperature_Cel") {
if line.contains("Temperature_Celsius") || line.contains("Airflow_Temperature_Cel") || line.contains("Temperature_Case") {
// Traditional SATA drives: attribute table format
if let Some(temp_str) = line.split_whitespace().nth(9) {
if let Ok(temp) = temp_str.parse::<f32>() {
@@ -379,7 +485,15 @@ impl DiskCollector {
}
// Wear level parsing for SSDs
if line.contains("Wear_Leveling_Count") || line.contains("SSD_Life_Left") {
if line.contains("Media_Wearout_Indicator") {
// Media_Wearout_Indicator stores remaining life % in column 3 (VALUE)
if let Some(wear_str) = line.split_whitespace().nth(3) {
if let Ok(remaining) = wear_str.parse::<f32>() {
wear_percent = Some(100.0 - remaining); // Convert remaining life to wear
}
}
} else if line.contains("Wear_Leveling_Count") || line.contains("SSD_Life_Left") {
// Other wear attributes store value in column 9 (RAW_VALUE)
if let Some(wear_str) = line.split_whitespace().nth(9) {
if let Ok(wear) = wear_str.parse::<f32>() {
wear_percent = Some(100.0 - wear); // Convert remaining life to wear
@@ -402,6 +516,7 @@ impl DiskCollector {
Ok(SmartData {
health,
serial_number,
temperature_celsius: temperature,
wear_percent,
})
@@ -427,6 +542,7 @@ impl DiskCollector {
agent_data.system.storage.drives.push(DriveData {
name: drive.name.clone(),
serial_number: smart.and_then(|s| s.serial_number.clone()),
health: smart.map(|s| s.health.clone()).unwrap_or_else(|| drive.health.clone()),
temperature_celsius: smart.and_then(|s| s.temperature_celsius),
wear_percent: smart.and_then(|s| s.wear_percent),
@@ -444,28 +560,25 @@ impl DiskCollector {
}
/// Populate pools data into AgentData
fn populate_pools_data(&self, mergerfs_pools: &[MergerfsPool], _smart_data: &HashMap<String, SmartData>, agent_data: &mut AgentData) -> Result<(), CollectorError> {
fn populate_pools_data(&self, mergerfs_pools: &[MergerfsPool], smart_data: &HashMap<String, SmartData>, agent_data: &mut AgentData) -> Result<(), CollectorError> {
for pool in mergerfs_pools {
// Calculate pool health and statuses based on member drive health
let (pool_health, health_status, usage_status, data_drive_data, parity_drive_data) = self.calculate_pool_health(pool, smart_data);
let pool_data = PoolData {
name: pool.name.clone(),
mount: pool.mount_point.clone(),
pool_type: "mergerfs".to_string(),
health: "healthy".to_string(), // TODO: Calculate based on member drives
usage_percent: (pool.used_bytes as f32 / pool.total_bytes as f32) * 100.0,
pool_type: format!("mergerfs ({}+{})", pool.data_drives.len(), pool.parity_drives.len()),
health: pool_health,
usage_percent: if pool.total_bytes > 0 {
(pool.used_bytes as f32 / pool.total_bytes as f32) * 100.0
} else { 0.0 },
used_gb: pool.used_bytes as f32 / (1024.0 * 1024.0 * 1024.0),
total_gb: pool.total_bytes as f32 / (1024.0 * 1024.0 * 1024.0),
data_drives: pool.data_drives.iter().map(|d| cm_dashboard_shared::PoolDriveData {
name: d.name.clone(),
temperature_celsius: d.temperature_celsius,
health: "unknown".to_string(),
wear_percent: None,
}).collect(),
parity_drives: pool.parity_drives.iter().map(|d| cm_dashboard_shared::PoolDriveData {
name: d.name.clone(),
temperature_celsius: d.temperature_celsius,
health: "unknown".to_string(),
wear_percent: None,
}).collect(),
data_drives: data_drive_data,
parity_drives: parity_drive_data,
health_status,
usage_status,
};
agent_data.system.storage.pools.push(pool_data);
@@ -474,6 +587,93 @@ impl DiskCollector {
Ok(())
}
/// Calculate pool health based on member drive status
fn calculate_pool_health(&self, pool: &MergerfsPool, smart_data: &HashMap<String, SmartData>) -> (String, cm_dashboard_shared::Status, cm_dashboard_shared::Status, Vec<cm_dashboard_shared::PoolDriveData>, Vec<cm_dashboard_shared::PoolDriveData>) {
let mut failed_data = 0;
let mut failed_parity = 0;
// Process data drives
let data_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.data_drives.iter().map(|d| {
let smart = smart_data.get(&d.name);
let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string());
let temperature = smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius);
if health == "FAILED" {
failed_data += 1;
}
// Calculate drive statuses using config thresholds
let health_status = self.calculate_health_status(&health);
let temperature_status = temperature.map(|t| self.temperature_thresholds.evaluate(t)).unwrap_or(cm_dashboard_shared::Status::Unknown);
cm_dashboard_shared::PoolDriveData {
name: d.name.clone(),
serial_number: smart.and_then(|s| s.serial_number.clone()),
temperature_celsius: temperature,
health,
wear_percent: smart.and_then(|s| s.wear_percent),
health_status,
temperature_status,
}
}).collect();
// Process parity drives
let parity_drive_data: Vec<cm_dashboard_shared::PoolDriveData> = pool.parity_drives.iter().map(|d| {
let smart = smart_data.get(&d.name);
let health = smart.map(|s| s.health.clone()).unwrap_or_else(|| "UNKNOWN".to_string());
let temperature = smart.and_then(|s| s.temperature_celsius).or(d.temperature_celsius);
if health == "FAILED" {
failed_parity += 1;
}
// Calculate drive statuses using config thresholds
let health_status = self.calculate_health_status(&health);
let temperature_status = temperature.map(|t| self.temperature_thresholds.evaluate(t)).unwrap_or(cm_dashboard_shared::Status::Unknown);
cm_dashboard_shared::PoolDriveData {
name: d.name.clone(),
serial_number: smart.and_then(|s| s.serial_number.clone()),
temperature_celsius: temperature,
health,
wear_percent: smart.and_then(|s| s.wear_percent),
health_status,
temperature_status,
}
}).collect();
// Calculate overall pool health string and status
// SnapRAID logic: can tolerate up to N parity drive failures (where N = number of parity drives)
// If data drives fail AND we've lost parity protection, that's critical
let (pool_health, health_status) = if failed_data == 0 && failed_parity == 0 {
("healthy".to_string(), cm_dashboard_shared::Status::Ok)
} else if failed_data == 0 && failed_parity > 0 {
// Parity failed but no data loss - degraded (reduced protection)
("degraded".to_string(), cm_dashboard_shared::Status::Warning)
} else if failed_data == 1 && failed_parity == 0 {
// One data drive failed, parity intact - degraded (recoverable)
("degraded".to_string(), cm_dashboard_shared::Status::Warning)
} else {
// Multiple data drives failed OR data+parity failed = data loss risk
("critical".to_string(), cm_dashboard_shared::Status::Critical)
};
// Calculate pool usage status using config thresholds
let usage_percent = if pool.total_bytes > 0 {
(pool.used_bytes as f32 / pool.total_bytes as f32) * 100.0
} else { 0.0 };
let usage_status = if usage_percent >= self.config.usage_critical_percent {
cm_dashboard_shared::Status::Critical
} else if usage_percent >= self.config.usage_warning_percent {
cm_dashboard_shared::Status::Warning
} else {
cm_dashboard_shared::Status::Ok
};
(pool_health, health_status, usage_status, data_drive_data, parity_drive_data)
}
/// Calculate filesystem usage status
fn calculate_filesystem_usage_status(&self, usage_percent: f32) -> Status {
// Use standard filesystem warning/critical thresholds
@@ -499,6 +699,134 @@ impl DiskCollector {
_ => Status::Unknown,
}
}
/// Discover parity drives that are related to the given data drives
fn discover_related_parity_drives(&self, data_drives: &[String]) -> anyhow::Result<Vec<String>> {
let mount_devices = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.get_mount_devices())
}).map_err(|e| anyhow::anyhow!("Failed to get mount devices: {}", e))?;
let mut related_parity = Vec::new();
// Find parity drives that share the same parent directory as the data drives
for data_path in data_drives {
if let Some(parent_dir) = self.get_parent_directory(data_path) {
// Look for parity drives in the same parent directory
for (mount_point, _device) in &mount_devices {
if mount_point.contains("parity") && mount_point.starts_with(&parent_dir) {
if !related_parity.contains(mount_point) {
related_parity.push(mount_point.clone());
}
}
}
}
}
Ok(related_parity)
}
/// Get parent directory of a mount path (e.g., "/mnt/disk1" -> "/mnt")
fn get_parent_directory(&self, path: &str) -> Option<String> {
if let Some(last_slash) = path.rfind('/') {
if last_slash > 0 {
return Some(path[..last_slash].to_string());
}
}
None
}
/// Categorize pool member drives as data vs parity
fn categorize_pool_drives(&self, member_paths: &[String]) -> anyhow::Result<(Vec<PoolDrive>, Vec<PoolDrive>)> {
let mut data_drives = Vec::new();
let mut parity_drives = Vec::new();
for path in member_paths {
let drive_info = self.get_drive_info_for_path(path)?;
// Heuristic: if path contains "parity", it's parity
if path.to_lowercase().contains("parity") {
parity_drives.push(drive_info);
} else {
data_drives.push(drive_info);
}
}
Ok((data_drives, parity_drives))
}
/// Get drive information for a mount path
fn get_drive_info_for_path(&self, path: &str) -> anyhow::Result<PoolDrive> {
// Use lsblk to find the backing device
let output = Command::new("lsblk")
.args(&["-rn", "-o", "NAME,MOUNTPOINT"])
.output()
.map_err(|e| anyhow::anyhow!("Failed to run lsblk: {}", e))?;
let output_str = String::from_utf8_lossy(&output.stdout);
let mut device = String::new();
for line in output_str.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1] == path {
device = parts[0].to_string();
break;
}
}
if device.is_empty() {
return Err(anyhow::anyhow!("Could not find device for path {}", path));
}
// Extract base device name (e.g., "sda1" -> "sda")
let base_device = self.extract_base_device(&format!("/dev/{}", device));
// Get temperature from SMART data if available
let temperature = if let Ok(smart_data) = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.get_smart_data(&base_device))
}) {
smart_data.temperature_celsius
} else {
None
};
Ok(PoolDrive {
name: base_device,
mount_point: path.to_string(),
temperature_celsius: temperature,
})
}
/// Resolve numeric mergerfs references like "1:2" to actual mount paths
fn resolve_numeric_mergerfs_paths(&self, numeric_refs: &[String]) -> anyhow::Result<Vec<String>> {
let mut resolved_paths = Vec::new();
// Get all mount points that look like /mnt/disk* or /mnt/parity*
let mount_devices = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.get_mount_devices())
}).map_err(|e| anyhow::anyhow!("Failed to get mount devices: {}", e))?;
let mut disk_mounts: Vec<String> = mount_devices.keys()
.filter(|path| path.starts_with("/mnt/disk") || path.starts_with("/mnt/parity"))
.cloned()
.collect();
disk_mounts.sort(); // Ensure consistent ordering
for num_ref in numeric_refs {
if let Ok(index) = num_ref.parse::<usize>() {
// Convert 1-based index to 0-based
if index > 0 && index <= disk_mounts.len() {
resolved_paths.push(disk_mounts[index - 1].clone());
}
}
}
// Fallback: if we couldn't resolve, return the original paths
if resolved_paths.is_empty() {
resolved_paths = numeric_refs.to_vec();
}
Ok(resolved_paths)
}
}
#[async_trait]
@@ -512,6 +840,7 @@ impl Collector for DiskCollector {
#[derive(Debug, Clone)]
struct SmartData {
health: String,
serial_number: Option<String>,
temperature_celsius: Option<f32>,
wear_percent: Option<f32>,
}

View File

@@ -7,6 +7,7 @@ pub mod cpu;
pub mod disk;
pub mod error;
pub mod memory;
pub mod network;
pub mod nixos;
pub mod systemd;

View File

@@ -0,0 +1,143 @@
use async_trait::async_trait;
use cm_dashboard_shared::{AgentData, NetworkInterfaceData, Status};
use std::process::Command;
use tracing::debug;
use super::{Collector, CollectorError};
use crate::config::NetworkConfig;
/// Network interface collector with physical/virtual classification and link status
pub struct NetworkCollector {
_config: NetworkConfig,
}
impl NetworkCollector {
pub fn new(config: NetworkConfig) -> Self {
Self { _config: config }
}
/// Check if interface is physical (not virtual)
fn is_physical_interface(name: &str) -> bool {
// Physical interface patterns
matches!(
&name[..],
s if s.starts_with("eth")
|| s.starts_with("ens")
|| s.starts_with("enp")
|| s.starts_with("wlan")
|| s.starts_with("wlp")
|| s.starts_with("eno")
|| s.starts_with("enx")
)
}
/// Get link status for an interface
fn get_link_status(interface: &str) -> Status {
let operstate_path = format!("/sys/class/net/{}/operstate", interface);
match std::fs::read_to_string(&operstate_path) {
Ok(state) => {
let state = state.trim();
match state {
"up" => Status::Ok,
"down" => Status::Inactive,
"unknown" => Status::Warning,
_ => Status::Unknown,
}
}
Err(_) => Status::Unknown,
}
}
/// Collect network interfaces using ip command
async fn collect_interfaces(&self) -> Vec<NetworkInterfaceData> {
let mut interfaces = Vec::new();
match Command::new("ip").args(["-j", "addr"]).output() {
Ok(output) if output.status.success() => {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json_data) = serde_json::from_str::<serde_json::Value>(&json_str) {
if let Some(ifaces) = json_data.as_array() {
for iface in ifaces {
let name = iface["ifname"].as_str().unwrap_or("").to_string();
// Skip loopback and empty names
if name.is_empty() || name == "lo" {
continue;
}
let mut ipv4_addresses = Vec::new();
let mut ipv6_addresses = Vec::new();
// Extract IP addresses
if let Some(addr_info) = iface["addr_info"].as_array() {
for addr in addr_info {
if let Some(family) = addr["family"].as_str() {
if let Some(local) = addr["local"].as_str() {
match family {
"inet" => ipv4_addresses.push(local.to_string()),
"inet6" => {
// Skip link-local IPv6 addresses (fe80::)
if !local.starts_with("fe80:") {
ipv6_addresses.push(local.to_string());
}
}
_ => {}
}
}
}
}
}
// Only add interfaces that have at least one IP address
// This filters out ifb*, dummy interfaces, etc. that have no IPs
if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
continue;
}
// Determine if physical and get status
let is_physical = Self::is_physical_interface(&name);
let link_status = if is_physical {
Self::get_link_status(&name)
} else {
Status::Unknown // Virtual interfaces don't have meaningful link status
};
interfaces.push(NetworkInterfaceData {
name,
ipv4_addresses,
ipv6_addresses,
is_physical,
link_status,
parent_interface: None,
});
}
}
}
}
Err(e) => {
debug!("Failed to execute ip command: {}", e);
}
Ok(output) => {
debug!("ip command failed with status: {}", output.status);
}
}
interfaces
}
}
#[async_trait]
impl Collector for NetworkCollector {
async fn collect_structured(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> {
debug!("Collecting network interface data");
// Collect all network interfaces
let interfaces = self.collect_interfaces().await;
agent_data.system.network.interfaces = interfaces;
Ok(())
}
}

View File

@@ -5,21 +5,18 @@ use std::process::Command;
use tracing::debug;
use super::{Collector, CollectorError};
use crate::config::NixOSConfig;
/// NixOS system information collector with structured data output
///
///
/// This collector gathers NixOS-specific information like:
/// - System generation/build information
/// - Version information
/// - Agent version from Nix store path
pub struct NixOSCollector {
config: NixOSConfig,
}
pub struct NixOSCollector;
impl NixOSCollector {
pub fn new(config: NixOSConfig) -> Self {
Self { config }
pub fn new(_config: crate::config::NixOSConfig) -> Self {
Self
}
/// Collect NixOS system information and populate AgentData
@@ -83,14 +80,25 @@ impl NixOSCollector {
std::env::var("CM_DASHBOARD_VERSION").unwrap_or_else(|_| "unknown".to_string())
}
/// Get NixOS system generation (build) information
/// Get NixOS system generation (build) information from git commit
async fn get_nixos_generation(&self) -> Option<String> {
match Command::new("nixos-version").output() {
Ok(output) => {
let version_str = String::from_utf8_lossy(&output.stdout);
Some(version_str.trim().to_string())
// Try to read git commit hash from file written during rebuild
let commit_file = "/var/lib/cm-dashboard/git-commit";
match fs::read_to_string(commit_file) {
Ok(content) => {
let commit_hash = content.trim();
if commit_hash.len() >= 7 {
debug!("Found git commit hash: {}", commit_hash);
Some(commit_hash.to_string())
} else {
debug!("Git commit hash too short: {}", commit_hash);
None
}
}
Err(e) => {
debug!("Failed to read git commit file {}: {}", commit_file, e);
None
}
Err(_) => None,
}
}
}

View File

@@ -22,8 +22,6 @@ pub struct SystemdCollector {
struct ServiceCacheState {
/// Last collection time for performance tracking
last_collection: Option<Instant>,
/// Cached service data
services: Vec<ServiceInfo>,
/// Cached complete service data with sub-services
cached_service_data: Vec<ServiceData>,
/// Interesting services to monitor (cached after discovery)
@@ -50,20 +48,10 @@ struct ServiceStatusInfo {
sub_state: String,
}
/// Internal service information
#[derive(Debug, Clone)]
struct ServiceInfo {
name: String,
status: String, // "active", "inactive", "failed", etc.
memory_mb: f32, // Memory usage in MB
disk_gb: f32, // Disk usage in GB
}
impl SystemdCollector {
pub fn new(config: SystemdConfig) -> Self {
let state = ServiceCacheState {
last_collection: None,
services: Vec::new(),
cached_service_data: Vec::new(),
monitored_services: Vec::new(),
service_status_cache: std::collections::HashMap::new(),
@@ -73,7 +61,7 @@ impl SystemdCollector {
last_nginx_check_time: None,
nginx_check_interval_seconds: config.nginx_check_interval_seconds,
};
Self {
state: RwLock::new(state),
config,
@@ -95,7 +83,6 @@ impl SystemdCollector {
};
// Collect service data for each monitored service
let mut services = Vec::new();
let mut complete_service_data = Vec::new();
for service_name in &monitored_services {
match self.get_service_status(service_name) {
@@ -145,14 +132,6 @@ impl SystemdCollector {
}
}
let service_info = ServiceInfo {
name: service_name.clone(),
status: active_status.clone(),
memory_mb,
disk_gb,
};
services.push(service_info);
// Create complete service data
let service_data = ServiceData {
name: service_name.clone(),
@@ -177,7 +156,6 @@ impl SystemdCollector {
{
let mut state = self.state.write().unwrap();
state.last_collection = Some(start_time);
state.services = services;
state.cached_service_data = complete_service_data;
}
@@ -483,25 +461,6 @@ impl SystemdCollector {
}
}
/// Get service memory usage (if available)
fn get_service_memory(&self, service: &str) -> Option<f32> {
let output = Command::new("systemctl")
.args(&["show", &format!("{}.service", service), "--property=MemoryCurrent"])
.output()
.ok()?;
let output_str = String::from_utf8(output.stdout).ok()?;
for line in output_str.lines() {
if line.starts_with("MemoryCurrent=") {
let memory_str = line.strip_prefix("MemoryCurrent=")?;
if let Ok(memory_bytes) = memory_str.parse::<u64>() {
return Some(memory_bytes as f32 / (1024.0 * 1024.0)); // Convert to MB
}
}
}
None
}
/// Calculate service status, taking user-stopped services into account
fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status {
match active_status.to_lowercase().as_str() {
@@ -549,7 +508,7 @@ impl SystemdCollector {
/// Check if service collection cache should be updated
fn should_update_cache(&self) -> bool {
let state = self.state.read().unwrap();
match state.last_collection {
None => true,
Some(last) => {
@@ -559,16 +518,6 @@ impl SystemdCollector {
}
}
/// Get cached service data if available and fresh
fn get_cached_services(&self) -> Option<Vec<ServiceInfo>> {
if !self.should_update_cache() {
let state = self.state.read().unwrap();
Some(state.services.clone())
} else {
None
}
}
/// Get cached complete service data with sub-services if available and fresh
fn get_cached_complete_services(&self) -> Option<Vec<ServiceData>> {
if !self.should_update_cache() {
@@ -807,9 +756,9 @@ impl SystemdCollector {
fn get_docker_containers(&self) -> Vec<(String, String)> {
let mut containers = Vec::new();
// Check if docker is available
let output = Command::new("docker")
.args(&["ps", "--format", "{{.Names}},{{.Status}}"])
// Check if docker is available (use sudo for permissions)
let output = Command::new("sudo")
.args(&["docker", "ps", "--format", "{{.Names}},{{.Status}}"])
.output();
let output = match output {

View File

@@ -1,2 +0,0 @@
// This file is now empty - all configuration values come from config files
// No hardcoded defaults are used

View File

@@ -8,7 +8,6 @@ mod collectors;
mod communication;
mod config;
mod notifications;
mod service_tracker;
use agent::Agent;

View File

@@ -1,164 +0,0 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use tracing::{debug, info, warn};
/// Shared instance for global access
static GLOBAL_TRACKER: OnceLock<Arc<Mutex<UserStoppedServiceTracker>>> = OnceLock::new();
/// Tracks services that have been stopped by user action
/// These services should be treated as OK status instead of Warning
#[derive(Debug)]
pub struct UserStoppedServiceTracker {
/// Set of services stopped by user action
user_stopped_services: HashSet<String>,
/// Path to persistent storage file
storage_path: String,
}
/// Serializable data structure for persistence
#[derive(Debug, Serialize, Deserialize)]
struct UserStoppedData {
services: Vec<String>,
}
impl UserStoppedServiceTracker {
/// Create new tracker with default storage path
pub fn new() -> Self {
Self::with_storage_path("/var/lib/cm-dashboard/user-stopped-services.json")
}
/// Initialize global instance (called by agent)
pub fn init_global() -> Result<Self> {
let tracker = Self::new();
// Set global instance
let global_instance = Arc::new(Mutex::new(tracker));
if GLOBAL_TRACKER.set(global_instance).is_err() {
warn!("Global service tracker was already initialized");
}
// Return a new instance for the agent to use
Ok(Self::new())
}
/// Check if a service is user-stopped (global access for collectors)
pub fn is_service_user_stopped(service_name: &str) -> bool {
if let Some(global) = GLOBAL_TRACKER.get() {
if let Ok(tracker) = global.lock() {
tracker.is_user_stopped(service_name)
} else {
debug!("Failed to lock global service tracker");
false
}
} else {
debug!("Global service tracker not initialized");
false
}
}
/// Update global tracker (called by agent when tracker state changes)
pub fn update_global(updated_tracker: &UserStoppedServiceTracker) {
if let Some(global) = GLOBAL_TRACKER.get() {
if let Ok(mut tracker) = global.lock() {
tracker.user_stopped_services = updated_tracker.user_stopped_services.clone();
} else {
debug!("Failed to lock global service tracker for update");
}
} else {
debug!("Global service tracker not initialized for update");
}
}
/// Create new tracker with custom storage path
pub fn with_storage_path<P: AsRef<Path>>(storage_path: P) -> Self {
let storage_path = storage_path.as_ref().to_string_lossy().to_string();
let mut tracker = Self {
user_stopped_services: HashSet::new(),
storage_path,
};
// Load existing data from storage
if let Err(e) = tracker.load_from_storage() {
warn!("Failed to load user-stopped services from storage: {}", e);
info!("Starting with empty user-stopped services list");
}
tracker
}
/// Clear user-stopped flag for a service (when user starts it)
pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> {
if self.user_stopped_services.remove(service_name) {
info!("Cleared user-stopped flag for service '{}'", service_name);
self.save_to_storage()?;
debug!("Service '{}' user-stopped flag cleared and saved to storage", service_name);
} else {
debug!("Service '{}' was not marked as user-stopped", service_name);
}
Ok(())
}
/// Check if a service is marked as user-stopped
pub fn is_user_stopped(&self, service_name: &str) -> bool {
let is_stopped = self.user_stopped_services.contains(service_name);
debug!("Service '{}' user-stopped status: {}", service_name, is_stopped);
is_stopped
}
/// Save current state to persistent storage
fn save_to_storage(&self) -> Result<()> {
// Create parent directory if it doesn't exist
if let Some(parent_dir) = Path::new(&self.storage_path).parent() {
if !parent_dir.exists() {
fs::create_dir_all(parent_dir)?;
debug!("Created parent directory: {}", parent_dir.display());
}
}
let data = UserStoppedData {
services: self.user_stopped_services.iter().cloned().collect(),
};
let json_data = serde_json::to_string_pretty(&data)?;
fs::write(&self.storage_path, json_data)?;
debug!(
"Saved {} user-stopped services to {}",
data.services.len(),
self.storage_path
);
Ok(())
}
/// Load state from persistent storage
fn load_from_storage(&mut self) -> Result<()> {
if !Path::new(&self.storage_path).exists() {
debug!("Storage file {} does not exist, starting fresh", self.storage_path);
return Ok(());
}
let json_data = fs::read_to_string(&self.storage_path)?;
let data: UserStoppedData = serde_json::from_str(&json_data)?;
self.user_stopped_services = data.services.into_iter().collect();
info!(
"Loaded {} user-stopped services from {}",
self.user_stopped_services.len(),
self.storage_path
);
if !self.user_stopped_services.is_empty() {
debug!("User-stopped services: {:?}", self.user_stopped_services);
}
Ok(())
}
}

View File

@@ -1,1001 +0,0 @@
warning: fields `total_services`, `backup_disk_filesystem_label`, `services_completed_count`, `services_failed_count`, and `services_disabled_count` are never read
--> dashboard/src/ui/widgets/backup.rs:22:5
|
14 | pub struct BackupWidget {
| ------------ fields in this struct
...
22 | total_services: Option<i64>,
| ^^^^^^^^^^^^^^
...
36 | backup_disk_filesystem_label: Option<String>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
37 | /// Number of completed services
38 | services_completed_count: Option<i64>,
| ^^^^^^^^^^^^^^^^^^^^^^^^
39 | /// Number of failed services
40 | services_failed_count: Option<i64>,
| ^^^^^^^^^^^^^^^^^^^^^
41 | /// Number of disabled services
42 | services_disabled_count: Option<i64>,
| ^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `BackupWidget` has a derived impl for the trait `Clone`, but this is intentionally ignored during dead code analysis
= note: `#[warn(dead_code)]` on by default
warning: field `exit_code` is never read
--> dashboard/src/ui/widgets/backup.rs:53:5
|
50 | struct ServiceMetricData {
| ----------------- field in this struct
...
53 | exit_code: Option<i64>,
| ^^^^^^^^^
|
= note: `ServiceMetricData` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
warning: associated function `extract_service_name` is never used
--> dashboard/src/ui/widgets/backup.rs:115:8
|
58 | impl BackupWidget {
| ----------------- associated function in this implementation
...
115 | fn extract_service_name(metric_name: &str) -> Option<String> {
| ^^^^^^^^^^^^^^^^^^^^
warning: method `update_from_metrics` is never used
--> dashboard/src/ui/widgets/backup.rs:157:8
|
156 | impl BackupWidget {
| ----------------- method in this implementation
157 | fn update_from_metrics(&mut self, metrics: &[&Metric]) {
| ^^^^^^^^^^^^^^^^^^^
warning: associated function `extract_service_info` is never used
--> dashboard/src/ui/widgets/services.rs:50:8
|
38 | impl ServicesWidget {
| ------------------- associated function in this implementation
...
50 | fn extract_service_info(metric_name: &str) -> Option<(String, Option<String>)> {
| ^^^^^^^^^^^^^^^^^^^^
warning: method `update_from_metrics` is never used
--> dashboard/src/ui/widgets/services.rs:285:8
|
284 | impl ServicesWidget {
| ------------------- method in this implementation
285 | fn update_from_metrics(&mut self, metrics: &[&Metric]) {
| ^^^^^^^^^^^^^^^^^^^
warning: field `health_status` is never read
--> dashboard/src/ui/widgets/system.rs:53:5
|
43 | struct StoragePool {
| ----------- field in this struct
...
53 | health_status: Status, // Separate status for pool health vs usage
| ^^^^^^^^^^^^^
|
= note: `StoragePool` has a derived impl for the trait `Clone`, but this is intentionally ignored during dead code analysis
warning: `cm-dashboard` (bin "cm-dashboard") generated 7 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
Running `target/debug/cm-dashboard --headless --raw-data`
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936501,
"system": {
"cpu": {
"load_1min": 1.82,
"load_5min": 2.1,
"load_15min": 2.1,
"frequency_mhz": 3743.09,
"temperature_celsius": 55.0
},
"memory": {
"usage_percent": 27.183601,
"total_gb": 23.339516,
"used_gb": 6.3445206,
"available_gb": 16.994995,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.094376,
"used_gb": 0.3018875,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.582031,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936502,
"system": {
"cpu": {
"load_1min": 1.82,
"load_5min": 2.1,
"load_15min": 2.1,
"frequency_mhz": 3743.09,
"temperature_celsius": 55.0
},
"memory": {
"usage_percent": 27.183601,
"total_gb": 23.339516,
"used_gb": 6.3445206,
"available_gb": 16.994995,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.094376,
"used_gb": 0.3018875,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.582031,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936503,
"system": {
"cpu": {
"load_1min": 1.82,
"load_5min": 2.1,
"load_15min": 2.1,
"frequency_mhz": 3743.09,
"temperature_celsius": 55.0
},
"memory": {
"usage_percent": 27.183601,
"total_gb": 23.339516,
"used_gb": 6.3445206,
"available_gb": 16.994995,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.094376,
"used_gb": 0.3018875,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.582031,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936505,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3600.005,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 26.780334,
"total_gb": 23.339516,
"used_gb": 6.2504005,
"available_gb": 17.089115,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936506,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3600.005,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 26.780334,
"total_gb": 23.339516,
"used_gb": 6.2504005,
"available_gb": 17.089115,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936507,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3600.005,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 26.780334,
"total_gb": 23.339516,
"used_gb": 6.2504005,
"available_gb": 17.089115,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936508,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3600.005,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 26.780334,
"total_gb": 23.339516,
"used_gb": 6.2504005,
"available_gb": 17.089115,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936509,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3638.71,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 27.014532,
"total_gb": 23.339516,
"used_gb": 6.3050613,
"available_gb": 17.034454,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936509,
"system": {
"cpu": {
"load_1min": 0.0,
"load_5min": 0.0,
"load_15min": 0.0,
"frequency_mhz": 0.0,
"temperature_celsius": null
},
"memory": {
"usage_percent": 0.0,
"total_gb": 0.0,
"used_gb": 0.0,
"available_gb": 0.0,
"swap_total_gb": 0.0,
"swap_used_gb": 0.0,
"tmpfs": []
},
"storage": {
"drives": [],
"pools": []
}
},
"services": [],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936510,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3638.71,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 27.014532,
"total_gb": 23.339516,
"used_gb": 6.3050613,
"available_gb": 17.034454,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936511,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3638.71,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 27.014532,
"total_gb": 23.339516,
"used_gb": 6.3050613,
"available_gb": 17.034454,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
RAW AGENT DATA FROM cmbox:
{
"hostname": "cmbox",
"agent_version": "v0.1.133",
"timestamp": 1763936512,
"system": {
"cpu": {
"load_1min": 1.75,
"load_5min": 2.08,
"load_15min": 2.1,
"frequency_mhz": 3638.71,
"temperature_celsius": 56.0
},
"memory": {
"usage_percent": 27.014532,
"total_gb": 23.339516,
"used_gb": 6.3050613,
"available_gb": 17.034454,
"swap_total_gb": 14.634708,
"swap_used_gb": 0.17599106,
"tmpfs": [
{
"mount": "/tmp",
"usage_percent": 15.095139,
"used_gb": 0.30190277,
"total_gb": 2.0
}
]
},
"storage": {
"drives": [
{
"name": "nvme0n1",
"health": "PASSED",
"temperature_celsius": 28.0,
"wear_percent": 1.0,
"filesystems": [
{
"mount": "root",
"usage_percent": 24.404377,
"used_gb": 226.51398,
"total_gb": 928.1695
},
{
"mount": "boot",
"usage_percent": 10.666672,
"used_gb": 0.10645676,
"total_gb": 0.9980316
}
]
}
],
"pools": []
}
},
"services": [
{
"name": "tailscaled",
"status": "active",
"memory_mb": 25.59375,
"disk_gb": 0.0,
"user_stopped": false
},
{
"name": "sshd",
"status": "active",
"memory_mb": 4.3085938,
"disk_gb": 0.0,
"user_stopped": false
}
],
"backup": {
"status": "unknown",
"last_run": null,
"next_scheduled": null,
"total_size_gb": null,
"repository_health": null
}
}
────────────────────────────────────────────────────────────────────────────────
Terminated

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard"
version = "0.1.149"
version = "0.1.168"
edition = "2021"
[dependencies]

View File

@@ -20,13 +20,12 @@ pub struct Dashboard {
tui_app: Option<TuiApp>,
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
headless: bool,
raw_data: bool,
initial_commands_sent: std::collections::HashSet<String>,
config: DashboardConfig,
}
impl Dashboard {
pub async fn new(config_path: Option<String>, headless: bool, raw_data: bool) -> Result<Self> {
pub async fn new(config_path: Option<String>, headless: bool) -> Result<Self> {
info!("Initializing dashboard");
// Load configuration - try default path if not specified
@@ -120,7 +119,6 @@ impl Dashboard {
tui_app,
terminal,
headless,
raw_data,
initial_commands_sent: std::collections::HashSet::new(),
config,
})
@@ -205,13 +203,6 @@ impl Dashboard {
.insert(agent_data.hostname.clone());
}
// Show raw data if requested (before processing)
if self.raw_data {
println!("RAW AGENT DATA FROM {}:", agent_data.hostname);
println!("{}", serde_json::to_string_pretty(&agent_data).unwrap_or_else(|e| format!("Serialization error: {}", e)));
println!("{}", "".repeat(80));
}
// Store structured data directly
self.metric_store.store_agent_data(agent_data);

View File

@@ -51,10 +51,6 @@ struct Cli {
/// Run in headless mode (no TUI, just logging)
#[arg(long)]
headless: bool,
/// Show raw agent data in headless mode
#[arg(long)]
raw_data: bool,
}
#[tokio::main]
@@ -90,7 +86,7 @@ async fn main() -> Result<()> {
}
// Create and run dashboard
let mut dashboard = Dashboard::new(cli.config, cli.headless, cli.raw_data).await?;
let mut dashboard = Dashboard::new(cli.config, cli.headless).await?;
// Setup graceful shutdown
let ctrl_c = async {

View File

@@ -225,9 +225,6 @@ impl Layout {
pub const LEFT_PANEL_WIDTH: u16 = 45;
/// Right panel percentage (services)
pub const RIGHT_PANEL_WIDTH: u16 = 55;
/// System vs backup split (equal)
pub const SYSTEM_PANEL_HEIGHT: u16 = 50;
pub const BACKUP_PANEL_HEIGHT: u16 = 50;
}
/// Typography system

View File

@@ -1 +0,0 @@
// This file is intentionally left minimal - CPU functionality is handled by the SystemWidget

View File

@@ -1 +0,0 @@
// This file is intentionally left minimal - Memory functionality is handled by the SystemWidget

View File

@@ -1,7 +1,5 @@
use cm_dashboard_shared::AgentData;
pub mod cpu;
pub mod memory;
pub mod services;
pub mod system;

View File

@@ -8,13 +8,16 @@ use ratatui::{
use crate::ui::theme::{StatusIcons, Typography};
/// System widget displaying NixOS info, CPU, RAM, and Storage in unified layout
/// 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>,
@@ -39,8 +42,7 @@ pub struct SystemWidget {
// Backup metrics
backup_status: String,
backup_last_run: Option<u64>,
backup_next_scheduled: Option<u64>,
backup_start_time_raw: Option<String>,
backup_disk_serial: Option<String>,
backup_disk_usage_percent: Option<f32>,
backup_disk_used_gb: Option<f32>,
@@ -58,7 +60,9 @@ struct StoragePool {
name: String,
mount_point: String,
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
drives: Vec<StorageDrive>,
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>,
@@ -88,6 +92,7 @@ impl SystemWidget {
Self {
nixos_build: None,
agent_hash: None,
network_interfaces: Vec::new(),
cpu_load_1min: None,
cpu_load_5min: None,
cpu_load_15min: None,
@@ -104,8 +109,7 @@ impl SystemWidget {
tmpfs_mounts: Vec::new(),
storage_pools: Vec::new(),
backup_status: "unknown".to_string(),
backup_last_run: None,
backup_next_scheduled: None,
backup_start_time_raw: None,
backup_disk_serial: None,
backup_disk_usage_percent: None,
backup_disk_used_gb: None,
@@ -164,6 +168,9 @@ impl Widget for SystemWidget {
// 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);
@@ -196,8 +203,7 @@ impl Widget for SystemWidget {
// Extract backup data
let backup = &agent_data.backup;
self.backup_status = backup.status.clone();
self.backup_last_run = backup.last_run;
self.backup_next_scheduled = backup.next_scheduled;
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 {
@@ -230,6 +236,8 @@ impl SystemWidget {
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,
@@ -238,8 +246,11 @@ impl SystemWidget {
};
// 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: drive.name.clone(),
name: display_name,
temperature: drive.temperature_celsius,
wear_percent: drive.wear_percent,
status: Status::Ok,
@@ -270,7 +281,85 @@ impl SystemWidget {
pools.insert(drive.name.clone(), pool);
}
// Convert pools
// 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();
@@ -286,12 +375,8 @@ impl SystemWidget {
// 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
// Look for any drive with temp/wear data (physical drives may have drives named after the pool)
let drive_info = pool.drives.iter()
.find(|d| d.name == pool.name)
.or_else(|| pool.drives.first());
if let Some(drive) = drive_info {
// 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));
@@ -299,18 +384,18 @@ impl SystemWidget {
if let Some(wear) = drive.wear_percent {
drive_details.push(format!("W: {}%", wear as i32));
}
if !drive_details.is_empty() {
format!("{} {}", pool.name, drive_details.join(" "))
format!("{} {}", drive.name, drive_details.join(" "))
} else {
pool.name.clone()
drive.name.clone()
}
} else {
pool.name.clone()
}
} else {
// For mergerfs pools, show pool name with format
format!("{} ({})", pool.mount_point, pool.pool_type)
// 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);
@@ -339,28 +424,78 @@ impl SystemWidget {
lines.push(Line::from(fs_spans));
}
} else {
// For mergerfs pools, show data drives and parity drives in tree structure
if !pool.drives.is_empty() {
// Group drives by type based on naming conventions or show all as data drives
let (data_drives, parity_drives): (Vec<_>, Vec<_>) = pool.drives.iter()
.partition(|d| !d.name.contains("parity") && !d.name.starts_with("sdc"));
// 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));
if !data_drives.is_empty() {
lines.push(Line::from(vec![
Span::styled(" ├─ Data Disks:", Typography::secondary())
]));
for (i, drive) in data_drives.iter().enumerate() {
render_pool_drive(drive, i == data_drives.len() - 1 && parity_drives.is_empty(), &mut lines);
}
// 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));
}
if !parity_drives.is_empty() {
lines.push(Line::from(vec![
Span::styled(" └─ Parity:", Typography::secondary())
]));
for (i, drive) in parity_drives.iter().enumerate() {
render_pool_drive(drive, i == parity_drives.len() - 1, &mut lines);
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));
}
}
}
@@ -370,30 +505,14 @@ impl SystemWidget {
}
}
/// Helper function to render a drive in a storage pool
fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'_>>) {
let tree_symbol = if is_last { " └─" } else { " ├─" };
let mut drive_details = Vec::new();
if let Some(temp) = drive.temperature {
drive_details.push(format!("T: {}°C", temp as i32));
}
if let Some(wear) = drive.wear_percent {
drive_details.push(format!("W: {}%", wear as i32));
}
let drive_text = if !drive_details.is_empty() {
format!("{} {}", drive.name, drive_details.join(" "))
/// 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 {
format!("{}", drive.name)
};
let mut drive_spans = vec![
Span::styled(tree_symbol, Typography::tree()),
Span::raw(" "),
];
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
lines.push(Line::from(drive_spans));
serial.to_string()
}
}
impl SystemWidget {
@@ -403,6 +522,7 @@ impl SystemWidget {
// 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));
@@ -412,9 +532,9 @@ impl SystemWidget {
}
let disk_text = if !details.is_empty() {
format!("{} {}", serial, details.join(" "))
format!("{} {}", truncated_serial, details.join(" "))
} else {
serial.clone()
truncated_serial
};
let backup_status = match self.backup_status.as_str() {
@@ -427,27 +547,17 @@ impl SystemWidget {
let disk_spans = StatusIcons::create_status_spans(backup_status, &disk_text);
lines.push(Line::from(disk_spans));
// Last backup time
if let Some(last_run) = self.backup_last_run {
let time_ago = self.format_time_ago(last_run);
let last_text = if let Some(size) = self.backup_last_size_gb {
format!("Last: {} ({:.1}GB)", time_ago, size)
// 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!("Last: {}", time_ago)
format!("Time: {}", start_time)
};
lines.push(Line::from(vec![
Span::styled(" ├─ ", Typography::tree()),
Span::styled(last_text, Typography::secondary())
]));
}
// Next backup time
if let Some(next_scheduled) = self.backup_next_scheduled {
let next_text = format!("Next: {}", self.format_time_until(next_scheduled));
lines.push(Line::from(vec![
Span::styled(" ├─ ", Typography::tree()),
Span::styled(next_text, Typography::secondary())
Span::styled(time_text, Typography::secondary())
]));
}
@@ -470,45 +580,124 @@ impl SystemWidget {
lines
}
/// Format time ago from timestamp
fn format_time_ago(&self, timestamp: u64) -> String {
let now = chrono::Utc::now().timestamp() as u64;
let seconds_ago = now.saturating_sub(timestamp);
let hours = seconds_ago / 3600;
let minutes = (seconds_ago % 3600) / 60;
if hours > 0 {
format!("{}h ago", hours)
} else if minutes > 0 {
format!("{}m ago", minutes)
} else {
"now".to_string()
/// 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(", ")
}
/// Format time until from future timestamp
fn format_time_until(&self, timestamp: u64) -> String {
let now = chrono::Utc::now().timestamp() as u64;
if timestamp <= now {
return "overdue".to_string();
/// 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;
}
let seconds_until = timestamp - now;
let hours = seconds_until / 3600;
let minutes = (seconds_until % 3600) / 60;
if hours > 0 {
format!("in {}h", hours)
} else if minutes > 0 {
format!("in {}m", minutes)
} else {
"soon".to_string()
// 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();
// Render physical interfaces
for (phy_idx, interface) in physical.iter().enumerate() {
let is_last_physical = phy_idx == physical.len() - 1 && virtual_interfaces.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));
// Show IPs nested under the interface
let ip_count = interface.ipv4_addresses.len() + interface.ipv6_addresses.len();
let mut ip_index = 0;
// IPv4 addresses
for ipv4 in &interface.ipv4_addresses {
ip_index += 1;
let is_last_ip = ip_index == ip_count && is_last_physical;
let tree_symbol = if is_last_ip { " └─ " } else { " ├─ " };
lines.push(Line::from(vec![
Span::styled(tree_symbol, Typography::tree()),
Span::styled(format!("ip: {}", ipv4), Typography::secondary()),
]));
}
// IPv6 addresses
for ipv6 in &interface.ipv6_addresses {
ip_index += 1;
let is_last_ip = ip_index == ip_count && is_last_physical;
let tree_symbol = if is_last_ip { " └─ " } else { " ├─ " };
lines.push(Line::from(vec![
Span::styled(tree_symbol, Typography::tree()),
Span::styled(format!("ip: {}", ipv6), Typography::secondary()),
]));
}
}
// Render standalone virtual interfaces (those without a parent)
for (virt_idx, interface) in virtual_interfaces.iter().enumerate() {
let is_last = virt_idx == virtual_interfaces.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()
};
let interface_text = 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>) {
/// 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
@@ -525,30 +714,19 @@ impl SystemWidget {
lines.push(Line::from(vec![
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
]));
// Display detected connection IP
if let Some(config) = config {
if let Some(host_details) = config.hosts.get(hostname) {
let detected_ip = host_details.get_connection_ip(hostname);
lines.push(Line::from(vec![
Span::styled(format!("IP: {}", detected_ip), Typography::secondary())
]));
}
}
// CPU section
lines.push(Line::from(vec![
Span::styled("CPU:", Typography::widget_title())
]));
let load_text = self.format_cpu_load();
let cpu_spans = StatusIcons::create_status_spans(
self.cpu_status.clone(),
&format!("Load: {}", load_text)
);
lines.push(Line::from(cpu_spans));
let freq_text = self.format_cpu_frequency();
lines.push(Line::from(vec![
Span::styled(" └─ ", Typography::tree()),
@@ -559,7 +737,7 @@ impl SystemWidget {
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(),
@@ -571,16 +749,16 @@ impl SystemWidget {
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,
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()),
];
@@ -591,6 +769,16 @@ impl SystemWidget {
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())
@@ -602,9 +790,6 @@ impl SystemWidget {
// Backup section (if available)
if self.backup_status != "unavailable" && self.backup_status != "unknown" {
lines.push(Line::from(vec![
Span::styled("", Typography::secondary()) // Empty line for spacing
]));
lines.push(Line::from(vec![
Span::styled("Backup:", Typography::widget_title())
]));

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-dashboard-shared"
version = "0.1.149"
version = "0.1.168"
edition = "2021"
[dependencies]

View File

@@ -16,11 +16,29 @@ pub struct AgentData {
/// System-level monitoring data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemData {
pub network: NetworkData,
pub cpu: CpuData,
pub memory: MemoryData,
pub storage: StorageData,
}
/// Network interface monitoring data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkData {
pub interfaces: Vec<NetworkInterfaceData>,
}
/// Individual network interface data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkInterfaceData {
pub name: String,
pub ipv4_addresses: Vec<String>,
pub ipv6_addresses: Vec<String>,
pub is_physical: bool,
pub link_status: Status,
pub parent_interface: Option<String>,
}
/// CPU monitoring data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CpuData {
@@ -66,6 +84,7 @@ pub struct StorageData {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriveData {
pub name: String,
pub serial_number: Option<String>,
pub health: String,
pub temperature_celsius: Option<f32>,
pub wear_percent: Option<f32>,
@@ -96,15 +115,20 @@ pub struct PoolData {
pub total_gb: f32,
pub data_drives: Vec<PoolDriveData>,
pub parity_drives: Vec<PoolDriveData>,
pub health_status: Status,
pub usage_status: Status,
}
/// Drive in a storage pool
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolDriveData {
pub name: String,
pub serial_number: Option<String>,
pub temperature_celsius: Option<f32>,
pub wear_percent: Option<f32>,
pub health: String,
pub health_status: Status,
pub temperature_status: Status,
}
/// Service monitoring data
@@ -138,12 +162,11 @@ pub struct SubServiceMetric {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupData {
pub status: String,
pub last_run: Option<u64>,
pub next_scheduled: Option<u64>,
pub total_size_gb: Option<f32>,
pub repository_health: Option<String>,
pub repository_disk: Option<BackupDiskData>,
pub last_backup_size_gb: Option<f32>,
pub start_time_raw: Option<String>,
}
/// Backup repository disk information
@@ -166,6 +189,9 @@ impl AgentData {
build_version: None,
timestamp: chrono::Utc::now().timestamp() as u64,
system: SystemData {
network: NetworkData {
interfaces: Vec::new(),
},
cpu: CpuData {
load_1min: 0.0,
load_5min: 0.0,
@@ -193,12 +219,11 @@ impl AgentData {
services: Vec::new(),
backup: BackupData {
status: "unknown".to_string(),
last_run: None,
next_scheduled: None,
total_size_gb: None,
repository_health: None,
repository_disk: None,
last_backup_size_gb: None,
start_time_raw: None,
},
}
}