Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b85bd6b153 | |||
| c9b2d5e342 | |||
| b2b301332f | |||
| adf3b0f51c | |||
| 41ded0170c | |||
| 9b4191b2c3 | |||
| 53dbb43352 | |||
| ba03623110 | |||
| f24c4ed650 | |||
| 86501fd486 | |||
| 192eea6e0c | |||
| 43fb838c9b | |||
| 54483653f9 | |||
| e47803b705 | |||
| 439d0d9af6 | |||
| 2242b5ddfe | |||
| 9d0f42d55c | |||
| 1da7b5f6e7 | |||
| 006f27f7d9 | |||
| 07422cd0a7 | |||
| de30b80219 | |||
| 7d96ca9fad | |||
| 9b940ebd19 | |||
| 6d4da1b7da | |||
| 1e7f1616aa | |||
| 7a3ee3d5ba | |||
| 0e8b149718 | |||
| 2c27d0e1db | |||
| 9f18488752 | |||
| fab6404cca | |||
| c3626cc362 | |||
| d68ecfbc64 | |||
| d1272a6c13 | |||
| 33b3beb342 | |||
| f9384d9df6 | |||
| 156d707377 | |||
| dc1a2e3a0f | |||
| 5d6b8e6253 | |||
| 0cba083305 | |||
| a6be7a4788 | |||
| 2384f7f9b9 | |||
| cd5ef65d3d | |||
| 7bf9ca6201 | |||
| f587b42797 | |||
| 7ae464e172 | |||
| 980c9a20a2 | |||
| 448a38dede | |||
| f12e20b0f3 | |||
| 564d1f37e7 | |||
| 65bfb9f617 | |||
| 4f4ef6259b | |||
| 505263cec6 | |||
| 61dd686fb9 | |||
| c0f7a97a6f | |||
| 9575077045 | |||
| 34a1f7b9dc | |||
| d11aa11f99 | |||
| 0ca06d2507 | |||
| 6693f3a05f | |||
| de252d27b9 | |||
| db0e41a7d3 | |||
| ec460496d8 | |||
| 33e700529e | |||
| d644b7d40a | |||
| f635ba9c75 | |||
| 76b6e3373e | |||
| 0a13cab897 | |||
| d33ec5d225 | |||
| d31c2384df | |||
| c8db463204 | |||
| e8e50ef9bb | |||
| 0faed9309e | |||
| c980346d05 | |||
| 3e3d3f0c2b | |||
| 9eb7444d56 | |||
| 278d1763aa |
@@ -113,13 +113,13 @@ jobs:
|
|||||||
NIX_HASH="sha256-$(python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('$NEW_HASH')).decode())")"
|
NIX_HASH="sha256-$(python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('$NEW_HASH')).decode())")"
|
||||||
|
|
||||||
# Update the NixOS configuration
|
# Update the NixOS configuration
|
||||||
sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" hosts/common/cm-dashboard.nix
|
sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" hosts/services/cm-dashboard.nix
|
||||||
sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" hosts/common/cm-dashboard.nix
|
sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" hosts/services/cm-dashboard.nix
|
||||||
|
|
||||||
# Commit and push changes
|
# Commit and push changes
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
git config user.email "actions@gitea.cmtec.se"
|
git config user.email "actions@gitea.cmtec.se"
|
||||||
git add hosts/common/cm-dashboard.nix
|
git add hosts/services/cm-dashboard.nix
|
||||||
git commit -m "Auto-update cm-dashboard to $VERSION
|
git commit -m "Auto-update cm-dashboard to $VERSION
|
||||||
|
|
||||||
- Update version to $VERSION with automated release
|
- Update version to $VERSION with automated release
|
||||||
|
|||||||
267
CLAUDE.md
267
CLAUDE.md
@@ -49,17 +49,97 @@ hostname2 = [
|
|||||||
### Navigation
|
### Navigation
|
||||||
- **Tab**: Switch between hosts
|
- **Tab**: Switch between hosts
|
||||||
- **↑↓ or j/k**: Select services
|
- **↑↓ or j/k**: Select services
|
||||||
|
- **s**: Start selected service (UserStart)
|
||||||
|
- **S**: Stop selected service (UserStop)
|
||||||
- **J**: Show service logs (journalctl)
|
- **J**: Show service logs (journalctl)
|
||||||
- **L**: Show custom log files
|
- **L**: Show custom log files
|
||||||
|
- **R**: Rebuild current host
|
||||||
|
- **B**: Run backup on current host
|
||||||
- **q**: Quit dashboard
|
- **q**: Quit dashboard
|
||||||
|
|
||||||
## Core Architecture Principles
|
## Core Architecture Principles
|
||||||
|
|
||||||
### Individual Metrics Philosophy
|
### Structured Data Architecture (✅ IMPLEMENTED v0.1.131)
|
||||||
- Agent collects individual metrics, dashboard composes widgets
|
Complete migration from string-based metrics to structured JSON data. Eliminates all string parsing bugs and provides type-safe data access.
|
||||||
- Each metric collected, transmitted, and stored individually
|
|
||||||
- Agent calculates status for each metric using thresholds
|
**Previous (String Metrics):**
|
||||||
- Dashboard aggregates individual metric statuses for widget status
|
- ❌ Agent sent individual metrics with string names like `disk_nvme0n1_temperature`
|
||||||
|
- ❌ Dashboard parsed metric names with underscore counting and string splitting
|
||||||
|
- ❌ Complex and error-prone metric filtering and extraction logic
|
||||||
|
|
||||||
|
**Current (Structured Data):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hostname": "cmbox",
|
||||||
|
"agent_version": "v0.1.131",
|
||||||
|
"timestamp": 1763926877,
|
||||||
|
"system": {
|
||||||
|
"cpu": {
|
||||||
|
"load_1min": 3.50,
|
||||||
|
"load_5min": 3.57,
|
||||||
|
"load_15min": 3.58,
|
||||||
|
"frequency_mhz": 1500,
|
||||||
|
"temperature_celsius": 45.2
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"usage_percent": 25.0,
|
||||||
|
"total_gb": 23.3,
|
||||||
|
"used_gb": 5.9,
|
||||||
|
"swap_total_gb": 10.7,
|
||||||
|
"swap_used_gb": 0.99,
|
||||||
|
"tmpfs": [
|
||||||
|
{"mount": "/tmp", "usage_percent": 15.0, "used_gb": 0.3, "total_gb": 2.0}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"drives": [
|
||||||
|
{
|
||||||
|
"name": "nvme0n1",
|
||||||
|
"health": "PASSED",
|
||||||
|
"temperature_celsius": 29.0,
|
||||||
|
"wear_percent": 1.0,
|
||||||
|
"filesystems": [
|
||||||
|
{"mount": "/", "usage_percent": 24.0, "used_gb": 224.9, "total_gb": 928.2}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pools": [
|
||||||
|
{
|
||||||
|
"name": "srv_media",
|
||||||
|
"mount": "/srv/media",
|
||||||
|
"type": "mergerfs",
|
||||||
|
"health": "healthy",
|
||||||
|
"usage_percent": 63.0,
|
||||||
|
"used_gb": 2355.2,
|
||||||
|
"total_gb": 3686.4,
|
||||||
|
"data_drives": [
|
||||||
|
{"name": "sdb", "temperature_celsius": 24.0}
|
||||||
|
],
|
||||||
|
"parity_drives": [
|
||||||
|
{"name": "sdc", "temperature_celsius": 24.0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{"name": "sshd", "status": "active", "memory_mb": 4.5, "disk_gb": 0.0}
|
||||||
|
],
|
||||||
|
"backup": {
|
||||||
|
"status": "completed",
|
||||||
|
"last_run": 1763920000,
|
||||||
|
"next_scheduled": 1764006400,
|
||||||
|
"total_size_gb": 150.5,
|
||||||
|
"repository_health": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ✅ Agent sends structured JSON over ZMQ (no legacy support)
|
||||||
|
- ✅ Type-safe data access: `data.system.storage.drives[0].temperature_celsius`
|
||||||
|
- ✅ Complete metric coverage: CPU, memory, storage, services, backup
|
||||||
|
- ✅ Backward compatibility via bridge conversion to existing UI widgets
|
||||||
|
- ✅ All string parsing bugs eliminated
|
||||||
|
|
||||||
|
|
||||||
### Maintenance Mode
|
### Maintenance Mode
|
||||||
- Agent checks for `/tmp/cm-maintenance` file before sending notifications
|
- Agent checks for `/tmp/cm-maintenance` file before sending notifications
|
||||||
@@ -115,7 +195,7 @@ This automatically:
|
|||||||
- Uploads binaries via Gitea API
|
- Uploads binaries via Gitea API
|
||||||
|
|
||||||
### NixOS Configuration Updates
|
### NixOS Configuration Updates
|
||||||
Edit `~/projects/nixosbox/hosts/common/cm-dashboard.nix`:
|
Edit `~/projects/nixosbox/hosts/services/cm-dashboard.nix`:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
version = "v0.1.X";
|
version = "v0.1.X";
|
||||||
@@ -140,6 +220,130 @@ nix-build --no-out-link -E 'with import <nixpkgs> {}; fetchurl {
|
|||||||
- **Workspace builds**: `nix-shell -p openssl pkg-config --run "cargo build --workspace"`
|
- **Workspace builds**: `nix-shell -p openssl pkg-config --run "cargo build --workspace"`
|
||||||
- **Clean compilation**: Remove `target/` between major changes
|
- **Clean compilation**: Remove `target/` between major changes
|
||||||
|
|
||||||
|
## Enhanced Storage Pool Visualization
|
||||||
|
|
||||||
|
### Auto-Discovery Architecture
|
||||||
|
|
||||||
|
The dashboard uses automatic storage discovery to eliminate manual configuration complexity while providing intelligent storage pool grouping.
|
||||||
|
|
||||||
|
### Discovery Process
|
||||||
|
|
||||||
|
**At Agent Startup:**
|
||||||
|
1. Parse `/proc/mounts` to identify all mounted filesystems
|
||||||
|
2. Detect MergerFS pools by analyzing `fuse.mergerfs` mount sources
|
||||||
|
3. Identify member disks and potential parity relationships via heuristics
|
||||||
|
4. Store discovered storage topology for continuous monitoring
|
||||||
|
5. Generate pool-aware metrics with hierarchical relationships
|
||||||
|
|
||||||
|
**Continuous Monitoring:**
|
||||||
|
- Use stored discovery data for efficient metric collection
|
||||||
|
- Monitor individual drives for SMART data, temperature, wear
|
||||||
|
- Calculate pool-level health based on member drive status
|
||||||
|
- Generate enhanced metrics for dashboard visualization
|
||||||
|
|
||||||
|
### Supported Storage Types
|
||||||
|
|
||||||
|
**Single Disks:**
|
||||||
|
- ext4, xfs, btrfs mounted directly
|
||||||
|
- Individual drive monitoring with SMART data
|
||||||
|
- Traditional single-disk display for root, boot, etc.
|
||||||
|
|
||||||
|
**MergerFS Pools:**
|
||||||
|
- Auto-detect from `/proc/mounts` fuse.mergerfs entries
|
||||||
|
- Parse source paths to identify member disks (e.g., "/mnt/disk1:/mnt/disk2")
|
||||||
|
- Heuristic parity disk detection (sequential device names, "parity" in path)
|
||||||
|
- Pool health calculation (healthy/degraded/critical)
|
||||||
|
- Hierarchical tree display with data/parity disk grouping
|
||||||
|
|
||||||
|
**Future Extensions Ready:**
|
||||||
|
- RAID arrays via `/proc/mdstat` parsing
|
||||||
|
- ZFS pools via `zpool status` integration
|
||||||
|
- LVM logical volumes via `lvs` discovery
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[collectors.disk]
|
||||||
|
enabled = true
|
||||||
|
auto_discover = true # Default: true
|
||||||
|
# Optional exclusions for special filesystems
|
||||||
|
exclude_mount_points = ["/tmp", "/proc", "/sys", "/dev"]
|
||||||
|
exclude_fs_types = ["tmpfs", "devtmpfs", "sysfs", "proc"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display Format
|
||||||
|
|
||||||
|
```
|
||||||
|
Storage:
|
||||||
|
● /srv/media (mergerfs (2+1)):
|
||||||
|
├─ Pool Status: ● Healthy (3 drives)
|
||||||
|
├─ Total: ● 63% 2355.2GB/3686.4GB
|
||||||
|
├─ Data Disks:
|
||||||
|
│ ├─ ● sdb T: 24°C
|
||||||
|
│ └─ ● sdd T: 27°C
|
||||||
|
└─ Parity: ● sdc T: 24°C
|
||||||
|
● /:
|
||||||
|
├─ ● nvme0n1 W: 13%
|
||||||
|
└─ ● 7% 14.5GB/218.5GB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Benefits
|
||||||
|
|
||||||
|
- **Zero Configuration**: No manual pool definitions required
|
||||||
|
- **Always Accurate**: Reflects actual system state automatically
|
||||||
|
- **Scales Automatically**: Handles any number of pools without config changes
|
||||||
|
- **Backwards Compatible**: Single disks continue working unchanged
|
||||||
|
- **Future Ready**: Easy extension for additional storage technologies
|
||||||
|
|
||||||
|
### Current Status (v0.1.100)
|
||||||
|
|
||||||
|
**✅ Completed:**
|
||||||
|
- Auto-discovery system implemented and deployed
|
||||||
|
- `/proc/mounts` parsing with smart heuristics for parity detection
|
||||||
|
- Storage topology stored at agent startup for efficient monitoring
|
||||||
|
- Universal zero-configuration for all hosts (cmbox, steambox, simonbox, srv01, srv02, srv03)
|
||||||
|
- Enhanced pool health calculation (healthy/degraded/critical)
|
||||||
|
- Hierarchical tree visualization with data/parity disk separation
|
||||||
|
|
||||||
|
**🔄 In Progress - Complete Disk Collector Rewrite:**
|
||||||
|
|
||||||
|
The current disk collector has grown complex with mixed legacy/auto-discovery approaches. Planning complete rewrite with clean, simple workflow supporting both physical drives and mergerfs pools.
|
||||||
|
|
||||||
|
**New Clean Architecture:**
|
||||||
|
|
||||||
|
**Discovery Workflow:**
|
||||||
|
1. **`lsblk`** to detect all mount points and backing devices
|
||||||
|
2. **`df`** to get filesystem usage for each mount point
|
||||||
|
3. **Group by physical drive** (nvme0n1, sda, etc.)
|
||||||
|
4. **Parse `/proc/mounts`** for mergerfs pools
|
||||||
|
5. **Generate unified metrics** for both storage types
|
||||||
|
|
||||||
|
**Physical Drive Display:**
|
||||||
|
```
|
||||||
|
● nvme0n1:
|
||||||
|
├─ ● Drive: T: 35°C W: 1%
|
||||||
|
├─ ● Total: 23% 218.0GB/928.2GB
|
||||||
|
├─ ● /boot: 11% 0.1GB/1.0GB
|
||||||
|
└─ ● /: 23% 214.9GB/928.2GB
|
||||||
|
```
|
||||||
|
|
||||||
|
**MergerFS Pool Display:**
|
||||||
|
```
|
||||||
|
● /srv/media (mergerfs):
|
||||||
|
├─ ● Pool: 63% 2355.2GB/3686.4GB
|
||||||
|
├─ Data Disks:
|
||||||
|
│ ├─ ● sdb T: 24°C
|
||||||
|
│ └─ ● sdd T: 27°C
|
||||||
|
└─ ● sdc T: 24°C (parity)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Benefits:**
|
||||||
|
- **Pure auto-discovery**: No configuration needed
|
||||||
|
- **Clean code paths**: Single workflow for all storage types
|
||||||
|
- **Consistent display**: Status icons on every line, no redundant text
|
||||||
|
- **Simple pipeline**: lsblk → df → group → metrics
|
||||||
|
- **Support for both**: Physical drives and mergerfs pools
|
||||||
|
|
||||||
## Important Communication Guidelines
|
## Important Communication Guidelines
|
||||||
|
|
||||||
Keep responses concise and focused. Avoid extensive implementation summaries unless requested.
|
Keep responses concise and focused. Avoid extensive implementation summaries unless requested.
|
||||||
@@ -165,12 +369,55 @@ Keep responses concise and focused. Avoid extensive implementation summaries unl
|
|||||||
- ✅ "Restructure storage widget with improved layout"
|
- ✅ "Restructure storage widget with improved layout"
|
||||||
- ✅ "Update CPU thresholds to production values"
|
- ✅ "Update CPU thresholds to production values"
|
||||||
|
|
||||||
|
## Completed Architecture Migration (v0.1.131)
|
||||||
|
|
||||||
|
### ✅ Phase 1: Structured Data Types (Shared Crate) - COMPLETED
|
||||||
|
- ✅ Created AgentData struct matching JSON structure
|
||||||
|
- ✅ Added complete type hierarchy: CPU, memory, storage, services, backup
|
||||||
|
- ✅ Implemented serde serialization/deserialization
|
||||||
|
- ✅ Updated ZMQ protocol for structured data transmission
|
||||||
|
|
||||||
|
### ✅ Phase 2: Agent Refactor - COMPLETED
|
||||||
|
- ✅ Agent converts all metrics to structured AgentData
|
||||||
|
- ✅ Comprehensive metric parsing: storage (drives, temp, wear), services, backup
|
||||||
|
- ✅ Structured JSON transmission over ZMQ (no legacy support)
|
||||||
|
- ✅ Type-safe data flow throughout agent pipeline
|
||||||
|
|
||||||
|
### ✅ Phase 3: Dashboard Refactor - COMPLETED
|
||||||
|
- ✅ Dashboard receives structured data and bridges to existing UI
|
||||||
|
- ✅ Bridge conversion maintains compatibility with current widgets
|
||||||
|
- ✅ All metric types converted: storage, services, backup, CPU, memory
|
||||||
|
- ✅ Foundation ready for direct structured data widget migration
|
||||||
|
|
||||||
|
### 🚀 Next Phase: Direct Widget Migration
|
||||||
|
- Replace metric bridge with direct structured data access in widgets
|
||||||
|
- Eliminate temporary conversion layer
|
||||||
|
- Full end-to-end type safety from agent to UI
|
||||||
|
|
||||||
|
## Key Achievements (v0.1.131)
|
||||||
|
|
||||||
|
**✅ NVMe Temperature Issue SOLVED**
|
||||||
|
- Temperature data now flows as typed field: `agent_data.system.storage.drives[0].temperature_celsius: f32`
|
||||||
|
- Eliminates string parsing bugs: no more `"disk_nvme0n1_temperature"` extraction failures
|
||||||
|
- Type-safe access prevents all similar parsing issues across the system
|
||||||
|
|
||||||
|
**✅ Complete Structured Data Implementation**
|
||||||
|
- Agent: Collects metrics → structured JSON → ZMQ transmission
|
||||||
|
- Dashboard: Receives JSON → bridge conversion → existing UI widgets
|
||||||
|
- Full metric coverage: CPU, memory, storage (drives, pools), services, backup
|
||||||
|
- Zero legacy support - clean architecture with no compatibility cruft
|
||||||
|
|
||||||
|
**✅ Foundation for Future Enhancements**
|
||||||
|
- Type-safe data structures enable easy feature additions
|
||||||
|
- Self-documenting JSON schema shows all available metrics
|
||||||
|
- Direct field access eliminates entire class of parsing bugs
|
||||||
|
- Ready for next phase: direct widget migration for ultimate performance
|
||||||
|
|
||||||
## Implementation Rules
|
## Implementation Rules
|
||||||
|
|
||||||
1. **Individual Metrics**: Each metric is collected, transmitted, and stored individually
|
1. **Agent Status Authority**: Agent calculates status for each metric using thresholds
|
||||||
2. **Agent Status Authority**: Agent calculates status for each metric using thresholds
|
2. **Dashboard Composition**: Dashboard widgets subscribe to specific metrics by name
|
||||||
3. **Dashboard Composition**: Dashboard widgets subscribe to specific metrics by name
|
3. **Status Aggregation**: Dashboard aggregates individual metric statuses for widget status
|
||||||
4. **Status Aggregation**: Dashboard aggregates individual metric statuses for widget status
|
|
||||||
|
|
||||||
**NEVER:**
|
**NEVER:**
|
||||||
- Copy/paste ANY code from legacy implementations
|
- Copy/paste ANY code from legacy implementations
|
||||||
|
|||||||
230
Cargo.lock
generated
230
Cargo.lock
generated
@@ -17,9 +17,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.3"
|
version = "1.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -71,22 +71,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle-query"
|
name = "anstyle-query"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle-wincon"
|
name = "anstyle-wincon"
|
||||||
version = "3.0.10"
|
version = "3.0.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -95,6 +95,15 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ar_archive_writer"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a"
|
||||||
|
dependencies = [
|
||||||
|
"object",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -144,9 +153,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cassowary"
|
name = "cassowary"
|
||||||
@@ -156,9 +165,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.41"
|
version = "1.2.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7"
|
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -230,9 +239,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.49"
|
version = "4.5.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f"
|
checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -240,9 +249,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.49"
|
version = "4.5.52"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730"
|
checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -270,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.57"
|
version = "0.1.133"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -292,7 +301,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.57"
|
version = "0.1.133"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -315,7 +324,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.57"
|
version = "0.1.133"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -503,9 +512,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
|
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
@@ -768,9 +777,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.0.0"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
|
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"potential_utf",
|
"potential_utf",
|
||||||
@@ -781,9 +790,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_locale_core"
|
name = "icu_locale_core"
|
||||||
version = "2.0.0"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
|
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"litemap",
|
"litemap",
|
||||||
@@ -794,11 +803,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer"
|
name = "icu_normalizer"
|
||||||
version = "2.0.0"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
|
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_normalizer_data",
|
"icu_normalizer_data",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
@@ -809,42 +817,38 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer_data"
|
name = "icu_normalizer_data"
|
||||||
version = "2.0.0"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
|
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties"
|
name = "icu_properties"
|
||||||
version = "2.0.1"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
|
checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
"icu_properties_data",
|
"icu_properties_data",
|
||||||
"icu_provider",
|
"icu_provider",
|
||||||
"potential_utf",
|
|
||||||
"zerotrie",
|
"zerotrie",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties_data"
|
name = "icu_properties_data"
|
||||||
version = "2.0.1"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
|
checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_provider"
|
name = "icu_provider"
|
||||||
version = "2.0.0"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
|
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
"stable_deref_trait",
|
|
||||||
"tinystr",
|
|
||||||
"writeable",
|
"writeable",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
@@ -885,9 +889,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indoc"
|
||||||
version = "2.0.6"
|
version = "2.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
@@ -897,9 +904,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.1"
|
version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
@@ -928,9 +935,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.82"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
|
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -988,9 +995,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
@@ -1104,6 +1111,15 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "object"
|
||||||
|
version = "0.32.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -1112,15 +1128,15 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.1"
|
version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.74"
|
version = "0.10.75"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -1150,9 +1166,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.110"
|
version = "0.9.111"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1262,36 +1278,37 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a"
|
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.101"
|
version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psm"
|
name = "psm"
|
||||||
version = "0.1.27"
|
version = "0.1.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c"
|
checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ar_archive_writer",
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.41"
|
version = "1.0.42"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -1611,9 +1628,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-mio"
|
name = "signal-hook-mio"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"mio 0.8.11",
|
"mio 0.8.11",
|
||||||
@@ -1716,9 +1733,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.107"
|
version = "2.0.110"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b"
|
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1826,9 +1843,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
|
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
@@ -1874,9 +1891,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.16"
|
version = "0.7.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
|
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -2001,9 +2018,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.19"
|
version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
@@ -2055,9 +2072,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
|
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
@@ -2107,9 +2124,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.104"
|
version = "0.2.105"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
|
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -2118,25 +2135,11 @@ dependencies = [
|
|||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-backend"
|
|
||||||
version = "0.2.104"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"log",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"wasm-bindgen-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.54"
|
version = "0.4.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
|
checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -2147,9 +2150,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.104"
|
version = "0.2.105"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
|
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -2157,31 +2160,31 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.104"
|
version = "0.2.105"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
"wasm-bindgen-backend",
|
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.104"
|
version = "0.2.105"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
|
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.82"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
|
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -2535,17 +2538,16 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
|
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
@@ -2553,9 +2555,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2616,9 +2618,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
|
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"yoke",
|
"yoke",
|
||||||
@@ -2627,9 +2629,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec"
|
name = "zerovec"
|
||||||
version = "0.11.4"
|
version = "0.11.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b"
|
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
@@ -2638,9 +2640,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec-derive"
|
name = "zerovec-derive"
|
||||||
version = "0.11.1"
|
version = "0.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ cm-dashboard • ● cmbox ● srv01 ● srv02 ● steambox
|
|||||||
- **s**: Start selected service (UserStart)
|
- **s**: Start selected service (UserStart)
|
||||||
- **S**: Stop selected service (UserStop)
|
- **S**: Stop selected service (UserStop)
|
||||||
- **J**: Show service logs (journalctl in tmux popup)
|
- **J**: Show service logs (journalctl in tmux popup)
|
||||||
|
- **L**: Show custom log files (tail -f custom paths in tmux popup)
|
||||||
- **R**: Rebuild current host
|
- **R**: Rebuild current host
|
||||||
|
- **B**: Run backup on current host
|
||||||
- **q**: Quit
|
- **q**: Quit
|
||||||
|
|
||||||
### Status Indicators
|
### Status Indicators
|
||||||
@@ -173,9 +175,10 @@ subscriber_ports = [6130]
|
|||||||
[hosts]
|
[hosts]
|
||||||
predefined_hosts = ["cmbox", "srv01", "srv02"]
|
predefined_hosts = ["cmbox", "srv01", "srv02"]
|
||||||
|
|
||||||
[ui]
|
[ssh]
|
||||||
ssh_user = "cm"
|
rebuild_user = "cm"
|
||||||
rebuild_alias = "nixos-rebuild-cmtec"
|
rebuild_alias = "nixos-rebuild-cmtec"
|
||||||
|
backup_alias = "cm-backup-run"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technical Implementation
|
## Technical Implementation
|
||||||
@@ -329,7 +332,7 @@ This triggers automated:
|
|||||||
- Tarball upload to Gitea
|
- Tarball upload to Gitea
|
||||||
|
|
||||||
### NixOS Integration
|
### NixOS Integration
|
||||||
Update `~/projects/nixosbox/hosts/common/cm-dashboard.nix`:
|
Update `~/projects/nixosbox/hosts/services/cm-dashboard.nix`:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
version = "v0.1.43";
|
version = "v0.1.43";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.58"
|
version = "0.1.134"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ use std::time::Duration;
|
|||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
use crate::communication::{AgentCommand, ServiceAction, ZmqHandler};
|
use crate::communication::{AgentCommand, ZmqHandler};
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::metrics::MetricCollectionManager;
|
use crate::metrics::MetricCollectionManager;
|
||||||
use crate::notifications::NotificationManager;
|
use crate::notifications::NotificationManager;
|
||||||
use crate::service_tracker::UserStoppedServiceTracker;
|
use crate::service_tracker::UserStoppedServiceTracker;
|
||||||
use crate::status::HostStatusManager;
|
use crate::status::HostStatusManager;
|
||||||
use cm_dashboard_shared::{Metric, MetricMessage, MetricValue, Status};
|
use cm_dashboard_shared::{AgentData, Metric, MetricValue, Status, TmpfsData, DriveData, FilesystemData, ServiceData};
|
||||||
|
|
||||||
pub struct Agent {
|
pub struct Agent {
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@@ -78,10 +78,11 @@ impl Agent {
|
|||||||
info!("Initial metric collection completed - all data cached and ready");
|
info!("Initial metric collection completed - all data cached and ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate intervals for collection, transmission, and email notifications
|
// Separate intervals for collection, transmission, heartbeat, and email notifications
|
||||||
let mut collection_interval =
|
let mut collection_interval =
|
||||||
interval(Duration::from_secs(self.config.collection_interval_seconds));
|
interval(Duration::from_secs(self.config.collection_interval_seconds));
|
||||||
let mut transmission_interval = interval(Duration::from_secs(self.config.zmq.transmission_interval_seconds));
|
let mut transmission_interval = interval(Duration::from_secs(self.config.zmq.transmission_interval_seconds));
|
||||||
|
let mut heartbeat_interval = interval(Duration::from_secs(self.config.zmq.heartbeat_interval_seconds));
|
||||||
let mut notification_interval = interval(Duration::from_secs(self.config.notifications.aggregation_interval_seconds));
|
let mut notification_interval = interval(Duration::from_secs(self.config.notifications.aggregation_interval_seconds));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -98,6 +99,12 @@ impl Agent {
|
|||||||
error!("Failed to broadcast metrics: {}", e);
|
error!("Failed to broadcast metrics: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = heartbeat_interval.tick() => {
|
||||||
|
// Send standalone heartbeat for host connectivity detection
|
||||||
|
if let Err(e) = self.send_heartbeat().await {
|
||||||
|
error!("Failed to send heartbeat: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = notification_interval.tick() => {
|
_ = notification_interval.tick() => {
|
||||||
// Process batched email notifications (separate from dashboard updates)
|
// Process batched email notifications (separate from dashboard updates)
|
||||||
if let Err(e) = self.host_status_manager.process_pending_notifications(&mut self.notification_manager).await {
|
if let Err(e) = self.host_status_manager.process_pending_notifications(&mut self.notification_manager).await {
|
||||||
@@ -192,21 +199,315 @@ impl Agent {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Broadcasting {} cached metrics (including host status summary)", metrics.len());
|
debug!("Broadcasting {} cached metrics as structured data", metrics.len());
|
||||||
|
|
||||||
// Create and send message with all current data
|
// Convert metrics to structured data and send
|
||||||
let message = MetricMessage::new(self.hostname.clone(), metrics);
|
let agent_data = self.metrics_to_structured_data(&metrics)?;
|
||||||
self.zmq_handler.publish_metrics(&message).await?;
|
self.zmq_handler.publish_agent_data(&agent_data).await?;
|
||||||
|
|
||||||
debug!("Metrics broadcasted successfully");
|
debug!("Structured data broadcasted successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert legacy metrics to structured data format
|
||||||
|
fn metrics_to_structured_data(&self, metrics: &[Metric]) -> Result<AgentData> {
|
||||||
|
let mut agent_data = AgentData::new(self.hostname.clone(), self.get_agent_version());
|
||||||
|
|
||||||
|
// Parse metrics into structured data
|
||||||
|
for metric in metrics {
|
||||||
|
self.parse_metric_into_agent_data(&mut agent_data, metric)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(agent_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single metric into the appropriate structured data field
|
||||||
|
fn parse_metric_into_agent_data(&self, agent_data: &mut AgentData, metric: &Metric) -> Result<()> {
|
||||||
|
// CPU metrics
|
||||||
|
if metric.name == "cpu_load_1min" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.cpu.load_1min = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "cpu_load_5min" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.cpu.load_5min = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "cpu_load_15min" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.cpu.load_15min = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "cpu_frequency_mhz" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.cpu.frequency_mhz = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "cpu_temperature_celsius" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.cpu.temperature_celsius = Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Memory metrics
|
||||||
|
else if metric.name == "memory_usage_percent" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.memory.usage_percent = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_total_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.memory.total_gb = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_used_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.memory.used_gb = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_available_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.memory.available_gb = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_swap_total_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.memory.swap_total_gb = value;
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_swap_used_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
agent_data.system.memory.swap_used_gb = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tmpfs metrics
|
||||||
|
else if metric.name.starts_with("memory_tmp_") {
|
||||||
|
// For now, create a single /tmp tmpfs entry
|
||||||
|
if metric.name == "memory_tmp_usage_percent" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
if let Some(tmpfs) = agent_data.system.memory.tmpfs.get_mut(0) {
|
||||||
|
tmpfs.usage_percent = value;
|
||||||
|
} else {
|
||||||
|
agent_data.system.memory.tmpfs.push(TmpfsData {
|
||||||
|
mount: "/tmp".to_string(),
|
||||||
|
usage_percent: value,
|
||||||
|
used_gb: 0.0,
|
||||||
|
total_gb: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_tmp_used_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
if let Some(tmpfs) = agent_data.system.memory.tmpfs.get_mut(0) {
|
||||||
|
tmpfs.used_gb = value;
|
||||||
|
} else {
|
||||||
|
agent_data.system.memory.tmpfs.push(TmpfsData {
|
||||||
|
mount: "/tmp".to_string(),
|
||||||
|
usage_percent: 0.0,
|
||||||
|
used_gb: value,
|
||||||
|
total_gb: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if metric.name == "memory_tmp_total_gb" {
|
||||||
|
if let Some(value) = metric.value.as_f32() {
|
||||||
|
if let Some(tmpfs) = agent_data.system.memory.tmpfs.get_mut(0) {
|
||||||
|
tmpfs.total_gb = value;
|
||||||
|
} else {
|
||||||
|
agent_data.system.memory.tmpfs.push(TmpfsData {
|
||||||
|
mount: "/tmp".to_string(),
|
||||||
|
usage_percent: 0.0,
|
||||||
|
used_gb: 0.0,
|
||||||
|
total_gb: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Storage metrics
|
||||||
|
else if metric.name.starts_with("disk_") {
|
||||||
|
if metric.name.contains("_temperature") {
|
||||||
|
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||||
|
if let Some(temp) = metric.value.as_f32() {
|
||||||
|
self.ensure_drive_exists(agent_data, &drive_name);
|
||||||
|
if let Some(drive) = agent_data.system.storage.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||||
|
drive.temperature_celsius = Some(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if metric.name.contains("_wear_percent") {
|
||||||
|
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||||
|
if let Some(wear) = metric.value.as_f32() {
|
||||||
|
self.ensure_drive_exists(agent_data, &drive_name);
|
||||||
|
if let Some(drive) = agent_data.system.storage.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||||
|
drive.wear_percent = Some(wear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if metric.name.contains("_health") {
|
||||||
|
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
||||||
|
let health = metric.value.as_string();
|
||||||
|
self.ensure_drive_exists(agent_data, &drive_name);
|
||||||
|
if let Some(drive) = agent_data.system.storage.drives.iter_mut().find(|d| d.name == drive_name) {
|
||||||
|
drive.health = health;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if metric.name.contains("_fs_") {
|
||||||
|
// Filesystem metrics: disk_{pool}_fs_{filesystem}_{metric}
|
||||||
|
if let Some((pool_name, fs_name)) = self.extract_pool_and_filesystem(&metric.name) {
|
||||||
|
if metric.name.contains("_usage_percent") {
|
||||||
|
if let Some(usage) = metric.value.as_f32() {
|
||||||
|
self.ensure_filesystem_exists(agent_data, &pool_name, &fs_name, usage, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
} else if metric.name.contains("_used_gb") {
|
||||||
|
if let Some(used) = metric.value.as_f32() {
|
||||||
|
self.update_filesystem_field(agent_data, &pool_name, &fs_name, |fs| fs.used_gb = used);
|
||||||
|
}
|
||||||
|
} else if metric.name.contains("_total_gb") {
|
||||||
|
if let Some(total) = metric.value.as_f32() {
|
||||||
|
self.update_filesystem_field(agent_data, &pool_name, &fs_name, |fs| fs.total_gb = total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Service metrics
|
||||||
|
else if metric.name.starts_with("service_") {
|
||||||
|
if let Some(service_name) = self.extract_service_name(&metric.name) {
|
||||||
|
if metric.name.contains("_status") {
|
||||||
|
let status = metric.value.as_string();
|
||||||
|
self.ensure_service_exists(agent_data, &service_name, &status);
|
||||||
|
} else if metric.name.contains("_memory_mb") {
|
||||||
|
if let Some(memory) = metric.value.as_f32() {
|
||||||
|
self.update_service_field(agent_data, &service_name, |svc| svc.memory_mb = memory);
|
||||||
|
}
|
||||||
|
} else if metric.name.contains("_disk_gb") {
|
||||||
|
if let Some(disk) = metric.value.as_f32() {
|
||||||
|
self.update_service_field(agent_data, &service_name, |svc| svc.disk_gb = disk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Backup metrics
|
||||||
|
else if metric.name.starts_with("backup_") {
|
||||||
|
if metric.name == "backup_status" {
|
||||||
|
agent_data.backup.status = metric.value.as_string();
|
||||||
|
} else if metric.name == "backup_last_run_timestamp" {
|
||||||
|
if let Some(timestamp) = metric.value.as_i64() {
|
||||||
|
agent_data.backup.last_run = Some(timestamp as u64);
|
||||||
|
}
|
||||||
|
} else if metric.name == "backup_next_scheduled_timestamp" {
|
||||||
|
if let Some(timestamp) = metric.value.as_i64() {
|
||||||
|
agent_data.backup.next_scheduled = Some(timestamp as u64);
|
||||||
|
}
|
||||||
|
} else if metric.name == "backup_size_gb" {
|
||||||
|
if let Some(size) = metric.value.as_f32() {
|
||||||
|
agent_data.backup.total_size_gb = Some(size);
|
||||||
|
}
|
||||||
|
} else if metric.name == "backup_repository_health" {
|
||||||
|
agent_data.backup.repository_health = Some(metric.value.as_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract drive name from metric like "disk_nvme0n1_temperature"
|
||||||
|
fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
|
||||||
|
if metric_name.starts_with("disk_") {
|
||||||
|
let suffixes = ["_temperature", "_wear_percent", "_health"];
|
||||||
|
for suffix in suffixes {
|
||||||
|
if let Some(suffix_pos) = metric_name.rfind(suffix) {
|
||||||
|
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract pool and filesystem from "disk_{pool}_fs_{filesystem}_{metric}"
|
||||||
|
fn extract_pool_and_filesystem(&self, metric_name: &str) -> Option<(String, String)> {
|
||||||
|
if let Some(fs_pos) = metric_name.find("_fs_") {
|
||||||
|
let pool_name = metric_name[5..fs_pos].to_string(); // Skip "disk_"
|
||||||
|
let after_fs = &metric_name[fs_pos + 4..]; // Skip "_fs_"
|
||||||
|
if let Some(metric_pos) = after_fs.find('_') {
|
||||||
|
let fs_name = after_fs[..metric_pos].to_string();
|
||||||
|
return Some((pool_name, fs_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract service name from "service_{name}_{metric}"
|
||||||
|
fn extract_service_name(&self, metric_name: &str) -> Option<String> {
|
||||||
|
if metric_name.starts_with("service_") {
|
||||||
|
let suffixes = ["_status", "_memory_mb", "_disk_gb"];
|
||||||
|
for suffix in suffixes {
|
||||||
|
if let Some(suffix_pos) = metric_name.rfind(suffix) {
|
||||||
|
return Some(metric_name[8..suffix_pos].to_string()); // Skip "service_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure drive exists in agent_data
|
||||||
|
fn ensure_drive_exists(&self, agent_data: &mut AgentData, drive_name: &str) {
|
||||||
|
if !agent_data.system.storage.drives.iter().any(|d| d.name == drive_name) {
|
||||||
|
agent_data.system.storage.drives.push(DriveData {
|
||||||
|
name: drive_name.to_string(),
|
||||||
|
health: "UNKNOWN".to_string(),
|
||||||
|
temperature_celsius: None,
|
||||||
|
wear_percent: None,
|
||||||
|
filesystems: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure filesystem exists in the correct drive
|
||||||
|
fn ensure_filesystem_exists(&self, agent_data: &mut AgentData, pool_name: &str, fs_name: &str, usage_percent: f32, used_gb: f32, total_gb: f32) {
|
||||||
|
self.ensure_drive_exists(agent_data, pool_name);
|
||||||
|
if let Some(drive) = agent_data.system.storage.drives.iter_mut().find(|d| d.name == pool_name) {
|
||||||
|
if !drive.filesystems.iter().any(|fs| fs.mount == fs_name) {
|
||||||
|
drive.filesystems.push(FilesystemData {
|
||||||
|
mount: fs_name.to_string(),
|
||||||
|
usage_percent,
|
||||||
|
used_gb,
|
||||||
|
total_gb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update filesystem field
|
||||||
|
fn update_filesystem_field<F>(&self, agent_data: &mut AgentData, pool_name: &str, fs_name: &str, update_fn: F)
|
||||||
|
where F: FnOnce(&mut FilesystemData) {
|
||||||
|
if let Some(drive) = agent_data.system.storage.drives.iter_mut().find(|d| d.name == pool_name) {
|
||||||
|
if let Some(fs) = drive.filesystems.iter_mut().find(|fs| fs.mount == fs_name) {
|
||||||
|
update_fn(fs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure service exists
|
||||||
|
fn ensure_service_exists(&self, agent_data: &mut AgentData, service_name: &str, status: &str) {
|
||||||
|
if !agent_data.services.iter().any(|s| s.name == service_name) {
|
||||||
|
agent_data.services.push(ServiceData {
|
||||||
|
name: service_name.to_string(),
|
||||||
|
status: status.to_string(),
|
||||||
|
memory_mb: 0.0,
|
||||||
|
disk_gb: 0.0,
|
||||||
|
user_stopped: false, // TODO: Get from service tracker
|
||||||
|
});
|
||||||
|
} else if let Some(service) = agent_data.services.iter_mut().find(|s| s.name == service_name) {
|
||||||
|
service.status = status.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update service field
|
||||||
|
fn update_service_field<F>(&self, agent_data: &mut AgentData, service_name: &str, update_fn: F)
|
||||||
|
where F: FnOnce(&mut ServiceData) {
|
||||||
|
if let Some(service) = agent_data.services.iter_mut().find(|s| s.name == service_name) {
|
||||||
|
update_fn(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn process_metrics(&mut self, metrics: &[Metric]) -> bool {
|
async fn process_metrics(&mut self, metrics: &[Metric]) -> bool {
|
||||||
let mut status_changed = false;
|
let mut status_changed = false;
|
||||||
for metric in metrics {
|
for metric in metrics {
|
||||||
// Filter excluded metrics from email notification processing only
|
// Filter excluded metrics from email notification processing only
|
||||||
if self.config.exclude_email_metrics.contains(&metric.name) {
|
if self.config.notifications.exclude_email_metrics.contains(&metric.name) {
|
||||||
debug!("Excluding metric '{}' from email notification processing", metric.name);
|
debug!("Excluding metric '{}' from email notification processing", metric.name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -252,6 +553,17 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send standalone heartbeat for connectivity detection
|
||||||
|
async fn send_heartbeat(&mut self) -> Result<()> {
|
||||||
|
// Create minimal agent data with just heartbeat
|
||||||
|
let agent_data = AgentData::new(self.hostname.clone(), self.get_agent_version());
|
||||||
|
// Heartbeat timestamp is already set in AgentData::new()
|
||||||
|
|
||||||
|
self.zmq_handler.publish_agent_data(&agent_data).await?;
|
||||||
|
debug!("Sent standalone heartbeat for connectivity detection");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_commands(&mut self) -> Result<()> {
|
async fn handle_commands(&mut self) -> Result<()> {
|
||||||
// Try to receive commands (non-blocking)
|
// Try to receive commands (non-blocking)
|
||||||
match self.zmq_handler.try_receive_command() {
|
match self.zmq_handler.try_receive_command() {
|
||||||
@@ -295,75 +607,10 @@ impl Agent {
|
|||||||
info!("Processing Ping command - agent is alive");
|
info!("Processing Ping command - agent is alive");
|
||||||
// Could send a response back via ZMQ if needed
|
// Could send a response back via ZMQ if needed
|
||||||
}
|
}
|
||||||
AgentCommand::ServiceControl { service_name, action } => {
|
|
||||||
info!("Processing ServiceControl command: {} {:?}", service_name, action);
|
|
||||||
if let Err(e) = self.handle_service_control(&service_name, &action).await {
|
|
||||||
error!("Failed to execute service control: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle systemd service control commands
|
|
||||||
async fn handle_service_control(&mut self, service_name: &str, action: &ServiceAction) -> Result<()> {
|
|
||||||
let (action_str, is_user_action) = match action {
|
|
||||||
ServiceAction::Start => ("start", false),
|
|
||||||
ServiceAction::Stop => ("stop", false),
|
|
||||||
ServiceAction::Status => ("status", false),
|
|
||||||
ServiceAction::UserStart => ("start", true),
|
|
||||||
ServiceAction::UserStop => ("stop", true),
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("Executing systemctl {} {} (user action: {})", action_str, service_name, is_user_action);
|
|
||||||
|
|
||||||
// Handle user-stopped service tracking before systemctl execution (stop only)
|
|
||||||
match action {
|
|
||||||
ServiceAction::UserStop => {
|
|
||||||
info!("Marking service '{}' as user-stopped", service_name);
|
|
||||||
if let Err(e) = self.service_tracker.mark_user_stopped(service_name) {
|
|
||||||
error!("Failed to mark service as user-stopped: {}", e);
|
|
||||||
} else {
|
|
||||||
// Sync to global tracker
|
|
||||||
UserStoppedServiceTracker::update_global(&self.service_tracker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = tokio::process::Command::new("sudo")
|
|
||||||
.arg("systemctl")
|
|
||||||
.arg(action_str)
|
|
||||||
.arg(format!("{}.service", service_name))
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
info!("Service {} {} completed successfully", service_name, action_str);
|
|
||||||
if !output.stdout.is_empty() {
|
|
||||||
debug!("stdout: {}", String::from_utf8_lossy(&output.stdout));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: User-stopped flag will be cleared by systemd collector
|
|
||||||
// when service actually reaches 'active' state, not here
|
|
||||||
} else {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
error!("Service {} {} failed: {}", service_name, action_str, stderr);
|
|
||||||
return Err(anyhow::anyhow!("systemctl {} {} failed: {}", action_str, service_name, stderr));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force refresh metrics after service control to update service status
|
|
||||||
if matches!(action, ServiceAction::Start | ServiceAction::Stop | ServiceAction::UserStart | ServiceAction::UserStop) {
|
|
||||||
info!("Triggering immediate metric refresh after service control");
|
|
||||||
if let Err(e) = self.collect_metrics_only().await {
|
|
||||||
error!("Failed to refresh metrics after service control: {}", e);
|
|
||||||
} else {
|
|
||||||
info!("Service status refreshed immediately after {} {}", action_str, service_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check metrics for user-stopped services that are now active and clear their flags
|
/// Check metrics for user-stopped services that are now active and clear their flags
|
||||||
fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) {
|
fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) {
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ impl BackupCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
||||||
|
// Check if we're in maintenance mode
|
||||||
|
if std::fs::metadata("/tmp/cm-maintenance").is_ok() {
|
||||||
|
// Return special maintenance mode status
|
||||||
|
let maintenance_status = BackupStatusToml {
|
||||||
|
backup_name: "maintenance".to_string(),
|
||||||
|
start_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
current_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
duration_seconds: 0,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
last_updated: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
disk_space: None,
|
||||||
|
disk_product_name: None,
|
||||||
|
disk_serial_number: None,
|
||||||
|
disk_wear_percent: None,
|
||||||
|
services: HashMap::new(),
|
||||||
|
};
|
||||||
|
return Ok(Some(maintenance_status));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if backup status file exists
|
// Check if backup status file exists
|
||||||
if !std::path::Path::new(&self.backup_status_file).exists() {
|
if !std::path::Path::new(&self.backup_status_file).exists() {
|
||||||
return Ok(None); // File doesn't exist, but this is not an error
|
return Ok(None); // File doesn't exist, but this is not an error
|
||||||
@@ -79,7 +98,9 @@ impl BackupCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"failed" => Status::Critical,
|
"failed" => Status::Critical,
|
||||||
|
"warning" => Status::Warning, // Backup completed with warnings
|
||||||
"running" => Status::Ok, // Currently running is OK
|
"running" => Status::Ok, // Currently running is OK
|
||||||
|
"pending" => Status::Pending, // Maintenance mode or backup starting
|
||||||
_ => Status::Unknown,
|
_ => Status::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +157,7 @@ impl Collector for BackupCollector {
|
|||||||
name: "backup_overall_status".to_string(),
|
name: "backup_overall_status".to_string(),
|
||||||
value: MetricValue::String(match overall_status {
|
value: MetricValue::String(match overall_status {
|
||||||
Status::Ok => "ok".to_string(),
|
Status::Ok => "ok".to_string(),
|
||||||
|
Status::Inactive => "inactive".to_string(),
|
||||||
Status::Pending => "pending".to_string(),
|
Status::Pending => "pending".to_string(),
|
||||||
Status::Warning => "warning".to_string(),
|
Status::Warning => "warning".to_string(),
|
||||||
Status::Critical => "critical".to_string(),
|
Status::Critical => "critical".to_string(),
|
||||||
@@ -199,6 +221,7 @@ impl Collector for BackupCollector {
|
|||||||
name: format!("backup_service_{}_status", service_name),
|
name: format!("backup_service_{}_status", service_name),
|
||||||
value: MetricValue::String(match service_status {
|
value: MetricValue::String(match service_status {
|
||||||
Status::Ok => "ok".to_string(),
|
Status::Ok => "ok".to_string(),
|
||||||
|
Status::Inactive => "inactive".to_string(),
|
||||||
Status::Pending => "pending".to_string(),
|
Status::Pending => "pending".to_string(),
|
||||||
Status::Warning => "warning".to_string(),
|
Status::Warning => "warning".to_string(),
|
||||||
Status::Critical => "critical".to_string(),
|
Status::Critical => "critical".to_string(),
|
||||||
@@ -377,6 +400,25 @@ impl Collector for BackupCollector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(wear_percent) = backup_status.disk_wear_percent {
|
||||||
|
let wear_status = if wear_percent >= 90.0 {
|
||||||
|
Status::Critical
|
||||||
|
} else if wear_percent >= 75.0 {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "backup_disk_wear_percent".to_string(),
|
||||||
|
value: MetricValue::Float(wear_percent),
|
||||||
|
status: wear_status,
|
||||||
|
timestamp,
|
||||||
|
description: Some("Backup disk wear percentage from SMART data".to_string()),
|
||||||
|
unit: Some("percent".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Count services by status
|
// Count services by status
|
||||||
let mut status_counts = HashMap::new();
|
let mut status_counts = HashMap::new();
|
||||||
for service in backup_status.services.values() {
|
for service in backup_status.services.values() {
|
||||||
@@ -410,6 +452,7 @@ pub struct BackupStatusToml {
|
|||||||
pub disk_space: Option<DiskSpace>,
|
pub disk_space: Option<DiskSpace>,
|
||||||
pub disk_product_name: Option<String>,
|
pub disk_product_name: Option<String>,
|
||||||
pub disk_serial_number: Option<String>,
|
pub disk_serial_number: Option<String>,
|
||||||
|
pub disk_wear_percent: Option<f32>,
|
||||||
pub services: HashMap<String, ServiceStatus>,
|
pub services: HashMap<String, ServiceStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,353 +5,159 @@ use cm_dashboard_shared::{Metric, MetricValue, Status, StatusTracker, Hysteresis
|
|||||||
use crate::config::DiskConfig;
|
use crate::config::DiskConfig;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use std::collections::HashMap;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::{Collector, CollectorError};
|
use super::{Collector, CollectorError};
|
||||||
|
|
||||||
/// Information about a storage pool (mount point with underlying drives)
|
/// Storage collector with clean architecture
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct StoragePool {
|
|
||||||
name: String, // e.g., "steampool", "root"
|
|
||||||
mount_point: String, // e.g., "/mnt/steampool", "/"
|
|
||||||
filesystem: String, // e.g., "mergerfs", "ext4", "zfs", "btrfs"
|
|
||||||
storage_type: String, // e.g., "mergerfs", "single", "raid", "zfs"
|
|
||||||
size: String, // e.g., "2.5TB"
|
|
||||||
used: String, // e.g., "2.1TB"
|
|
||||||
available: String, // e.g., "400GB"
|
|
||||||
usage_percent: f32, // e.g., 85.0
|
|
||||||
underlying_drives: Vec<DriveInfo>, // Individual physical drives
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Information about an individual physical drive
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct DriveInfo {
|
|
||||||
device: String, // e.g., "sda", "nvme0n1"
|
|
||||||
health_status: String, // e.g., "PASSED", "FAILED"
|
|
||||||
temperature: Option<f32>, // e.g., 45.0°C
|
|
||||||
wear_level: Option<f32>, // e.g., 12.0% (for SSDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disk usage collector for monitoring filesystem sizes
|
|
||||||
pub struct DiskCollector {
|
pub struct DiskCollector {
|
||||||
config: DiskConfig,
|
config: DiskConfig,
|
||||||
temperature_thresholds: HysteresisThresholds,
|
temperature_thresholds: HysteresisThresholds,
|
||||||
detected_devices: std::collections::HashMap<String, Vec<String>>, // mount_point -> devices
|
}
|
||||||
|
|
||||||
|
/// A physical drive with its filesystems
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PhysicalDrive {
|
||||||
|
device: String, // e.g., "nvme0n1", "sda"
|
||||||
|
filesystems: Vec<Filesystem>, // mounted filesystems on this drive
|
||||||
|
temperature: Option<f32>, // drive temperature
|
||||||
|
wear_level: Option<f32>, // SSD wear level
|
||||||
|
health_status: String, // SMART health
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mergerfs pool
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct MergerfsPool {
|
||||||
|
mount_point: String, // e.g., "/srv/media"
|
||||||
|
total_bytes: u64, // pool total capacity
|
||||||
|
used_bytes: u64, // pool used space
|
||||||
|
data_drives: Vec<DriveInfo>, // data member drives
|
||||||
|
parity_drives: Vec<DriveInfo>, // parity drives
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual filesystem on a drive
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Filesystem {
|
||||||
|
mount_point: String, // e.g., "/", "/boot"
|
||||||
|
total_bytes: u64, // filesystem capacity
|
||||||
|
used_bytes: u64, // filesystem used space
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive information for pools
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DriveInfo {
|
||||||
|
device: String, // e.g., "sdb", "sdc"
|
||||||
|
mount_point: String, // e.g., "/mnt/disk1"
|
||||||
|
temperature: Option<f32>, // drive temperature
|
||||||
|
wear_level: Option<f32>, // SSD wear level
|
||||||
|
health_status: String, // SMART health
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discovered storage topology
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct StorageTopology {
|
||||||
|
physical_drives: Vec<PhysicalDrive>,
|
||||||
|
mergerfs_pools: Vec<MergerfsPool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiskCollector {
|
impl DiskCollector {
|
||||||
pub fn new(config: DiskConfig) -> Self {
|
pub fn new(config: DiskConfig) -> Self {
|
||||||
// Create hysteresis thresholds for disk temperature from config
|
|
||||||
let temperature_thresholds = HysteresisThresholds::with_custom_gaps(
|
let temperature_thresholds = HysteresisThresholds::with_custom_gaps(
|
||||||
config.temperature_warning_celsius,
|
config.temperature_warning_celsius,
|
||||||
5.0, // 5°C gap for recovery
|
5.0,
|
||||||
config.temperature_critical_celsius,
|
config.temperature_critical_celsius,
|
||||||
5.0, // 5°C gap for recovery
|
5.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detect devices for all configured filesystems at startup
|
|
||||||
let mut detected_devices = std::collections::HashMap::new();
|
|
||||||
for fs_config in &config.filesystems {
|
|
||||||
if fs_config.monitor {
|
|
||||||
if let Ok(devices) = Self::detect_device_for_mount_point_static(&fs_config.mount_point) {
|
|
||||||
detected_devices.insert(fs_config.mount_point.clone(), devices);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
temperature_thresholds,
|
temperature_thresholds,
|
||||||
detected_devices,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate disk temperature status using hysteresis thresholds
|
/// Discover all storage using clean workflow: lsblk → df → group
|
||||||
fn calculate_temperature_status(&self, metric_name: &str, temperature: f32, status_tracker: &mut StatusTracker) -> Status {
|
fn discover_storage(&self) -> Result<StorageTopology> {
|
||||||
status_tracker.calculate_with_hysteresis(metric_name, temperature, &self.temperature_thresholds)
|
debug!("Starting storage discovery");
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Get configured storage pools with individual drive information
|
|
||||||
fn get_configured_storage_pools(&self) -> Result<Vec<StoragePool>> {
|
|
||||||
let mut storage_pools = Vec::new();
|
|
||||||
|
|
||||||
for fs_config in &self.config.filesystems {
|
|
||||||
if !fs_config.monitor {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get filesystem stats for the mount point
|
|
||||||
match self.get_filesystem_info(&fs_config.mount_point) {
|
|
||||||
Ok((total_bytes, used_bytes)) => {
|
|
||||||
let available_bytes = total_bytes - used_bytes;
|
|
||||||
let usage_percent = if total_bytes > 0 {
|
|
||||||
(used_bytes as f64 / total_bytes as f64) * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert bytes to human-readable format
|
|
||||||
let size = self.bytes_to_human_readable(total_bytes);
|
|
||||||
let used = self.bytes_to_human_readable(used_bytes);
|
|
||||||
let available = self.bytes_to_human_readable(available_bytes);
|
|
||||||
|
|
||||||
// Get individual drive information using pre-detected devices
|
|
||||||
let device_names = self.detected_devices.get(&fs_config.mount_point).cloned().unwrap_or_default();
|
|
||||||
let underlying_drives = self.get_drive_info_for_devices(&device_names)?;
|
|
||||||
|
|
||||||
storage_pools.push(StoragePool {
|
|
||||||
name: fs_config.name.clone(),
|
|
||||||
mount_point: fs_config.mount_point.clone(),
|
|
||||||
filesystem: fs_config.fs_type.clone(),
|
|
||||||
storage_type: fs_config.storage_type.clone(),
|
|
||||||
size,
|
|
||||||
used,
|
|
||||||
available,
|
|
||||||
usage_percent: usage_percent as f32,
|
|
||||||
underlying_drives,
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Storage pool '{}' ({}) at {} with {} detected drives",
|
|
||||||
fs_config.name, fs_config.storage_type, fs_config.mount_point, device_names.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"Failed to get filesystem info for storage pool '{}': {}",
|
|
||||||
fs_config.name, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(storage_pools)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get drive information for a list of device names
|
|
||||||
fn get_drive_info_for_devices(&self, device_names: &[String]) -> Result<Vec<DriveInfo>> {
|
|
||||||
let mut drives = Vec::new();
|
|
||||||
|
|
||||||
for device_name in device_names {
|
// Step 1: Get all mount points and their backing devices using lsblk
|
||||||
let device_path = format!("/dev/{}", device_name);
|
let mount_devices = self.get_mount_devices()?;
|
||||||
|
debug!("Found {} mount points", mount_devices.len());
|
||||||
// Get SMART data for this drive
|
|
||||||
let (health_status, temperature, wear_level) = self.get_smart_data(&device_path);
|
|
||||||
|
|
||||||
drives.push(DriveInfo {
|
|
||||||
device: device_name.clone(),
|
|
||||||
health_status: health_status.clone(),
|
|
||||||
temperature,
|
|
||||||
wear_level,
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Drive info for {}: health={}, temp={:?}°C, wear={:?}%",
|
|
||||||
device_name, health_status, temperature, wear_level
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(drives)
|
// Step 2: Get filesystem usage for each mount point using df
|
||||||
|
let filesystem_usage = self.get_filesystem_usage(&mount_devices)?;
|
||||||
|
debug!("Got usage data for {} filesystems", filesystem_usage.len());
|
||||||
|
|
||||||
|
// Step 3: Detect mergerfs pools from /proc/mounts
|
||||||
|
let mergerfs_pools = self.discover_mergerfs_pools()?;
|
||||||
|
debug!("Found {} mergerfs pools", mergerfs_pools.len());
|
||||||
|
|
||||||
|
// Step 4: Group regular filesystems by physical drive
|
||||||
|
let physical_drives = self.group_by_physical_drive(&mount_devices, &filesystem_usage, &mergerfs_pools)?;
|
||||||
|
debug!("Grouped into {} physical drives", physical_drives.len());
|
||||||
|
|
||||||
|
Ok(StorageTopology {
|
||||||
|
physical_drives,
|
||||||
|
mergerfs_pools,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get SMART data for a drive (health, temperature, wear level)
|
/// Use lsblk to get mount points and their backing devices
|
||||||
fn get_smart_data(&self, device_path: &str) -> (String, Option<f32>, Option<f32>) {
|
fn get_mount_devices(&self) -> Result<HashMap<String, String>> {
|
||||||
// Try to get SMART data using smartctl
|
|
||||||
let output = Command::new("sudo")
|
|
||||||
.arg("smartctl")
|
|
||||||
.arg("-a")
|
|
||||||
.arg(device_path)
|
|
||||||
.output();
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(result) if result.status.success() => {
|
|
||||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
|
||||||
|
|
||||||
// Parse health status
|
|
||||||
let health = if stdout.contains("PASSED") {
|
|
||||||
"PASSED".to_string()
|
|
||||||
} else if stdout.contains("FAILED") {
|
|
||||||
"FAILED".to_string()
|
|
||||||
} else {
|
|
||||||
"UNKNOWN".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse temperature (look for various temperature indicators)
|
|
||||||
let temperature = self.parse_temperature_from_smart(&stdout);
|
|
||||||
|
|
||||||
// Parse wear level (for SSDs)
|
|
||||||
let wear_level = self.parse_wear_level_from_smart(&stdout);
|
|
||||||
|
|
||||||
(health, temperature, wear_level)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
debug!("Failed to get SMART data for {}", device_path);
|
|
||||||
("UNKNOWN".to_string(), None, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse temperature from SMART output
|
|
||||||
fn parse_temperature_from_smart(&self, smart_output: &str) -> Option<f32> {
|
|
||||||
for line in smart_output.lines() {
|
|
||||||
// Look for temperature in various formats
|
|
||||||
if line.contains("Temperature_Celsius") || line.contains("Temperature") {
|
|
||||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
if parts.len() >= 10 {
|
|
||||||
if let Ok(temp) = parts[9].parse::<f32>() {
|
|
||||||
return Some(temp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// NVMe drives might show temperature differently
|
|
||||||
if line.contains("temperature:") {
|
|
||||||
if let Some(temp_part) = line.split("temperature:").nth(1) {
|
|
||||||
if let Some(temp_str) = temp_part.split_whitespace().next() {
|
|
||||||
if let Ok(temp) = temp_str.parse::<f32>() {
|
|
||||||
return Some(temp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse wear level from SMART output (SSD wear leveling)
|
|
||||||
/// Supports both NVMe and SATA SSD wear indicators
|
|
||||||
fn parse_wear_level_from_smart(&self, smart_output: &str) -> Option<f32> {
|
|
||||||
for line in smart_output.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
|
|
||||||
// NVMe drives - direct percentage used
|
|
||||||
if line.contains("Percentage Used:") {
|
|
||||||
if let Some(wear_part) = line.split("Percentage Used:").nth(1) {
|
|
||||||
if let Some(wear_str) = wear_part.split('%').next() {
|
|
||||||
if let Ok(wear) = wear_str.trim().parse::<f32>() {
|
|
||||||
return Some(wear);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SATA SSD attributes - parse SMART table format
|
|
||||||
// Format: ID ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
|
|
||||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
if parts.len() >= 10 {
|
|
||||||
// SSD Life Left / Percent Lifetime Remaining (higher = less wear)
|
|
||||||
if line.contains("SSD_Life_Left") || line.contains("Percent_Lifetime_Remain") {
|
|
||||||
if let Ok(remaining) = parts[3].parse::<f32>() { // VALUE column
|
|
||||||
return Some(100.0 - remaining); // Convert remaining to used
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media Wearout Indicator (lower = more wear, normalize to 0-100)
|
|
||||||
if line.contains("Media_Wearout_Indicator") {
|
|
||||||
if let Ok(remaining) = parts[3].parse::<f32>() { // VALUE column
|
|
||||||
return Some(100.0 - remaining); // Convert remaining to used
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wear Leveling Count (higher = less wear, but varies by manufacturer)
|
|
||||||
if line.contains("Wear_Leveling_Count") {
|
|
||||||
if let Ok(wear_count) = parts[3].parse::<f32>() { // VALUE column
|
|
||||||
// Most SSDs: 100 = new, decreases with wear
|
|
||||||
if wear_count <= 100.0 {
|
|
||||||
return Some(100.0 - wear_count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total LBAs Written - calculate against typical endurance if available
|
|
||||||
// This is more complex and manufacturer-specific, so we skip for now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert bytes to human-readable format
|
|
||||||
fn bytes_to_human_readable(&self, bytes: u64) -> String {
|
|
||||||
const UNITS: &[&str] = &["B", "K", "M", "G", "T"];
|
|
||||||
let mut size = bytes as f64;
|
|
||||||
let mut unit_index = 0;
|
|
||||||
|
|
||||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
|
||||||
size /= 1024.0;
|
|
||||||
unit_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if unit_index == 0 {
|
|
||||||
format!("{:.0}{}", size, UNITS[unit_index])
|
|
||||||
} else {
|
|
||||||
format!("{:.1}{}", size, UNITS[unit_index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect device backing a mount point using lsblk (static version for startup)
|
|
||||||
fn detect_device_for_mount_point_static(mount_point: &str) -> Result<Vec<String>> {
|
|
||||||
let output = Command::new("lsblk")
|
let output = Command::new("lsblk")
|
||||||
.args(&["-n", "-o", "NAME,MOUNTPOINT"])
|
.args(&["-n", "-o", "NAME,MOUNTPOINT"])
|
||||||
.output()?;
|
.output()?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Ok(Vec::new());
|
return Err(anyhow::anyhow!("lsblk command failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut mount_devices = HashMap::new();
|
||||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
for line in output_str.lines() {
|
for line in output_str.lines() {
|
||||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
if parts.len() >= 2 && parts[1] == mount_point {
|
if parts.len() >= 2 {
|
||||||
// Remove tree symbols and extract device name (e.g., "├─nvme0n1p2" -> "nvme0n1p2")
|
|
||||||
let device_name = parts[0]
|
let device_name = parts[0]
|
||||||
.trim_start_matches('├')
|
.trim_start_matches(&['├', '└', '─', ' '][..]);
|
||||||
.trim_start_matches('└')
|
let mount_point = parts[1];
|
||||||
.trim_start_matches('─')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Extract base device name (e.g., "nvme0n1p2" -> "nvme0n1")
|
// Skip unwanted mount points
|
||||||
if let Some(base_device) = Self::extract_base_device(device_name) {
|
if self.should_skip_mount_point(mount_point) {
|
||||||
return Ok(vec![base_device]);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount_devices.insert(mount_point.to_string(), device_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(mount_devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we should skip this mount point
|
||||||
|
fn should_skip_mount_point(&self, mount_point: &str) -> bool {
|
||||||
|
let skip_prefixes = ["/proc", "/sys", "/dev", "/tmp", "/run"];
|
||||||
|
skip_prefixes.iter().any(|prefix| mount_point.starts_with(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use df to get filesystem usage for mount points
|
||||||
|
fn get_filesystem_usage(&self, mount_devices: &HashMap<String, String>) -> Result<HashMap<String, (u64, u64)>> {
|
||||||
|
let mut filesystem_usage = HashMap::new();
|
||||||
|
|
||||||
|
for mount_point in mount_devices.keys() {
|
||||||
|
match self.get_filesystem_info(mount_point) {
|
||||||
|
Ok((total, used)) => {
|
||||||
|
filesystem_usage.insert(mount_point.clone(), (total, used));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to get filesystem info for {}: {}", mount_point, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Vec::new())
|
Ok(filesystem_usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract base device name from partition (e.g., "nvme0n1p2" -> "nvme0n1", "sda1" -> "sda")
|
|
||||||
fn extract_base_device(device_name: &str) -> Option<String> {
|
|
||||||
// Handle NVMe devices (nvme0n1p1 -> nvme0n1)
|
|
||||||
if device_name.starts_with("nvme") {
|
|
||||||
if let Some(p_pos) = device_name.find('p') {
|
|
||||||
return Some(device_name[..p_pos].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle traditional devices (sda1 -> sda)
|
|
||||||
if device_name.len() > 1 {
|
|
||||||
let chars: Vec<char> = device_name.chars().collect();
|
|
||||||
let mut end_idx = chars.len();
|
|
||||||
|
|
||||||
// Find where the device name ends and partition number begins
|
|
||||||
for (i, &c) in chars.iter().enumerate().rev() {
|
|
||||||
if !c.is_ascii_digit() {
|
|
||||||
end_idx = i + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if end_idx > 0 && end_idx < chars.len() {
|
|
||||||
return Some(chars[..end_idx].iter().collect());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no partition detected, return as-is
|
|
||||||
Some(device_name.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Get filesystem info using df command
|
/// Get filesystem info using df command
|
||||||
fn get_filesystem_info(&self, path: &str) -> Result<(u64, u64)> {
|
fn get_filesystem_info(&self, path: &str) -> Result<(u64, u64)> {
|
||||||
let output = Command::new("df")
|
let output = Command::new("df")
|
||||||
@@ -381,216 +187,815 @@ impl DiskCollector {
|
|||||||
Ok((total_bytes, used_bytes))
|
Ok((total_bytes, used_bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discover mergerfs pools from /proc/mounts
|
||||||
/// Parse size string (e.g., "120G", "45M") to GB value
|
fn discover_mergerfs_pools(&self) -> Result<Vec<MergerfsPool>> {
|
||||||
fn parse_size_to_gb(&self, size_str: &str) -> f32 {
|
let mounts_content = std::fs::read_to_string("/proc/mounts")?;
|
||||||
let size_str = size_str.trim();
|
let mut pools = Vec::new();
|
||||||
if size_str.is_empty() || size_str == "-" {
|
|
||||||
return 0.0;
|
for line in mounts_content.lines() {
|
||||||
}
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 3 && parts[2] == "fuse.mergerfs" {
|
||||||
// Extract numeric part and unit
|
let mount_point = parts[1].to_string();
|
||||||
let (num_str, unit) = if let Some(last_char) = size_str.chars().last() {
|
let device_sources = parts[0]; // e.g., "/mnt/disk1:/mnt/disk2"
|
||||||
if last_char.is_alphabetic() {
|
|
||||||
let num_part = &size_str[..size_str.len() - 1];
|
// Get pool usage
|
||||||
let unit_part = &size_str[size_str.len() - 1..];
|
let (total_bytes, used_bytes) = self.get_filesystem_info(&mount_point)
|
||||||
(num_part, unit_part)
|
.unwrap_or((0, 0));
|
||||||
} else {
|
|
||||||
(size_str, "")
|
// 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 mut 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 related_parity_paths = self.discover_related_parity_drives(&member_paths)?;
|
||||||
|
member_paths.extend(related_parity_paths);
|
||||||
|
|
||||||
|
// Categorize as data vs parity drives
|
||||||
|
let (data_drives, parity_drives) = match self.categorize_pool_drives(&member_paths) {
|
||||||
|
Ok(drives) => drives,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to categorize drives for pool {}: {}. Skipping.", mount_point, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pools.push(MergerfsPool {
|
||||||
|
mount_point,
|
||||||
|
total_bytes,
|
||||||
|
used_bytes,
|
||||||
|
data_drives,
|
||||||
|
parity_drives,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
(size_str, "")
|
|
||||||
};
|
|
||||||
|
|
||||||
let number: f32 = num_str.parse().unwrap_or(0.0);
|
|
||||||
|
|
||||||
match unit.to_uppercase().as_str() {
|
|
||||||
"T" | "TB" => number * 1024.0,
|
|
||||||
"G" | "GB" => number,
|
|
||||||
"M" | "MB" => number / 1024.0,
|
|
||||||
"K" | "KB" => number / (1024.0 * 1024.0),
|
|
||||||
"B" | "" => number / (1024.0 * 1024.0 * 1024.0),
|
|
||||||
_ => number, // Assume GB if unknown unit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover parity drives that are related to the given data drives
|
||||||
|
fn discover_related_parity_drives(&self, data_drives: &[String]) -> Result<Vec<String>> {
|
||||||
|
let mount_devices = self.get_mount_devices()?;
|
||||||
|
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]) -> Result<(Vec<DriveInfo>, Vec<DriveInfo>)> {
|
||||||
|
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) -> Result<DriveInfo> {
|
||||||
|
// Use lsblk to find the backing device
|
||||||
|
let output = Command::new("lsblk")
|
||||||
|
.args(&["-n", "-o", "NAME,MOUNTPOINT"])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
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]
|
||||||
|
.trim_start_matches('├')
|
||||||
|
.trim_start_matches('└')
|
||||||
|
.trim_start_matches('─')
|
||||||
|
.trim()
|
||||||
|
.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(&device);
|
||||||
|
|
||||||
|
// Get SMART data
|
||||||
|
let (health, temperature, wear) = self.get_smart_data(&format!("/dev/{}", base_device));
|
||||||
|
|
||||||
|
Ok(DriveInfo {
|
||||||
|
device: base_device,
|
||||||
|
mount_point: path.to_string(),
|
||||||
|
temperature,
|
||||||
|
wear_level: wear,
|
||||||
|
health_status: health,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve numeric mergerfs references like "1:2" to actual mount paths
|
||||||
|
fn resolve_numeric_mergerfs_paths(&self, numeric_refs: &[String]) -> Result<Vec<String>> {
|
||||||
|
let mut resolved_paths = Vec::new();
|
||||||
|
|
||||||
|
// Get all mount points that look like /mnt/disk* or /mnt/parity*
|
||||||
|
let mount_devices = self.get_mount_devices()?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract base device name from partition (e.g., "nvme0n1p2" -> "nvme0n1", "sda1" -> "sda")
|
||||||
|
fn extract_base_device(&self, device_name: &str) -> String {
|
||||||
|
// Handle NVMe devices (nvme0n1p1 -> nvme0n1)
|
||||||
|
if device_name.starts_with("nvme") {
|
||||||
|
if let Some(p_pos) = device_name.find('p') {
|
||||||
|
return device_name[..p_pos].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle traditional devices (sda1 -> sda)
|
||||||
|
if device_name.len() > 1 {
|
||||||
|
let chars: Vec<char> = device_name.chars().collect();
|
||||||
|
let mut end_idx = chars.len();
|
||||||
|
|
||||||
|
// Find where the device name ends and partition number begins
|
||||||
|
for (i, &c) in chars.iter().enumerate().rev() {
|
||||||
|
if !c.is_ascii_digit() {
|
||||||
|
end_idx = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if end_idx > 0 && end_idx < chars.len() {
|
||||||
|
return chars[..end_idx].iter().collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no partition detected, return as-is
|
||||||
|
device_name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group filesystems by physical drive (excluding mergerfs members)
|
||||||
|
fn group_by_physical_drive(
|
||||||
|
&self,
|
||||||
|
mount_devices: &HashMap<String, String>,
|
||||||
|
filesystem_usage: &HashMap<String, (u64, u64)>,
|
||||||
|
mergerfs_pools: &[MergerfsPool]
|
||||||
|
) -> Result<Vec<PhysicalDrive>> {
|
||||||
|
let mut drive_groups: HashMap<String, Vec<Filesystem>> = HashMap::new();
|
||||||
|
|
||||||
|
// Get all mergerfs member paths to exclude them
|
||||||
|
let mut mergerfs_members = std::collections::HashSet::new();
|
||||||
|
for pool in mergerfs_pools {
|
||||||
|
for drive in &pool.data_drives {
|
||||||
|
mergerfs_members.insert(drive.mount_point.clone());
|
||||||
|
}
|
||||||
|
for drive in &pool.parity_drives {
|
||||||
|
mergerfs_members.insert(drive.mount_point.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group filesystems by base device
|
||||||
|
for (mount_point, device) in mount_devices {
|
||||||
|
// Skip mergerfs member mounts
|
||||||
|
if mergerfs_members.contains(mount_point) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_device = self.extract_base_device(device);
|
||||||
|
|
||||||
|
if let Some((total, used)) = filesystem_usage.get(mount_point) {
|
||||||
|
let filesystem = Filesystem {
|
||||||
|
mount_point: mount_point.clone(),
|
||||||
|
total_bytes: *total,
|
||||||
|
used_bytes: *used,
|
||||||
|
};
|
||||||
|
|
||||||
|
drive_groups.entry(base_device).or_insert_with(Vec::new).push(filesystem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to PhysicalDrive structs with SMART data
|
||||||
|
let mut physical_drives = Vec::new();
|
||||||
|
for (device, filesystems) in drive_groups {
|
||||||
|
let (health, temperature, wear) = self.get_smart_data(&format!("/dev/{}", device));
|
||||||
|
|
||||||
|
physical_drives.push(PhysicalDrive {
|
||||||
|
device,
|
||||||
|
filesystems,
|
||||||
|
temperature,
|
||||||
|
wear_level: wear,
|
||||||
|
health_status: health,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(physical_drives)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SMART data for a drive
|
||||||
|
fn get_smart_data(&self, device_path: &str) -> (String, Option<f32>, Option<f32>) {
|
||||||
|
let output = Command::new("sudo")
|
||||||
|
.arg("smartctl")
|
||||||
|
.arg("-a")
|
||||||
|
.arg(device_path)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(result) if result.status.success() => {
|
||||||
|
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||||
|
|
||||||
|
// Parse health status
|
||||||
|
let health = if stdout.contains("PASSED") {
|
||||||
|
"PASSED".to_string()
|
||||||
|
} else if stdout.contains("FAILED") {
|
||||||
|
"FAILED".to_string()
|
||||||
|
} else {
|
||||||
|
"UNKNOWN".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse temperature and wear level
|
||||||
|
let temperature = self.parse_temperature_from_smart(&stdout);
|
||||||
|
let wear_level = self.parse_wear_level_from_smart(&stdout);
|
||||||
|
|
||||||
|
(health, temperature, wear_level)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
debug!("Failed to get SMART data for {}", device_path);
|
||||||
|
("UNKNOWN".to_string(), None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse temperature from SMART output
|
||||||
|
fn parse_temperature_from_smart(&self, smart_output: &str) -> Option<f32> {
|
||||||
|
for line in smart_output.lines() {
|
||||||
|
if line.contains("Temperature_Celsius") || line.contains("Temperature") {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 10 {
|
||||||
|
if let Ok(temp) = parts[9].parse::<f32>() {
|
||||||
|
return Some(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NVMe format: "Temperature:" (capital T)
|
||||||
|
if line.contains("Temperature:") {
|
||||||
|
if let Some(temp_part) = line.split("Temperature:").nth(1) {
|
||||||
|
if let Some(temp_str) = temp_part.split_whitespace().next() {
|
||||||
|
if let Ok(temp) = temp_str.parse::<f32>() {
|
||||||
|
return Some(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy format: "temperature:" (lowercase)
|
||||||
|
if line.contains("temperature:") {
|
||||||
|
if let Some(temp_part) = line.split("temperature:").nth(1) {
|
||||||
|
if let Some(temp_str) = temp_part.split_whitespace().next() {
|
||||||
|
if let Ok(temp) = temp_str.parse::<f32>() {
|
||||||
|
return Some(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse wear level from SMART output
|
||||||
|
fn parse_wear_level_from_smart(&self, smart_output: &str) -> Option<f32> {
|
||||||
|
for line in smart_output.lines() {
|
||||||
|
if line.contains("Percentage Used:") {
|
||||||
|
if let Some(wear_part) = line.split("Percentage Used:").nth(1) {
|
||||||
|
if let Some(wear_str) = wear_part.split('%').next() {
|
||||||
|
if let Ok(wear) = wear_str.trim().parse::<f32>() {
|
||||||
|
return Some(wear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 10 {
|
||||||
|
if line.contains("SSD_Life_Left") || line.contains("Percent_Lifetime_Remain") {
|
||||||
|
if let Ok(remaining) = parts[3].parse::<f32>() {
|
||||||
|
return Some(100.0 - remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if line.contains("Wear_Leveling_Count") {
|
||||||
|
if let Ok(wear_count) = parts[3].parse::<f32>() {
|
||||||
|
if wear_count <= 100.0 {
|
||||||
|
return Some(100.0 - wear_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate temperature status with hysteresis
|
||||||
|
fn calculate_temperature_status(&self, metric_name: &str, temperature: f32, status_tracker: &mut StatusTracker) -> Status {
|
||||||
|
status_tracker.calculate_with_hysteresis(metric_name, temperature, &self.temperature_thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert bytes to human readable format
|
||||||
|
fn bytes_to_human_readable(&self, bytes: u64) -> String {
|
||||||
|
const UNITS: &[&str] = &["B", "K", "M", "G", "T"];
|
||||||
|
let mut size = bytes as f64;
|
||||||
|
let mut unit_index = 0;
|
||||||
|
|
||||||
|
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if unit_index == 0 {
|
||||||
|
format!("{:.0}{}", size, UNITS[unit_index])
|
||||||
|
} else {
|
||||||
|
format!("{:.1}{}", size, UNITS[unit_index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert bytes to gigabytes
|
||||||
|
fn bytes_to_gb(&self, bytes: u64) -> f32 {
|
||||||
|
bytes as f32 / (1024.0 * 1024.0 * 1024.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Collector for DiskCollector {
|
impl Collector for DiskCollector {
|
||||||
|
|
||||||
async fn collect(&self, status_tracker: &mut StatusTracker) -> Result<Vec<Metric>, CollectorError> {
|
async fn collect(&self, status_tracker: &mut StatusTracker) -> Result<Vec<Metric>, CollectorError> {
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
debug!("Collecting storage pool and individual drive metrics");
|
debug!("Starting clean storage collection");
|
||||||
|
|
||||||
let mut metrics = Vec::new();
|
let mut metrics = Vec::new();
|
||||||
|
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||||
|
|
||||||
// Get configured storage pools with individual drive data
|
// Discover storage topology
|
||||||
let storage_pools = match self.get_configured_storage_pools() {
|
let topology = match self.discover_storage() {
|
||||||
Ok(pools) => {
|
Ok(topology) => topology,
|
||||||
debug!("Found {} storage pools", pools.len());
|
|
||||||
pools
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
debug!("Failed to get storage pools: {}", e);
|
tracing::error!("Storage discovery failed: {}", e);
|
||||||
Vec::new()
|
return Ok(metrics);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate metrics for each storage pool and its underlying drives
|
// Generate metrics for physical drives
|
||||||
for storage_pool in &storage_pools {
|
for drive in &topology.physical_drives {
|
||||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
self.generate_physical_drive_metrics(&mut metrics, drive, timestamp, status_tracker);
|
||||||
|
}
|
||||||
|
|
||||||
// Storage pool overall metrics
|
// Generate metrics for mergerfs pools
|
||||||
let pool_name = &storage_pool.name;
|
for pool in &topology.mergerfs_pools {
|
||||||
|
self.generate_mergerfs_pool_metrics(&mut metrics, pool, timestamp, status_tracker);
|
||||||
// Parse size strings to get actual values for calculations
|
}
|
||||||
let size_gb = self.parse_size_to_gb(&storage_pool.size);
|
|
||||||
let used_gb = self.parse_size_to_gb(&storage_pool.used);
|
|
||||||
let avail_gb = self.parse_size_to_gb(&storage_pool.available);
|
|
||||||
|
|
||||||
// Calculate status based on configured thresholds
|
// Add total storage count
|
||||||
let pool_status = if storage_pool.usage_percent >= self.config.usage_critical_percent {
|
let total_storage = topology.physical_drives.len() + topology.mergerfs_pools.len();
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "disk_count".to_string(),
|
||||||
|
value: MetricValue::Integer(total_storage as i64),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Total storage: {} drives, {} pools", topology.physical_drives.len(), topology.mergerfs_pools.len())),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
let collection_time = start_time.elapsed();
|
||||||
|
debug!("Clean storage collection completed in {:?} with {} metrics", collection_time, metrics.len());
|
||||||
|
|
||||||
|
Ok(metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskCollector {
|
||||||
|
/// Generate metrics for a physical drive and its filesystems
|
||||||
|
fn generate_physical_drive_metrics(
|
||||||
|
&self,
|
||||||
|
metrics: &mut Vec<Metric>,
|
||||||
|
drive: &PhysicalDrive,
|
||||||
|
timestamp: u64,
|
||||||
|
status_tracker: &mut StatusTracker
|
||||||
|
) {
|
||||||
|
let drive_name = &drive.device;
|
||||||
|
|
||||||
|
// Calculate drive totals
|
||||||
|
let total_capacity: u64 = drive.filesystems.iter().map(|fs| fs.total_bytes).sum();
|
||||||
|
let total_used: u64 = drive.filesystems.iter().map(|fs| fs.used_bytes).sum();
|
||||||
|
let total_available = total_capacity.saturating_sub(total_used);
|
||||||
|
let usage_percent = if total_capacity > 0 {
|
||||||
|
(total_used as f64 / total_capacity as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
// Drive health status
|
||||||
|
let health_status = if drive.health_status == "PASSED" { Status::Ok }
|
||||||
|
else if drive.health_status == "FAILED" { Status::Critical }
|
||||||
|
else { Status::Unknown };
|
||||||
|
|
||||||
|
// Usage status
|
||||||
|
let usage_status = if usage_percent >= self.config.usage_critical_percent as f64 {
|
||||||
|
Status::Critical
|
||||||
|
} else if usage_percent >= self.config.usage_warning_percent as f64 {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
};
|
||||||
|
|
||||||
|
let drive_status = if health_status == Status::Critical { Status::Critical } else { usage_status };
|
||||||
|
|
||||||
|
// Drive info metrics
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_health", drive_name),
|
||||||
|
value: MetricValue::String(drive.health_status.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("{}: {}", drive_name, drive.health_status)),
|
||||||
|
status: health_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive temperature
|
||||||
|
if let Some(temp) = drive.temperature {
|
||||||
|
let temp_status = self.calculate_temperature_status(
|
||||||
|
&format!("disk_{}_temperature", drive_name), temp, status_tracker
|
||||||
|
);
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_temperature", drive_name),
|
||||||
|
value: MetricValue::Float(temp),
|
||||||
|
unit: Some("°C".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}°C", drive_name, temp)),
|
||||||
|
status: temp_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive wear level
|
||||||
|
if let Some(wear) = drive.wear_level {
|
||||||
|
let wear_status = if wear >= self.config.wear_critical_percent { Status::Critical }
|
||||||
|
else if wear >= self.config.wear_warning_percent { Status::Warning }
|
||||||
|
else { Status::Ok };
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_wear_percent", drive_name),
|
||||||
|
value: MetricValue::Float(wear),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}% wear", drive_name, wear)),
|
||||||
|
status: wear_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive capacity metrics
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_total_gb", drive_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(total_capacity)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}", drive_name, self.bytes_to_human_readable(total_capacity))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_used_gb", drive_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(total_used)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}", drive_name, self.bytes_to_human_readable(total_used))),
|
||||||
|
status: drive_status.clone(),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_available_gb", drive_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(total_available)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}", drive_name, self.bytes_to_human_readable(total_available))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_usage_percent", drive_name),
|
||||||
|
value: MetricValue::Float(usage_percent as f32),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("{}: {:.1}%", drive_name, usage_percent)),
|
||||||
|
status: drive_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pool type indicator
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_pool_type", drive_name),
|
||||||
|
value: MetricValue::String(format!("drive ({})", drive.filesystems.len())),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Type: physical drive")),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual filesystem metrics
|
||||||
|
for filesystem in &drive.filesystems {
|
||||||
|
let fs_name = if filesystem.mount_point == "/" {
|
||||||
|
"root".to_string()
|
||||||
|
} else {
|
||||||
|
filesystem.mount_point.trim_start_matches('/').replace('/', "_")
|
||||||
|
};
|
||||||
|
|
||||||
|
let fs_usage_percent = if filesystem.total_bytes > 0 {
|
||||||
|
(filesystem.used_bytes as f64 / filesystem.total_bytes as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
let fs_status = if fs_usage_percent >= self.config.usage_critical_percent as f64 {
|
||||||
Status::Critical
|
Status::Critical
|
||||||
} else if storage_pool.usage_percent >= self.config.usage_warning_percent {
|
} else if fs_usage_percent >= self.config.usage_warning_percent as f64 {
|
||||||
Status::Warning
|
Status::Warning
|
||||||
} else {
|
} else {
|
||||||
Status::Ok
|
Status::Ok
|
||||||
};
|
};
|
||||||
|
|
||||||
// Storage pool info metrics
|
|
||||||
metrics.push(Metric {
|
metrics.push(Metric {
|
||||||
name: format!("disk_{}_mount_point", pool_name),
|
name: format!("disk_{}_fs_{}_usage_percent", drive_name, fs_name),
|
||||||
value: MetricValue::String(storage_pool.mount_point.clone()),
|
value: MetricValue::Float(fs_usage_percent as f32),
|
||||||
unit: None,
|
|
||||||
description: Some(format!("Mount: {}", storage_pool.mount_point)),
|
|
||||||
status: Status::Ok,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_filesystem", pool_name),
|
|
||||||
value: MetricValue::String(storage_pool.filesystem.clone()),
|
|
||||||
unit: None,
|
|
||||||
description: Some(format!("FS: {}", storage_pool.filesystem)),
|
|
||||||
status: Status::Ok,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_storage_type", pool_name),
|
|
||||||
value: MetricValue::String(storage_pool.storage_type.clone()),
|
|
||||||
unit: None,
|
|
||||||
description: Some(format!("Type: {}", storage_pool.storage_type)),
|
|
||||||
status: Status::Ok,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Storage pool size metrics
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_total_gb", pool_name),
|
|
||||||
value: MetricValue::Float(size_gb),
|
|
||||||
unit: Some("GB".to_string()),
|
|
||||||
description: Some(format!("Total: {}", storage_pool.size)),
|
|
||||||
status: Status::Ok,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_used_gb", pool_name),
|
|
||||||
value: MetricValue::Float(used_gb),
|
|
||||||
unit: Some("GB".to_string()),
|
|
||||||
description: Some(format!("Used: {}", storage_pool.used)),
|
|
||||||
status: pool_status,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_available_gb", pool_name),
|
|
||||||
value: MetricValue::Float(avail_gb),
|
|
||||||
unit: Some("GB".to_string()),
|
|
||||||
description: Some(format!("Available: {}", storage_pool.available)),
|
|
||||||
status: Status::Ok,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_usage_percent", pool_name),
|
|
||||||
value: MetricValue::Float(storage_pool.usage_percent),
|
|
||||||
unit: Some("%".to_string()),
|
unit: Some("%".to_string()),
|
||||||
description: Some(format!("Usage: {:.1}%", storage_pool.usage_percent)),
|
description: Some(format!("{}: {:.0}%", filesystem.mount_point, fs_usage_percent)),
|
||||||
status: pool_status,
|
status: fs_status.clone(),
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Individual drive metrics for this storage pool
|
metrics.push(Metric {
|
||||||
for drive in &storage_pool.underlying_drives {
|
name: format!("disk_{}_fs_{}_used_gb", drive_name, fs_name),
|
||||||
// Drive health status
|
value: MetricValue::Float(self.bytes_to_gb(filesystem.used_bytes)),
|
||||||
metrics.push(Metric {
|
unit: Some("GB".to_string()),
|
||||||
name: format!("disk_{}_{}_health", pool_name, drive.device),
|
description: Some(format!("{}: {}", filesystem.mount_point, self.bytes_to_human_readable(filesystem.used_bytes))),
|
||||||
value: MetricValue::String(drive.health_status.clone()),
|
status: fs_status.clone(),
|
||||||
unit: None,
|
timestamp,
|
||||||
description: Some(format!("{}: {}", drive.device, drive.health_status)),
|
});
|
||||||
status: if drive.health_status == "PASSED" { Status::Ok }
|
|
||||||
else if drive.health_status == "FAILED" { Status::Critical }
|
|
||||||
else { Status::Unknown },
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drive temperature
|
metrics.push(Metric {
|
||||||
if let Some(temp) = drive.temperature {
|
name: format!("disk_{}_fs_{}_total_gb", drive_name, fs_name),
|
||||||
let temp_status = self.calculate_temperature_status(
|
value: MetricValue::Float(self.bytes_to_gb(filesystem.total_bytes)),
|
||||||
&format!("disk_{}_{}_temperature", pool_name, drive.device),
|
unit: Some("GB".to_string()),
|
||||||
temp,
|
description: Some(format!("{}: {}", filesystem.mount_point, self.bytes_to_human_readable(filesystem.total_bytes))),
|
||||||
status_tracker
|
status: fs_status.clone(),
|
||||||
);
|
timestamp,
|
||||||
|
});
|
||||||
metrics.push(Metric {
|
|
||||||
name: format!("disk_{}_{}_temperature", pool_name, drive.device),
|
|
||||||
value: MetricValue::Float(temp),
|
|
||||||
unit: Some("°C".to_string()),
|
|
||||||
description: Some(format!("{}: {:.0}°C", drive.device, temp)),
|
|
||||||
status: temp_status,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive wear level (for SSDs)
|
let fs_available = filesystem.total_bytes.saturating_sub(filesystem.used_bytes);
|
||||||
if let Some(wear) = drive.wear_level {
|
metrics.push(Metric {
|
||||||
let wear_status = if wear >= self.config.wear_critical_percent { Status::Critical }
|
name: format!("disk_{}_fs_{}_available_gb", drive_name, fs_name),
|
||||||
else if wear >= self.config.wear_warning_percent { Status::Warning }
|
value: MetricValue::Float(self.bytes_to_gb(fs_available)),
|
||||||
else { Status::Ok };
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}", filesystem.mount_point, self.bytes_to_human_readable(fs_available))),
|
||||||
metrics.push(Metric {
|
status: Status::Ok,
|
||||||
name: format!("disk_{}_{}_wear_percent", pool_name, drive.device),
|
timestamp,
|
||||||
value: MetricValue::Float(wear),
|
});
|
||||||
unit: Some("%".to_string()),
|
|
||||||
description: Some(format!("{}: {:.0}% wear", drive.device, wear)),
|
metrics.push(Metric {
|
||||||
status: wear_status,
|
name: format!("disk_{}_fs_{}_mount_point", drive_name, fs_name),
|
||||||
timestamp,
|
value: MetricValue::String(filesystem.mount_point.clone()),
|
||||||
});
|
unit: None,
|
||||||
}
|
description: Some(format!("Mount: {}", filesystem.mount_point)),
|
||||||
}
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add storage pool count metric
|
|
||||||
metrics.push(Metric {
|
|
||||||
name: "disk_count".to_string(),
|
|
||||||
value: MetricValue::Integer(storage_pools.len() as i64),
|
|
||||||
unit: None,
|
|
||||||
description: Some(format!("Total storage pools: {}", storage_pools.len())),
|
|
||||||
status: Status::Ok,
|
|
||||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let collection_time = start_time.elapsed();
|
|
||||||
debug!(
|
|
||||||
"Multi-disk collection completed in {:?} with {} metrics",
|
|
||||||
collection_time,
|
|
||||||
metrics.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(metrics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
/// Generate metrics for a mergerfs pool
|
||||||
|
fn generate_mergerfs_pool_metrics(
|
||||||
|
&self,
|
||||||
|
metrics: &mut Vec<Metric>,
|
||||||
|
pool: &MergerfsPool,
|
||||||
|
timestamp: u64,
|
||||||
|
status_tracker: &mut StatusTracker
|
||||||
|
) {
|
||||||
|
// Use consistent pool naming: extract mount point without leading slash
|
||||||
|
let pool_name = if pool.mount_point == "/" {
|
||||||
|
"root".to_string()
|
||||||
|
} else {
|
||||||
|
pool.mount_point.trim_start_matches('/').replace('/', "_")
|
||||||
|
};
|
||||||
|
|
||||||
|
if pool_name.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage_percent = if pool.total_bytes > 0 {
|
||||||
|
(pool.used_bytes as f64 / pool.total_bytes as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
// Calculate pool health based on drive health
|
||||||
|
let failed_data = pool.data_drives.iter()
|
||||||
|
.filter(|d| d.health_status != "PASSED")
|
||||||
|
.count();
|
||||||
|
let failed_parity = pool.parity_drives.iter()
|
||||||
|
.filter(|d| d.health_status != "PASSED")
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let pool_health = match (failed_data, failed_parity) {
|
||||||
|
(0, 0) => Status::Ok,
|
||||||
|
(1, 0) | (0, 1) => Status::Warning,
|
||||||
|
_ => Status::Critical,
|
||||||
|
};
|
||||||
|
|
||||||
|
let usage_status = if usage_percent >= self.config.usage_critical_percent as f64 {
|
||||||
|
Status::Critical
|
||||||
|
} else if usage_percent >= self.config.usage_warning_percent as f64 {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool_status = if pool_health == Status::Critical { Status::Critical } else { usage_status };
|
||||||
|
|
||||||
|
// Pool metrics
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_mount_point", pool_name),
|
||||||
|
value: MetricValue::String(pool.mount_point.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Mount: {}", pool.mount_point)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_pool_type", pool_name),
|
||||||
|
value: MetricValue::String(format!("mergerfs ({}+{})", pool.data_drives.len(), pool.parity_drives.len())),
|
||||||
|
unit: None,
|
||||||
|
description: Some("Type: mergerfs".to_string()),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_pool_health", pool_name),
|
||||||
|
value: MetricValue::String(match pool_health {
|
||||||
|
Status::Ok => "healthy".to_string(),
|
||||||
|
Status::Warning => "degraded".to_string(),
|
||||||
|
Status::Critical => "critical".to_string(),
|
||||||
|
_ => "unknown".to_string(),
|
||||||
|
}),
|
||||||
|
unit: None,
|
||||||
|
description: Some("Pool health".to_string()),
|
||||||
|
status: pool_health,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_total_gb", pool_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(pool.total_bytes)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("Total: {}", self.bytes_to_human_readable(pool.total_bytes))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_used_gb", pool_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(pool.used_bytes)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("Used: {}", self.bytes_to_human_readable(pool.used_bytes))),
|
||||||
|
status: pool_status.clone(),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
let available_bytes = pool.total_bytes.saturating_sub(pool.used_bytes);
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_available_gb", pool_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(available_bytes)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("Available: {}", self.bytes_to_human_readable(available_bytes))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_usage_percent", pool_name),
|
||||||
|
value: MetricValue::Float(usage_percent as f32),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("Usage: {:.1}%", usage_percent)),
|
||||||
|
status: pool_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual drive metrics
|
||||||
|
for drive in &pool.data_drives {
|
||||||
|
self.generate_pool_drive_metrics(metrics, &pool_name, &drive.device, drive, timestamp, status_tracker);
|
||||||
|
}
|
||||||
|
|
||||||
|
for drive in &pool.parity_drives {
|
||||||
|
self.generate_pool_drive_metrics(metrics, &pool_name, &drive.device, drive, timestamp, status_tracker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate metrics for drives in mergerfs pools
|
||||||
|
fn generate_pool_drive_metrics(
|
||||||
|
&self,
|
||||||
|
metrics: &mut Vec<Metric>,
|
||||||
|
pool_name: &str,
|
||||||
|
drive_role: &str,
|
||||||
|
drive: &DriveInfo,
|
||||||
|
timestamp: u64,
|
||||||
|
status_tracker: &mut StatusTracker
|
||||||
|
) {
|
||||||
|
let drive_health = if drive.health_status == "PASSED" { Status::Ok }
|
||||||
|
else if drive.health_status == "FAILED" { Status::Critical }
|
||||||
|
else { Status::Unknown };
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_{}_health", pool_name, drive_role),
|
||||||
|
value: MetricValue::String(drive.health_status.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("{}: {}", drive.device, drive.health_status)),
|
||||||
|
status: drive_health,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(temp) = drive.temperature {
|
||||||
|
let temp_status = self.calculate_temperature_status(
|
||||||
|
&format!("disk_{}_{}_temperature", pool_name, drive_role), temp, status_tracker
|
||||||
|
);
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_{}_temperature", pool_name, drive_role),
|
||||||
|
value: MetricValue::Float(temp),
|
||||||
|
unit: Some("°C".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}°C", drive.device, temp)),
|
||||||
|
status: temp_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(wear) = drive.wear_level {
|
||||||
|
let wear_status = if wear >= self.config.wear_critical_percent { Status::Critical }
|
||||||
|
else if wear >= self.config.wear_warning_percent { Status::Warning }
|
||||||
|
else { Status::Ok };
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_{}_wear_percent", pool_name, drive_role),
|
||||||
|
value: MetricValue::Float(wear),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}% wear", drive.device, wear)),
|
||||||
|
status: wear_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1327
agent/src/collectors/disk_old.rs
Normal file
1327
agent/src/collectors/disk_old.rs
Normal file
@@ -0,0 +1,1327 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use cm_dashboard_shared::{Metric, MetricValue, Status, StatusTracker, HysteresisThresholds};
|
||||||
|
|
||||||
|
use crate::config::DiskConfig;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Instant;
|
||||||
|
use std::fs;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use super::{Collector, CollectorError};
|
||||||
|
|
||||||
|
/// Mount point information from /proc/mounts
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct MountInfo {
|
||||||
|
device: String, // e.g., "/dev/sda1" or "/mnt/disk1:/mnt/disk2"
|
||||||
|
mount_point: String, // e.g., "/", "/srv/media"
|
||||||
|
fs_type: String, // e.g., "ext4", "xfs", "fuse.mergerfs"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-discovered storage topology
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct StorageTopology {
|
||||||
|
single_disks: Vec<MountInfo>,
|
||||||
|
mergerfs_pools: Vec<MergerfsPoolInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MergerFS pool information
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct MergerfsPoolInfo {
|
||||||
|
mount_point: String, // e.g., "/srv/media"
|
||||||
|
data_members: Vec<String>, // e.g., ["/mnt/disk1", "/mnt/disk2"]
|
||||||
|
parity_disks: Vec<String>, // e.g., ["/mnt/parity"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about a storage pool (mount point with underlying drives)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct StoragePool {
|
||||||
|
name: String, // e.g., "steampool", "root"
|
||||||
|
mount_point: String, // e.g., "/mnt/steampool", "/"
|
||||||
|
filesystem: String, // e.g., "mergerfs", "ext4", "zfs", "btrfs"
|
||||||
|
pool_type: StoragePoolType, // Enhanced pool type with configuration
|
||||||
|
size: String, // e.g., "2.5TB"
|
||||||
|
used: String, // e.g., "2.1TB"
|
||||||
|
available: String, // e.g., "400GB"
|
||||||
|
usage_percent: f32, // e.g., 85.0
|
||||||
|
underlying_drives: Vec<DriveInfo>, // Individual physical drives
|
||||||
|
pool_health: PoolHealth, // Overall pool health status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enhanced storage pool types with specific configurations
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum StoragePoolType {
|
||||||
|
Single, // Traditional single disk (legacy)
|
||||||
|
PhysicalDrive { // Physical drive with multiple filesystems
|
||||||
|
filesystems: Vec<String>, // Mount points on this drive
|
||||||
|
},
|
||||||
|
MergerfsPool { // MergerFS with optional parity
|
||||||
|
data_disks: Vec<String>, // Member disk names (sdb, sdd)
|
||||||
|
parity_disks: Vec<String>, // Parity disk names (sdc)
|
||||||
|
},
|
||||||
|
#[allow(dead_code)]
|
||||||
|
RaidArray { // Hardware RAID (future)
|
||||||
|
level: String, // "RAID1", "RAID5", etc.
|
||||||
|
member_disks: Vec<String>,
|
||||||
|
spare_disks: Vec<String>,
|
||||||
|
},
|
||||||
|
#[allow(dead_code)]
|
||||||
|
ZfsPool { // ZFS pool (future)
|
||||||
|
pool_name: String,
|
||||||
|
vdevs: Vec<String>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pool health status for redundant storage
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum PoolHealth {
|
||||||
|
Healthy, // All drives OK, parity current
|
||||||
|
Degraded, // One drive failed or parity outdated, still functional
|
||||||
|
Critical, // Multiple failures, data at risk
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Rebuilding, // Actively rebuilding/scrubbing (future: SnapRAID status integration)
|
||||||
|
Unknown, // Cannot determine status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about an individual physical drive
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DriveInfo {
|
||||||
|
device: String, // e.g., "sda", "nvme0n1"
|
||||||
|
health_status: String, // e.g., "PASSED", "FAILED"
|
||||||
|
temperature: Option<f32>, // e.g., 45.0°C
|
||||||
|
wear_level: Option<f32>, // e.g., 12.0% (for SSDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disk usage collector for monitoring filesystem sizes
|
||||||
|
pub struct DiskCollector {
|
||||||
|
config: DiskConfig,
|
||||||
|
temperature_thresholds: HysteresisThresholds,
|
||||||
|
detected_devices: std::collections::HashMap<String, Vec<String>>, // mount_point -> devices
|
||||||
|
storage_topology: Option<StorageTopology>, // Auto-discovered storage layout
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskCollector {
|
||||||
|
pub fn new(config: DiskConfig) -> Self {
|
||||||
|
// Create hysteresis thresholds for disk temperature from config
|
||||||
|
let temperature_thresholds = HysteresisThresholds::with_custom_gaps(
|
||||||
|
config.temperature_warning_celsius,
|
||||||
|
5.0, // 5°C gap for recovery
|
||||||
|
config.temperature_critical_celsius,
|
||||||
|
5.0, // 5°C gap for recovery
|
||||||
|
);
|
||||||
|
|
||||||
|
// Perform auto-discovery of storage topology
|
||||||
|
let storage_topology = match Self::auto_discover_storage() {
|
||||||
|
Ok(topology) => {
|
||||||
|
debug!("Auto-discovered storage topology: {} single disks, {} mergerfs pools",
|
||||||
|
topology.single_disks.len(), topology.mergerfs_pools.len());
|
||||||
|
Some(topology)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to auto-discover storage topology: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect devices for discovered storage
|
||||||
|
let mut detected_devices = std::collections::HashMap::new();
|
||||||
|
if let Some(ref topology) = storage_topology {
|
||||||
|
// Add single disks
|
||||||
|
for disk in &topology.single_disks {
|
||||||
|
if let Ok(devices) = Self::detect_device_for_mount_point_static(&disk.mount_point) {
|
||||||
|
detected_devices.insert(disk.mount_point.clone(), devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mergerfs pools and their members
|
||||||
|
for pool in &topology.mergerfs_pools {
|
||||||
|
// Detect devices for the pool itself
|
||||||
|
if let Ok(devices) = Self::detect_device_for_mount_point_static(&pool.mount_point) {
|
||||||
|
detected_devices.insert(pool.mount_point.clone(), devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect devices for member disks
|
||||||
|
for member in &pool.data_members {
|
||||||
|
if let Ok(devices) = Self::detect_device_for_mount_point_static(member) {
|
||||||
|
detected_devices.insert(member.clone(), devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect devices for parity disks
|
||||||
|
for parity in &pool.parity_disks {
|
||||||
|
if let Ok(devices) = Self::detect_device_for_mount_point_static(parity) {
|
||||||
|
detected_devices.insert(parity.clone(), devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: use legacy filesystem config detection
|
||||||
|
for fs_config in &config.filesystems {
|
||||||
|
if fs_config.monitor {
|
||||||
|
if let Ok(devices) = Self::detect_device_for_mount_point_static(&fs_config.mount_point) {
|
||||||
|
detected_devices.insert(fs_config.mount_point.clone(), devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
temperature_thresholds,
|
||||||
|
detected_devices,
|
||||||
|
storage_topology,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-discover storage topology by parsing system information
|
||||||
|
fn auto_discover_storage() -> Result<StorageTopology> {
|
||||||
|
let mounts = Self::parse_proc_mounts()?;
|
||||||
|
let mut single_disks = Vec::new();
|
||||||
|
let mut mergerfs_pools = Vec::new();
|
||||||
|
|
||||||
|
// Filter out unwanted filesystem types and mount points
|
||||||
|
let exclude_fs_types = ["tmpfs", "devtmpfs", "sysfs", "proc", "cgroup", "cgroup2", "devpts"];
|
||||||
|
let exclude_mount_prefixes = ["/proc", "/sys", "/dev", "/tmp", "/run"];
|
||||||
|
|
||||||
|
for mount in mounts {
|
||||||
|
// Skip excluded filesystem types
|
||||||
|
if exclude_fs_types.contains(&mount.fs_type.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip excluded mount point prefixes
|
||||||
|
if exclude_mount_prefixes.iter().any(|prefix| mount.mount_point.starts_with(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match mount.fs_type.as_str() {
|
||||||
|
"fuse.mergerfs" => {
|
||||||
|
// Parse mergerfs pool
|
||||||
|
let data_members = Self::parse_mergerfs_sources(&mount.device);
|
||||||
|
let parity_disks = Self::detect_parity_disks(&data_members);
|
||||||
|
|
||||||
|
mergerfs_pools.push(MergerfsPoolInfo {
|
||||||
|
mount_point: mount.mount_point.clone(),
|
||||||
|
data_members,
|
||||||
|
parity_disks,
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!("Discovered mergerfs pool at {}", mount.mount_point);
|
||||||
|
}
|
||||||
|
"ext4" | "xfs" | "btrfs" | "ntfs" | "vfat" => {
|
||||||
|
// Check if this mount is part of a mergerfs pool
|
||||||
|
let is_mergerfs_member = mergerfs_pools.iter()
|
||||||
|
.any(|pool| pool.data_members.contains(&mount.mount_point) ||
|
||||||
|
pool.parity_disks.contains(&mount.mount_point));
|
||||||
|
|
||||||
|
if !is_mergerfs_member {
|
||||||
|
debug!("Discovered single disk at {}", mount.mount_point);
|
||||||
|
single_disks.push(mount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
debug!("Skipping unsupported filesystem type: {}", mount.fs_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StorageTopology {
|
||||||
|
single_disks,
|
||||||
|
mergerfs_pools,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse /proc/mounts to get all mount information
|
||||||
|
fn parse_proc_mounts() -> Result<Vec<MountInfo>> {
|
||||||
|
let mounts_content = fs::read_to_string("/proc/mounts")?;
|
||||||
|
let mut mounts = Vec::new();
|
||||||
|
|
||||||
|
for line in mounts_content.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
mounts.push(MountInfo {
|
||||||
|
device: parts[0].to_string(),
|
||||||
|
mount_point: parts[1].to_string(),
|
||||||
|
fs_type: parts[2].to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(mounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse mergerfs source string to extract member paths
|
||||||
|
fn parse_mergerfs_sources(source: &str) -> Vec<String> {
|
||||||
|
// MergerFS source format: "/mnt/disk1:/mnt/disk2:/mnt/disk3"
|
||||||
|
source.split(':')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect potential parity disks based on data member heuristics
|
||||||
|
fn detect_parity_disks(data_members: &[String]) -> Vec<String> {
|
||||||
|
let mut parity_disks = Vec::new();
|
||||||
|
|
||||||
|
// Heuristic 1: Look for mount points with "parity" in the name
|
||||||
|
if let Ok(mounts) = Self::parse_proc_mounts() {
|
||||||
|
for mount in mounts {
|
||||||
|
if mount.mount_point.to_lowercase().contains("parity") &&
|
||||||
|
(mount.fs_type == "xfs" || mount.fs_type == "ext4") {
|
||||||
|
debug!("Detected parity disk by name: {}", mount.mount_point);
|
||||||
|
parity_disks.push(mount.mount_point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic 2: Look for sequential device pattern
|
||||||
|
// If data members are /mnt/disk1, /mnt/disk2, look for /mnt/disk* that's not in data
|
||||||
|
if parity_disks.is_empty() {
|
||||||
|
if let Some(pattern) = Self::extract_mount_pattern(data_members) {
|
||||||
|
if let Ok(mounts) = Self::parse_proc_mounts() {
|
||||||
|
for mount in mounts {
|
||||||
|
if mount.mount_point.starts_with(&pattern) &&
|
||||||
|
!data_members.contains(&mount.mount_point) &&
|
||||||
|
(mount.fs_type == "xfs" || mount.fs_type == "ext4") {
|
||||||
|
debug!("Detected parity disk by pattern: {}", mount.mount_point);
|
||||||
|
parity_disks.push(mount.mount_point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parity_disks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract common mount point pattern from data members
|
||||||
|
fn extract_mount_pattern(data_members: &[String]) -> Option<String> {
|
||||||
|
if data_members.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find common prefix (e.g., "/mnt/disk" from "/mnt/disk1", "/mnt/disk2")
|
||||||
|
let first = &data_members[0];
|
||||||
|
if let Some(last_slash) = first.rfind('/') {
|
||||||
|
let base = &first[..last_slash + 1]; // Include the slash
|
||||||
|
|
||||||
|
// Check if all members share this base
|
||||||
|
if data_members.iter().all(|member| member.starts_with(base)) {
|
||||||
|
return Some(base.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate disk temperature status using hysteresis thresholds
|
||||||
|
fn calculate_temperature_status(&self, metric_name: &str, temperature: f32, status_tracker: &mut StatusTracker) -> Status {
|
||||||
|
status_tracker.calculate_with_hysteresis(metric_name, temperature, &self.temperature_thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Get storage pools using auto-discovered topology or fallback to configuration
|
||||||
|
fn get_configured_storage_pools(&self) -> Result<Vec<StoragePool>> {
|
||||||
|
if let Some(ref topology) = self.storage_topology {
|
||||||
|
self.get_auto_discovered_storage_pools(topology)
|
||||||
|
} else {
|
||||||
|
self.get_legacy_configured_storage_pools()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get storage pools from auto-discovered topology
|
||||||
|
fn get_auto_discovered_storage_pools(&self, topology: &StorageTopology) -> Result<Vec<StoragePool>> {
|
||||||
|
let mut storage_pools = Vec::new();
|
||||||
|
|
||||||
|
// Group single disks by physical drive for unified pool display
|
||||||
|
let grouped_disks = self.group_filesystems_by_physical_drive(&topology.single_disks)?;
|
||||||
|
|
||||||
|
// Process grouped single disks (each physical drive becomes a pool)
|
||||||
|
for (drive_name, filesystems) in grouped_disks {
|
||||||
|
// Create a unified pool for this physical drive
|
||||||
|
let pool = self.create_physical_drive_pool(&drive_name, &filesystems)?;
|
||||||
|
storage_pools.push(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Do not create individual filesystem pools when using auto-discovery
|
||||||
|
// All single disk filesystems should be grouped into physical drive pools above
|
||||||
|
|
||||||
|
// Process mergerfs pools (these remain as logical pools)
|
||||||
|
for pool_info in &topology.mergerfs_pools {
|
||||||
|
if let Ok((total_bytes, used_bytes)) = self.get_filesystem_info(&pool_info.mount_point) {
|
||||||
|
let available_bytes = total_bytes - used_bytes;
|
||||||
|
let usage_percent = if total_bytes > 0 {
|
||||||
|
(used_bytes as f64 / total_bytes as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
let size = self.bytes_to_human_readable(total_bytes);
|
||||||
|
let used = self.bytes_to_human_readable(used_bytes);
|
||||||
|
let available = self.bytes_to_human_readable(available_bytes);
|
||||||
|
|
||||||
|
// Collect all member and parity drives
|
||||||
|
let mut all_drives = Vec::new();
|
||||||
|
|
||||||
|
// Add data member drives
|
||||||
|
for member in &pool_info.data_members {
|
||||||
|
if let Some(devices) = self.detected_devices.get(member) {
|
||||||
|
all_drives.extend(devices.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add parity drives
|
||||||
|
for parity in &pool_info.parity_disks {
|
||||||
|
if let Some(devices) = self.detected_devices.get(parity) {
|
||||||
|
all_drives.extend(devices.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let underlying_drives = self.get_drive_info_for_devices(&all_drives)?;
|
||||||
|
|
||||||
|
// Calculate pool health
|
||||||
|
let pool_health = self.calculate_mergerfs_pool_health(&pool_info.data_members, &pool_info.parity_disks, &underlying_drives);
|
||||||
|
|
||||||
|
// Generate pool name from mount point
|
||||||
|
let name = pool_info.mount_point.trim_start_matches('/').replace('/', "_");
|
||||||
|
|
||||||
|
storage_pools.push(StoragePool {
|
||||||
|
name,
|
||||||
|
mount_point: pool_info.mount_point.clone(),
|
||||||
|
filesystem: "fuse.mergerfs".to_string(),
|
||||||
|
pool_type: StoragePoolType::MergerfsPool {
|
||||||
|
data_disks: pool_info.data_members.iter()
|
||||||
|
.filter_map(|member| self.detected_devices.get(member).and_then(|devices| devices.first().cloned()))
|
||||||
|
.collect(),
|
||||||
|
parity_disks: pool_info.parity_disks.iter()
|
||||||
|
.filter_map(|parity| self.detected_devices.get(parity).and_then(|devices| devices.first().cloned()))
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
used,
|
||||||
|
available,
|
||||||
|
usage_percent: usage_percent as f32,
|
||||||
|
underlying_drives,
|
||||||
|
pool_health,
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!("Auto-discovered mergerfs pool: {} with {} data + {} parity disks",
|
||||||
|
pool_info.mount_point, pool_info.data_members.len(), pool_info.parity_disks.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(storage_pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group filesystems by their backing physical drive
|
||||||
|
fn group_filesystems_by_physical_drive(&self, filesystems: &[MountInfo]) -> Result<std::collections::HashMap<String, Vec<MountInfo>>> {
|
||||||
|
let mut grouped = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for fs in filesystems {
|
||||||
|
// Get the physical drive name for this mount point
|
||||||
|
if let Some(devices) = self.detected_devices.get(&fs.mount_point) {
|
||||||
|
if let Some(device_name) = devices.first() {
|
||||||
|
// Extract base drive name from detected device
|
||||||
|
let drive_name = Self::extract_base_device(device_name)
|
||||||
|
.unwrap_or_else(|| device_name.clone());
|
||||||
|
|
||||||
|
debug!("Grouping filesystem {} (device: {}) under drive: {}",
|
||||||
|
fs.mount_point, device_name, drive_name);
|
||||||
|
|
||||||
|
grouped.entry(drive_name).or_insert_with(Vec::new).push(fs.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Filesystem grouping result: {} drives with filesystems: {:?}",
|
||||||
|
grouped.len(),
|
||||||
|
grouped.keys().collect::<Vec<_>>());
|
||||||
|
|
||||||
|
Ok(grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a physical drive pool containing multiple filesystems
|
||||||
|
fn create_physical_drive_pool(&self, drive_name: &str, filesystems: &[MountInfo]) -> Result<StoragePool> {
|
||||||
|
if filesystems.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("No filesystems for drive {}", drive_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total usage across all filesystems on this drive
|
||||||
|
let mut total_capacity = 0u64;
|
||||||
|
let mut total_used = 0u64;
|
||||||
|
|
||||||
|
for fs in filesystems {
|
||||||
|
if let Ok((capacity, used)) = self.get_filesystem_info(&fs.mount_point) {
|
||||||
|
total_capacity += capacity;
|
||||||
|
total_used += used;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_available = total_capacity.saturating_sub(total_used);
|
||||||
|
let usage_percent = if total_capacity > 0 {
|
||||||
|
(total_used as f64 / total_capacity as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
// Get drive information for SMART data
|
||||||
|
let device_names = vec![drive_name.to_string()];
|
||||||
|
let underlying_drives = self.get_drive_info_for_devices(&device_names)?;
|
||||||
|
|
||||||
|
// Collect filesystem mount points for this drive
|
||||||
|
let filesystem_mount_points: Vec<String> = filesystems.iter()
|
||||||
|
.map(|fs| fs.mount_point.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(StoragePool {
|
||||||
|
name: drive_name.to_string(),
|
||||||
|
mount_point: format!("(physical drive)"), // Special marker for physical drives
|
||||||
|
filesystem: "physical".to_string(),
|
||||||
|
pool_type: StoragePoolType::PhysicalDrive {
|
||||||
|
filesystems: filesystem_mount_points,
|
||||||
|
},
|
||||||
|
size: self.bytes_to_human_readable(total_capacity),
|
||||||
|
used: self.bytes_to_human_readable(total_used),
|
||||||
|
available: self.bytes_to_human_readable(total_available),
|
||||||
|
usage_percent: usage_percent as f32,
|
||||||
|
pool_health: if underlying_drives.iter().all(|d| d.health_status == "PASSED") {
|
||||||
|
PoolHealth::Healthy
|
||||||
|
} else {
|
||||||
|
PoolHealth::Critical
|
||||||
|
},
|
||||||
|
underlying_drives,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate pool health specifically for mergerfs pools
|
||||||
|
fn calculate_mergerfs_pool_health(&self, data_members: &[String], parity_disks: &[String], drives: &[DriveInfo]) -> PoolHealth {
|
||||||
|
// Get device names for data and parity drives
|
||||||
|
let mut data_device_names = Vec::new();
|
||||||
|
let mut parity_device_names = Vec::new();
|
||||||
|
|
||||||
|
for member in data_members {
|
||||||
|
if let Some(devices) = self.detected_devices.get(member) {
|
||||||
|
data_device_names.extend(devices.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for parity in parity_disks {
|
||||||
|
if let Some(devices) = self.detected_devices.get(parity) {
|
||||||
|
parity_device_names.extend(devices.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed_data = drives.iter()
|
||||||
|
.filter(|d| data_device_names.contains(&d.device) && d.health_status != "PASSED")
|
||||||
|
.count();
|
||||||
|
let failed_parity = drives.iter()
|
||||||
|
.filter(|d| parity_device_names.contains(&d.device) && d.health_status != "PASSED")
|
||||||
|
.count();
|
||||||
|
|
||||||
|
match (failed_data, failed_parity) {
|
||||||
|
(0, 0) => PoolHealth::Healthy,
|
||||||
|
(1, 0) => PoolHealth::Degraded, // Can recover with parity
|
||||||
|
(0, 1) => PoolHealth::Degraded, // Lost parity protection
|
||||||
|
_ => PoolHealth::Critical, // Multiple failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback to legacy configuration-based storage pools
|
||||||
|
fn get_legacy_configured_storage_pools(&self) -> Result<Vec<StoragePool>> {
|
||||||
|
let mut storage_pools = Vec::new();
|
||||||
|
let mut processed_pools = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
// Legacy implementation: use filesystem configuration
|
||||||
|
for fs_config in &self.config.filesystems {
|
||||||
|
if !fs_config.monitor {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (pool_type, skip_in_single_mode) = self.determine_pool_type(&fs_config.storage_type);
|
||||||
|
|
||||||
|
// Skip member disks if they're part of a pool
|
||||||
|
if skip_in_single_mode {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this pool was already processed (in case of multiple member disks)
|
||||||
|
let pool_key = match &pool_type {
|
||||||
|
StoragePoolType::MergerfsPool { .. } => {
|
||||||
|
// For mergerfs pools, use the main mount point
|
||||||
|
if fs_config.fs_type == "fuse.mergerfs" {
|
||||||
|
fs_config.mount_point.clone()
|
||||||
|
} else {
|
||||||
|
continue; // Skip member disks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => fs_config.mount_point.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if processed_pools.contains(&pool_key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processed_pools.insert(pool_key.clone());
|
||||||
|
|
||||||
|
// Get filesystem stats for the mount point
|
||||||
|
match self.get_filesystem_info(&fs_config.mount_point) {
|
||||||
|
Ok((total_bytes, used_bytes)) => {
|
||||||
|
let available_bytes = total_bytes - used_bytes;
|
||||||
|
let usage_percent = if total_bytes > 0 {
|
||||||
|
(used_bytes as f64 / total_bytes as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
// Convert bytes to human-readable format
|
||||||
|
let size = self.bytes_to_human_readable(total_bytes);
|
||||||
|
let used = self.bytes_to_human_readable(used_bytes);
|
||||||
|
let available = self.bytes_to_human_readable(available_bytes);
|
||||||
|
|
||||||
|
// Get underlying drives based on pool type
|
||||||
|
let underlying_drives = self.get_pool_drives(&pool_type, &fs_config.mount_point)?;
|
||||||
|
|
||||||
|
// Calculate pool health
|
||||||
|
let pool_health = self.calculate_pool_health(&pool_type, &underlying_drives);
|
||||||
|
let drive_count = underlying_drives.len();
|
||||||
|
|
||||||
|
storage_pools.push(StoragePool {
|
||||||
|
name: fs_config.name.clone(),
|
||||||
|
mount_point: fs_config.mount_point.clone(),
|
||||||
|
filesystem: fs_config.fs_type.clone(),
|
||||||
|
pool_type: pool_type.clone(),
|
||||||
|
size,
|
||||||
|
used,
|
||||||
|
available,
|
||||||
|
usage_percent: usage_percent as f32,
|
||||||
|
underlying_drives,
|
||||||
|
pool_health,
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Legacy configured storage pool '{}' ({:?}) at {} with {} drives, health: {:?}",
|
||||||
|
fs_config.name, pool_type, fs_config.mount_point, drive_count, pool_health
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(
|
||||||
|
"Failed to get filesystem info for storage pool '{}': {}",
|
||||||
|
fs_config.name, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(storage_pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the storage pool type from configuration
|
||||||
|
fn determine_pool_type(&self, storage_type: &str) -> (StoragePoolType, bool) {
|
||||||
|
match storage_type {
|
||||||
|
"single" => (StoragePoolType::Single, false),
|
||||||
|
"mergerfs_pool" | "mergerfs" => {
|
||||||
|
// Find associated member disks
|
||||||
|
let data_disks = self.find_pool_member_disks("mergerfs_member");
|
||||||
|
let parity_disks = self.find_pool_member_disks("parity");
|
||||||
|
(StoragePoolType::MergerfsPool { data_disks, parity_disks }, false)
|
||||||
|
}
|
||||||
|
"mergerfs_member" => (StoragePoolType::Single, true), // Skip, part of pool
|
||||||
|
"parity" => (StoragePoolType::Single, true), // Skip, part of pool
|
||||||
|
"raid1" | "raid5" | "raid6" => {
|
||||||
|
let member_disks = self.find_pool_member_disks(&format!("{}_member", storage_type));
|
||||||
|
(StoragePoolType::RaidArray {
|
||||||
|
level: storage_type.to_uppercase(),
|
||||||
|
member_disks,
|
||||||
|
spare_disks: Vec::new()
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
_ => (StoragePoolType::Single, false) // Default to single
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find member disks for a specific storage type
|
||||||
|
fn find_pool_member_disks(&self, member_type: &str) -> Vec<String> {
|
||||||
|
let mut member_disks = Vec::new();
|
||||||
|
|
||||||
|
for fs_config in &self.config.filesystems {
|
||||||
|
if fs_config.storage_type == member_type && fs_config.monitor {
|
||||||
|
// Get device names for this mount point
|
||||||
|
if let Some(devices) = self.detected_devices.get(&fs_config.mount_point) {
|
||||||
|
member_disks.extend(devices.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member_disks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get drive information for a specific pool type
|
||||||
|
fn get_pool_drives(&self, pool_type: &StoragePoolType, mount_point: &str) -> Result<Vec<DriveInfo>> {
|
||||||
|
match pool_type {
|
||||||
|
StoragePoolType::Single => {
|
||||||
|
// Single disk - use detected devices for this mount point
|
||||||
|
let device_names = self.detected_devices.get(mount_point).cloned().unwrap_or_default();
|
||||||
|
self.get_drive_info_for_devices(&device_names)
|
||||||
|
}
|
||||||
|
StoragePoolType::PhysicalDrive { .. } => {
|
||||||
|
// Physical drive - get drive info for the drive directly (mount_point not used)
|
||||||
|
let device_names = vec![mount_point.to_string()];
|
||||||
|
self.get_drive_info_for_devices(&device_names)
|
||||||
|
}
|
||||||
|
StoragePoolType::MergerfsPool { data_disks, parity_disks } => {
|
||||||
|
// Mergerfs pool - collect all member drives
|
||||||
|
let mut all_disks = data_disks.clone();
|
||||||
|
all_disks.extend(parity_disks.clone());
|
||||||
|
self.get_drive_info_for_devices(&all_disks)
|
||||||
|
}
|
||||||
|
StoragePoolType::RaidArray { member_disks, spare_disks, .. } => {
|
||||||
|
// RAID array - collect member and spare drives
|
||||||
|
let mut all_disks = member_disks.clone();
|
||||||
|
all_disks.extend(spare_disks.clone());
|
||||||
|
self.get_drive_info_for_devices(&all_disks)
|
||||||
|
}
|
||||||
|
StoragePoolType::ZfsPool { .. } => {
|
||||||
|
// ZFS pool - use detected devices (future implementation)
|
||||||
|
let device_names = self.detected_devices.get(mount_point).cloned().unwrap_or_default();
|
||||||
|
self.get_drive_info_for_devices(&device_names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate pool health based on drive status and pool type
|
||||||
|
fn calculate_pool_health(&self, pool_type: &StoragePoolType, drives: &[DriveInfo]) -> PoolHealth {
|
||||||
|
match pool_type {
|
||||||
|
StoragePoolType::Single => {
|
||||||
|
// Single disk - health is just the drive health
|
||||||
|
if drives.is_empty() {
|
||||||
|
PoolHealth::Unknown
|
||||||
|
} else if drives.iter().all(|d| d.health_status == "PASSED") {
|
||||||
|
PoolHealth::Healthy
|
||||||
|
} else {
|
||||||
|
PoolHealth::Critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoragePoolType::PhysicalDrive { .. } => {
|
||||||
|
// Physical drive - health is just the drive health (similar to Single)
|
||||||
|
if drives.is_empty() {
|
||||||
|
PoolHealth::Unknown
|
||||||
|
} else if drives.iter().all(|d| d.health_status == "PASSED") {
|
||||||
|
PoolHealth::Healthy
|
||||||
|
} else {
|
||||||
|
PoolHealth::Critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoragePoolType::MergerfsPool { data_disks, parity_disks } => {
|
||||||
|
let failed_data = drives.iter()
|
||||||
|
.filter(|d| data_disks.contains(&d.device) && d.health_status != "PASSED")
|
||||||
|
.count();
|
||||||
|
let failed_parity = drives.iter()
|
||||||
|
.filter(|d| parity_disks.contains(&d.device) && d.health_status != "PASSED")
|
||||||
|
.count();
|
||||||
|
|
||||||
|
match (failed_data, failed_parity) {
|
||||||
|
(0, 0) => PoolHealth::Healthy,
|
||||||
|
(1, 0) => PoolHealth::Degraded, // Can recover with parity
|
||||||
|
(0, 1) => PoolHealth::Degraded, // Lost parity protection
|
||||||
|
_ => PoolHealth::Critical, // Multiple failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoragePoolType::RaidArray { level, .. } => {
|
||||||
|
let failed_drives = drives.iter().filter(|d| d.health_status != "PASSED").count();
|
||||||
|
|
||||||
|
// Basic RAID health logic (can be enhanced per RAID level)
|
||||||
|
match failed_drives {
|
||||||
|
0 => PoolHealth::Healthy,
|
||||||
|
1 if level.contains('1') || level.contains('5') || level.contains('6') => PoolHealth::Degraded,
|
||||||
|
_ => PoolHealth::Critical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoragePoolType::ZfsPool { .. } => {
|
||||||
|
// ZFS health would require zpool status parsing (future)
|
||||||
|
if drives.iter().all(|d| d.health_status == "PASSED") {
|
||||||
|
PoolHealth::Healthy
|
||||||
|
} else {
|
||||||
|
PoolHealth::Degraded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get drive information for a list of device names
|
||||||
|
fn get_drive_info_for_devices(&self, device_names: &[String]) -> Result<Vec<DriveInfo>> {
|
||||||
|
let mut drives = Vec::new();
|
||||||
|
|
||||||
|
for device_name in device_names {
|
||||||
|
let device_path = format!("/dev/{}", device_name);
|
||||||
|
|
||||||
|
// Get SMART data for this drive
|
||||||
|
let (health_status, temperature, wear_level) = self.get_smart_data(&device_path);
|
||||||
|
|
||||||
|
drives.push(DriveInfo {
|
||||||
|
device: device_name.clone(),
|
||||||
|
health_status: health_status.clone(),
|
||||||
|
temperature,
|
||||||
|
wear_level,
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Drive info for {}: health={}, temp={:?}°C, wear={:?}%",
|
||||||
|
device_name, health_status, temperature, wear_level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(drives)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SMART data for a drive (health, temperature, wear level)
|
||||||
|
fn get_smart_data(&self, device_path: &str) -> (String, Option<f32>, Option<f32>) {
|
||||||
|
// Try to get SMART data using smartctl
|
||||||
|
let output = Command::new("sudo")
|
||||||
|
.arg("smartctl")
|
||||||
|
.arg("-a")
|
||||||
|
.arg(device_path)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(result) if result.status.success() => {
|
||||||
|
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||||
|
|
||||||
|
// Parse health status
|
||||||
|
let health = if stdout.contains("PASSED") {
|
||||||
|
"PASSED".to_string()
|
||||||
|
} else if stdout.contains("FAILED") {
|
||||||
|
"FAILED".to_string()
|
||||||
|
} else {
|
||||||
|
"UNKNOWN".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse temperature (look for various temperature indicators)
|
||||||
|
let temperature = self.parse_temperature_from_smart(&stdout);
|
||||||
|
|
||||||
|
// Parse wear level (for SSDs)
|
||||||
|
let wear_level = self.parse_wear_level_from_smart(&stdout);
|
||||||
|
|
||||||
|
(health, temperature, wear_level)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
debug!("Failed to get SMART data for {}", device_path);
|
||||||
|
("UNKNOWN".to_string(), None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse temperature from SMART output
|
||||||
|
fn parse_temperature_from_smart(&self, smart_output: &str) -> Option<f32> {
|
||||||
|
for line in smart_output.lines() {
|
||||||
|
// Look for temperature in various formats
|
||||||
|
if line.contains("Temperature_Celsius") || line.contains("Temperature") {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 10 {
|
||||||
|
if let Ok(temp) = parts[9].parse::<f32>() {
|
||||||
|
return Some(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NVMe drives might show temperature differently
|
||||||
|
if line.contains("temperature:") {
|
||||||
|
if let Some(temp_part) = line.split("temperature:").nth(1) {
|
||||||
|
if let Some(temp_str) = temp_part.split_whitespace().next() {
|
||||||
|
if let Ok(temp) = temp_str.parse::<f32>() {
|
||||||
|
return Some(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse wear level from SMART output (SSD wear leveling)
|
||||||
|
/// Supports both NVMe and SATA SSD wear indicators
|
||||||
|
fn parse_wear_level_from_smart(&self, smart_output: &str) -> Option<f32> {
|
||||||
|
for line in smart_output.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// NVMe drives - direct percentage used
|
||||||
|
if line.contains("Percentage Used:") {
|
||||||
|
if let Some(wear_part) = line.split("Percentage Used:").nth(1) {
|
||||||
|
if let Some(wear_str) = wear_part.split('%').next() {
|
||||||
|
if let Ok(wear) = wear_str.trim().parse::<f32>() {
|
||||||
|
return Some(wear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SATA SSD attributes - parse SMART table format
|
||||||
|
// Format: ID ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 10 {
|
||||||
|
// SSD Life Left / Percent Lifetime Remaining (higher = less wear)
|
||||||
|
if line.contains("SSD_Life_Left") || line.contains("Percent_Lifetime_Remain") {
|
||||||
|
if let Ok(remaining) = parts[3].parse::<f32>() { // VALUE column
|
||||||
|
return Some(100.0 - remaining); // Convert remaining to used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media Wearout Indicator (lower = more wear, normalize to 0-100)
|
||||||
|
if line.contains("Media_Wearout_Indicator") {
|
||||||
|
if let Ok(remaining) = parts[3].parse::<f32>() { // VALUE column
|
||||||
|
return Some(100.0 - remaining); // Convert remaining to used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wear Leveling Count (higher = less wear, but varies by manufacturer)
|
||||||
|
if line.contains("Wear_Leveling_Count") {
|
||||||
|
if let Ok(wear_count) = parts[3].parse::<f32>() { // VALUE column
|
||||||
|
// Most SSDs: 100 = new, decreases with wear
|
||||||
|
if wear_count <= 100.0 {
|
||||||
|
return Some(100.0 - wear_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total LBAs Written - calculate against typical endurance if available
|
||||||
|
// This is more complex and manufacturer-specific, so we skip for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert bytes to human-readable format
|
||||||
|
fn bytes_to_human_readable(&self, bytes: u64) -> String {
|
||||||
|
const UNITS: &[&str] = &["B", "K", "M", "G", "T"];
|
||||||
|
let mut size = bytes as f64;
|
||||||
|
let mut unit_index = 0;
|
||||||
|
|
||||||
|
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if unit_index == 0 {
|
||||||
|
format!("{:.0}{}", size, UNITS[unit_index])
|
||||||
|
} else {
|
||||||
|
format!("{:.1}{}", size, UNITS[unit_index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert bytes to gigabytes
|
||||||
|
fn bytes_to_gb(&self, bytes: u64) -> f32 {
|
||||||
|
bytes as f32 / (1024.0 * 1024.0 * 1024.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect device backing a mount point using lsblk (static version for startup)
|
||||||
|
fn detect_device_for_mount_point_static(mount_point: &str) -> Result<Vec<String>> {
|
||||||
|
let output = Command::new("lsblk")
|
||||||
|
.args(&["-n", "-o", "NAME,MOUNTPOINT"])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
for line in output_str.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 2 && parts[1] == mount_point {
|
||||||
|
// Remove tree symbols and extract device name (e.g., "├─nvme0n1p2" -> "nvme0n1p2")
|
||||||
|
let device_name = parts[0]
|
||||||
|
.trim_start_matches('├')
|
||||||
|
.trim_start_matches('└')
|
||||||
|
.trim_start_matches('─')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Extract base device name (e.g., "nvme0n1p2" -> "nvme0n1")
|
||||||
|
if let Some(base_device) = Self::extract_base_device(device_name) {
|
||||||
|
return Ok(vec![base_device]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract base device name from partition (e.g., "nvme0n1p2" -> "nvme0n1", "sda1" -> "sda")
|
||||||
|
fn extract_base_device(device_name: &str) -> Option<String> {
|
||||||
|
// Handle NVMe devices (nvme0n1p1 -> nvme0n1)
|
||||||
|
if device_name.starts_with("nvme") {
|
||||||
|
if let Some(p_pos) = device_name.find('p') {
|
||||||
|
return Some(device_name[..p_pos].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle traditional devices (sda1 -> sda)
|
||||||
|
if device_name.len() > 1 {
|
||||||
|
let chars: Vec<char> = device_name.chars().collect();
|
||||||
|
let mut end_idx = chars.len();
|
||||||
|
|
||||||
|
// Find where the device name ends and partition number begins
|
||||||
|
for (i, &c) in chars.iter().enumerate().rev() {
|
||||||
|
if !c.is_ascii_digit() {
|
||||||
|
end_idx = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if end_idx > 0 && end_idx < chars.len() {
|
||||||
|
return Some(chars[..end_idx].iter().collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no partition detected, return as-is
|
||||||
|
Some(device_name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Get filesystem info using df command
|
||||||
|
fn get_filesystem_info(&self, path: &str) -> Result<(u64, u64)> {
|
||||||
|
let output = Command::new("df")
|
||||||
|
.arg("--block-size=1")
|
||||||
|
.arg(path)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!("df command failed for {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_str = String::from_utf8(output.stdout)?;
|
||||||
|
let lines: Vec<&str> = output_str.lines().collect();
|
||||||
|
|
||||||
|
if lines.len() < 2 {
|
||||||
|
return Err(anyhow::anyhow!("Unexpected df output format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields: Vec<&str> = lines[1].split_whitespace().collect();
|
||||||
|
if fields.len() < 4 {
|
||||||
|
return Err(anyhow::anyhow!("Unexpected df fields count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_bytes = fields[1].parse::<u64>()?;
|
||||||
|
let used_bytes = fields[2].parse::<u64>()?;
|
||||||
|
|
||||||
|
Ok((total_bytes, used_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Parse size string (e.g., "120G", "45M") to GB value
|
||||||
|
fn parse_size_to_gb(&self, size_str: &str) -> f32 {
|
||||||
|
let size_str = size_str.trim();
|
||||||
|
if size_str.is_empty() || size_str == "-" {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract numeric part and unit
|
||||||
|
let (num_str, unit) = if let Some(last_char) = size_str.chars().last() {
|
||||||
|
if last_char.is_alphabetic() {
|
||||||
|
let num_part = &size_str[..size_str.len() - 1];
|
||||||
|
let unit_part = &size_str[size_str.len() - 1..];
|
||||||
|
(num_part, unit_part)
|
||||||
|
} else {
|
||||||
|
(size_str, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(size_str, "")
|
||||||
|
};
|
||||||
|
|
||||||
|
let number: f32 = num_str.parse().unwrap_or(0.0);
|
||||||
|
|
||||||
|
match unit.to_uppercase().as_str() {
|
||||||
|
"T" | "TB" => number * 1024.0,
|
||||||
|
"G" | "GB" => number,
|
||||||
|
"M" | "MB" => number / 1024.0,
|
||||||
|
"K" | "KB" => number / (1024.0 * 1024.0),
|
||||||
|
"B" | "" => number / (1024.0 * 1024.0 * 1024.0),
|
||||||
|
_ => number, // Assume GB if unknown unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Collector for DiskCollector {
|
||||||
|
|
||||||
|
async fn collect(&self, status_tracker: &mut StatusTracker) -> Result<Vec<Metric>, CollectorError> {
|
||||||
|
let start_time = Instant::now();
|
||||||
|
debug!("Collecting storage pool and individual drive metrics");
|
||||||
|
|
||||||
|
let mut metrics = Vec::new();
|
||||||
|
|
||||||
|
// Get configured storage pools with individual drive data
|
||||||
|
let storage_pools = match self.get_configured_storage_pools() {
|
||||||
|
Ok(pools) => {
|
||||||
|
debug!("Found {} storage pools", pools.len());
|
||||||
|
pools
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to get storage pools: {}", e);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate metrics for each storage pool and its underlying drives
|
||||||
|
for storage_pool in &storage_pools {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||||
|
|
||||||
|
// Storage pool overall metrics
|
||||||
|
let pool_name = &storage_pool.name;
|
||||||
|
|
||||||
|
// Parse size strings to get actual values for calculations
|
||||||
|
let size_gb = self.parse_size_to_gb(&storage_pool.size);
|
||||||
|
let used_gb = self.parse_size_to_gb(&storage_pool.used);
|
||||||
|
let avail_gb = self.parse_size_to_gb(&storage_pool.available);
|
||||||
|
|
||||||
|
// Calculate status based on configured thresholds and pool health
|
||||||
|
let usage_status = if storage_pool.usage_percent >= self.config.usage_critical_percent {
|
||||||
|
Status::Critical
|
||||||
|
} else if storage_pool.usage_percent >= self.config.usage_warning_percent {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool_status = match storage_pool.pool_health {
|
||||||
|
PoolHealth::Critical => Status::Critical,
|
||||||
|
PoolHealth::Degraded => Status::Warning,
|
||||||
|
PoolHealth::Rebuilding => Status::Warning,
|
||||||
|
PoolHealth::Healthy => usage_status,
|
||||||
|
PoolHealth::Unknown => Status::Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage pool info metrics
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_mount_point", pool_name),
|
||||||
|
value: MetricValue::String(storage_pool.mount_point.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Mount: {}", storage_pool.mount_point)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_filesystem", pool_name),
|
||||||
|
value: MetricValue::String(storage_pool.filesystem.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("FS: {}", storage_pool.filesystem)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhanced pool type information
|
||||||
|
let pool_type_str = match &storage_pool.pool_type {
|
||||||
|
StoragePoolType::Single => "single".to_string(),
|
||||||
|
StoragePoolType::PhysicalDrive { filesystems } => {
|
||||||
|
format!("drive ({})", filesystems.len())
|
||||||
|
}
|
||||||
|
StoragePoolType::MergerfsPool { data_disks, parity_disks } => {
|
||||||
|
format!("mergerfs ({}+{})", data_disks.len(), parity_disks.len())
|
||||||
|
}
|
||||||
|
StoragePoolType::RaidArray { level, member_disks, spare_disks } => {
|
||||||
|
format!("{} ({}+{})", level, member_disks.len(), spare_disks.len())
|
||||||
|
}
|
||||||
|
StoragePoolType::ZfsPool { pool_name, .. } => {
|
||||||
|
format!("zfs ({})", pool_name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_pool_type", pool_name),
|
||||||
|
value: MetricValue::String(pool_type_str.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Type: {}", pool_type_str)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pool health status
|
||||||
|
let health_str = match storage_pool.pool_health {
|
||||||
|
PoolHealth::Healthy => "healthy",
|
||||||
|
PoolHealth::Degraded => "degraded",
|
||||||
|
PoolHealth::Critical => "critical",
|
||||||
|
PoolHealth::Rebuilding => "rebuilding",
|
||||||
|
PoolHealth::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_pool_health", pool_name),
|
||||||
|
value: MetricValue::String(health_str.to_string()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Health: {}", health_str)),
|
||||||
|
status: pool_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Storage pool size metrics
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_total_gb", pool_name),
|
||||||
|
value: MetricValue::Float(size_gb),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("Total: {}", storage_pool.size)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_used_gb", pool_name),
|
||||||
|
value: MetricValue::Float(used_gb),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("Used: {}", storage_pool.used)),
|
||||||
|
status: pool_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_available_gb", pool_name),
|
||||||
|
value: MetricValue::Float(avail_gb),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("Available: {}", storage_pool.available)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_usage_percent", pool_name),
|
||||||
|
value: MetricValue::Float(storage_pool.usage_percent),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("Usage: {:.1}%", storage_pool.usage_percent)),
|
||||||
|
status: pool_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual drive metrics for this storage pool
|
||||||
|
for drive in &storage_pool.underlying_drives {
|
||||||
|
// Drive health status
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_{}_health", pool_name, drive.device),
|
||||||
|
value: MetricValue::String(drive.health_status.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("{}: {}", drive.device, drive.health_status)),
|
||||||
|
status: if drive.health_status == "PASSED" { Status::Ok }
|
||||||
|
else if drive.health_status == "FAILED" { Status::Critical }
|
||||||
|
else { Status::Unknown },
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive temperature
|
||||||
|
if let Some(temp) = drive.temperature {
|
||||||
|
let temp_status = self.calculate_temperature_status(
|
||||||
|
&format!("disk_{}_{}_temperature", pool_name, drive.device),
|
||||||
|
temp,
|
||||||
|
status_tracker
|
||||||
|
);
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_{}_temperature", pool_name, drive.device),
|
||||||
|
value: MetricValue::Float(temp),
|
||||||
|
unit: Some("°C".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}°C", drive.device, temp)),
|
||||||
|
status: temp_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive wear level (for SSDs)
|
||||||
|
if let Some(wear) = drive.wear_level {
|
||||||
|
let wear_status = if wear >= self.config.wear_critical_percent { Status::Critical }
|
||||||
|
else if wear >= self.config.wear_warning_percent { Status::Warning }
|
||||||
|
else { Status::Ok };
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_{}_wear_percent", pool_name, drive.device),
|
||||||
|
value: MetricValue::Float(wear),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}% wear", drive.device, wear)),
|
||||||
|
status: wear_status,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual filesystem metrics for PhysicalDrive pools
|
||||||
|
if let StoragePoolType::PhysicalDrive { filesystems } = &storage_pool.pool_type {
|
||||||
|
for filesystem_mount in filesystems {
|
||||||
|
if let Ok((total_bytes, used_bytes)) = self.get_filesystem_info(filesystem_mount) {
|
||||||
|
let available_bytes = total_bytes - used_bytes;
|
||||||
|
let usage_percent = if total_bytes > 0 {
|
||||||
|
(used_bytes as f64 / total_bytes as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
let filesystem_name = if filesystem_mount == "/" {
|
||||||
|
"root".to_string()
|
||||||
|
} else {
|
||||||
|
filesystem_mount.trim_start_matches('/').replace('/', "_")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate filesystem status based on usage
|
||||||
|
let fs_status = if usage_percent >= self.config.usage_critical_percent as f64 {
|
||||||
|
Status::Critical
|
||||||
|
} else if usage_percent >= self.config.usage_warning_percent as f64 {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filesystem usage metrics
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_fs_{}_usage_percent", pool_name, filesystem_name),
|
||||||
|
value: MetricValue::Float(usage_percent as f32),
|
||||||
|
unit: Some("%".to_string()),
|
||||||
|
description: Some(format!("{}: {:.0}%", filesystem_mount, usage_percent)),
|
||||||
|
status: fs_status.clone(),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_fs_{}_used_gb", pool_name, filesystem_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(used_bytes)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}GB used", filesystem_mount, self.bytes_to_human_readable(used_bytes))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_fs_{}_total_gb", pool_name, filesystem_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(total_bytes)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}GB total", filesystem_mount, self.bytes_to_human_readable(total_bytes))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_fs_{}_available_gb", pool_name, filesystem_name),
|
||||||
|
value: MetricValue::Float(self.bytes_to_gb(available_bytes)),
|
||||||
|
unit: Some("GB".to_string()),
|
||||||
|
description: Some(format!("{}: {}GB available", filesystem_mount, self.bytes_to_human_readable(available_bytes))),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: format!("disk_{}_fs_{}_mount_point", pool_name, filesystem_name),
|
||||||
|
value: MetricValue::String(filesystem_mount.clone()),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Mount: {}", filesystem_mount)),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add storage pool count metric
|
||||||
|
metrics.push(Metric {
|
||||||
|
name: "disk_count".to_string(),
|
||||||
|
value: MetricValue::Integer(storage_pools.len() as i64),
|
||||||
|
unit: None,
|
||||||
|
description: Some(format!("Total storage pools: {}", storage_pools.len())),
|
||||||
|
status: Status::Ok,
|
||||||
|
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
let collection_time = start_time.elapsed();
|
||||||
|
debug!(
|
||||||
|
"Multi-disk collection completed in {:?} with {} metrics",
|
||||||
|
collection_time,
|
||||||
|
metrics.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ use tracing::debug;
|
|||||||
|
|
||||||
use super::{Collector, CollectorError};
|
use super::{Collector, CollectorError};
|
||||||
use crate::config::SystemdConfig;
|
use crate::config::SystemdConfig;
|
||||||
use crate::service_tracker::UserStoppedServiceTracker;
|
|
||||||
|
|
||||||
/// Systemd collector for monitoring systemd services
|
/// Systemd collector for monitoring systemd services
|
||||||
pub struct SystemdCollector {
|
pub struct SystemdCollector {
|
||||||
@@ -357,33 +356,15 @@ impl SystemdCollector {
|
|||||||
/// Calculate service status, taking user-stopped services into account
|
/// Calculate service status, taking user-stopped services into account
|
||||||
fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status {
|
fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status {
|
||||||
match active_status.to_lowercase().as_str() {
|
match active_status.to_lowercase().as_str() {
|
||||||
"active" => {
|
"active" => Status::Ok,
|
||||||
// If service is now active and was marked as user-stopped, clear the flag
|
|
||||||
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
|
|
||||||
debug!("Service '{}' is now active - clearing user-stopped flag", service_name);
|
|
||||||
// Note: We can't directly clear here because this is a read-only context
|
|
||||||
// The agent will need to handle this differently
|
|
||||||
}
|
|
||||||
Status::Ok
|
|
||||||
},
|
|
||||||
"inactive" | "dead" => {
|
"inactive" | "dead" => {
|
||||||
// Check if this service was stopped by user action
|
debug!("Service '{}' is inactive - treating as Inactive status", service_name);
|
||||||
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
|
Status::Inactive
|
||||||
debug!("Service '{}' is inactive but marked as user-stopped - treating as OK", service_name);
|
|
||||||
Status::Ok
|
|
||||||
} else {
|
|
||||||
Status::Warning
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"failed" | "error" => Status::Critical,
|
"failed" | "error" => Status::Critical,
|
||||||
"activating" | "deactivating" | "reloading" | "start" | "stop" | "restart" => {
|
"activating" | "deactivating" | "reloading" | "start" | "stop" | "restart" => {
|
||||||
// For user-stopped services that are transitioning, keep them as OK during transition
|
debug!("Service '{}' is transitioning - treating as Pending", service_name);
|
||||||
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
|
Status::Pending
|
||||||
debug!("Service '{}' is transitioning but was user-stopped - treating as OK", service_name);
|
|
||||||
Status::Ok
|
|
||||||
} else {
|
|
||||||
Status::Pending
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
_ => Status::Unknown,
|
_ => Status::Unknown,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use cm_dashboard_shared::{MessageEnvelope, MetricMessage};
|
use cm_dashboard_shared::{AgentData, MessageEnvelope};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use zmq::{Context, Socket, SocketType};
|
use zmq::{Context, Socket, SocketType};
|
||||||
|
|
||||||
@@ -43,17 +43,17 @@ impl ZmqHandler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish metrics message via ZMQ
|
|
||||||
pub async fn publish_metrics(&self, message: &MetricMessage) -> Result<()> {
|
/// Publish agent data via ZMQ
|
||||||
|
pub async fn publish_agent_data(&self, data: &AgentData) -> Result<()> {
|
||||||
debug!(
|
debug!(
|
||||||
"Publishing {} metrics for host {}",
|
"Publishing agent data for host {}",
|
||||||
message.metrics.len(),
|
data.hostname
|
||||||
message.hostname
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create message envelope
|
// Create message envelope for agent data
|
||||||
let envelope = MessageEnvelope::metrics(message.clone())
|
let envelope = MessageEnvelope::agent_data(data.clone())
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create message envelope: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to create agent data envelope: {}", e))?;
|
||||||
|
|
||||||
// Serialize envelope
|
// Serialize envelope
|
||||||
let serialized = serde_json::to_vec(&envelope)?;
|
let serialized = serde_json::to_vec(&envelope)?;
|
||||||
@@ -61,11 +61,10 @@ impl ZmqHandler {
|
|||||||
// Send via ZMQ
|
// Send via ZMQ
|
||||||
self.publisher.send(&serialized, 0)?;
|
self.publisher.send(&serialized, 0)?;
|
||||||
|
|
||||||
debug!("Published metrics message ({} bytes)", serialized.len());
|
debug!("Published agent data message ({} bytes)", serialized.len());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Try to receive a command (non-blocking)
|
/// Try to receive a command (non-blocking)
|
||||||
pub fn try_receive_command(&self) -> Result<Option<AgentCommand>> {
|
pub fn try_receive_command(&self) -> Result<Option<AgentCommand>> {
|
||||||
match self.command_receiver.recv_bytes(zmq::DONTWAIT) {
|
match self.command_receiver.recv_bytes(zmq::DONTWAIT) {
|
||||||
@@ -98,19 +97,4 @@ pub enum AgentCommand {
|
|||||||
ToggleCollector { name: String, enabled: bool },
|
ToggleCollector { name: String, enabled: bool },
|
||||||
/// Request status/health check
|
/// Request status/health check
|
||||||
Ping,
|
Ping,
|
||||||
/// Control systemd service
|
|
||||||
ServiceControl {
|
|
||||||
service_name: String,
|
|
||||||
action: ServiceAction,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service control actions
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub enum ServiceAction {
|
|
||||||
Start,
|
|
||||||
Stop,
|
|
||||||
Status,
|
|
||||||
UserStart, // User-initiated start (clears user-stopped flag)
|
|
||||||
UserStop, // User-initiated stop (marks as user-stopped)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ pub struct AgentConfig {
|
|||||||
pub notifications: NotificationConfig,
|
pub notifications: NotificationConfig,
|
||||||
pub status_aggregation: HostStatusConfig,
|
pub status_aggregation: HostStatusConfig,
|
||||||
pub collection_interval_seconds: u64,
|
pub collection_interval_seconds: u64,
|
||||||
/// List of metric names to exclude from email notifications
|
|
||||||
#[serde(default)]
|
|
||||||
pub exclude_email_metrics: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ZMQ communication configuration
|
/// ZMQ communication configuration
|
||||||
@@ -29,6 +26,9 @@ pub struct ZmqConfig {
|
|||||||
pub command_port: u16,
|
pub command_port: u16,
|
||||||
pub bind_address: String,
|
pub bind_address: String,
|
||||||
pub transmission_interval_seconds: u64,
|
pub transmission_interval_seconds: u64,
|
||||||
|
/// Heartbeat transmission interval in seconds for host connectivity detection
|
||||||
|
#[serde(default = "default_heartbeat_interval_seconds")]
|
||||||
|
pub heartbeat_interval_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collector configuration
|
/// Collector configuration
|
||||||
@@ -147,9 +147,23 @@ pub struct NotificationConfig {
|
|||||||
pub rate_limit_minutes: u64,
|
pub rate_limit_minutes: u64,
|
||||||
/// Email notification batching interval in seconds (default: 60)
|
/// Email notification batching interval in seconds (default: 60)
|
||||||
pub aggregation_interval_seconds: u64,
|
pub aggregation_interval_seconds: u64,
|
||||||
|
/// List of metric names to exclude from email notifications
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude_email_metrics: Vec<String>,
|
||||||
|
/// Path to maintenance mode file that suppresses email notifications when present
|
||||||
|
#[serde(default = "default_maintenance_mode_file")]
|
||||||
|
pub maintenance_mode_file: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_heartbeat_interval_seconds() -> u64 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_maintenance_mode_file() -> String {
|
||||||
|
"/tmp/cm-maintenance".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
loader::load_config(path)
|
loader::load_config(path)
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ impl MetricCollectionManager {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Collector {} failed: {}", timed_collector.name, e);
|
error!("Collector {} failed: {}", timed_collector.name, e);
|
||||||
|
// Update last_collection time even on failure to prevent immediate retries
|
||||||
|
timed_collector.last_collection = Some(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,6 @@ impl NotificationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_maintenance_mode(&self) -> bool {
|
fn is_maintenance_mode(&self) -> bool {
|
||||||
std::fs::metadata("/tmp/cm-maintenance").is_ok()
|
std::fs::metadata(&self.config.maintenance_mode_file).is_ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,14 +90,6 @@ impl UserStoppedServiceTracker {
|
|||||||
tracker
|
tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a service as user-stopped
|
|
||||||
pub fn mark_user_stopped(&mut self, service_name: &str) -> Result<()> {
|
|
||||||
info!("Marking service '{}' as user-stopped", service_name);
|
|
||||||
self.user_stopped_services.insert(service_name.to_string());
|
|
||||||
self.save_to_storage()?;
|
|
||||||
debug!("Service '{}' marked as user-stopped and saved to storage", service_name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear user-stopped flag for a service (when user starts it)
|
/// Clear user-stopped flag for a service (when user starts it)
|
||||||
pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> {
|
pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.58"
|
version = "0.1.134"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -9,24 +9,24 @@ use std::io;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::communication::{AgentCommand, ServiceAction, ZmqCommandSender, ZmqConsumer};
|
use crate::communication::{ZmqConsumer};
|
||||||
use crate::config::DashboardConfig;
|
use crate::config::DashboardConfig;
|
||||||
use crate::metrics::MetricStore;
|
use crate::metrics::MetricStore;
|
||||||
use crate::ui::{TuiApp, UiCommand};
|
use crate::ui::TuiApp;
|
||||||
|
|
||||||
pub struct Dashboard {
|
pub struct Dashboard {
|
||||||
zmq_consumer: ZmqConsumer,
|
zmq_consumer: ZmqConsumer,
|
||||||
zmq_command_sender: ZmqCommandSender,
|
|
||||||
metric_store: MetricStore,
|
metric_store: MetricStore,
|
||||||
tui_app: Option<TuiApp>,
|
tui_app: Option<TuiApp>,
|
||||||
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
|
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
|
||||||
headless: bool,
|
headless: bool,
|
||||||
|
raw_data: bool,
|
||||||
initial_commands_sent: std::collections::HashSet<String>,
|
initial_commands_sent: std::collections::HashSet<String>,
|
||||||
config: DashboardConfig,
|
config: DashboardConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dashboard {
|
impl Dashboard {
|
||||||
pub async fn new(config_path: Option<String>, headless: bool) -> Result<Self> {
|
pub async fn new(config_path: Option<String>, headless: bool, raw_data: bool) -> Result<Self> {
|
||||||
info!("Initializing dashboard");
|
info!("Initializing dashboard");
|
||||||
|
|
||||||
// Load configuration - try default path if not specified
|
// Load configuration - try default path if not specified
|
||||||
@@ -58,20 +58,9 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize ZMQ command sender
|
|
||||||
let zmq_command_sender = match ZmqCommandSender::new(&config.zmq) {
|
|
||||||
Ok(sender) => sender,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to initialize ZMQ command sender: {}", e);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to configured hosts from configuration
|
|
||||||
let hosts: Vec<String> = config.hosts.keys().cloned().collect();
|
|
||||||
|
|
||||||
// Try to connect to hosts but don't fail if none are available
|
// Try to connect to hosts but don't fail if none are available
|
||||||
match zmq_consumer.connect_to_predefined_hosts(&hosts).await {
|
match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await {
|
||||||
Ok(_) => info!("Successfully connected to ZMQ hosts"),
|
Ok(_) => info!("Successfully connected to ZMQ hosts"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -127,28 +116,24 @@ impl Dashboard {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
zmq_consumer,
|
zmq_consumer,
|
||||||
zmq_command_sender,
|
|
||||||
metric_store,
|
metric_store,
|
||||||
tui_app,
|
tui_app,
|
||||||
terminal,
|
terminal,
|
||||||
headless,
|
headless,
|
||||||
|
raw_data,
|
||||||
initial_commands_sent: std::collections::HashSet::new(),
|
initial_commands_sent: std::collections::HashSet::new(),
|
||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a command to a specific agent
|
|
||||||
pub async fn send_command(&mut self, hostname: &str, command: AgentCommand) -> Result<()> {
|
|
||||||
self.zmq_command_sender
|
|
||||||
.send_command(hostname, command)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(&mut self) -> Result<()> {
|
pub async fn run(&mut self) -> Result<()> {
|
||||||
info!("Starting dashboard main loop");
|
info!("Starting dashboard main loop");
|
||||||
|
|
||||||
let mut last_metrics_check = Instant::now();
|
let mut last_metrics_check = Instant::now();
|
||||||
let metrics_check_interval = Duration::from_millis(100); // Check for metrics every 100ms
|
let metrics_check_interval = Duration::from_millis(100); // Check for metrics every 100ms
|
||||||
|
let mut last_heartbeat_check = Instant::now();
|
||||||
|
let heartbeat_check_interval = Duration::from_secs(1); // Check for host connectivity every 1 second
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Handle terminal events (keyboard input) only if not headless
|
// Handle terminal events (keyboard input) only if not headless
|
||||||
@@ -158,16 +143,10 @@ impl Dashboard {
|
|||||||
match event::read() {
|
match event::read() {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
if let Some(ref mut tui_app) = self.tui_app {
|
if let Some(ref mut tui_app) = self.tui_app {
|
||||||
// Handle input and check for commands
|
// Handle input
|
||||||
match tui_app.handle_input(event) {
|
match tui_app.handle_input(event) {
|
||||||
Ok(Some(command)) => {
|
Ok(_) => {
|
||||||
// Execute the command
|
// Check if we should quit
|
||||||
if let Err(e) = self.execute_ui_command(command).await {
|
|
||||||
error!("Failed to execute UI command: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// No command, check if we should quit
|
|
||||||
if tui_app.should_quit() {
|
if tui_app.should_quit() {
|
||||||
info!("Quit requested, exiting dashboard");
|
info!("Quit requested, exiting dashboard");
|
||||||
break;
|
break;
|
||||||
@@ -191,50 +170,50 @@ impl Dashboard {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render UI immediately after handling input for responsive feedback
|
||||||
|
if let Some(ref mut terminal) = self.terminal {
|
||||||
|
if let Some(ref mut tui_app) = self.tui_app {
|
||||||
|
if let Err(e) = terminal.draw(|frame| {
|
||||||
|
tui_app.render(frame, &self.metric_store);
|
||||||
|
}) {
|
||||||
|
error!("Error rendering TUI after input: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for new metrics
|
// Check for new metrics
|
||||||
if last_metrics_check.elapsed() >= metrics_check_interval {
|
if last_metrics_check.elapsed() >= metrics_check_interval {
|
||||||
if let Ok(Some(metric_message)) = self.zmq_consumer.receive_metrics().await {
|
if let Ok(Some(agent_data)) = self.zmq_consumer.receive_agent_data().await {
|
||||||
debug!(
|
debug!(
|
||||||
"Received metrics from {}: {} metrics",
|
"Received agent data from {}",
|
||||||
metric_message.hostname,
|
agent_data.hostname
|
||||||
metric_message.metrics.len()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if this is the first time we've seen this host
|
// Track first contact with host (no command needed - agent sends data every 2s)
|
||||||
let is_new_host = !self
|
let is_new_host = !self
|
||||||
.initial_commands_sent
|
.initial_commands_sent
|
||||||
.contains(&metric_message.hostname);
|
.contains(&agent_data.hostname);
|
||||||
|
|
||||||
if is_new_host {
|
if is_new_host {
|
||||||
info!(
|
info!(
|
||||||
"First contact with host {}, sending initial CollectNow command",
|
"First contact with host {} - data will update automatically",
|
||||||
metric_message.hostname
|
agent_data.hostname
|
||||||
);
|
);
|
||||||
|
self.initial_commands_sent
|
||||||
// Send CollectNow command for immediate refresh
|
.insert(agent_data.hostname.clone());
|
||||||
if let Err(e) = self
|
|
||||||
.send_command(&metric_message.hostname, AgentCommand::CollectNow)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!(
|
|
||||||
"Failed to send initial CollectNow command to {}: {}",
|
|
||||||
metric_message.hostname, e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!(
|
|
||||||
"✓ Sent initial CollectNow command to {}",
|
|
||||||
metric_message.hostname
|
|
||||||
);
|
|
||||||
self.initial_commands_sent
|
|
||||||
.insert(metric_message.hostname.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update metric store
|
// Show raw data if requested (before processing)
|
||||||
self.metric_store
|
if self.raw_data {
|
||||||
.update_metrics(&metric_message.hostname, metric_message.metrics);
|
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);
|
||||||
|
|
||||||
// Check for agent version mismatches across hosts
|
// Check for agent version mismatches across hosts
|
||||||
if let Some((current_version, outdated_hosts)) = self.metric_store.get_version_mismatches() {
|
if let Some((current_version, outdated_hosts)) = self.metric_store.get_version_mismatches() {
|
||||||
@@ -243,14 +222,8 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update TUI with new hosts and metrics (only if not headless)
|
// Update TUI with new metrics (only if not headless)
|
||||||
if let Some(ref mut tui_app) = self.tui_app {
|
if let Some(ref mut tui_app) = self.tui_app {
|
||||||
let connected_hosts = self
|
|
||||||
.metric_store
|
|
||||||
.get_connected_hosts(Duration::from_secs(self.config.zmq.heartbeat_timeout_seconds));
|
|
||||||
|
|
||||||
|
|
||||||
tui_app.update_hosts(connected_hosts);
|
|
||||||
tui_app.update_metrics(&self.metric_store);
|
tui_app.update_metrics(&self.metric_store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +242,20 @@ impl Dashboard {
|
|||||||
last_metrics_check = Instant::now();
|
last_metrics_check = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for host connectivity changes (heartbeat timeouts) periodically
|
||||||
|
if last_heartbeat_check.elapsed() >= heartbeat_check_interval {
|
||||||
|
let timeout = Duration::from_secs(self.config.zmq.heartbeat_timeout_seconds);
|
||||||
|
|
||||||
|
// Clean up metrics for offline hosts
|
||||||
|
self.metric_store.cleanup_offline_hosts(timeout);
|
||||||
|
|
||||||
|
if let Some(ref mut tui_app) = self.tui_app {
|
||||||
|
let connected_hosts = self.metric_store.get_connected_hosts(timeout);
|
||||||
|
tui_app.update_hosts(connected_hosts);
|
||||||
|
}
|
||||||
|
last_heartbeat_check = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
// Render TUI (only if not headless)
|
// Render TUI (only if not headless)
|
||||||
if !self.headless {
|
if !self.headless {
|
||||||
if let Some(ref mut terminal) = self.terminal {
|
if let Some(ref mut terminal) = self.terminal {
|
||||||
@@ -291,33 +278,6 @@ impl Dashboard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a UI command by sending it to the appropriate agent
|
|
||||||
async fn execute_ui_command(&self, command: UiCommand) -> Result<()> {
|
|
||||||
match command {
|
|
||||||
UiCommand::ServiceStart { hostname, service_name } => {
|
|
||||||
info!("Sending user start command for service {} on {}", service_name, hostname);
|
|
||||||
let agent_command = AgentCommand::ServiceControl {
|
|
||||||
service_name: service_name.clone(),
|
|
||||||
action: ServiceAction::UserStart,
|
|
||||||
};
|
|
||||||
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
|
|
||||||
}
|
|
||||||
UiCommand::ServiceStop { hostname, service_name } => {
|
|
||||||
info!("Sending user stop command for service {} on {}", service_name, hostname);
|
|
||||||
let agent_command = AgentCommand::ServiceControl {
|
|
||||||
service_name: service_name.clone(),
|
|
||||||
action: ServiceAction::UserStop,
|
|
||||||
};
|
|
||||||
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
|
|
||||||
}
|
|
||||||
UiCommand::TriggerBackup { hostname } => {
|
|
||||||
info!("Trigger backup requested for {}", hostname);
|
|
||||||
// TODO: Implement backup trigger command
|
|
||||||
info!("Backup trigger not yet implemented");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,10 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use cm_dashboard_shared::{CommandOutputMessage, MessageEnvelope, MessageType, MetricMessage};
|
use cm_dashboard_shared::{AgentData, CommandOutputMessage, MessageEnvelope, MessageType};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use zmq::{Context, Socket, SocketType};
|
use zmq::{Context, Socket, SocketType};
|
||||||
|
|
||||||
use crate::config::ZmqConfig;
|
use crate::config::ZmqConfig;
|
||||||
|
|
||||||
/// Commands that can be sent to agents
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub enum AgentCommand {
|
|
||||||
/// Request immediate metric collection
|
|
||||||
CollectNow,
|
|
||||||
/// Change collection interval
|
|
||||||
SetInterval { seconds: u64 },
|
|
||||||
/// Enable/disable a collector
|
|
||||||
ToggleCollector { name: String, enabled: bool },
|
|
||||||
/// Request status/health check
|
|
||||||
Ping,
|
|
||||||
/// Control systemd service
|
|
||||||
ServiceControl {
|
|
||||||
service_name: String,
|
|
||||||
action: ServiceAction,
|
|
||||||
},
|
|
||||||
/// Rebuild NixOS system
|
|
||||||
SystemRebuild {
|
|
||||||
git_url: String,
|
|
||||||
git_branch: String,
|
|
||||||
working_dir: String,
|
|
||||||
api_key_file: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service control actions
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub enum ServiceAction {
|
|
||||||
Start,
|
|
||||||
Stop,
|
|
||||||
Status,
|
|
||||||
UserStart, // User-initiated start (clears user-stopped flag)
|
|
||||||
UserStop, // User-initiated stop (marks as user-stopped)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ZMQ consumer for receiving metrics from agents
|
/// ZMQ consumer for receiving metrics from agents
|
||||||
pub struct ZmqConsumer {
|
pub struct ZmqConsumer {
|
||||||
@@ -84,13 +50,14 @@ impl ZmqConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to predefined hosts
|
|
||||||
pub async fn connect_to_predefined_hosts(&mut self, hosts: &[String]) -> Result<()> {
|
/// Connect to predefined hosts using their configuration
|
||||||
|
pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> {
|
||||||
let default_port = self.config.subscriber_ports[0];
|
let default_port = self.config.subscriber_ports[0];
|
||||||
|
|
||||||
for hostname in hosts {
|
for (hostname, host_details) in hosts {
|
||||||
// Try to connect, but don't fail if some hosts are unreachable
|
// Try to connect using configured IP, but don't fail if some hosts are unreachable
|
||||||
if let Err(e) = self.connect_to_host(hostname, default_port).await {
|
if let Err(e) = self.connect_to_host_with_details(hostname, host_details, default_port).await {
|
||||||
warn!("Could not connect to {}: {}", hostname, e);
|
warn!("Could not connect to {}: {}", hostname, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +71,15 @@ impl ZmqConsumer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connect to a host using its configuration details
|
||||||
|
pub async fn connect_to_host_with_details(&mut self, hostname: &str, host_details: &crate::config::HostDetails, port: u16) -> Result<()> {
|
||||||
|
// Get primary connection IP only - no fallbacks
|
||||||
|
let primary_ip = host_details.get_connection_ip(hostname);
|
||||||
|
|
||||||
|
// Connect directly without fallback attempts
|
||||||
|
self.connect_to_host(&primary_ip, port).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Receive command output from any connected agent (non-blocking)
|
/// Receive command output from any connected agent (non-blocking)
|
||||||
pub async fn receive_command_output(&mut self) -> Result<Option<CommandOutputMessage>> {
|
pub async fn receive_command_output(&mut self) -> Result<Option<CommandOutputMessage>> {
|
||||||
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
|
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
|
||||||
@@ -141,9 +117,9 @@ impl ZmqConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive metrics from any connected agent (with timeout)
|
/// Receive agent data (non-blocking)
|
||||||
pub async fn receive_metrics(&mut self) -> Result<Option<MetricMessage>> {
|
pub async fn receive_agent_data(&mut self) -> Result<Option<AgentData>> {
|
||||||
match self.subscriber.recv_bytes(0) {
|
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
debug!("Received {} bytes from ZMQ", data.len());
|
debug!("Received {} bytes from ZMQ", data.len());
|
||||||
|
|
||||||
@@ -153,29 +129,27 @@ impl ZmqConsumer {
|
|||||||
|
|
||||||
// Check message type
|
// Check message type
|
||||||
match envelope.message_type {
|
match envelope.message_type {
|
||||||
MessageType::Metrics => {
|
MessageType::AgentData => {
|
||||||
let metrics = envelope
|
let agent_data = envelope
|
||||||
.decode_metrics()
|
.decode_agent_data()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to decode metrics: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to decode agent data: {}", e))?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Received {} metrics from {}",
|
"Received agent data from host {}",
|
||||||
metrics.metrics.len(),
|
agent_data.hostname
|
||||||
metrics.hostname
|
|
||||||
);
|
);
|
||||||
|
Ok(Some(agent_data))
|
||||||
Ok(Some(metrics))
|
|
||||||
}
|
}
|
||||||
MessageType::Heartbeat => {
|
MessageType::Heartbeat => {
|
||||||
debug!("Received heartbeat");
|
debug!("Received heartbeat");
|
||||||
Ok(None) // Don't return heartbeats as metrics
|
Ok(None) // Don't return heartbeats
|
||||||
}
|
}
|
||||||
MessageType::CommandOutput => {
|
MessageType::CommandOutput => {
|
||||||
debug!("Received command output (will be handled by receive_command_output)");
|
debug!("Received command output (will be handled by receive_command_output)");
|
||||||
Ok(None) // Command output handled by separate method
|
Ok(None) // Command output handled by separate method
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
debug!("Received non-metrics message: {:?}", envelope.message_type);
|
debug!("Received unsupported message: {:?}", envelope.message_type);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,44 +164,6 @@ impl ZmqConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ZMQ command sender for sending commands to agents
|
|
||||||
pub struct ZmqCommandSender {
|
|
||||||
context: Context,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ZmqCommandSender {
|
|
||||||
pub fn new(_config: &ZmqConfig) -> Result<Self> {
|
|
||||||
let context = Context::new();
|
|
||||||
|
|
||||||
info!("ZMQ command sender initialized");
|
|
||||||
|
|
||||||
Ok(Self { context })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a command to a specific agent
|
|
||||||
pub async fn send_command(&self, hostname: &str, command: AgentCommand) -> Result<()> {
|
|
||||||
// Create a new PUSH socket for this command (ZMQ best practice)
|
|
||||||
let socket = self.context.socket(SocketType::PUSH)?;
|
|
||||||
|
|
||||||
// Set socket options
|
|
||||||
socket.set_linger(1000)?; // Wait up to 1 second on close
|
|
||||||
socket.set_sndtimeo(5000)?; // 5 second send timeout
|
|
||||||
|
|
||||||
// Connect to agent's command port (6131)
|
|
||||||
let address = format!("tcp://{}:6131", hostname);
|
|
||||||
socket.connect(&address)?;
|
|
||||||
|
|
||||||
// Serialize command
|
|
||||||
let serialized = serde_json::to_vec(&command)?;
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
socket.send(&serialized, 0)?;
|
|
||||||
|
|
||||||
info!("Sent command {:?} to agent at {}", command, hostname);
|
|
||||||
|
|
||||||
// Socket will be automatically closed when dropped
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ fn default_heartbeat_timeout_seconds() -> u64 {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HostDetails {
|
pub struct HostDetails {
|
||||||
pub mac_address: Option<String>,
|
pub mac_address: Option<String>,
|
||||||
|
/// Primary IP address (local network)
|
||||||
|
pub ip: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl HostDetails {
|
||||||
|
/// Get the IP address for connection (uses ip field or hostname as fallback)
|
||||||
|
pub fn get_connection_ip(&self, hostname: &str) -> String {
|
||||||
|
self.ip.as_ref().unwrap_or(&hostname.to_string()).clone()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System configuration
|
/// System configuration
|
||||||
@@ -40,11 +51,12 @@ pub struct SystemConfig {
|
|||||||
pub nixos_config_api_key_file: Option<String>,
|
pub nixos_config_api_key_file: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SSH configuration for rebuild operations
|
/// SSH configuration for rebuild and backup operations
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SshConfig {
|
pub struct SshConfig {
|
||||||
pub rebuild_user: String,
|
pub rebuild_user: String,
|
||||||
pub rebuild_alias: String,
|
pub rebuild_cmd: String,
|
||||||
|
pub service_manage_cmd: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service log file configuration per host
|
/// Service log file configuration per host
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ struct Cli {
|
|||||||
/// Run in headless mode (no TUI, just logging)
|
/// Run in headless mode (no TUI, just logging)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
headless: bool,
|
headless: bool,
|
||||||
|
|
||||||
|
/// Show raw agent data in headless mode
|
||||||
|
#[arg(long)]
|
||||||
|
raw_data: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -86,7 +90,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and run dashboard
|
// Create and run dashboard
|
||||||
let mut dashboard = Dashboard::new(cli.config, cli.headless).await?;
|
let mut dashboard = Dashboard::new(cli.config, cli.headless, cli.raw_data).await?;
|
||||||
|
|
||||||
// Setup graceful shutdown
|
// Setup graceful shutdown
|
||||||
let ctrl_c = async {
|
let ctrl_c = async {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use cm_dashboard_shared::Metric;
|
use cm_dashboard_shared::AgentData;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -7,8 +7,8 @@ use super::MetricDataPoint;
|
|||||||
|
|
||||||
/// Central metric storage for the dashboard
|
/// Central metric storage for the dashboard
|
||||||
pub struct MetricStore {
|
pub struct MetricStore {
|
||||||
/// Current metrics: hostname -> metric_name -> metric
|
/// Current structured data: hostname -> AgentData
|
||||||
current_metrics: HashMap<String, HashMap<String, Metric>>,
|
current_agent_data: HashMap<String, AgentData>,
|
||||||
/// Historical metrics for trending
|
/// Historical metrics for trending
|
||||||
historical_metrics: HashMap<String, Vec<MetricDataPoint>>,
|
historical_metrics: HashMap<String, Vec<MetricDataPoint>>,
|
||||||
/// Last heartbeat timestamp per host
|
/// Last heartbeat timestamp per host
|
||||||
@@ -21,7 +21,7 @@ pub struct MetricStore {
|
|||||||
impl MetricStore {
|
impl MetricStore {
|
||||||
pub fn new(max_metrics_per_host: usize, history_retention_hours: u64) -> Self {
|
pub fn new(max_metrics_per_host: usize, history_retention_hours: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
current_metrics: HashMap::new(),
|
current_agent_data: HashMap::new(),
|
||||||
historical_metrics: HashMap::new(),
|
historical_metrics: HashMap::new(),
|
||||||
last_heartbeat: HashMap::new(),
|
last_heartbeat: HashMap::new(),
|
||||||
max_metrics_per_host,
|
max_metrics_per_host,
|
||||||
@@ -29,68 +29,43 @@ impl MetricStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update metrics for a specific host
|
|
||||||
pub fn update_metrics(&mut self, hostname: &str, metrics: Vec<Metric>) {
|
/// Store structured agent data directly
|
||||||
|
pub fn store_agent_data(&mut self, agent_data: AgentData) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
let hostname = agent_data.hostname.clone();
|
||||||
|
|
||||||
debug!("Updating {} metrics for host {}", metrics.len(), hostname);
|
debug!("Storing structured data for host {}", hostname);
|
||||||
|
|
||||||
// Get or create host entry
|
// Store the structured data directly
|
||||||
let host_metrics = self
|
self.current_agent_data.insert(hostname.clone(), agent_data);
|
||||||
.current_metrics
|
|
||||||
.entry(hostname.to_string())
|
|
||||||
.or_insert_with(HashMap::new);
|
|
||||||
|
|
||||||
// Get or create historical entry
|
// Update heartbeat timestamp
|
||||||
|
self.last_heartbeat.insert(hostname.clone(), now);
|
||||||
|
debug!("Updated heartbeat for host {}", hostname);
|
||||||
|
|
||||||
|
// Add to history
|
||||||
let host_history = self
|
let host_history = self
|
||||||
.historical_metrics
|
.historical_metrics
|
||||||
.entry(hostname.to_string())
|
.entry(hostname.clone())
|
||||||
.or_insert_with(Vec::new);
|
.or_insert_with(Vec::new);
|
||||||
|
host_history.push(MetricDataPoint { received_at: now });
|
||||||
|
|
||||||
// Update current metrics and add to history
|
// Cleanup old data
|
||||||
for metric in metrics {
|
self.cleanup_host_data(&hostname);
|
||||||
let metric_name = metric.name.clone();
|
|
||||||
|
|
||||||
// Store current metric
|
info!("Stored structured data for {}", hostname);
|
||||||
host_metrics.insert(metric_name.clone(), metric.clone());
|
|
||||||
|
|
||||||
// Add to history
|
|
||||||
host_history.push(MetricDataPoint { received_at: now });
|
|
||||||
|
|
||||||
// Track heartbeat metrics for connectivity detection
|
|
||||||
if metric_name == "agent_heartbeat" {
|
|
||||||
self.last_heartbeat.insert(hostname.to_string(), now);
|
|
||||||
debug!("Updated heartbeat for host {}", hostname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get metrics count before cleanup
|
|
||||||
let metrics_count = host_metrics.len();
|
|
||||||
|
|
||||||
// Cleanup old history and enforce limits
|
|
||||||
self.cleanup_host_data(hostname);
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Updated metrics for {}: {} current metrics",
|
|
||||||
hostname, metrics_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current metric for a specific host
|
|
||||||
pub fn get_metric(&self, hostname: &str, metric_name: &str) -> Option<&Metric> {
|
|
||||||
self.current_metrics.get(hostname)?.get(metric_name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Get all current metrics for a host as a vector
|
|
||||||
pub fn get_metrics_for_host(&self, hostname: &str) -> Vec<&Metric> {
|
|
||||||
if let Some(metrics_map) = self.current_metrics.get(hostname) {
|
/// Get current structured data for a host
|
||||||
metrics_map.values().collect()
|
pub fn get_agent_data(&self, hostname: &str) -> Option<&AgentData> {
|
||||||
} else {
|
self.current_agent_data.get(hostname)
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Get connected hosts (hosts with recent heartbeats)
|
/// Get connected hosts (hosts with recent heartbeats)
|
||||||
pub fn get_connected_hosts(&self, timeout: Duration) -> Vec<String> {
|
pub fn get_connected_hosts(&self, timeout: Duration) -> Vec<String> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -109,6 +84,28 @@ impl MetricStore {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clean up data for offline hosts
|
||||||
|
pub fn cleanup_offline_hosts(&mut self, timeout: Duration) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut hosts_to_cleanup = Vec::new();
|
||||||
|
|
||||||
|
// Find hosts that are offline (no recent heartbeat)
|
||||||
|
for (hostname, &last_heartbeat) in &self.last_heartbeat {
|
||||||
|
if now.duration_since(last_heartbeat) > timeout {
|
||||||
|
hosts_to_cleanup.push(hostname.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear data for offline hosts
|
||||||
|
for hostname in hosts_to_cleanup {
|
||||||
|
if let Some(_agent_data) = self.current_agent_data.remove(&hostname) {
|
||||||
|
info!("Cleared structured data for offline host: {}", hostname);
|
||||||
|
}
|
||||||
|
// Keep heartbeat timestamp for reconnection detection
|
||||||
|
// Don't remove from last_heartbeat to track when host was last seen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cleanup old data and enforce limits
|
/// Cleanup old data and enforce limits
|
||||||
fn cleanup_host_data(&mut self, hostname: &str) {
|
fn cleanup_host_data(&mut self, hostname: &str) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -134,12 +131,8 @@ impl MetricStore {
|
|||||||
pub fn get_agent_versions(&self) -> HashMap<String, String> {
|
pub fn get_agent_versions(&self) -> HashMap<String, String> {
|
||||||
let mut versions = HashMap::new();
|
let mut versions = HashMap::new();
|
||||||
|
|
||||||
for (hostname, metrics) in &self.current_metrics {
|
for (hostname, agent_data) in &self.current_agent_data {
|
||||||
if let Some(version_metric) = metrics.get("agent_version") {
|
versions.insert(hostname.clone(), agent_data.agent_version.clone());
|
||||||
if let cm_dashboard_shared::MetricValue::String(version) = &version_metric.value {
|
|
||||||
versions.insert(hostname.clone(), version.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
versions
|
versions
|
||||||
|
|||||||
@@ -16,26 +16,12 @@ pub mod widgets;
|
|||||||
|
|
||||||
use crate::config::DashboardConfig;
|
use crate::config::DashboardConfig;
|
||||||
use crate::metrics::MetricStore;
|
use crate::metrics::MetricStore;
|
||||||
use cm_dashboard_shared::{Metric, Status};
|
use cm_dashboard_shared::Status;
|
||||||
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
|
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
|
||||||
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
||||||
|
|
||||||
/// Commands that can be triggered from the UI
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum UiCommand {
|
|
||||||
ServiceStart { hostname: String, service_name: String },
|
|
||||||
ServiceStop { hostname: String, service_name: String },
|
|
||||||
TriggerBackup { hostname: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Types of commands for status tracking
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum CommandType {
|
|
||||||
ServiceStart,
|
|
||||||
ServiceStop,
|
|
||||||
BackupTrigger,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panel types for focus management
|
/// Panel types for focus management
|
||||||
|
|
||||||
@@ -48,14 +34,8 @@ pub struct HostWidgets {
|
|||||||
pub services_widget: ServicesWidget,
|
pub services_widget: ServicesWidget,
|
||||||
/// Backup widget state
|
/// Backup widget state
|
||||||
pub backup_widget: BackupWidget,
|
pub backup_widget: BackupWidget,
|
||||||
/// Scroll offsets for each panel
|
|
||||||
pub system_scroll_offset: usize,
|
|
||||||
pub services_scroll_offset: usize,
|
|
||||||
pub backup_scroll_offset: usize,
|
|
||||||
/// Last update time for this host
|
/// Last update time for this host
|
||||||
pub last_update: Option<Instant>,
|
pub last_update: Option<Instant>,
|
||||||
/// Pending service transitions for immediate visual feedback
|
|
||||||
pub pending_service_transitions: HashMap<String, (CommandType, String, Instant)>, // service_name -> (command_type, original_status, start_time)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostWidgets {
|
impl HostWidgets {
|
||||||
@@ -64,11 +44,7 @@ impl HostWidgets {
|
|||||||
system_widget: SystemWidget::new(),
|
system_widget: SystemWidget::new(),
|
||||||
services_widget: ServicesWidget::new(),
|
services_widget: ServicesWidget::new(),
|
||||||
backup_widget: BackupWidget::new(),
|
backup_widget: BackupWidget::new(),
|
||||||
system_scroll_offset: 0,
|
|
||||||
services_scroll_offset: 0,
|
|
||||||
backup_scroll_offset: 0,
|
|
||||||
last_update: None,
|
last_update: None,
|
||||||
pending_service_transitions: HashMap::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,10 +66,13 @@ pub struct TuiApp {
|
|||||||
user_navigated_away: bool,
|
user_navigated_away: bool,
|
||||||
/// Dashboard configuration
|
/// Dashboard configuration
|
||||||
config: DashboardConfig,
|
config: DashboardConfig,
|
||||||
|
/// Cached localhost hostname to avoid repeated system calls
|
||||||
|
localhost: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TuiApp {
|
impl TuiApp {
|
||||||
pub fn new(config: DashboardConfig) -> Self {
|
pub fn new(config: DashboardConfig) -> Self {
|
||||||
|
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
host_widgets: HashMap::new(),
|
host_widgets: HashMap::new(),
|
||||||
current_host: None,
|
current_host: None,
|
||||||
@@ -102,6 +81,7 @@ impl TuiApp {
|
|||||||
should_quit: false,
|
should_quit: false,
|
||||||
user_navigated_away: false,
|
user_navigated_away: false,
|
||||||
config,
|
config,
|
||||||
|
localhost,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort predefined hosts
|
// Sort predefined hosts
|
||||||
@@ -122,60 +102,17 @@ impl TuiApp {
|
|||||||
.or_insert_with(HostWidgets::new)
|
.or_insert_with(HostWidgets::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update widgets with metrics from store (only for current host)
|
/// Update widgets with structured data from store (only for current host)
|
||||||
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
||||||
|
|
||||||
// Check for rebuild completion by agent hash change
|
|
||||||
|
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
// Only update widgets if we have metrics for this host
|
// Get structured data for this host
|
||||||
let all_metrics = metric_store.get_metrics_for_host(&hostname);
|
if let Some(agent_data) = metric_store.get_agent_data(&hostname) {
|
||||||
if !all_metrics.is_empty() {
|
|
||||||
// Single pass metric categorization for better performance
|
|
||||||
let mut cpu_metrics = Vec::new();
|
|
||||||
let mut memory_metrics = Vec::new();
|
|
||||||
let mut service_metrics = Vec::new();
|
|
||||||
let mut backup_metrics = Vec::new();
|
|
||||||
let mut nixos_metrics = Vec::new();
|
|
||||||
let mut disk_metrics = Vec::new();
|
|
||||||
|
|
||||||
for metric in all_metrics {
|
|
||||||
if metric.name.starts_with("cpu_")
|
|
||||||
|| metric.name.contains("c_state_")
|
|
||||||
|| metric.name.starts_with("process_top_") {
|
|
||||||
cpu_metrics.push(metric);
|
|
||||||
} else if metric.name.starts_with("memory_") || metric.name.starts_with("disk_tmp_") {
|
|
||||||
memory_metrics.push(metric);
|
|
||||||
} else if metric.name.starts_with("service_") {
|
|
||||||
service_metrics.push(metric);
|
|
||||||
} else if metric.name.starts_with("backup_") {
|
|
||||||
backup_metrics.push(metric);
|
|
||||||
} else if metric.name == "system_nixos_build" || metric.name == "system_active_users" || metric.name == "agent_version" {
|
|
||||||
nixos_metrics.push(metric);
|
|
||||||
} else if metric.name.starts_with("disk_") {
|
|
||||||
disk_metrics.push(metric);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear completed transitions first
|
|
||||||
self.clear_completed_transitions(&hostname, &service_metrics);
|
|
||||||
|
|
||||||
// Now get host widgets and update them
|
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
|
|
||||||
// Collect all system metrics (CPU, memory, NixOS, disk/storage)
|
// Update all widgets with structured data directly
|
||||||
let mut system_metrics = cpu_metrics;
|
host_widgets.system_widget.update_from_agent_data(agent_data);
|
||||||
system_metrics.extend(memory_metrics);
|
host_widgets.services_widget.update_from_agent_data(agent_data);
|
||||||
system_metrics.extend(nixos_metrics);
|
host_widgets.backup_widget.update_from_agent_data(agent_data);
|
||||||
system_metrics.extend(disk_metrics);
|
|
||||||
|
|
||||||
host_widgets.system_widget.update_from_metrics(&system_metrics);
|
|
||||||
host_widgets
|
|
||||||
.services_widget
|
|
||||||
.update_from_metrics(&service_metrics);
|
|
||||||
host_widgets
|
|
||||||
.backup_widget
|
|
||||||
.update_from_metrics(&backup_metrics);
|
|
||||||
|
|
||||||
host_widgets.last_update = Some(Instant::now());
|
host_widgets.last_update = Some(Instant::now());
|
||||||
}
|
}
|
||||||
@@ -194,26 +131,17 @@ impl TuiApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep hosts that have pending transitions even if they're offline
|
|
||||||
for (hostname, host_widgets) in &self.host_widgets {
|
|
||||||
if !host_widgets.pending_service_transitions.is_empty() {
|
|
||||||
if !all_hosts.contains(hostname) {
|
|
||||||
all_hosts.push(hostname.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
all_hosts.sort();
|
all_hosts.sort();
|
||||||
self.available_hosts = all_hosts;
|
self.available_hosts = all_hosts;
|
||||||
|
|
||||||
// Get the current hostname (localhost) for auto-selection
|
// Get the current hostname (localhost) for auto-selection
|
||||||
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
||||||
if !self.available_hosts.is_empty() {
|
if !self.available_hosts.is_empty() {
|
||||||
if self.available_hosts.contains(&localhost) && !self.user_navigated_away {
|
if self.available_hosts.contains(&self.localhost) && !self.user_navigated_away {
|
||||||
// Localhost is available and user hasn't navigated away - switch to it
|
// Localhost is available and user hasn't navigated away - switch to it
|
||||||
self.current_host = Some(localhost.clone());
|
self.current_host = Some(self.localhost.clone());
|
||||||
// Find the actual index of localhost in the sorted list
|
// Find the actual index of localhost in the sorted list
|
||||||
self.host_index = self.available_hosts.iter().position(|h| h == &localhost).unwrap_or(0);
|
self.host_index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0);
|
||||||
} else if self.current_host.is_none() {
|
} else if self.current_host.is_none() {
|
||||||
// No current host - select first available (which is localhost if available)
|
// No current host - select first available (which is localhost if available)
|
||||||
self.current_host = Some(self.available_hosts[0].clone());
|
self.current_host = Some(self.available_hosts[0].clone());
|
||||||
@@ -233,7 +161,7 @@ impl TuiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle keyboard input
|
/// Handle keyboard input
|
||||||
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
|
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
||||||
if let Event::Key(key) = event {
|
if let Event::Key(key) = event {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => {
|
KeyCode::Char('q') => {
|
||||||
@@ -248,13 +176,15 @@ impl TuiApp {
|
|||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
// System rebuild command - works on any panel for current host
|
// System rebuild command - works on any panel for current host
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
// Create command that shows logo, rebuilds, and waits for user input
|
// Create command that shows logo, rebuilds, and waits for user input
|
||||||
let logo_and_rebuild = format!(
|
let logo_and_rebuild = format!(
|
||||||
"bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {}\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
|
"echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
||||||
hostname,
|
hostname,
|
||||||
|
connection_ip,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
hostname,
|
connection_ip,
|
||||||
self.config.ssh.rebuild_alias
|
self.config.ssh.rebuild_cmd
|
||||||
);
|
);
|
||||||
|
|
||||||
std::process::Command::new("tmux")
|
std::process::Command::new("tmux")
|
||||||
@@ -267,29 +197,41 @@ impl TuiApp {
|
|||||||
.ok(); // Ignore errors, tmux will handle them
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('B') => {
|
||||||
// Service start command
|
// Backup command - works on any panel for current host
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
|
// Create command that shows logo, runs backup, and waits for user input
|
||||||
}
|
let logo_and_backup = format!(
|
||||||
}
|
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
||||||
}
|
|
||||||
KeyCode::Char('S') => {
|
|
||||||
// Service stop command
|
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
||||||
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
|
|
||||||
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('J') => {
|
|
||||||
// Show service logs via journalctl in tmux split window
|
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
||||||
let journalctl_command = format!(
|
|
||||||
"bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"",
|
|
||||||
self.config.ssh.rebuild_user,
|
|
||||||
hostname,
|
hostname,
|
||||||
|
connection_ip,
|
||||||
|
self.config.ssh.rebuild_user,
|
||||||
|
connection_ip,
|
||||||
|
format!("{} start borgbackup", self.config.ssh.service_manage_cmd)
|
||||||
|
);
|
||||||
|
|
||||||
|
std::process::Command::new("tmux")
|
||||||
|
.arg("split-window")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("-p")
|
||||||
|
.arg("30")
|
||||||
|
.arg(&logo_and_backup)
|
||||||
|
.spawn()
|
||||||
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
// Service start command via SSH with progress display
|
||||||
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
|
let service_start_command = format!(
|
||||||
|
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"",
|
||||||
|
service_name,
|
||||||
|
hostname,
|
||||||
|
self.config.ssh.rebuild_user,
|
||||||
|
connection_ip,
|
||||||
|
self.config.ssh.service_manage_cmd,
|
||||||
service_name
|
service_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -298,41 +240,55 @@ impl TuiApp {
|
|||||||
.arg("-v")
|
.arg("-v")
|
||||||
.arg("-p")
|
.arg("-p")
|
||||||
.arg("30")
|
.arg("30")
|
||||||
.arg(&journalctl_command)
|
.arg(&service_start_command)
|
||||||
|
.spawn()
|
||||||
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('S') => {
|
||||||
|
// Service stop command via SSH with progress display
|
||||||
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
|
let service_stop_command = format!(
|
||||||
|
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"",
|
||||||
|
service_name,
|
||||||
|
hostname,
|
||||||
|
self.config.ssh.rebuild_user,
|
||||||
|
connection_ip,
|
||||||
|
self.config.ssh.service_manage_cmd,
|
||||||
|
service_name
|
||||||
|
);
|
||||||
|
|
||||||
|
std::process::Command::new("tmux")
|
||||||
|
.arg("split-window")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("-p")
|
||||||
|
.arg("30")
|
||||||
|
.arg(&service_stop_command)
|
||||||
.spawn()
|
.spawn()
|
||||||
.ok(); // Ignore errors, tmux will handle them
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('L') => {
|
KeyCode::Char('L') => {
|
||||||
// Show custom service log file in tmux split window
|
// Show service logs via service-manage script in tmux split window
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
// Check if this service has a custom log file configured
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
if let Some(host_logs) = self.config.service_logs.get(&hostname) {
|
let logs_command = format!(
|
||||||
if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) {
|
"ssh -tt {}@{} '{} logs {}'",
|
||||||
let tail_command = format!(
|
self.config.ssh.rebuild_user,
|
||||||
"bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"",
|
connection_ip,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.service_manage_cmd,
|
||||||
hostname,
|
service_name
|
||||||
log_config.log_file_path
|
);
|
||||||
);
|
|
||||||
|
std::process::Command::new("tmux")
|
||||||
std::process::Command::new("tmux")
|
.arg("split-window")
|
||||||
.arg("split-window")
|
.arg("-v")
|
||||||
.arg("-v")
|
.arg("-p")
|
||||||
.arg("-p")
|
.arg("30")
|
||||||
.arg("30")
|
.arg(&logs_command)
|
||||||
.arg(&tail_command)
|
.spawn()
|
||||||
.spawn()
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
.ok(); // Ignore errors, tmux will handle them
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('b') => {
|
|
||||||
// Trigger backup
|
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
|
||||||
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
|
|
||||||
return Ok(Some(UiCommand::TriggerBackup { hostname }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('w') => {
|
KeyCode::Char('w') => {
|
||||||
@@ -362,6 +318,27 @@ impl TuiApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('t') => {
|
||||||
|
// Open SSH terminal session in tmux window
|
||||||
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
|
let ssh_command = format!(
|
||||||
|
"echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}",
|
||||||
|
hostname,
|
||||||
|
self.config.ssh.rebuild_user,
|
||||||
|
connection_ip
|
||||||
|
);
|
||||||
|
|
||||||
|
std::process::Command::new("tmux")
|
||||||
|
.arg("split-window")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("-p")
|
||||||
|
.arg("30") // Use 30% like other commands
|
||||||
|
.arg(&ssh_command)
|
||||||
|
.spawn()
|
||||||
|
.ok(); // Ignore errors, tmux will handle them
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
// Tab cycles to next host
|
// Tab cycles to next host
|
||||||
self.navigate_host(1);
|
self.navigate_host(1);
|
||||||
@@ -387,7 +364,7 @@ impl TuiApp {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate between hosts
|
/// Navigate between hosts
|
||||||
@@ -410,9 +387,8 @@ impl TuiApp {
|
|||||||
self.current_host = Some(self.available_hosts[self.host_index].clone());
|
self.current_host = Some(self.available_hosts[self.host_index].clone());
|
||||||
|
|
||||||
// Check if user navigated away from localhost
|
// Check if user navigated away from localhost
|
||||||
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
||||||
if let Some(ref current) = self.current_host {
|
if let Some(ref current) = self.current_host {
|
||||||
if current != &localhost {
|
if current != &self.localhost {
|
||||||
self.user_navigated_away = true;
|
self.user_navigated_away = true;
|
||||||
} else {
|
} else {
|
||||||
self.user_navigated_away = false; // User navigated back to localhost
|
self.user_navigated_away = false; // User navigated back to localhost
|
||||||
@@ -442,86 +418,8 @@ impl TuiApp {
|
|||||||
self.should_quit
|
self.should_quit
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current service status for state-aware command validation
|
|
||||||
fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option<String> {
|
|
||||||
if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
|
||||||
return host_widgets.services_widget.get_service_status(service_name);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start command execution with immediate visual feedback
|
|
||||||
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool {
|
|
||||||
// Get current service status to validate command
|
|
||||||
let current_status = self.get_current_service_status(hostname, &target);
|
|
||||||
|
|
||||||
// Validate if command makes sense for current state
|
|
||||||
let should_execute = match (&command_type, current_status.as_deref()) {
|
|
||||||
(CommandType::ServiceStart, Some("inactive") | Some("failed") | Some("dead")) => true,
|
|
||||||
(CommandType::ServiceStop, Some("active")) => true,
|
|
||||||
(CommandType::ServiceStart, Some("active")) => {
|
|
||||||
// Already running - don't execute
|
|
||||||
false
|
|
||||||
},
|
|
||||||
(CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => {
|
|
||||||
// Already stopped - don't execute
|
|
||||||
false
|
|
||||||
},
|
|
||||||
(_, None) => {
|
|
||||||
// Unknown service state - allow command to proceed
|
|
||||||
true
|
|
||||||
},
|
|
||||||
_ => true, // Default: allow other combinations
|
|
||||||
};
|
|
||||||
|
|
||||||
// ALWAYS store the pending transition for immediate visual feedback, even if we don't execute
|
|
||||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
|
||||||
host_widgets.pending_service_transitions.insert(
|
|
||||||
target.clone(),
|
|
||||||
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()), Instant::now())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
should_execute
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear pending transitions when real status updates arrive or timeout
|
|
||||||
fn clear_completed_transitions(&mut self, hostname: &str, service_metrics: &[&Metric]) {
|
|
||||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
|
||||||
let mut completed_services = Vec::new();
|
|
||||||
|
|
||||||
// Check each pending transition to see if real status has changed
|
|
||||||
for (service_name, (command_type, original_status, _start_time)) in &host_widgets.pending_service_transitions {
|
|
||||||
|
|
||||||
// Look for status metric for this service
|
|
||||||
for metric in service_metrics {
|
|
||||||
if metric.name == format!("service_{}_status", service_name) {
|
|
||||||
let new_status = metric.value.as_string();
|
|
||||||
|
|
||||||
// Check if status has changed from original (command completed)
|
|
||||||
if &new_status != original_status {
|
|
||||||
// Verify it changed in the expected direction
|
|
||||||
let expected_change = match command_type {
|
|
||||||
CommandType::ServiceStart => &new_status == "active",
|
|
||||||
CommandType::ServiceStop => &new_status != "active",
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if expected_change {
|
|
||||||
completed_services.push(service_name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove completed transitions
|
|
||||||
for service_name in completed_services {
|
|
||||||
host_widgets.pending_service_transitions.remove(&service_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -556,6 +454,21 @@ impl TuiApp {
|
|||||||
])
|
])
|
||||||
.split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar)
|
.split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar)
|
||||||
|
|
||||||
|
// Check if current host is offline
|
||||||
|
let current_host_offline = if let Some(hostname) = self.current_host.clone() {
|
||||||
|
self.calculate_host_status(&hostname, metric_store) == Status::Offline
|
||||||
|
} else {
|
||||||
|
true // No host selected is considered offline
|
||||||
|
};
|
||||||
|
|
||||||
|
// If host is offline, render wake-up message instead of panels
|
||||||
|
if current_host_offline {
|
||||||
|
self.render_offline_host_message(frame, main_chunks[1]);
|
||||||
|
self.render_btop_title(frame, main_chunks[0], metric_store);
|
||||||
|
self.render_statusbar(frame, main_chunks[2]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if backup panel should be shown
|
// Check if backup panel should be shown
|
||||||
let show_backup = if let Some(hostname) = self.current_host.clone() {
|
let show_backup = if let Some(hostname) = self.current_host.clone() {
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
@@ -594,14 +507,10 @@ impl TuiApp {
|
|||||||
// Render services widget for current host
|
// Render services widget for current host
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
let is_focused = true; // Always show service selection
|
let is_focused = true; // Always show service selection
|
||||||
let (scroll_offset, pending_transitions) = {
|
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
||||||
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone())
|
|
||||||
};
|
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
host_widgets
|
host_widgets
|
||||||
.services_widget
|
.services_widget
|
||||||
.render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // Services takes full right side
|
.render(frame, content_chunks[1], is_focused); // Services takes full right side
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render statusbar at the bottom
|
// Render statusbar at the bottom
|
||||||
@@ -639,12 +548,13 @@ impl TuiApp {
|
|||||||
// Split the title bar into left and right sections
|
// Split the title bar into left and right sections
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Length(15), Constraint::Min(0)])
|
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Left side: "cm-dashboard" text
|
// Left side: "cm-dashboard" text with version
|
||||||
|
let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
||||||
let left_span = Span::styled(
|
let left_span = Span::styled(
|
||||||
" cm-dashboard",
|
&title_text,
|
||||||
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
||||||
);
|
);
|
||||||
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
||||||
@@ -703,47 +613,14 @@ impl TuiApp {
|
|||||||
frame.render_widget(host_title, chunks[1]);
|
frame.render_widget(host_title, chunks[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate overall status for a host based on its metrics
|
/// Calculate overall status for a host based on its structured data
|
||||||
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
|
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
|
||||||
let metrics = metric_store.get_metrics_for_host(hostname);
|
// Check if we have structured data for this host
|
||||||
|
if let Some(_agent_data) = metric_store.get_agent_data(hostname) {
|
||||||
if metrics.is_empty() {
|
// Return OK since we have data
|
||||||
return Status::Offline;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check if we have the aggregated host status summary from the agent
|
|
||||||
if let Some(host_summary_metric) = metric_store.get_metric(hostname, "host_status_summary") {
|
|
||||||
return host_summary_metric.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to old aggregation logic with proper Pending handling
|
|
||||||
let mut has_critical = false;
|
|
||||||
let mut has_warning = false;
|
|
||||||
let mut has_pending = false;
|
|
||||||
let mut ok_count = 0;
|
|
||||||
|
|
||||||
for metric in &metrics {
|
|
||||||
match metric.status {
|
|
||||||
Status::Critical => has_critical = true,
|
|
||||||
Status::Warning => has_warning = true,
|
|
||||||
Status::Pending => has_pending = true,
|
|
||||||
Status::Ok => ok_count += 1,
|
|
||||||
Status::Unknown => {}, // Ignore unknown for aggregation
|
|
||||||
Status::Offline => {}, // Ignore offline for aggregation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority order: Critical > Warning > Pending > Ok > Unknown
|
|
||||||
if has_critical {
|
|
||||||
Status::Critical
|
|
||||||
} else if has_warning {
|
|
||||||
Status::Warning
|
|
||||||
} else if has_pending {
|
|
||||||
Status::Pending
|
|
||||||
} else if ok_count > 0 {
|
|
||||||
Status::Ok
|
Status::Ok
|
||||||
} else {
|
} else {
|
||||||
Status::Unknown
|
Status::Offline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,9 +644,10 @@ impl TuiApp {
|
|||||||
shortcuts.push("Tab: Host".to_string());
|
shortcuts.push("Tab: Host".to_string());
|
||||||
shortcuts.push("↑↓/jk: Select".to_string());
|
shortcuts.push("↑↓/jk: Select".to_string());
|
||||||
shortcuts.push("r: Rebuild".to_string());
|
shortcuts.push("r: Rebuild".to_string());
|
||||||
|
shortcuts.push("B: Backup".to_string());
|
||||||
shortcuts.push("s/S: Start/Stop".to_string());
|
shortcuts.push("s/S: Start/Stop".to_string());
|
||||||
shortcuts.push("J: Logs".to_string());
|
shortcuts.push("L: Logs".to_string());
|
||||||
shortcuts.push("L: Custom".to_string());
|
shortcuts.push("t: Terminal".to_string());
|
||||||
shortcuts.push("w: Wake".to_string());
|
shortcuts.push("w: Wake".to_string());
|
||||||
|
|
||||||
// Always show quit
|
// Always show quit
|
||||||
@@ -784,12 +662,10 @@ impl TuiApp {
|
|||||||
frame.render_widget(system_block, area);
|
frame.render_widget(system_block, area);
|
||||||
// Get current host widgets, create if none exist
|
// Get current host widgets, create if none exist
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
let scroll_offset = {
|
// Clone the config to avoid borrowing issues
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let config = self.config.clone();
|
||||||
host_widgets.system_scroll_offset
|
|
||||||
};
|
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset, &hostname);
|
host_widgets.system_widget.render(frame, inner_area, &hostname, Some(&config));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,16 +676,92 @@ impl TuiApp {
|
|||||||
|
|
||||||
// Get current host widgets for backup widget
|
// Get current host widgets for backup widget
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
let scroll_offset = {
|
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
||||||
host_widgets.backup_scroll_offset
|
|
||||||
};
|
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
host_widgets.backup_widget.render_with_scroll(frame, inner_area, scroll_offset);
|
host_widgets.backup_widget.render(frame, inner_area);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render offline host message with wake-up option
|
||||||
|
fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
use ratatui::layout::Alignment;
|
||||||
|
use ratatui::style::Modifier;
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
// Get hostname for message
|
||||||
|
let hostname = self.current_host.as_ref()
|
||||||
|
.map(|h| h.as_str())
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
|
||||||
|
// Check if host has MAC address for wake-on-LAN
|
||||||
|
let has_mac = self.current_host.as_ref()
|
||||||
|
.and_then(|hostname| self.config.hosts.get(hostname))
|
||||||
|
.and_then(|details| details.mac_address.as_ref())
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
// Create message content
|
||||||
|
let mut lines = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("Host '{}' is offline", hostname),
|
||||||
|
Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
];
|
||||||
|
|
||||||
|
if has_mac {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Press 'w' to wake up host",
|
||||||
|
Style::default().fg(Theme::primary_text()).add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"No MAC address configured - cannot wake up",
|
||||||
|
Style::default().fg(Theme::muted_text()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create centered message
|
||||||
|
let message = Paragraph::new(lines)
|
||||||
|
.block(Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Theme::muted_text()))
|
||||||
|
.title(" Offline Host ")
|
||||||
|
.title_style(Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD)))
|
||||||
|
.style(Style::default().bg(Theme::background()).fg(Theme::primary_text()))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
// Center the message in the available area
|
||||||
|
let popup_area = ratatui::layout::Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
])
|
||||||
|
.split(area)[1];
|
||||||
|
|
||||||
|
let popup_area = ratatui::layout::Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
])
|
||||||
|
.split(popup_area)[1];
|
||||||
|
|
||||||
|
frame.render_widget(message, popup_area);
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6]
|
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6]
|
||||||
|
/// Get the connection IP for a hostname based on host configuration
|
||||||
|
fn get_connection_ip(&self, hostname: &str) -> String {
|
||||||
|
if let Some(host_details) = self.config.hosts.get(hostname) {
|
||||||
|
host_details.get_connection_ip(hostname)
|
||||||
|
} else {
|
||||||
|
hostname.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
|
fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
|
||||||
let parts: Vec<&str> = mac_str.split(':').collect();
|
let parts: Vec<&str> = mac_str.split(':').collect();
|
||||||
if parts.len() != 6 {
|
if parts.len() != 6 {
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ impl Theme {
|
|||||||
pub fn status_color(status: Status) -> Color {
|
pub fn status_color(status: Status) -> Color {
|
||||||
match status {
|
match status {
|
||||||
Status::Ok => Self::success(),
|
Status::Ok => Self::success(),
|
||||||
|
Status::Inactive => Self::muted_text(), // Gray for inactive services in service list
|
||||||
Status::Pending => Self::highlight(), // Blue for pending
|
Status::Pending => Self::highlight(), // Blue for pending
|
||||||
Status::Warning => Self::warning(),
|
Status::Warning => Self::warning(),
|
||||||
Status::Critical => Self::error(),
|
Status::Critical => Self::error(),
|
||||||
@@ -243,6 +244,7 @@ impl StatusIcons {
|
|||||||
pub fn get_icon(status: Status) -> &'static str {
|
pub fn get_icon(status: Status) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
Status::Ok => "●",
|
Status::Ok => "●",
|
||||||
|
Status::Inactive => "○", // Empty circle for inactive services
|
||||||
Status::Pending => "◉", // Hollow circle for pending
|
Status::Pending => "◉", // Hollow circle for pending
|
||||||
Status::Warning => "◐",
|
Status::Warning => "◐",
|
||||||
Status::Critical => "!",
|
Status::Critical => "!",
|
||||||
@@ -256,6 +258,7 @@ impl StatusIcons {
|
|||||||
let icon = Self::get_icon(status);
|
let icon = Self::get_icon(status);
|
||||||
let status_color = match status {
|
let status_color = match status {
|
||||||
Status::Ok => Theme::success(), // Green
|
Status::Ok => Theme::success(), // Green
|
||||||
|
Status::Inactive => Theme::muted_text(), // Gray for inactive services
|
||||||
Status::Pending => Theme::highlight(), // Blue
|
Status::Pending => Theme::highlight(), // Blue
|
||||||
Status::Warning => Theme::warning(), // Yellow
|
Status::Warning => Theme::warning(), // Yellow
|
||||||
Status::Critical => Theme::error(), // Red
|
Status::Critical => Theme::error(), // Red
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use cm_dashboard_shared::{Metric, Status};
|
use cm_dashboard_shared::{Metric, Status};
|
||||||
|
use super::Widget;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
@@ -6,7 +7,6 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::Widget;
|
|
||||||
use crate::ui::theme::{StatusIcons, Typography};
|
use crate::ui::theme::{StatusIcons, Typography};
|
||||||
|
|
||||||
/// Backup widget displaying backup status, services, and repository information
|
/// Backup widget displaying backup status, services, and repository information
|
||||||
@@ -30,6 +30,8 @@ pub struct BackupWidget {
|
|||||||
backup_disk_product_name: Option<String>,
|
backup_disk_product_name: Option<String>,
|
||||||
/// Backup disk serial number from SMART data
|
/// Backup disk serial number from SMART data
|
||||||
backup_disk_serial_number: Option<String>,
|
backup_disk_serial_number: Option<String>,
|
||||||
|
/// Backup disk wear percentage from SMART data
|
||||||
|
backup_disk_wear_percent: Option<f32>,
|
||||||
/// Backup disk filesystem label
|
/// Backup disk filesystem label
|
||||||
backup_disk_filesystem_label: Option<String>,
|
backup_disk_filesystem_label: Option<String>,
|
||||||
/// Number of completed services
|
/// Number of completed services
|
||||||
@@ -65,6 +67,7 @@ impl BackupWidget {
|
|||||||
backup_disk_used_gb: None,
|
backup_disk_used_gb: None,
|
||||||
backup_disk_product_name: None,
|
backup_disk_product_name: None,
|
||||||
backup_disk_serial_number: None,
|
backup_disk_serial_number: None,
|
||||||
|
backup_disk_wear_percent: None,
|
||||||
backup_disk_filesystem_label: None,
|
backup_disk_filesystem_label: None,
|
||||||
services_completed_count: None,
|
services_completed_count: None,
|
||||||
services_failed_count: None,
|
services_failed_count: None,
|
||||||
@@ -134,6 +137,23 @@ impl BackupWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for BackupWidget {
|
impl Widget for BackupWidget {
|
||||||
|
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||||
|
self.has_data = true;
|
||||||
|
|
||||||
|
let backup = &agent_data.backup;
|
||||||
|
self.overall_status = Status::Ok;
|
||||||
|
|
||||||
|
if let Some(size) = backup.total_size_gb {
|
||||||
|
self.total_repo_size_gb = Some(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last_run) = backup.last_run {
|
||||||
|
self.last_run_timestamp = Some(last_run as i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackupWidget {
|
||||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||||
debug!("Backup widget updating with {} metrics", metrics.len());
|
debug!("Backup widget updating with {} metrics", metrics.len());
|
||||||
for metric in metrics {
|
for metric in metrics {
|
||||||
@@ -197,6 +217,9 @@ impl Widget for BackupWidget {
|
|||||||
"backup_disk_serial_number" => {
|
"backup_disk_serial_number" => {
|
||||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
self.backup_disk_serial_number = Some(metric.value.as_string());
|
||||||
}
|
}
|
||||||
|
"backup_disk_wear_percent" => {
|
||||||
|
self.backup_disk_wear_percent = metric.value.as_f32();
|
||||||
|
}
|
||||||
"backup_disk_filesystem_label" => {
|
"backup_disk_filesystem_label" => {
|
||||||
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
||||||
}
|
}
|
||||||
@@ -285,8 +308,8 @@ impl Widget for BackupWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BackupWidget {
|
impl BackupWidget {
|
||||||
/// Render with scroll offset support
|
/// Render backup widget
|
||||||
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) {
|
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
// Latest backup section
|
// Latest backup section
|
||||||
@@ -328,21 +351,31 @@ impl BackupWidget {
|
|||||||
);
|
);
|
||||||
lines.push(ratatui::text::Line::from(disk_spans));
|
lines.push(ratatui::text::Line::from(disk_spans));
|
||||||
|
|
||||||
// Serial number as sub-item
|
// Collect sub-items to determine tree structure
|
||||||
|
let mut sub_items = Vec::new();
|
||||||
|
|
||||||
if let Some(serial) = &self.backup_disk_serial_number {
|
if let Some(serial) = &self.backup_disk_serial_number {
|
||||||
lines.push(ratatui::text::Line::from(vec![
|
sub_items.push(format!("S/N: {}", serial));
|
||||||
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
|
|
||||||
ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary())
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage as sub-item
|
if let Some(wear) = self.backup_disk_wear_percent {
|
||||||
|
sub_items.push(format!("Wear: {:.0}%", wear));
|
||||||
|
}
|
||||||
|
|
||||||
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
||||||
let used_str = Self::format_size_with_proper_units(used);
|
let used_str = Self::format_size_with_proper_units(used);
|
||||||
let total_str = Self::format_size_with_proper_units(total);
|
let total_str = Self::format_size_with_proper_units(total);
|
||||||
|
sub_items.push(format!("Usage: {}/{}", used_str, total_str));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render sub-items with proper tree structure
|
||||||
|
let num_items = sub_items.len();
|
||||||
|
for (i, item) in sub_items.into_iter().enumerate() {
|
||||||
|
let is_last = i == num_items - 1;
|
||||||
|
let tree_char = if is_last { " └─ " } else { " ├─ " };
|
||||||
lines.push(ratatui::text::Line::from(vec![
|
lines.push(ratatui::text::Line::from(vec![
|
||||||
ratatui::text::Span::styled(" └─ ", Typography::tree()),
|
ratatui::text::Span::styled(tree_char, Typography::tree()),
|
||||||
ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary())
|
ratatui::text::Span::styled(item, Typography::secondary())
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,42 +399,20 @@ impl BackupWidget {
|
|||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
let available_height = area.height as usize;
|
let available_height = area.height as usize;
|
||||||
|
|
||||||
// Calculate scroll boundaries
|
// Show only what fits, with "X more below" if needed
|
||||||
let max_scroll = if total_lines > available_height {
|
if total_lines > available_height {
|
||||||
total_lines - available_height
|
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
|
||||||
} else {
|
|
||||||
total_lines.saturating_sub(1)
|
|
||||||
};
|
|
||||||
let effective_scroll = scroll_offset.min(max_scroll);
|
|
||||||
|
|
||||||
// Apply scrolling if needed
|
|
||||||
if scroll_offset > 0 || total_lines > available_height {
|
|
||||||
let mut visible_lines: Vec<_> = lines
|
let mut visible_lines: Vec<_> = lines
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(effective_scroll)
|
.take(lines_for_content)
|
||||||
.take(available_height)
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Add scroll indicator if there are hidden lines
|
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||||
if total_lines > available_height {
|
if hidden_below > 0 {
|
||||||
let hidden_above = effective_scroll;
|
let more_line = ratatui::text::Line::from(vec![
|
||||||
let hidden_below = total_lines.saturating_sub(effective_scroll + available_height);
|
ratatui::text::Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
||||||
|
]);
|
||||||
if (hidden_above > 0 || hidden_below > 0) && !visible_lines.is_empty() {
|
visible_lines.push(more_line);
|
||||||
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
|
||||||
format!("... {} above, {} below", hidden_above, hidden_below)
|
|
||||||
} else if hidden_above > 0 {
|
|
||||||
format!("... {} more above", hidden_above)
|
|
||||||
} else {
|
|
||||||
format!("... {} more below", hidden_below)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Replace last line with scroll indicator
|
|
||||||
visible_lines.pop();
|
|
||||||
visible_lines.push(ratatui::text::Line::from(vec![
|
|
||||||
ratatui::text::Span::styled(scroll_text, Typography::muted())
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines));
|
let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use cm_dashboard_shared::Metric;
|
use cm_dashboard_shared::AgentData;
|
||||||
|
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod cpu;
|
pub mod cpu;
|
||||||
@@ -10,9 +10,8 @@ pub use backup::BackupWidget;
|
|||||||
pub use services::ServicesWidget;
|
pub use services::ServicesWidget;
|
||||||
pub use system::SystemWidget;
|
pub use system::SystemWidget;
|
||||||
|
|
||||||
/// Widget trait for UI components that display metrics
|
/// Widget trait for UI components that display structured data
|
||||||
pub trait Widget {
|
pub trait Widget {
|
||||||
/// Update widget with new metrics data
|
/// Update widget with structured agent data
|
||||||
fn update_from_metrics(&mut self, metrics: &[&Metric]);
|
fn update_from_agent_data(&mut self, agent_data: &AgentData);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use cm_dashboard_shared::{Metric, Status};
|
use cm_dashboard_shared::{Metric, Status};
|
||||||
|
use super::Widget;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
@@ -7,9 +8,7 @@ use ratatui::{
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::Widget;
|
|
||||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||||
use crate::ui::CommandType;
|
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
|
|
||||||
/// Services widget displaying hierarchical systemd service statuses
|
/// Services widget displaying hierarchical systemd service statuses
|
||||||
@@ -125,41 +124,14 @@ impl ServicesWidget {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get status icon for service, considering pending transitions for visual feedback
|
|
||||||
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) -> (String, String, ratatui::prelude::Color) {
|
|
||||||
// Check if this service has a pending transition
|
|
||||||
if let Some((command_type, _original_status, _start_time)) = pending_transitions.get(service_name) {
|
|
||||||
// Show transitional icons for pending commands
|
|
||||||
let (icon, status_text) = match command_type {
|
|
||||||
CommandType::ServiceStart => ("↑", "starting"),
|
|
||||||
CommandType::ServiceStop => ("↓", "stopping"),
|
|
||||||
_ => return (StatusIcons::get_icon(info.widget_status).to_string(), info.status.clone(), Theme::status_color(info.widget_status)), // Not a service command
|
|
||||||
};
|
|
||||||
return (icon.to_string(), status_text.to_string(), Theme::highlight());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal status display
|
|
||||||
let icon = StatusIcons::get_icon(info.widget_status);
|
|
||||||
let status_color = match info.widget_status {
|
|
||||||
Status::Ok => Theme::success(),
|
|
||||||
Status::Pending => Theme::highlight(),
|
|
||||||
Status::Warning => Theme::warning(),
|
|
||||||
Status::Critical => Theme::error(),
|
|
||||||
Status::Unknown => Theme::muted_text(),
|
|
||||||
Status::Offline => Theme::muted_text(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(icon.to_string(), info.status.clone(), status_color)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Create spans for sub-service with icon next to name, considering pending transitions
|
/// Create spans for sub-service with icon next to name
|
||||||
fn create_sub_service_spans_with_transitions(
|
fn create_sub_service_spans(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
info: &ServiceInfo,
|
info: &ServiceInfo,
|
||||||
is_last: bool,
|
is_last: bool,
|
||||||
pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>,
|
|
||||||
) -> Vec<ratatui::text::Span<'static>> {
|
) -> Vec<ratatui::text::Span<'static>> {
|
||||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||||
let short_name = if name.len() > 18 {
|
let short_name = if name.len() > 18 {
|
||||||
@@ -168,19 +140,28 @@ impl ServicesWidget {
|
|||||||
name.to_string()
|
name.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get status icon and text, considering pending transitions
|
// Get status icon and text
|
||||||
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, pending_transitions);
|
let icon = StatusIcons::get_icon(info.widget_status);
|
||||||
|
let status_color = match info.widget_status {
|
||||||
|
Status::Ok => Theme::success(),
|
||||||
|
Status::Inactive => Theme::muted_text(),
|
||||||
|
Status::Pending => Theme::highlight(),
|
||||||
|
Status::Warning => Theme::warning(),
|
||||||
|
Status::Critical => Theme::error(),
|
||||||
|
Status::Unknown => Theme::muted_text(),
|
||||||
|
Status::Offline => Theme::muted_text(),
|
||||||
|
};
|
||||||
|
|
||||||
// For sub-services, prefer latency if available (unless transition is pending)
|
// For sub-services, prefer latency if available
|
||||||
if !pending_transitions.contains_key(name) {
|
let status_str = if let Some(latency) = info.latency_ms {
|
||||||
if let Some(latency) = info.latency_ms {
|
if latency < 0.0 {
|
||||||
status_str = if latency < 0.0 {
|
"timeout".to_string()
|
||||||
"timeout".to_string()
|
} else {
|
||||||
} else {
|
format!("{:.0}ms", latency)
|
||||||
format!("{:.0}ms", latency)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
info.status.clone()
|
||||||
|
};
|
||||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
@@ -228,36 +209,13 @@ impl ServicesWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get currently selected service name (for actions)
|
/// Get currently selected service name (for actions)
|
||||||
|
/// Only returns parent service names since only parent services can be selected
|
||||||
pub fn get_selected_service(&self) -> Option<String> {
|
pub fn get_selected_service(&self) -> Option<String> {
|
||||||
// Build the same display list to find the selected service
|
// Only parent services can be selected, so just get the parent service at selected_index
|
||||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new();
|
|
||||||
|
|
||||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||||
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||||
|
|
||||||
for (parent_name, parent_info) in parent_services {
|
|
||||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
|
||||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone()));
|
|
||||||
|
|
||||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
||||||
let mut sorted_subs = sub_list.clone();
|
|
||||||
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
||||||
|
|
||||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
|
||||||
let is_last_sub = i == sorted_subs.len() - 1;
|
|
||||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
|
||||||
display_lines.push((
|
|
||||||
sub_name.clone(),
|
|
||||||
sub_info.widget_status,
|
|
||||||
true,
|
|
||||||
Some((sub_info.clone(), is_last_sub)),
|
|
||||||
full_sub_name,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
display_lines.get(self.selected_index).map(|(_, _, _, _, raw_name)| raw_name.clone())
|
parent_services.get(self.selected_index).map(|(name, _)| name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total count of selectable services (parent services only, not sub-services)
|
/// Get total count of selectable services (parent services only, not sub-services)
|
||||||
@@ -266,25 +224,6 @@ impl ServicesWidget {
|
|||||||
self.parent_services.len()
|
self.parent_services.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current status of a specific service by name
|
|
||||||
pub fn get_service_status(&self, service_name: &str) -> Option<String> {
|
|
||||||
// Check if it's a parent service
|
|
||||||
if let Some(parent_info) = self.parent_services.get(service_name) {
|
|
||||||
return Some(parent_info.status.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check sub-services (format: parent_sub)
|
|
||||||
for (parent_name, sub_list) in &self.sub_services {
|
|
||||||
for (sub_name, sub_info) in sub_list {
|
|
||||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
|
||||||
if full_sub_name == service_name {
|
|
||||||
return Some(sub_info.status.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate which parent service index corresponds to a display line index
|
/// Calculate which parent service index corresponds to a display line index
|
||||||
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
||||||
@@ -316,6 +255,28 @@ impl ServicesWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for ServicesWidget {
|
impl Widget for ServicesWidget {
|
||||||
|
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||||
|
self.has_data = true;
|
||||||
|
self.parent_services.clear();
|
||||||
|
self.sub_services.clear();
|
||||||
|
|
||||||
|
for service in &agent_data.services {
|
||||||
|
let service_info = ServiceInfo {
|
||||||
|
status: service.status.clone(),
|
||||||
|
memory_mb: Some(service.memory_mb),
|
||||||
|
disk_gb: Some(service.disk_gb),
|
||||||
|
latency_ms: None,
|
||||||
|
widget_status: Status::Ok,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.parent_services.insert(service.name.clone(), service_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status = Status::Ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServicesWidget {
|
||||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||||
debug!("Services widget updating with {} metrics", metrics.len());
|
debug!("Services widget updating with {} metrics", metrics.len());
|
||||||
|
|
||||||
@@ -439,8 +400,8 @@ impl Widget for ServicesWidget {
|
|||||||
|
|
||||||
impl ServicesWidget {
|
impl ServicesWidget {
|
||||||
|
|
||||||
/// Render with focus, scroll, and pending transitions for visual feedback
|
/// Render with focus
|
||||||
pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) {
|
pub fn render(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
|
||||||
let services_block = Components::widget_block("services");
|
let services_block = Components::widget_block("services");
|
||||||
let inner_area = services_block.inner(area);
|
let inner_area = services_block.inner(area);
|
||||||
frame.render_widget(services_block, area);
|
frame.render_widget(services_block, area);
|
||||||
@@ -465,14 +426,14 @@ impl ServicesWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the existing render logic but with pending transitions
|
// Render the services list
|
||||||
self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions);
|
self.render_services(frame, content_chunks[1], is_focused);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render services list with pending transitions awareness
|
/// Render services list
|
||||||
fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) {
|
fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
|
||||||
// Build hierarchical service list for display - include raw service name for pending transition lookups
|
// Build hierarchical service list for display
|
||||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new(); // Added raw service name
|
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||||
|
|
||||||
// Sort parent services alphabetically for consistent order
|
// Sort parent services alphabetically for consistent order
|
||||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||||
@@ -481,7 +442,7 @@ impl ServicesWidget {
|
|||||||
for (parent_name, parent_info) in parent_services {
|
for (parent_name, parent_info) in parent_services {
|
||||||
// Add parent service line
|
// Add parent service line
|
||||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone())); // Include raw name
|
display_lines.push((parent_line, parent_info.widget_status, false, None));
|
||||||
|
|
||||||
// Add sub-services for this parent (if any)
|
// Add sub-services for this parent (if any)
|
||||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||||
@@ -491,49 +452,48 @@ impl ServicesWidget {
|
|||||||
|
|
||||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
||||||
let is_last_sub = i == sorted_subs.len() - 1;
|
let is_last_sub = i == sorted_subs.len() - 1;
|
||||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
|
||||||
// Store sub-service info for custom span rendering
|
// Store sub-service info for custom span rendering
|
||||||
display_lines.push((
|
display_lines.push((
|
||||||
sub_name.clone(),
|
sub_name.clone(),
|
||||||
sub_info.widget_status,
|
sub_info.widget_status,
|
||||||
true,
|
true,
|
||||||
Some((sub_info.clone(), is_last_sub)),
|
Some((sub_info.clone(), is_last_sub)),
|
||||||
full_sub_name, // Raw service name for pending transition lookup
|
|
||||||
)); // true = sub-service, with is_last info
|
)); // true = sub-service, with is_last info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply scroll offset and render visible lines (same as existing logic)
|
// Show only what fits, with "X more below" if needed
|
||||||
let available_lines = area.height as usize;
|
let available_lines = area.height as usize;
|
||||||
let total_lines = display_lines.len();
|
let total_lines = display_lines.len();
|
||||||
|
|
||||||
// Calculate scroll boundaries
|
// Reserve one line for "X more below" if needed
|
||||||
let max_scroll = if total_lines > available_lines {
|
let lines_for_content = if total_lines > available_lines {
|
||||||
total_lines - available_lines
|
available_lines.saturating_sub(1)
|
||||||
} else {
|
} else {
|
||||||
total_lines.saturating_sub(1)
|
available_lines
|
||||||
};
|
};
|
||||||
let effective_scroll = scroll_offset.min(max_scroll);
|
|
||||||
|
|
||||||
// Get visible lines after scrolling
|
|
||||||
let visible_lines: Vec<_> = display_lines
|
let visible_lines: Vec<_> = display_lines
|
||||||
.iter()
|
.iter()
|
||||||
.skip(effective_scroll)
|
.take(lines_for_content)
|
||||||
.take(available_lines)
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||||
|
|
||||||
let lines_to_show = visible_lines.len();
|
let lines_to_show = visible_lines.len();
|
||||||
|
|
||||||
if lines_to_show > 0 {
|
if lines_to_show > 0 {
|
||||||
|
// Add space for "X more below" message if needed
|
||||||
|
let total_chunks_needed = if hidden_below > 0 { lines_to_show + 1 } else { lines_to_show };
|
||||||
let service_chunks = Layout::default()
|
let service_chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(vec![Constraint::Length(1); lines_to_show])
|
.constraints(vec![Constraint::Length(1); total_chunks_needed])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
for (i, (line_text, line_status, is_sub, sub_info, raw_service_name)) in visible_lines.iter().enumerate()
|
for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
|
||||||
{
|
{
|
||||||
let actual_index = effective_scroll + i; // Real index in the full list
|
let actual_index = i; // Simple index since we're not scrolling
|
||||||
|
|
||||||
// Only parent services can be selected - calculate parent service index
|
// Only parent services can be selected - calculate parent service index
|
||||||
let is_selected = if !*is_sub {
|
let is_selected = if !*is_sub {
|
||||||
@@ -545,41 +505,16 @@ impl ServicesWidget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut spans = if *is_sub && sub_info.is_some() {
|
let mut spans = if *is_sub && sub_info.is_some() {
|
||||||
// Use custom sub-service span creation WITH pending transitions
|
// Use custom sub-service span creation
|
||||||
let (service_info, is_last) = sub_info.as_ref().unwrap();
|
let (service_info, is_last) = sub_info.as_ref().unwrap();
|
||||||
self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions)
|
self.create_sub_service_spans(line_text, service_info, *is_last)
|
||||||
} else {
|
} else {
|
||||||
// Parent services - check if this parent service has a pending transition using RAW service name
|
// Parent services - use normal status spans
|
||||||
if pending_transitions.contains_key(raw_service_name) {
|
StatusIcons::create_status_spans(*line_status, line_text)
|
||||||
// Create spans with transitional status
|
|
||||||
let (icon, status_text, _) = self.get_service_icon_and_status(raw_service_name, &ServiceInfo {
|
|
||||||
status: "".to_string(),
|
|
||||||
memory_mb: None,
|
|
||||||
disk_gb: None,
|
|
||||||
latency_ms: None,
|
|
||||||
widget_status: *line_status
|
|
||||||
}, pending_transitions);
|
|
||||||
|
|
||||||
// Use blue for transitional icons when not selected, background color when selected
|
|
||||||
let icon_color = if is_selected && !*is_sub && is_focused {
|
|
||||||
Theme::background() // Dark background color for visibility against blue selection
|
|
||||||
} else {
|
|
||||||
Theme::highlight() // Blue for normal case
|
|
||||||
};
|
|
||||||
|
|
||||||
vec![
|
|
||||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
|
|
||||||
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
|
|
||||||
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(icon_color)),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
StatusIcons::create_status_spans(*line_status, line_text)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply selection highlighting to parent services only, making icons background color when selected
|
// Apply selection highlighting to parent services only
|
||||||
// Only show selection when Services panel is focused
|
// Only show selection when Services panel is focused
|
||||||
// Show selection highlighting even when transitional icons are present
|
|
||||||
if is_selected && !*is_sub && is_focused {
|
if is_selected && !*is_sub && is_focused {
|
||||||
for (i, span) in spans.iter_mut().enumerate() {
|
for (i, span) in spans.iter_mut().enumerate() {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -600,33 +535,12 @@ impl ServicesWidget {
|
|||||||
|
|
||||||
frame.render_widget(service_para, service_chunks[i]);
|
frame.render_widget(service_para, service_chunks[i]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Show scroll indicator if there are more services than we can display (same as existing)
|
|
||||||
if total_lines > available_lines {
|
|
||||||
let hidden_above = effective_scroll;
|
|
||||||
let hidden_below = total_lines.saturating_sub(effective_scroll + available_lines);
|
|
||||||
|
|
||||||
if hidden_above > 0 || hidden_below > 0 {
|
// Show "X more below" message if content was truncated
|
||||||
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
if hidden_below > 0 {
|
||||||
format!("... {} above, {} below", hidden_above, hidden_below)
|
let more_text = format!("... {} more below", hidden_below);
|
||||||
} else if hidden_above > 0 {
|
let more_para = Paragraph::new(more_text).style(Typography::muted());
|
||||||
format!("... {} more above", hidden_above)
|
frame.render_widget(more_para, service_chunks[lines_to_show]);
|
||||||
} else {
|
|
||||||
format!("... {} more below", hidden_below)
|
|
||||||
};
|
|
||||||
|
|
||||||
if available_lines > 0 && lines_to_show > 0 {
|
|
||||||
let last_line_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: area.y + (lines_to_show - 1) as u16,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let scroll_para = Paragraph::new(scroll_text).style(Typography::muted());
|
|
||||||
frame.render_widget(scroll_para, last_line_area);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
use cm_dashboard_shared::Status;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
@@ -6,7 +6,6 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::Widget;
|
|
||||||
use crate::ui::theme::{StatusIcons, Typography};
|
use crate::ui::theme::{StatusIcons, Typography};
|
||||||
|
|
||||||
/// System widget displaying NixOS info, CPU, RAM, and Storage in unified layout
|
/// System widget displaying NixOS info, CPU, RAM, and Storage in unified layout
|
||||||
@@ -14,7 +13,6 @@ use crate::ui::theme::{StatusIcons, Typography};
|
|||||||
pub struct SystemWidget {
|
pub struct SystemWidget {
|
||||||
// NixOS information
|
// NixOS information
|
||||||
nixos_build: Option<String>,
|
nixos_build: Option<String>,
|
||||||
config_hash: Option<String>,
|
|
||||||
agent_hash: Option<String>,
|
agent_hash: Option<String>,
|
||||||
|
|
||||||
// CPU metrics
|
// CPU metrics
|
||||||
@@ -45,12 +43,14 @@ pub struct SystemWidget {
|
|||||||
struct StoragePool {
|
struct StoragePool {
|
||||||
name: String,
|
name: String,
|
||||||
mount_point: String,
|
mount_point: String,
|
||||||
pool_type: String, // "Single", "Raid0", etc.
|
pool_type: String, // "single", "mergerfs (2+1)", "RAID5 (3+1)", etc.
|
||||||
drives: Vec<StorageDrive>,
|
drives: Vec<StorageDrive>,
|
||||||
|
filesystems: Vec<FileSystem>, // For physical drive pools: individual filesystem children
|
||||||
usage_percent: Option<f32>,
|
usage_percent: Option<f32>,
|
||||||
used_gb: Option<f32>,
|
used_gb: Option<f32>,
|
||||||
total_gb: Option<f32>,
|
total_gb: Option<f32>,
|
||||||
status: Status,
|
status: Status,
|
||||||
|
health_status: Status, // Separate status for pool health vs usage
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -61,11 +61,19 @@ struct StorageDrive {
|
|||||||
status: Status,
|
status: Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FileSystem {
|
||||||
|
mount_point: String,
|
||||||
|
usage_percent: Option<f32>,
|
||||||
|
used_gb: Option<f32>,
|
||||||
|
total_gb: Option<f32>,
|
||||||
|
status: Status,
|
||||||
|
}
|
||||||
|
|
||||||
impl SystemWidget {
|
impl SystemWidget {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
nixos_build: None,
|
nixos_build: None,
|
||||||
config_hash: None,
|
|
||||||
agent_hash: None,
|
agent_hash: None,
|
||||||
cpu_load_1min: None,
|
cpu_load_1min: None,
|
||||||
cpu_load_5min: None,
|
cpu_load_5min: None,
|
||||||
@@ -132,213 +140,204 @@ impl SystemWidget {
|
|||||||
pub fn _get_agent_hash(&self) -> Option<&String> {
|
pub fn _get_agent_hash(&self) -> Option<&String> {
|
||||||
self.agent_hash.as_ref()
|
self.agent_hash.as_ref()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get mount point for a pool name
|
use super::Widget;
|
||||||
fn get_mount_point_for_pool(&self, pool_name: &str) -> String {
|
|
||||||
match pool_name {
|
impl Widget for SystemWidget {
|
||||||
"root" => "/".to_string(),
|
fn update_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||||
"steampool" => "/mnt/steampool".to_string(),
|
self.has_data = true;
|
||||||
"steampool_1" => "/steampool_1".to_string(),
|
|
||||||
"steampool_2" => "/steampool_2".to_string(),
|
// Extract agent version
|
||||||
_ => format!("/{}", pool_name), // Default fallback
|
self.agent_hash = Some(agent_data.agent_version.clone());
|
||||||
|
|
||||||
|
// Extract CPU data directly
|
||||||
|
let cpu = &agent_data.system.cpu;
|
||||||
|
self.cpu_load_1min = Some(cpu.load_1min);
|
||||||
|
self.cpu_load_5min = Some(cpu.load_5min);
|
||||||
|
self.cpu_load_15min = Some(cpu.load_15min);
|
||||||
|
self.cpu_frequency = Some(cpu.frequency_mhz);
|
||||||
|
self.cpu_status = Status::Ok;
|
||||||
|
|
||||||
|
// Extract memory data directly
|
||||||
|
let memory = &agent_data.system.memory;
|
||||||
|
self.memory_usage_percent = Some(memory.usage_percent);
|
||||||
|
self.memory_used_gb = Some(memory.used_gb);
|
||||||
|
self.memory_total_gb = Some(memory.total_gb);
|
||||||
|
self.memory_status = Status::Ok;
|
||||||
|
|
||||||
|
// Extract tmpfs data
|
||||||
|
if let Some(tmp_data) = memory.tmpfs.iter().find(|t| t.mount == "/tmp") {
|
||||||
|
self.tmp_usage_percent = Some(tmp_data.usage_percent);
|
||||||
|
self.tmp_used_gb = Some(tmp_data.used_gb);
|
||||||
|
self.tmp_total_gb = Some(tmp_data.total_gb);
|
||||||
|
self.tmp_status = Status::Ok;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse storage metrics into pools and drives
|
// Convert storage data to internal format
|
||||||
fn update_storage_from_metrics(&mut self, metrics: &[&Metric]) {
|
self.update_storage_from_agent_data(agent_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemWidget {
|
||||||
|
/// Convert structured storage data to internal format
|
||||||
|
fn update_storage_from_agent_data(&mut self, agent_data: &cm_dashboard_shared::AgentData) {
|
||||||
let mut pools: std::collections::HashMap<String, StoragePool> = std::collections::HashMap::new();
|
let mut pools: std::collections::HashMap<String, StoragePool> = std::collections::HashMap::new();
|
||||||
|
|
||||||
for metric in metrics {
|
// Convert drives
|
||||||
if metric.name.starts_with("disk_") {
|
for drive in &agent_data.system.storage.drives {
|
||||||
if let Some(pool_name) = self.extract_pool_name(&metric.name) {
|
let mut pool = StoragePool {
|
||||||
let mount_point = self.get_mount_point_for_pool(&pool_name);
|
name: drive.name.clone(),
|
||||||
let pool = pools.entry(pool_name.clone()).or_insert_with(|| StoragePool {
|
mount_point: drive.name.clone(),
|
||||||
name: pool_name.clone(),
|
pool_type: "drive".to_string(),
|
||||||
mount_point: mount_point.clone(),
|
drives: Vec::new(),
|
||||||
pool_type: "Single".to_string(), // Default, could be enhanced
|
filesystems: Vec::new(),
|
||||||
drives: Vec::new(),
|
usage_percent: None,
|
||||||
usage_percent: None,
|
used_gb: None,
|
||||||
used_gb: None,
|
total_gb: None,
|
||||||
total_gb: None,
|
status: Status::Ok,
|
||||||
status: Status::Unknown,
|
health_status: Status::Ok,
|
||||||
});
|
};
|
||||||
|
|
||||||
// Parse different metric types
|
// Add drive info
|
||||||
if metric.name.contains("_usage_percent") {
|
let storage_drive = StorageDrive {
|
||||||
if let MetricValue::Float(usage) = metric.value {
|
name: drive.name.clone(),
|
||||||
pool.usage_percent = Some(usage);
|
temperature: drive.temperature_celsius,
|
||||||
pool.status = metric.status.clone();
|
wear_percent: drive.wear_percent,
|
||||||
}
|
status: Status::Ok,
|
||||||
} else if metric.name.contains("_used_gb") {
|
};
|
||||||
if let MetricValue::Float(used) = metric.value {
|
pool.drives.push(storage_drive);
|
||||||
pool.used_gb = Some(used);
|
|
||||||
}
|
// Calculate totals from filesystems
|
||||||
} else if metric.name.contains("_total_gb") {
|
let total_used: f32 = drive.filesystems.iter().map(|fs| fs.used_gb).sum();
|
||||||
if let MetricValue::Float(total) = metric.value {
|
let total_size: f32 = drive.filesystems.iter().map(|fs| fs.total_gb).sum();
|
||||||
pool.total_gb = Some(total);
|
let average_usage = if total_size > 0.0 { (total_used / total_size) * 100.0 } else { 0.0 };
|
||||||
}
|
|
||||||
} else if metric.name.contains("_temperature") {
|
pool.usage_percent = Some(average_usage);
|
||||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
pool.used_gb = Some(total_used);
|
||||||
// Find existing drive or create new one
|
pool.total_gb = Some(total_size);
|
||||||
let drive_exists = pool.drives.iter().any(|d| d.name == drive_name);
|
|
||||||
if !drive_exists {
|
// Add filesystems
|
||||||
pool.drives.push(StorageDrive {
|
for fs in &drive.filesystems {
|
||||||
name: drive_name.clone(),
|
let filesystem = FileSystem {
|
||||||
temperature: None,
|
mount_point: fs.mount.clone(),
|
||||||
wear_percent: None,
|
usage_percent: Some(fs.usage_percent),
|
||||||
status: Status::Unknown,
|
used_gb: Some(fs.used_gb),
|
||||||
});
|
total_gb: Some(fs.total_gb),
|
||||||
}
|
status: Status::Ok,
|
||||||
|
};
|
||||||
if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) {
|
pool.filesystems.push(filesystem);
|
||||||
if let MetricValue::Float(temp) = metric.value {
|
|
||||||
drive.temperature = Some(temp);
|
|
||||||
drive.status = metric.status.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if metric.name.contains("_wear_percent") {
|
|
||||||
if let Some(drive_name) = self.extract_drive_name(&metric.name) {
|
|
||||||
// Find existing drive or create new one
|
|
||||||
let drive_exists = pool.drives.iter().any(|d| d.name == drive_name);
|
|
||||||
if !drive_exists {
|
|
||||||
pool.drives.push(StorageDrive {
|
|
||||||
name: drive_name.clone(),
|
|
||||||
temperature: None,
|
|
||||||
wear_percent: None,
|
|
||||||
status: Status::Unknown,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(drive) = pool.drives.iter_mut().find(|d| d.name == drive_name) {
|
|
||||||
if let MetricValue::Float(wear) = metric.value {
|
|
||||||
drive.wear_percent = Some(wear);
|
|
||||||
drive.status = metric.status.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pools.insert(drive.name.clone(), pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to sorted vec for consistent ordering
|
// Convert pools
|
||||||
|
|
||||||
|
// Store pools
|
||||||
let mut pool_list: Vec<StoragePool> = pools.into_values().collect();
|
let mut pool_list: Vec<StoragePool> = pools.into_values().collect();
|
||||||
pool_list.sort_by(|a, b| a.name.cmp(&b.name)); // Sort alphabetically by name
|
pool_list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
self.storage_pools = pool_list;
|
self.storage_pools = pool_list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract pool name from disk metric name
|
/// Render storage section with enhanced tree structure
|
||||||
fn extract_pool_name(&self, metric_name: &str) -> Option<String> {
|
|
||||||
// Pattern: disk_{pool_name}_{drive_name}_{metric_type}
|
|
||||||
// Since pool_name can contain underscores, work backwards from known metric suffixes
|
|
||||||
if metric_name.starts_with("disk_") {
|
|
||||||
// First try drive-specific metrics that have device names
|
|
||||||
if let Some(suffix_pos) = metric_name.rfind("_temperature")
|
|
||||||
.or_else(|| metric_name.rfind("_wear_percent"))
|
|
||||||
.or_else(|| metric_name.rfind("_health")) {
|
|
||||||
// Find the second-to-last underscore to get pool name
|
|
||||||
let before_suffix = &metric_name[..suffix_pos];
|
|
||||||
if let Some(drive_start) = before_suffix.rfind('_') {
|
|
||||||
return Some(metric_name[5..drive_start].to_string()); // Skip "disk_"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For pool-level metrics (usage_percent, used_gb, total_gb), take everything before the metric suffix
|
|
||||||
else if let Some(suffix_pos) = metric_name.rfind("_usage_percent")
|
|
||||||
.or_else(|| metric_name.rfind("_used_gb"))
|
|
||||||
.or_else(|| metric_name.rfind("_total_gb")) {
|
|
||||||
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
|
|
||||||
}
|
|
||||||
// Fallback to old behavior for unknown patterns
|
|
||||||
else if let Some(captures) = metric_name.strip_prefix("disk_") {
|
|
||||||
if let Some(pos) = captures.find('_') {
|
|
||||||
return Some(captures[..pos].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract drive name from disk metric name
|
|
||||||
fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
|
|
||||||
// Pattern: disk_{pool_name}_{drive_name}_{metric_type}
|
|
||||||
// Since pool_name can contain underscores, work backwards from known metric suffixes
|
|
||||||
if metric_name.starts_with("disk_") {
|
|
||||||
if let Some(suffix_pos) = metric_name.rfind("_temperature")
|
|
||||||
.or_else(|| metric_name.rfind("_wear_percent"))
|
|
||||||
.or_else(|| metric_name.rfind("_health")) {
|
|
||||||
// Find the second-to-last underscore to get the drive name
|
|
||||||
let before_suffix = &metric_name[..suffix_pos];
|
|
||||||
if let Some(drive_start) = before_suffix.rfind('_') {
|
|
||||||
return Some(before_suffix[drive_start + 1..].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render storage section with tree structure
|
|
||||||
fn render_storage(&self) -> Vec<Line<'_>> {
|
fn render_storage(&self) -> Vec<Line<'_>> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
for pool in &self.storage_pools {
|
for pool in &self.storage_pools {
|
||||||
// Pool header line
|
// Pool header line with type and health
|
||||||
let usage_text = match (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
let pool_label = if pool.pool_type.starts_with("drive (") {
|
||||||
(Some(pct), Some(used), Some(total)) => {
|
// For physical drives, show the drive name with temperature and wear percentage if available
|
||||||
format!("{:.0}% {:.1}GB/{:.1}GB", pct, used, total)
|
// 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 {
|
||||||
|
let mut drive_details = Vec::new();
|
||||||
|
if let Some(temp) = drive.temperature {
|
||||||
|
drive_details.push(format!("T: {}°C", temp as i32));
|
||||||
|
}
|
||||||
|
if let Some(wear) = drive.wear_percent {
|
||||||
|
drive_details.push(format!("W: {}%", wear as i32));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !drive_details.is_empty() {
|
||||||
|
format!("{} ({})", pool.name, drive_details.join(" "))
|
||||||
|
} else {
|
||||||
|
pool.name.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pool.name.clone()
|
||||||
}
|
}
|
||||||
_ => "—% —GB/—GB".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let pool_label = if pool.pool_type.to_lowercase() == "single" {
|
|
||||||
format!("{}:", pool.mount_point)
|
|
||||||
} else {
|
} else {
|
||||||
format!("{} ({}):", pool.mount_point, pool.pool_type)
|
// For mergerfs pools, show pool name with format
|
||||||
|
format!("{} ({})", pool.mount_point, pool.pool_type)
|
||||||
};
|
};
|
||||||
let pool_spans = StatusIcons::create_status_spans(
|
|
||||||
pool.status.clone(),
|
let pool_spans = StatusIcons::create_status_spans(pool.status.clone(), &pool_label);
|
||||||
&pool_label
|
|
||||||
);
|
|
||||||
lines.push(Line::from(pool_spans));
|
lines.push(Line::from(pool_spans));
|
||||||
|
|
||||||
// Drive lines with tree structure
|
// Pool total usage line
|
||||||
let has_usage_line = pool.usage_percent.is_some();
|
if let (Some(usage), Some(used), Some(total)) = (pool.usage_percent, pool.used_gb, pool.total_gb) {
|
||||||
for (i, drive) in pool.drives.iter().enumerate() {
|
let usage_spans = vec![
|
||||||
let is_last_drive = i == pool.drives.len() - 1;
|
Span::styled(" ├─ ", Typography::tree()),
|
||||||
let tree_symbol = if is_last_drive && !has_usage_line { "└─" } else { "├─" };
|
|
||||||
|
|
||||||
let mut drive_info = Vec::new();
|
|
||||||
if let Some(temp) = drive.temperature {
|
|
||||||
drive_info.push(format!("T: {:.0}C", temp));
|
|
||||||
}
|
|
||||||
if let Some(wear) = drive.wear_percent {
|
|
||||||
drive_info.push(format!("W: {:.0}%", wear));
|
|
||||||
}
|
|
||||||
let drive_text = if drive_info.is_empty() {
|
|
||||||
drive.name.clone()
|
|
||||||
} else {
|
|
||||||
format!("{} {}", drive.name, drive_info.join(" • "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut drive_spans = vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(tree_symbol, Typography::tree()),
|
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
];
|
];
|
||||||
drive_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
let mut usage_line_spans = usage_spans;
|
||||||
lines.push(Line::from(drive_spans));
|
usage_line_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &format!("Total: {}% {:.1}GB/{:.1}GB", usage as i32, used, total)));
|
||||||
|
lines.push(Line::from(usage_line_spans));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage line
|
// Drive details for physical drives
|
||||||
if pool.usage_percent.is_some() {
|
if pool.pool_type.starts_with("drive") {
|
||||||
let tree_symbol = "└─";
|
for drive in &pool.drives {
|
||||||
let mut usage_spans = vec![
|
if drive.name == pool.name {
|
||||||
Span::raw(" "),
|
let mut drive_details = Vec::new();
|
||||||
Span::styled(tree_symbol, Typography::tree()),
|
if let Some(temp) = drive.temperature {
|
||||||
Span::raw(" "),
|
drive_details.push(format!("T: {}°C", temp as i32));
|
||||||
];
|
}
|
||||||
usage_spans.extend(StatusIcons::create_status_spans(pool.status.clone(), &usage_text));
|
if let Some(wear) = drive.wear_percent {
|
||||||
lines.push(Line::from(usage_spans));
|
drive_details.push(format!("W: {}%", wear as i32));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !drive_details.is_empty() {
|
||||||
|
let drive_text = format!("● {} {}", drive.name, drive_details.join(" "));
|
||||||
|
let drive_spans = vec![
|
||||||
|
Span::styled(" └─ ", Typography::tree()),
|
||||||
|
Span::raw(" "),
|
||||||
|
];
|
||||||
|
let mut drive_line_spans = drive_spans;
|
||||||
|
drive_line_spans.extend(StatusIcons::create_status_spans(drive.status.clone(), &drive_text));
|
||||||
|
lines.push(Line::from(drive_line_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"));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,100 +345,35 @@ impl SystemWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for SystemWidget {
|
/// Helper function to render a drive in a storage pool
|
||||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
fn render_pool_drive(drive: &StorageDrive, is_last: bool, lines: &mut Vec<Line<'_>>) {
|
||||||
self.has_data = !metrics.is_empty();
|
let tree_symbol = if is_last { " └─" } else { " ├─" };
|
||||||
|
|
||||||
for metric in metrics {
|
let mut drive_details = Vec::new();
|
||||||
match metric.name.as_str() {
|
if let Some(temp) = drive.temperature {
|
||||||
// NixOS metrics
|
drive_details.push(format!("T: {}°C", temp as i32));
|
||||||
"system_nixos_build" => {
|
|
||||||
if let MetricValue::String(build) = &metric.value {
|
|
||||||
self.nixos_build = Some(build.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"system_config_hash" => {
|
|
||||||
if let MetricValue::String(hash) = &metric.value {
|
|
||||||
self.config_hash = Some(hash.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"agent_version" => {
|
|
||||||
if let MetricValue::String(version) = &metric.value {
|
|
||||||
self.agent_hash = Some(version.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU metrics
|
|
||||||
"cpu_load_1min" => {
|
|
||||||
if let MetricValue::Float(load) = metric.value {
|
|
||||||
self.cpu_load_1min = Some(load);
|
|
||||||
self.cpu_status = metric.status.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"cpu_load_5min" => {
|
|
||||||
if let MetricValue::Float(load) = metric.value {
|
|
||||||
self.cpu_load_5min = Some(load);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"cpu_load_15min" => {
|
|
||||||
if let MetricValue::Float(load) = metric.value {
|
|
||||||
self.cpu_load_15min = Some(load);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"cpu_frequency_mhz" => {
|
|
||||||
if let MetricValue::Float(freq) = metric.value {
|
|
||||||
self.cpu_frequency = Some(freq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory metrics
|
|
||||||
"memory_usage_percent" => {
|
|
||||||
if let MetricValue::Float(usage) = metric.value {
|
|
||||||
self.memory_usage_percent = Some(usage);
|
|
||||||
self.memory_status = metric.status.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"memory_used_gb" => {
|
|
||||||
if let MetricValue::Float(used) = metric.value {
|
|
||||||
self.memory_used_gb = Some(used);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"memory_total_gb" => {
|
|
||||||
if let MetricValue::Float(total) = metric.value {
|
|
||||||
self.memory_total_gb = Some(total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tmpfs metrics
|
|
||||||
"memory_tmp_usage_percent" => {
|
|
||||||
if let MetricValue::Float(usage) = metric.value {
|
|
||||||
self.tmp_usage_percent = Some(usage);
|
|
||||||
self.tmp_status = metric.status.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"memory_tmp_used_gb" => {
|
|
||||||
if let MetricValue::Float(used) = metric.value {
|
|
||||||
self.tmp_used_gb = Some(used);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"memory_tmp_total_gb" => {
|
|
||||||
if let MetricValue::Float(total) = metric.value {
|
|
||||||
self.tmp_total_gb = Some(total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update storage from all disk metrics
|
|
||||||
self.update_storage_from_metrics(metrics);
|
|
||||||
}
|
}
|
||||||
|
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(" "))
|
||||||
|
} 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemWidget {
|
impl SystemWidget {
|
||||||
/// Render with scroll offset support
|
/// Render system widget
|
||||||
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize, hostname: &str) {
|
pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, config: Option<&crate::config::DashboardConfig>) {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
// NixOS section
|
// NixOS section
|
||||||
@@ -457,6 +391,16 @@ impl SystemWidget {
|
|||||||
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
|
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
|
// CPU section
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
@@ -503,69 +447,30 @@ impl SystemWidget {
|
|||||||
Span::styled("Storage:", Typography::widget_title())
|
Span::styled("Storage:", Typography::widget_title())
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Storage items with overflow handling
|
// Storage items - let main overflow logic handle truncation
|
||||||
let storage_lines = self.render_storage();
|
let storage_lines = self.render_storage();
|
||||||
let remaining_space = area.height.saturating_sub(lines.len() as u16);
|
lines.extend(storage_lines);
|
||||||
|
|
||||||
if storage_lines.len() <= remaining_space as usize {
|
|
||||||
// All storage lines fit
|
|
||||||
lines.extend(storage_lines);
|
|
||||||
} else if remaining_space >= 2 {
|
|
||||||
// Show what we can and add overflow indicator
|
|
||||||
let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow
|
|
||||||
lines.extend(storage_lines.iter().take(lines_to_show).cloned());
|
|
||||||
|
|
||||||
// Count hidden pools
|
|
||||||
let mut hidden_pools = 0;
|
|
||||||
let mut current_pool = String::new();
|
|
||||||
for (i, line) in storage_lines.iter().enumerate() {
|
|
||||||
if i >= lines_to_show {
|
|
||||||
// Check if this line represents a new pool (no indentation)
|
|
||||||
if let Some(first_span) = line.spans.first() {
|
|
||||||
let text = first_span.content.as_ref();
|
|
||||||
if !text.starts_with(" ") && text.contains(':') {
|
|
||||||
let pool_name = text.split(':').next().unwrap_or("").trim();
|
|
||||||
if pool_name != current_pool {
|
|
||||||
hidden_pools += 1;
|
|
||||||
current_pool = pool_name.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hidden_pools > 0 {
|
|
||||||
let overflow_text = format!(
|
|
||||||
"... and {} more pool{}",
|
|
||||||
hidden_pools,
|
|
||||||
if hidden_pools == 1 { "" } else { "s" }
|
|
||||||
);
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(overflow_text, Typography::muted())
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply scroll offset
|
// Apply scroll offset
|
||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
let available_height = area.height as usize;
|
let available_height = area.height as usize;
|
||||||
|
|
||||||
// Always apply scrolling if scroll_offset > 0, even if content fits
|
// Show only what fits, with "X more below" if needed
|
||||||
if scroll_offset > 0 || total_lines > available_height {
|
if total_lines > available_height {
|
||||||
let max_scroll = if total_lines > available_height {
|
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
|
||||||
total_lines - available_height
|
let mut visible_lines: Vec<Line> = lines
|
||||||
} else {
|
|
||||||
total_lines.saturating_sub(1)
|
|
||||||
};
|
|
||||||
let effective_scroll = scroll_offset.min(max_scroll);
|
|
||||||
|
|
||||||
// Take only the visible portion after scrolling
|
|
||||||
let visible_lines: Vec<Line> = lines
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(effective_scroll)
|
.take(lines_for_content)
|
||||||
.take(available_height)
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||||
|
if hidden_below > 0 {
|
||||||
|
let more_line = Line::from(vec![
|
||||||
|
Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
||||||
|
]);
|
||||||
|
visible_lines.push(more_line);
|
||||||
|
}
|
||||||
|
|
||||||
let paragraph = Paragraph::new(Text::from(visible_lines));
|
let paragraph = Paragraph::new(Text::from(visible_lines));
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.58"
|
version = "0.1.134"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
161
shared/src/agent_data.rs
Normal file
161
shared/src/agent_data.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Complete structured data from an agent
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentData {
|
||||||
|
pub hostname: String,
|
||||||
|
pub agent_version: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub system: SystemData,
|
||||||
|
pub services: Vec<ServiceData>,
|
||||||
|
pub backup: BackupData,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System-level monitoring data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SystemData {
|
||||||
|
pub cpu: CpuData,
|
||||||
|
pub memory: MemoryData,
|
||||||
|
pub storage: StorageData,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU monitoring data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CpuData {
|
||||||
|
pub load_1min: f32,
|
||||||
|
pub load_5min: f32,
|
||||||
|
pub load_15min: f32,
|
||||||
|
pub frequency_mhz: f32,
|
||||||
|
pub temperature_celsius: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory monitoring data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryData {
|
||||||
|
pub usage_percent: f32,
|
||||||
|
pub total_gb: f32,
|
||||||
|
pub used_gb: f32,
|
||||||
|
pub available_gb: f32,
|
||||||
|
pub swap_total_gb: f32,
|
||||||
|
pub swap_used_gb: f32,
|
||||||
|
pub tmpfs: Vec<TmpfsData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tmpfs filesystem data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TmpfsData {
|
||||||
|
pub mount: String,
|
||||||
|
pub usage_percent: f32,
|
||||||
|
pub used_gb: f32,
|
||||||
|
pub total_gb: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage monitoring data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StorageData {
|
||||||
|
pub drives: Vec<DriveData>,
|
||||||
|
pub pools: Vec<PoolData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual drive data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DriveData {
|
||||||
|
pub name: String,
|
||||||
|
pub health: String,
|
||||||
|
pub temperature_celsius: Option<f32>,
|
||||||
|
pub wear_percent: Option<f32>,
|
||||||
|
pub filesystems: Vec<FilesystemData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem on a drive
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilesystemData {
|
||||||
|
pub mount: String,
|
||||||
|
pub usage_percent: f32,
|
||||||
|
pub used_gb: f32,
|
||||||
|
pub total_gb: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage pool (MergerFS, RAID, etc.)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PoolData {
|
||||||
|
pub name: String,
|
||||||
|
pub mount: String,
|
||||||
|
pub pool_type: String, // "mergerfs", "raid", etc.
|
||||||
|
pub health: String,
|
||||||
|
pub usage_percent: f32,
|
||||||
|
pub used_gb: f32,
|
||||||
|
pub total_gb: f32,
|
||||||
|
pub data_drives: Vec<PoolDriveData>,
|
||||||
|
pub parity_drives: Vec<PoolDriveData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive in a storage pool
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PoolDriveData {
|
||||||
|
pub name: String,
|
||||||
|
pub temperature_celsius: Option<f32>,
|
||||||
|
pub wear_percent: Option<f32>,
|
||||||
|
pub health: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service monitoring data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceData {
|
||||||
|
pub name: String,
|
||||||
|
pub status: String, // "active", "inactive", "failed"
|
||||||
|
pub memory_mb: f32,
|
||||||
|
pub disk_gb: f32,
|
||||||
|
pub user_stopped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backup system data
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentData {
|
||||||
|
/// Create new agent data with current timestamp
|
||||||
|
pub fn new(hostname: String, agent_version: String) -> Self {
|
||||||
|
Self {
|
||||||
|
hostname,
|
||||||
|
agent_version,
|
||||||
|
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||||
|
system: SystemData {
|
||||||
|
cpu: CpuData {
|
||||||
|
load_1min: 0.0,
|
||||||
|
load_5min: 0.0,
|
||||||
|
load_15min: 0.0,
|
||||||
|
frequency_mhz: 0.0,
|
||||||
|
temperature_celsius: None,
|
||||||
|
},
|
||||||
|
memory: MemoryData {
|
||||||
|
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: Vec::new(),
|
||||||
|
},
|
||||||
|
storage: StorageData {
|
||||||
|
drives: Vec::new(),
|
||||||
|
pools: Vec::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
services: Vec::new(),
|
||||||
|
backup: BackupData {
|
||||||
|
status: "unknown".to_string(),
|
||||||
|
last_run: None,
|
||||||
|
next_scheduled: None,
|
||||||
|
total_size_gb: None,
|
||||||
|
repository_health: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
pub mod agent_data;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
|
||||||
|
pub use agent_data::*;
|
||||||
pub use cache::*;
|
pub use cache::*;
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use metrics::*;
|
pub use metrics::*;
|
||||||
|
|||||||
@@ -82,12 +82,13 @@ impl MetricValue {
|
|||||||
/// Health status for metrics
|
/// Health status for metrics
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum Status {
|
pub enum Status {
|
||||||
Ok,
|
Inactive, // Lowest priority
|
||||||
Pending,
|
Unknown, //
|
||||||
Warning,
|
Offline, //
|
||||||
Critical,
|
Pending, //
|
||||||
Unknown,
|
Ok, // 5th place - good status has higher priority than unknown states
|
||||||
Offline,
|
Warning, //
|
||||||
|
Critical, // Highest priority
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Status {
|
impl Status {
|
||||||
@@ -181,6 +182,16 @@ impl HysteresisThresholds {
|
|||||||
Status::Ok
|
Status::Ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Status::Inactive => {
|
||||||
|
// Inactive services use normal thresholds like first measurement
|
||||||
|
if value >= self.critical_high {
|
||||||
|
Status::Critical
|
||||||
|
} else if value >= self.warning_high {
|
||||||
|
Status::Warning
|
||||||
|
} else {
|
||||||
|
Status::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
Status::Pending => {
|
Status::Pending => {
|
||||||
// Service transitioning, use normal thresholds like first measurement
|
// Service transitioning, use normal thresholds like first measurement
|
||||||
if value >= self.critical_high {
|
if value >= self.critical_high {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
use crate::metrics::Metric;
|
use crate::agent_data::AgentData;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Message sent from agent to dashboard via ZMQ
|
/// Message sent from agent to dashboard via ZMQ
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// Always structured data - no legacy metrics support
|
||||||
pub struct MetricMessage {
|
pub type AgentMessage = AgentData;
|
||||||
pub hostname: String,
|
|
||||||
pub timestamp: u64,
|
|
||||||
pub metrics: Vec<Metric>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Command output streaming message
|
/// Command output streaming message
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -20,15 +16,6 @@ pub struct CommandOutputMessage {
|
|||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MetricMessage {
|
|
||||||
pub fn new(hostname: String, metrics: Vec<Metric>) -> Self {
|
|
||||||
Self {
|
|
||||||
hostname,
|
|
||||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
|
||||||
metrics,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandOutputMessage {
|
impl CommandOutputMessage {
|
||||||
pub fn new(hostname: String, command_id: String, command_type: String, output_line: String, is_complete: bool) -> Self {
|
pub fn new(hostname: String, command_id: String, command_type: String, output_line: String, is_complete: bool) -> Self {
|
||||||
@@ -59,8 +46,8 @@ pub enum Command {
|
|||||||
pub enum CommandResponse {
|
pub enum CommandResponse {
|
||||||
/// Acknowledgment of command
|
/// Acknowledgment of command
|
||||||
Ack,
|
Ack,
|
||||||
/// Metrics response
|
/// Agent data response
|
||||||
Metrics(Vec<Metric>),
|
AgentData(AgentData),
|
||||||
/// Pong response to ping
|
/// Pong response to ping
|
||||||
Pong,
|
Pong,
|
||||||
/// Error response
|
/// Error response
|
||||||
@@ -76,7 +63,7 @@ pub struct MessageEnvelope {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum MessageType {
|
pub enum MessageType {
|
||||||
Metrics,
|
AgentData,
|
||||||
Command,
|
Command,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
CommandOutput,
|
CommandOutput,
|
||||||
@@ -84,10 +71,10 @@ pub enum MessageType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MessageEnvelope {
|
impl MessageEnvelope {
|
||||||
pub fn metrics(message: MetricMessage) -> Result<Self, crate::SharedError> {
|
pub fn agent_data(data: AgentData) -> Result<Self, crate::SharedError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
message_type: MessageType::Metrics,
|
message_type: MessageType::AgentData,
|
||||||
payload: serde_json::to_vec(&message)?,
|
payload: serde_json::to_vec(&data)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +106,11 @@ impl MessageEnvelope {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_metrics(&self) -> Result<MetricMessage, crate::SharedError> {
|
pub fn decode_agent_data(&self) -> Result<AgentData, crate::SharedError> {
|
||||||
match self.message_type {
|
match self.message_type {
|
||||||
MessageType::Metrics => Ok(serde_json::from_slice(&self.payload)?),
|
MessageType::AgentData => Ok(serde_json::from_slice(&self.payload)?),
|
||||||
_ => Err(crate::SharedError::Protocol {
|
_ => Err(crate::SharedError::Protocol {
|
||||||
message: "Expected metrics message".to_string(),
|
message: "Expected agent data message".to_string(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user