This commit is contained in:
Christoffer Martinsson 2025-10-12 14:53:27 +02:00
parent 2581435b10
commit 2239badc8a
16 changed files with 1116 additions and 1414 deletions

518
CLAUDE.md
View File

@ -1,11 +1,13 @@
# CM Dashboard - Infrastructure Monitoring TUI
## Overview
A high-performance Rust-based TUI dashboard for monitoring CMTEC infrastructure. Built to replace Glance with a custom solution tailored for our specific monitoring needs and API integrations.
## Project Goals
### Core Objectives
- **Real-time monitoring** of all infrastructure components
- **Multi-host support** for cmbox, labbox, simonbox, steambox, srv01
- **Performance-focused** with minimal resource usage
@ -13,6 +15,7 @@ A high-performance Rust-based TUI dashboard for monitoring CMTEC infrastructure.
- **Integration** with existing monitoring APIs (ports 6127, 6128, 6129)
### Key Features
- **NVMe health monitoring** with wear prediction
- **CPU / memory / GPU telemetry** with automatic thresholding
- **Service resource monitoring** with per-service CPU and RAM usage
@ -24,6 +27,7 @@ A high-performance Rust-based TUI dashboard for monitoring CMTEC infrastructure.
## Technical Architecture
### Technology Stack
- **Language**: Rust 🦀
- **TUI Framework**: ratatui (modern tui-rs fork)
- **Async Runtime**: tokio
@ -34,6 +38,7 @@ A high-performance Rust-based TUI dashboard for monitoring CMTEC infrastructure.
- **Time**: chrono
### Dependencies
```toml
[dependencies]
ratatui = "0.24" # Modern TUI framework
@ -84,27 +89,8 @@ cm-dashboard/
└── WIDGETS.md # Widget development guide
```
## API Integration
### Existing CMTEC APIs
1. **Smart Metrics API** (port 6127)
- NVMe health status (wear, temperature, power-on hours)
- Disk space information
- SMART health indicators
2. **Service Metrics API** (port 6128)
- Service status and resource usage
- Service memory consumption vs limits
- Host CPU load / frequency / temperature
- Root disk utilisation snapshot
- GPU utilisation and temperature (if available)
3. **Backup Metrics API** (port 6129)
- Backup status and history
- Repository statistics
- Service integration status
### Data Structures
```rust
#[derive(Deserialize, Debug)]
pub struct SmartMetrics {
@ -154,36 +140,35 @@ pub struct BackupMetrics {
## Dashboard Layout Design
### Main Dashboard View
```
┌─────────────────────────────────────────────────────────────────────┐
📊 CMTEC Infrastructure Dashboard srv01
CM Dashboard • cmbox
├─────────────────────────────────────────────────────────────────────┤
💾 NVMe Health │ 🐏 RAM Optimization
│ ┌─────────────────────────┐ │ ┌─────────────────────────────────────┐
│ │ Wear: 4% (█░░░░░░░░░░) │ │ │ Physical: 2.4G/7.6G (32%) │ │
│ │ Temp: 56°C │ │ │ zram: 64B/1.9G (64:1 compression) │ │
│ │ Hours: 11419h (475d) │ │ │ tmpfs: /var/log 88K/512M │ │
│ │ Status: ✅ PASSED │ │ │ Kernel: vm.dirty_ratio=5 │ │
│ └─────────────────────────┘ │ └─────────────────────────────────────┘
Storage • ok:1 warn:0 crit:0 │ Services • ok:1 warn:0 fail:0
│ ┌─────────────────────────────────┐ │ ┌───────────────────────────────
│ │Drive Temp Wear Spare Hours │ │ │Service memory: 7.1/23899.7 MiB│ │
│ │nvme0n1 28°C 1% 100% 14489 │ │ │Disk usage: — │ │
│ │ Capacity Usage │ │ │ Service Memory Disk │ │
│ │ 954G 77G (8%) │ │ │✔ sshd 7.1 MiB — │ │
│ └─────────────────────────────────┘ │ └───────────────────────────────
├─────────────────────────────────────────────────────────────────────┤
│ 🔧 Services Status │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ✅ Gitea (256M/4G, 15G/100G) ✅ smart-metrics-api │ │
│ │ ✅ Immich (1.2G/4G, 45G/500G) ✅ service-metrics-api │ │
│ │ ✅ Vaultwarden (45M/1G, 512M/1G) ✅ backup-metrics-api │ │
│ │ ✅ UniFi (234M/2G, 1.2G/5G) ✅ WordPress M2 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ CPU / Memory • warn │ Backups │
│ System memory: 5251.7/23899.7 MiB │ Host cmbox awaiting backup │ │
│ CPU load (1/5/15): 2.18 2.66 2.56 │ metrics │ │
│ CPU freq: 1100.1 MHz │ │ │
│ CPU temp: 47.0°C │ │ │
├─────────────────────────────────────────────────────────────────────┤
│ 📧 Recent Alerts │ 💾 Backup Status │
│ 10:15 NVMe wear OK → 4% │ Last: ✅ Success (04:00) │
│ 04:00 Backup completed successfully │ Duration: 45m 32s │
│ Yesterday: Email notification test │ Size: 15.2GB → 4.1GB │
│ │ Next: Tomorrow 04:00 │
│ Alerts • ok:0 warn:3 fail:0 │ Status • ZMQ connected │
│ cmbox: warning: CPU load 2.18 │ Monitoring • hosts: 3 │ │
│ srv01: pending: awaiting metrics │ Data source: ZMQ connected │ │
│ labbox: pending: awaiting metrics │ Active host: cmbox (1/3) │ │
└─────────────────────────────────────────────────────────────────────┘
Keys: [h]osts [r]efresh [s]ettings [a]lerts [←→] navigate [q]uit
Keys: [←→] hosts [r]efresh [q]uit
```
### Multi-Host View
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🖥️ CMTEC Host Overview │
@ -199,445 +184,28 @@ Keys: [h]osts [r]efresh [s]ettings [a]lerts [←→] navigate [q]uit
Keys: [Enter] details [r]efresh [s]ort [f]ilter [q]uit
```
## Development Phases
## Development Status
### Phase 1: Foundation (Week 1-2)
- [x] Project setup with Cargo.toml
- [ ] Basic TUI framework with ratatui
- [ ] HTTP client for API connections
- [ ] Data structures for metrics
- [ ] Simple single-host dashboard
### Immediate TODOs
**Deliverables:**
- Working TUI that connects to srv01
- Real-time display of basic metrics
- Keyboard navigation
- Refactor all dashboard widgets to use a shared table/layout helper so icons, padding, and titles remain consistent across panels
### Phase 2: Core Features (Week 3-4)
- [ ] All widget implementations
- [ ] Multi-host configuration
- [ ] Historical data storage
- [ ] Alert system integration
- [ ] Configuration management
- Investigate why the backup metrics agent is not publishing data to the dashboard
- Resize the services widget so it can display more services without truncation
- Remove the dedicated status widget and redistribute the layout space
- Add responsive scaling within each widget so columns and content adapt dynamically
**Deliverables:**
- Full-featured dashboard
- Multi-host monitoring
- Historical trending
- Configuration file support
### Phase 3: Advanced Features 🚧 IN PROGRESS
### Phase 3: Advanced Features (Week 5-6)
- [ ] Predictive analytics
- [ ] Custom alert rules
- [ ] Export capabilities
- [ ] Performance optimizations
- [ ] Error handling & resilience
- [x] ZMQ gossip network implementation
- [x] Comprehensive error handling
- [x] Performance optimizations
- [ ] Predictive analytics for wear levels
- [ ] Custom alert rules engine
- [ ] Historical data export capabilities
**Deliverables:**
- Production-ready dashboard
- Advanced monitoring features
- Comprehensive error handling
- Performance benchmarks
# Important Communication Guidelines
### Phase 4: Polish & Documentation (Week 7-8)
- [ ] Code documentation
- [ ] User documentation
- [ ] Installation scripts
- [ ] Testing suite
- [ ] Release preparation
NEVER write that you have "successfully implemented" something or generate extensive summary text without first verifying with the user that the implementation is correct. This wastes tokens. Keep responses concise.
**Deliverables:**
- Complete documentation
- Installation packages
- Test coverage
- Release v1.0
## Configuration
### Host Configuration (config/hosts.toml)
```toml
[hosts]
[hosts.srv01]
name = "srv01"
address = "192.168.30.100"
smart_api = 6127
service_api = 6128
backup_api = 6129
role = "server"
[hosts.cmbox]
name = "cmbox"
address = "192.168.30.101"
smart_api = 6127
service_api = 6128
backup_api = 6129
role = "workstation"
[hosts.labbox]
name = "labbox"
address = "192.168.30.102"
smart_api = 6127
service_api = 6128
backup_api = 6129
role = "lab"
```
### Dashboard Configuration (config/dashboard.toml)
```toml
[dashboard]
refresh_interval = 5 # seconds
history_retention = 7 # days
theme = "dark"
[widgets]
nvme_wear_threshold = 70
temperature_threshold = 70
memory_warning_threshold = 80
memory_critical_threshold = 90
[alerts]
email_enabled = true
sound_enabled = false
desktop_notifications = true
```
## Key Features
### Real-time Monitoring
- **Auto-refresh** configurable intervals (1-60 seconds)
- **Async data fetching** from multiple hosts simultaneously
- **Connection status** indicators for each host
- **Graceful degradation** when hosts are unreachable
### Historical Tracking
- **SQLite database** for local storage
- **Trend analysis** for wear levels and resource usage
- **Retention policies** configurable per metric type
- **Export capabilities** (CSV, JSON)
### Alert System
- **Threshold-based alerts** for all metrics
- **Email integration** with existing notification system
- **Alert acknowledgment** and history
- **Custom alert rules** with logical operators
### Multi-Host Management
- **Auto-discovery** of hosts on network
- **Host grouping** by role (server, workstation, lab)
- **Bulk operations** across multiple hosts
- **Host-specific configurations**
## Performance Requirements
### Resource Usage
- **Memory**: < 50MB runtime footprint
- **CPU**: < 1% average CPU usage
- **Network**: Minimal bandwidth (< 1KB/s per host)
- **Startup**: < 2 seconds cold start
### Responsiveness
- **UI updates**: 60 FPS smooth rendering
- **Data refresh**: < 500ms API response handling
- **Navigation**: Instant keyboard response
- **Error recovery**: < 5 seconds reconnection
## Security Considerations
### Network Security
- **Local network only** - no external connections
- **Authentication** for API access if implemented
- **Encrypted storage** for sensitive configuration
- **Audit logging** for administrative actions
### Data Privacy
- **Local storage** only - no cloud dependencies
- **Configurable retention** for historical data
- **Secure deletion** of expired data
- **No sensitive data logging**
## Testing Strategy
### Unit Tests
- API client modules
- Data parsing and validation
- Configuration management
- Alert logic
### Integration Tests
- Multi-host connectivity
- API error handling
- Database operations
- Alert delivery
### Performance Tests
- Memory usage under load
- Network timeout handling
- Large dataset rendering
- Extended runtime stability
## Deployment
### Installation
```bash
# Development build
cargo build --release
# Install from source
cargo install --path .
# Future: Package distribution
# Package for NixOS inclusion
```
### Usage
```bash
# Start dashboard
cm-dashboard
# Specify config
cm-dashboard --config /path/to/config
# Single host mode
cm-dashboard --host srv01
# Debug mode
cm-dashboard --verbose
```
## Maintenance
### Regular Tasks
- **Database cleanup** - automated retention policies
- **Log rotation** - configurable log levels and retention
- **Configuration validation** - startup configuration checks
- **Performance monitoring** - built-in metrics for dashboard itself
### Updates
- **Auto-update checks** - optional feature
- **Configuration migration** - version compatibility
- **API compatibility** - backwards compatibility with monitoring APIs
- **Feature toggles** - enable/disable features without rebuild
## Future Enhancements
### Proposed: ZMQ Metrics Agent Architecture
#### **Current Limitations of HTTP-based APIs**
- **Performance overhead**: Python scripts with HTTP servers on each host
- **Network complexity**: Multiple firewall ports (6127-6129) per host
- **Polling inefficiency**: Manual refresh cycles instead of real-time streaming
- **Scalability concerns**: Resource usage grows linearly with hosts
#### **Proposed: Rust ZMQ Gossip Network**
**Core Concept**: Replace HTTP polling with a peer-to-peer ZMQ gossip network where lightweight Rust agents stream metrics in real-time.
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ cmbox │<-->│ labbox │<-->│ srv01 │<-->│steambox │
│ :6130 │ │ :6130 │ │ :6130 │ │ :6130 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
^ ^ ^
└────────────────────────────┼──────────────┘
v
┌─────────┐
│simonbox │
│ :6130 │
└─────────┘
```
**Architecture Benefits**:
- **No central router**: Peer-to-peer gossip eliminates single point of failure
- **Self-healing**: Network automatically routes around failed hosts
- **Real-time streaming**: Metrics pushed immediately on change
- **Performance**: Rust agents ~10-100x faster than Python
- **Simplified networking**: Single ZMQ port (6130) vs multiple HTTP ports
- **Lower resource usage**: Minimal memory/CPU footprint per agent
#### **Implementation Plan**
**Phase 1: Agent Development**
```rust
// Lightweight agent on each host
pub struct MetricsAgent {
neighbors: Vec<String>, // ["srv01:6130", "cmbox:6130"]
collectors: Vec<Box<dyn Collector>>, // SMART, Service, Backup
gossip_interval: Duration, // How often to broadcast
zmq_context: zmq::Context,
}
// Message format for metrics
#[derive(Serialize, Deserialize)]
struct MetricsMessage {
hostname: String,
agent_type: AgentType, // Smart, Service, Backup
timestamp: u64,
metrics: MetricsData,
hop_count: u8, // Prevent infinite loops
}
```
**Phase 2: Dashboard Integration**
- **ZMQ Subscriber**: Dashboard subscribes to gossip stream on srv01
- **Real-time updates**: WebSocket connection to TUI for live streaming
- **Historical storage**: Optional persistence layer for trending
**Phase 3: Migration Strategy**
- **Parallel deployment**: Run ZMQ agents alongside existing HTTP APIs
- **A/B comparison**: Validate metrics accuracy and performance
- **Gradual cutover**: Switch dashboard to ZMQ, then remove HTTP services
#### **Configuration Integration**
**Agent Configuration** (per-host):
```toml
[metrics_agent]
enabled = true
port = 6130
neighbors = ["srv01:6130", "cmbox:6130"] # Redundant connections
role = "agent" # or "dashboard" for srv01
[collectors]
smart_metrics = { enabled = true, interval_ms = 5000 }
service_metrics = { enabled = true, interval_ms = 2000 } # srv01 only
backup_metrics = { enabled = true, interval_ms = 30000 } # srv01 only
```
**Dashboard Configuration** (updated):
```toml
[data_source]
type = "zmq_gossip" # vs current "http_polling"
listen_port = 6130
buffer_size = 1000
real_time_updates = true
[legacy_support]
http_apis_enabled = true # For migration period
fallback_to_http = true # If ZMQ unavailable
```
#### **Performance Comparison**
| Metric | Current (HTTP) | Proposed (ZMQ) |
|--------|---------------|----------------|
| Collection latency | ~50ms | ~1ms |
| Network overhead | HTTP headers + JSON | Binary ZMQ frames |
| Resource per host | ~5MB (Python + HTTP) | ~1MB (Rust agent) |
| Update frequency | 5s polling | Real-time push |
| Network ports | 3 per host | 1 per host |
| Failure recovery | Manual retry | Auto-reconnect |
#### **Development Roadmap**
**Week 1-2**: Basic ZMQ agent
- Rust binary with ZMQ gossip protocol
- SMART metrics collection
- Configuration management
**Week 3-4**: Dashboard integration
- ZMQ subscriber in cm-dashboard
- Real-time TUI updates
- Parallel HTTP/ZMQ operation
**Week 5-6**: Production readiness
- Service/backup metrics support
- Error handling and resilience
- Performance benchmarking
**Week 7-8**: Migration and cleanup
- Switch dashboard to ZMQ-only
- Remove legacy HTTP APIs
- Documentation and deployment
### Potential Features
- **Plugin system** for custom widgets
- **REST API** for external integrations
- **Mobile companion app** for alerts
- **Grafana integration** for advanced graphing
- **Prometheus metrics export**
- **Custom scripting** for automated responses
- **Machine learning** for predictive analytics
- **Clustering support** for high availability
### Integration Opportunities
- **Home Assistant** integration
- **Slack/Discord** notifications
- **SNMP support** for network equipment
- **Docker/Kubernetes** container monitoring
- **Cloud metrics** integration (if needed)
## Success Metrics
### Technical Success
- **Zero crashes** during normal operation
- **Sub-second response** times for all operations
- **99.9% uptime** for monitoring (excluding network issues)
- **Minimal resource usage** as specified
### User Success
- **Faster problem detection** compared to Glance
- **Reduced time to resolution** for issues
- **Improved infrastructure awareness**
- **Enhanced operational efficiency**
---
## Development Log
### Project Initialization
- Repository created: `/home/cm/projects/cm-dashboard`
- Initial planning: TUI dashboard to replace Glance
- Technology selected: Rust + ratatui
- Architecture designed: Multi-host monitoring with existing API integration
### Current Status (HTTP-based)
- **Functional TUI**: Basic dashboard rendering with ratatui
- **HTTP API integration**: Connects to ports 6127, 6128, 6129
- **Multi-host support**: Configurable host management
- **Async architecture**: Tokio-based concurrent metrics fetching
- **Configuration system**: TOML-based host and dashboard configuration
### Proposed Evolution: ZMQ Agent System
**Rationale for Change**: The current HTTP polling approach has fundamental limitations:
1. **Latency**: 5-second refresh cycles miss rapid changes
2. **Resource overhead**: Python HTTP servers consume unnecessary resources
3. **Network complexity**: Multiple ports per host complicate firewall management
4. **Scalability**: Linear resource growth with host count
**Solution**: Peer-to-peer ZMQ gossip network with Rust agents provides:
- **Real-time streaming**: Sub-second metric propagation
- **Fault tolerance**: Network self-heals around failed hosts
- **Performance**: Native Rust speed vs interpreted Python
- **Simplicity**: Single port per host, no central coordination
### ZMQ Agent Development Plan
**Component 1: cm-metrics-agent** (New Rust binary)
```toml
[package]
name = "cm-metrics-agent"
version = "0.1.0"
[dependencies]
zmq = "0.10"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
smartmontools-rs = "0.1" # Or direct smartctl bindings
```
**Component 2: Dashboard Integration** (Update cm-dashboard)
- Add ZMQ subscriber mode alongside HTTP client
- Implement real-time metric streaming
- Provide migration path from HTTP to ZMQ
**Migration Strategy**:
1. **Phase 1**: Deploy agents alongside existing APIs
2. **Phase 2**: Switch dashboard to ZMQ mode
3. **Phase 3**: Remove HTTP APIs from NixOS configurations
**Performance Targets**:
- **Agent footprint**: < 2MB RAM, < 1% CPU
- **Metric latency**: < 100ms propagation across network
- **Network efficiency**: < 1KB/s per host steady state
NEVER implement code without first getting explicit user agreement on the approach. Always ask for confirmation before proceeding with implementation.

View File

@ -3,30 +3,29 @@
CM Dashboard is a Rust-powered terminal UI for real-time monitoring of CMTEC infrastructure hosts. It subscribes to the CMTEC ZMQ gossip network where lightweight agents publish SMART, service, and backup metrics, and presents them in an efficient, keyboard-driven interface built with `ratatui`.
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ CM Dashboard │
├────────────────────────────┬────────────────────────────┬────────────────────┤
│ NVMe Health │ Services │ CPU / Memory │
│ Host: srv01 │ Host: srv01 │ Host: srv01 │
│ Status: Healthy │ Service memory: 1.2G/4.0G │ RAM: 6.9 / 7.8 GiB │
│ Healthy/Warning/Critical: │ Disk usage: 45 / 500 GiB │ CPU load (1/5/15): │
│ 4 / 0 / 0 │ Services tracked: 8 │ 1.2 0.9 0.7 │
│ Capacity used: 512 / 2048G │ │ CPU temp: 68°C │
│ Issue: — │ nginx running 320M │ GPU temp: — │
│ │ immich running 1.2G │ Status • ok │
│ │ backup-api running 40M │ │
├────────────────────────────┴────────────┬───────────────┴────────────────────┤
│ Backups │ Alerts │
│ Host: srv01 │ srv01: ok │
│ Overall: Healthy │ labbox: warning: RAM 82% │
│ Last success: 2024-02-01 03:12:45 │ cmbox: critical: CPU temp 92°C │
│ Snapshots: 17 • Size: 512.0 GiB │ Update: 2024-02-01 10:15:32 │
│ Pending jobs: 0 (enabled: true) │ │
└──────────────────────────────┬───────────────────────────────────────────────┘
│ Status │ │
│ Active host: srv01 (1/3) │ History retention ≈ 3600s │
│ Config: config/dashboard.toml│ Default host: labbox │
└──────────────────────────────┴───────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ CM Dashboard • cmbox │
├─────────────────────────────────────────────────────────────────────┤
│ Storage • ok:1 warn:0 crit:0 │ Services • ok:1 warn:0 fail:0 │
│ ┌─────────────────────────────────┐ │ ┌─────────────────────────────── │ │
│ │Drive Temp Wear Spare Hours │ │ │Service memory: 7.1/23899.7 MiB│ │
│ │nvme0n1 28°C 1% 100% 14489 │ │ │Disk usage: — │ │
│ │ Capacity Usage │ │ │ Service Memory Disk │ │
│ │ 954G 77G (8%) │ │ │✔ sshd 7.1 MiB — │ │
│ └─────────────────────────────────┘ │ └─────────────────────────────── │ │
├─────────────────────────────────────────────────────────────────────┤
│ CPU / Memory • warn │ Backups │
│ System memory: 5251.7/23899.7 MiB │ Host cmbox awaiting backup │ │
│ CPU load (1/5/15): 2.18 2.66 2.56 │ metrics │ │
│ CPU freq: 1100.1 MHz │ │ │
│ CPU temp: 47.0°C │ │ │
├─────────────────────────────────────────────────────────────────────┤
│ Alerts • ok:0 warn:3 fail:0 │ Status • ZMQ connected │
│ cmbox: warning: CPU load 2.18 │ Monitoring • hosts: 3 │ │
│ srv01: pending: awaiting metrics │ Data source: ZMQ connected │ │
│ labbox: pending: awaiting metrics │ Active host: cmbox (1/3) │ │
└─────────────────────────────────────────────────────────────────────┘
Keys: [←→] hosts [r]efresh [q]uit
```
## Requirements
@ -100,12 +99,15 @@ Adjust the host list and `data_source.zmq.endpoints` to match your CMTEC gossip
## Features
- Rotating host selection with left/right arrows (`←`, `→`, `h`, `l`, `Tab`)
- Live NVMe, service, CPU/memory, backup, and alert panels per host
- Health scoring that rolls CPU/RAM/GPU pressure into alerts automatically
- Structured logging with `tracing` (`-v`/`-vv` to increase verbosity)
- Help overlay (`?`) outlining keyboard shortcuts
- Config-driven host discovery via `config/dashboard.toml`
- **Real-time monitoring** with ZMQ gossip network architecture
- **Storage health** with drive capacity, usage, temperature, and wear tracking
- **Per-service resource tracking** including memory and disk usage by service
- **CPU/Memory monitoring** with load averages, temperature, and GPU metrics
- **Alert system** with color-coded highlighting and threshold-based warnings
- **Multi-host support** with seamless host switching (`←`, `→`, `h`, `l`, `Tab`)
- **Backup status** monitoring with restic integration
- **Keyboard-driven interface** with help overlay (`?`)
- **Configuration management** via TOML files for hosts and dashboard settings
## Getting Started
@ -131,13 +133,30 @@ cargo run -p cm-dashboard -- -v
## Agent
The metrics agent publishes SMART/service/backup data to the gossip network. Run it on each host (or under systemd/NixOS) and point the dashboard at its endpoint. Example:
The metrics agent runs on each host and publishes SMART, service, and backup data to the ZMQ gossip network. The agent auto-detects system configuration and requires root privileges for hardware monitoring.
```bash
cargo run -p cm-dashboard-agent -- --hostname srv01 --bind tcp://*:6130 --interval-ms 5000
# Run agent with auto-detection
sudo cargo run -p cm-dashboard-agent
# Run with specific configuration
sudo cargo run -p cm-dashboard-agent -- --config config/agent.toml
# Manual configuration
sudo cargo run -p cm-dashboard-agent -- \
--hostname srv01 \
--bind tcp://*:6130 \
--smart-devices nvme0n1,sda \
--services nginx,postgres
```
Use `--disable-*` flags to skip collectors when a host doesnt expose those metrics.
The agent automatically:
- Detects available storage devices for SMART monitoring
- Discovers running systemd services for resource tracking
- Configures appropriate collection intervals per host type
- Requires root access for `smartctl` and system metrics
Use `--disable-smart`, `--disable-service`, or `--disable-backup` to skip specific collectors.
## Development

View File

@ -82,7 +82,7 @@ impl ServiceCollector {
// Get memory quota from systemd if available
let memory_quota_mb = self.get_service_memory_limit(service).await.unwrap_or(0.0);
// Get disk usage for this service
let disk_used_gb = self.get_service_disk_usage(service).await.unwrap_or(0.0);
@ -149,7 +149,7 @@ impl ServiceCollector {
async fn get_service_disk_usage(&self, service: &str) -> Result<f32, CollectorError> {
// For systemd services, check if they have private /var directories or specific data paths
// This is a simplified implementation - could be enhanced to check actual service-specific paths
// Common service data directories to check
let potential_paths = vec![
format!("/var/lib/{}", service),
@ -158,21 +158,21 @@ impl ServiceCollector {
format!("/opt/{}", service),
format!("/srv/{}", service),
];
let mut total_usage = 0.0;
for path in potential_paths {
if let Ok(usage) = self.get_directory_size(&path).await {
total_usage += usage;
}
}
Ok(total_usage)
}
async fn get_directory_size(&self, path: &str) -> Result<f32, CollectorError> {
let output = Command::new("du")
.args(["-s", "-k", path]) // Use kilobytes instead of forcing GB
.args(["-s", "-k", path]) // Use kilobytes instead of forcing GB
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
@ -195,7 +195,7 @@ impl ServiceCollector {
return Ok(size_gb);
}
}
Ok(0.0)
}

View File

@ -89,7 +89,10 @@ impl SmartCollector {
Ok(SmartDeviceData::from_smartctl_output(device, smart_output))
}
async fn get_drive_usage(&self, device: &str) -> Result<(Option<f32>, Option<f32>), CollectorError> {
async fn get_drive_usage(
&self,
device: &str,
) -> Result<(Option<f32>, Option<f32>), CollectorError> {
// Get capacity first
let capacity = match self.get_drive_capacity(device).await {
Ok(cap) => Some(cap),
@ -134,8 +137,8 @@ impl SmartCollector {
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lsblk_output: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| CollectorError::ParseError {
let lsblk_output: serde_json::Value =
serde_json::from_str(&stdout).map_err(|e| CollectorError::ParseError {
message: format!("Failed to parse lsblk JSON: {}", e),
})?;
@ -155,25 +158,28 @@ impl SmartCollector {
fn parse_lsblk_size(&self, size_str: &str) -> Result<f32, CollectorError> {
// Parse sizes like "953,9G", "1T", "512M"
let size_str = size_str.replace(',', "."); // Handle European decimal separator
let size_str = size_str.replace(',', "."); // Handle European decimal separator
if let Some(pos) = size_str.find(|c: char| c.is_alphabetic()) {
let (number_part, unit_part) = size_str.split_at(pos);
let number: f32 = number_part.parse()
let number: f32 = number_part
.parse()
.map_err(|e| CollectorError::ParseError {
message: format!("Failed to parse size number '{}': {}", number_part, e),
})?;
let multiplier = match unit_part.to_uppercase().as_str() {
"T" | "TB" => 1024.0,
"G" | "GB" => 1.0,
"M" | "MB" => 1.0 / 1024.0,
"K" | "KB" => 1.0 / (1024.0 * 1024.0),
_ => return Err(CollectorError::ParseError {
message: format!("Unknown size unit: {}", unit_part),
}),
_ => {
return Err(CollectorError::ParseError {
message: format!("Unknown size unit: {}", unit_part),
})
}
};
Ok(number * multiplier)
} else {
Err(CollectorError::ParseError {
@ -428,18 +434,18 @@ mod tests {
#[test]
fn test_parse_lsblk_size() {
let collector = SmartCollector::new(true, 5000, vec![]);
// Test gigabyte sizes
assert!((collector.parse_lsblk_size("953,9G").unwrap() - 953.9).abs() < 0.1);
assert!((collector.parse_lsblk_size("1G").unwrap() - 1.0).abs() < 0.1);
// Test terabyte sizes
assert!((collector.parse_lsblk_size("1T").unwrap() - 1024.0).abs() < 0.1);
assert!((collector.parse_lsblk_size("2,5T").unwrap() - 2560.0).abs() < 0.1);
// Test megabyte sizes
assert!((collector.parse_lsblk_size("512M").unwrap() - 0.5).abs() < 0.1);
// Test error cases
assert!(collector.parse_lsblk_size("invalid").is_err());
assert!(collector.parse_lsblk_size("1X").is_err());

View File

@ -32,6 +32,17 @@ enabled = true
id = "alerts"
enabled = true
[data_source]
kind = "zmq"
[data_source.zmq]
endpoints = [
"tcp://192.168.30.100:6130", # srv01
"tcp://192.168.30.105:6130", # cmbox
"tcp://192.168.30.107:6130", # simonbox
"tcp://192.168.30.101:6130" # steambox
]
[filesystem]
# cache_dir = "/var/lib/cm-dashboard/cache"
# history_dir = "/var/lib/cm-dashboard/history"

View File

@ -41,7 +41,9 @@ struct HostRuntimeState {
#[derive(Debug)]
pub struct App {
options: AppOptions,
#[allow(dead_code)]
config: Option<AppConfig>,
#[allow(dead_code)]
active_config_path: Option<PathBuf>,
hosts: Vec<HostTarget>,
history: MetricsHistory,
@ -136,10 +138,12 @@ impl App {
self.should_quit
}
#[allow(dead_code)]
pub fn status_text(&self) -> &str {
&self.status
}
#[allow(dead_code)]
pub fn zmq_connected(&self) -> bool {
self.zmq_connected
}
@ -148,14 +152,17 @@ impl App {
self.options.tick_rate()
}
#[allow(dead_code)]
pub fn config(&self) -> Option<&AppConfig> {
self.config.as_ref()
}
#[allow(dead_code)]
pub fn active_config_path(&self) -> Option<&PathBuf> {
self.active_config_path.as_ref()
}
#[allow(dead_code)]
pub fn hosts(&self) -> &[HostTarget] {
&self.hosts
}
@ -171,6 +178,7 @@ impl App {
}
}
#[allow(dead_code)]
pub fn history(&self) -> &MetricsHistory {
&self.history
}

View File

@ -80,6 +80,8 @@ pub struct ServiceInfo {
pub sandbox_limit: Option<f32>,
#[serde(default)]
pub disk_used_gb: f32,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -1,95 +1,79 @@
use chrono::{DateTime, Utc};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::ui::memory::{evaluate_performance, PerfSeverity};
use crate::ui::system::{evaluate_performance, PerfSeverity};
use crate::ui::widget::{render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
let (severity, ok_count, warn_count, fail_count) = classify_hosts(hosts);
let color = match severity {
let mut color = match severity {
AlertSeverity::Critical => Color::Red,
AlertSeverity::Warning => Color::Yellow,
AlertSeverity::Healthy => Color::Green,
AlertSeverity::Unknown => Color::LightCyan,
AlertSeverity::Unknown => Color::Gray,
};
if hosts.is_empty() {
color = Color::Gray;
}
let title = format!(
"Alerts • ok:{} warn:{} fail:{}",
ok_count, warn_count, fail_count
);
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
if hosts.is_empty() {
frame.render_widget(
Paragraph::new("No hosts configured")
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
return;
}
let header = Row::new(vec![
Cell::from("Host"),
Cell::from("Status"),
Cell::from("Timestamp"),
])
.style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
let widget_status = match severity {
AlertSeverity::Critical => StatusLevel::Error,
AlertSeverity::Warning => StatusLevel::Warning,
AlertSeverity::Healthy => StatusLevel::Ok,
AlertSeverity::Unknown => StatusLevel::Unknown,
};
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Host".to_string(), "Status".to_string(), "Timestamp".to_string()]
);
let rows = hosts.iter().map(|host| {
let (status, severity, emphasize) = host_status(host);
let row_style = severity_style(severity);
let update = latest_timestamp(host)
.map(|ts| ts.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "".to_string());
if hosts.is_empty() {
data.add_row(
None,
"",
vec![
WidgetValue::new("No hosts configured"),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
} else {
for host in hosts {
let (status_text, severity, _emphasize) = host_status(host);
let status_level = match severity {
AlertSeverity::Critical => StatusLevel::Error,
AlertSeverity::Warning => StatusLevel::Warning,
AlertSeverity::Healthy => StatusLevel::Ok,
AlertSeverity::Unknown => StatusLevel::Unknown,
};
let update = latest_timestamp(host)
.map(|ts| ts.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "".to_string());
let status_cell = if emphasize {
Cell::from(Span::styled(
status.clone(),
Style::default().add_modifier(Modifier::BOLD),
))
} else {
Cell::from(status.clone())
};
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new(host.name.clone()),
WidgetValue::new(status_text),
WidgetValue::new(update),
],
);
}
}
Row::new(vec![
Cell::from(host.name.clone()),
status_cell,
Cell::from(update),
])
.style(row_style)
});
let table = Table::new(rows)
.header(header)
.style(Style::default().fg(Color::White))
.widths(&[
Constraint::Percentage(20),
Constraint::Length(20),
Constraint::Min(24),
])
.column_spacing(2);
frame.render_widget(table, inner);
render_widget_data(frame, area, data);
}
#[derive(Copy, Clone, Eq, PartialEq)]
@ -262,12 +246,12 @@ fn host_status(host: &HostDisplayData) -> (String, AlertSeverity, bool) {
("ok".to_string(), AlertSeverity::Healthy, false)
}
fn severity_style(severity: AlertSeverity) -> Style {
fn severity_color(severity: AlertSeverity) -> Color {
match severity {
AlertSeverity::Critical => Style::default().fg(Color::Red),
AlertSeverity::Warning => Style::default().fg(Color::Yellow),
AlertSeverity::Healthy => Style::default().fg(Color::White),
AlertSeverity::Unknown => Style::default().fg(Color::LightCyan),
AlertSeverity::Critical => Color::Red,
AlertSeverity::Warning => Color::Yellow,
AlertSeverity::Healthy => Color::Green,
AlertSeverity::Unknown => Color::Gray,
}
}
@ -297,3 +281,12 @@ fn latest_timestamp(host: &HostDisplayData) -> Option<DateTime<Utc>> {
latest
}
fn severity_symbol(severity: AlertSeverity) -> &'static str {
match severity {
AlertSeverity::Critical => "",
AlertSeverity::Warning => "!",
AlertSeverity::Healthy => "",
AlertSeverity::Unknown => "?",
}
}

View File

@ -1,11 +1,10 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{BackupMetrics, BackupStatus};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@ -16,113 +15,85 @@ pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
render_placeholder(
frame,
area,
"Backups",
&format!("Host {} awaiting backup metrics", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
None => render_placeholder(frame, area, "Backups", "No hosts configured"),
}
}
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMetrics, area: Rect) {
let color = backup_status_color(&metrics.overall_status);
let title = format!("Backups • status: {:?}", metrics.overall_status);
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(inner);
let summary_line = Line::from(vec![
Span::styled("Snapshots: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(metrics.backup.snapshot_count.to_string()),
Span::raw(" • Size: "),
Span::raw(format!("{:.1} GiB", metrics.backup.size_gb)),
Span::raw(" • Last success: "),
Span::raw(format_timestamp(metrics.backup.last_success.as_ref())),
]);
frame.render_widget(
Paragraph::new(summary_line)
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
chunks[0],
let widget_status = match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
BackupStatus::Unknown => StatusLevel::Unknown,
BackupStatus::Healthy => StatusLevel::Ok,
};
let mut data = WidgetData::new(
"Backups",
Some(WidgetStatus::new(widget_status)),
vec!["Aspect".to_string(), "Details".to_string()]
);
let header = Row::new(vec![Cell::from("Aspect"), Cell::from("Details")]).style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
let mut rows = Vec::new();
rows.push(
Row::new(vec![
Cell::from("Repo"),
Cell::from(format!(
let repo_status = repo_status_level(metrics);
data.add_row(
Some(WidgetStatus::new(repo_status)),
"",
vec![
WidgetValue::new("Repo"),
WidgetValue::new(format!(
"Snapshots: {} • Size: {:.1} GiB",
metrics.backup.snapshot_count, metrics.backup.size_gb
)),
])
.style(Style::default().fg(Color::White)),
],
);
rows.push(
Row::new(vec![
Cell::from("Service"),
Cell::from(format!(
let service_status = service_status_level(metrics);
data.add_row(
Some(WidgetStatus::new(service_status)),
"",
vec![
WidgetValue::new("Service"),
WidgetValue::new(format!(
"Enabled: {} • Pending jobs: {}",
metrics.service.enabled, metrics.service.pending_jobs
)),
])
.style(backup_severity_style(&metrics.overall_status)),
],
);
if let Some(last_failure) = metrics.backup.last_failure.as_ref() {
rows.push(
Row::new(vec![
Cell::from("Last failure"),
Cell::from(last_failure.format("%Y-%m-%d %H:%M:%S").to_string()),
])
.style(Style::default().fg(Color::Red)),
data.add_row(
Some(WidgetStatus::new(StatusLevel::Error)),
"",
vec![
WidgetValue::new("Last failure"),
WidgetValue::new(format_timestamp(Some(last_failure))),
],
);
}
if let Some(message) = metrics.service.last_message.as_ref() {
let message_style = match metrics.overall_status {
BackupStatus::Failed => Style::default().fg(Color::Red),
BackupStatus::Warning => Style::default().fg(Color::Yellow),
_ => Style::default().fg(Color::White),
let status_level = match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
BackupStatus::Unknown => StatusLevel::Unknown,
BackupStatus::Healthy => StatusLevel::Ok,
};
rows.push(
Row::new(vec![
Cell::from("Last message"),
Cell::from(message.clone()),
])
.style(message_style),
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new("Last message"),
WidgetValue::new(message.clone()),
],
);
}
let table = Table::new(rows)
.header(header)
.style(Style::default().fg(Color::White))
.widths(&[Constraint::Length(13), Constraint::Min(10)])
.column_spacing(2);
frame.render_widget(table, chunks[1]);
render_widget_data(frame, area, data);
}
fn backup_status_color(status: &BackupStatus) -> Color {
@ -140,27 +111,31 @@ fn format_timestamp(timestamp: Option<&chrono::DateTime<chrono::Utc>>) -> String
.unwrap_or_else(|| "".to_string())
}
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
let block = Block::default()
.title("Backups")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightGreen))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(message))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
}
fn backup_severity_style(status: &BackupStatus) -> Style {
match status {
BackupStatus::Failed => Style::default().fg(Color::Red),
BackupStatus::Warning => Style::default().fg(Color::Yellow),
BackupStatus::Unknown => Style::default().fg(Color::LightCyan),
BackupStatus::Healthy => Style::default().fg(Color::White),
fn repo_status_level(metrics: &BackupMetrics) -> StatusLevel {
match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
_ => {
if metrics.backup.snapshot_count > 0 {
StatusLevel::Ok
} else {
StatusLevel::Warning
}
}
}
}
fn service_status_level(metrics: &BackupMetrics) -> StatusLevel {
match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
BackupStatus::Unknown => StatusLevel::Unknown,
BackupStatus::Healthy => {
if metrics.service.enabled {
StatusLevel::Ok
} else {
StatusLevel::Warning
}
}
}
}

View File

@ -1,12 +1,12 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Cell, Row, Table};
use ratatui::widgets::Block;
use ratatui::Frame;
use crate::app::App;
use super::{alerts, backup, memory, storage, services};
use super::{alerts, backup, services, storage, system};
pub fn render(frame: &mut Frame, app: &App) {
let host_summaries = app.host_display_data();
@ -17,7 +17,7 @@ pub fn render(frame: &mut Frame, app: &App) {
} else {
"CM Dashboard".to_string()
};
let root_block = Block::default().title(Span::styled(
title,
Style::default()
@ -30,164 +30,39 @@ pub fn render(frame: &mut Frame, app: &App) {
let outer = inner_rect(size);
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35),
Constraint::Percentage(35),
Constraint::Percentage(30),
])
let main_columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(outer);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[0]);
let left_side = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(75), Constraint::Percentage(25)])
.split(main_columns[0]);
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[1]);
let left_widgets = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(left_side[0]);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[2]);
let services_area = main_columns[1];
storage::render(frame, primary_host.as_ref(), top[0]);
services::render(frame, primary_host.as_ref(), top[1]);
memory::render(frame, primary_host.as_ref(), middle[0]);
backup::render(frame, primary_host.as_ref(), middle[1]);
alerts::render(frame, &host_summaries, bottom[0]);
render_status(frame, app, bottom[1]);
system::render(frame, primary_host.as_ref(), left_widgets[0]);
storage::render(frame, primary_host.as_ref(), left_widgets[1]);
backup::render(frame, primary_host.as_ref(), left_widgets[2]);
services::render(frame, primary_host.as_ref(), services_area);
alerts::render(frame, &host_summaries, left_side[1]);
if app.help_visible() {
render_help(frame, size);
}
}
fn render_status(frame: &mut Frame, app: &App, area: Rect) {
let connected = app.zmq_connected();
let title_color = if connected { Color::Green } else { Color::Red };
let title_suffix = if connected {
"connected"
} else {
"disconnected"
};
let block = Block::default()
.title(Span::styled(
format!("Status • ZMQ {title_suffix}"),
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
))
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(title_color))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut rows: Vec<Row> = Vec::new();
let status_style = if connected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::Red)
};
let default_style = Style::default().fg(Color::White);
rows.push(
Row::new(vec![
Cell::from("Status"),
Cell::from(app.status_text().to_string()),
])
.style(status_style),
);
rows.push(
Row::new(vec![
Cell::from("Data source"),
Cell::from(if connected {
"ZMQ connected"
} else {
"ZMQ disconnected"
}),
])
.style(status_style),
);
if let Some((index, host)) = app.active_host_info() {
let mut detail = format!("{} ({}/{})", host.name, index + 1, app.hosts().len());
if let Some(state) = app
.host_display_data()
.into_iter()
.find(|entry| entry.name == host.name)
{
if let Some(last_success) = state.last_success {
detail = format!(
"{} • last success {}",
detail,
last_success.format("%H:%M:%S")
);
}
}
rows.push(
Row::new(vec![Cell::from("Active host"), Cell::from(detail)]).style(default_style),
);
} else {
rows.push(Row::new(vec![Cell::from("Active host"), Cell::from("")]).style(default_style));
}
if let Some(path) = app.active_config_path() {
rows.push(
Row::new(vec![
Cell::from("Config"),
Cell::from(path.display().to_string()),
])
.style(default_style),
);
}
let retention = app.history().retention();
rows.push(
Row::new(vec![
Cell::from("History"),
Cell::from(format!("{} seconds", retention.as_secs())),
])
.style(default_style),
);
if let Some(config) = app.config() {
if let Some(default_host) = &config.hosts.default_host {
rows.push(
Row::new(vec![
Cell::from("Default host"),
Cell::from(default_host.clone()),
])
.style(default_style),
);
}
}
rows.push(
Row::new(vec![
Cell::from("Monitored hosts"),
Cell::from(app.hosts().len().to_string()),
])
.style(default_style),
);
let table = Table::new(rows)
.widths(&[Constraint::Length(18), Constraint::Min(24)])
.column_spacing(2)
.style(default_style);
frame.render_widget(table, inner);
}
fn inner_rect(area: Rect) -> Rect {
Rect {
x: area.x + 1,

View File

@ -1,281 +0,0 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{ServiceMetrics, ServiceSummary};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
Some(data) => {
if let Some(metrics) = data.services.as_ref() {
render_metrics(frame, data, metrics, area);
} else {
render_placeholder(
frame,
area,
&format!("Host {} awaiting service metrics", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
}
}
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &ServiceMetrics, area: Rect) {
let summary = &metrics.summary;
let system_total = if summary.system_memory_total_mb > 0.0 {
summary.system_memory_total_mb
} else {
summary.memory_quota_mb
};
let system_used = if summary.system_memory_used_mb > 0.0 {
summary.system_memory_used_mb
} else {
summary.memory_used_mb
};
let usage_ratio = if system_total > 0.0 {
(system_used / system_total) * 100.0
} else {
0.0
};
let (perf_severity, _reason) = evaluate_performance(summary);
let (color, severity_label) = match perf_severity {
PerfSeverity::Critical => (Color::Red, "crit"),
PerfSeverity::Warning => (Color::Yellow, "warn"),
PerfSeverity::Ok => (Color::Green, "ok"),
};
let title = format!("CPU / Memory • {}", severity_label);
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = Vec::new();
// Check if memory should be highlighted due to alert
let memory_color = if usage_ratio >= 95.0 {
Color::Red // Critical
} else if usage_ratio >= 80.0 {
Color::Yellow // Warning
} else {
Color::White // Normal
};
lines.push(Line::from(vec![
Span::styled(
format!("System memory: {:.1} / {:.1} MiB ({:.1}%)",
system_used, system_total, usage_ratio),
Style::default().fg(memory_color)
)
]));
// Check if CPU load should be highlighted due to alert
let cpu_load_color = if summary.cpu_load_5 >= 4.0 {
Color::Red // Critical
} else if summary.cpu_load_5 >= 2.0 {
Color::Yellow // Warning
} else {
Color::White // Normal
};
lines.push(Line::from(vec![
Span::styled(
format!("CPU load (1/5/15): {:.2} {:.2} {:.2}",
summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15),
Style::default().fg(cpu_load_color)
)
]));
lines.push(Line::from(vec![
Span::raw("CPU freq: "),
Span::raw(format_optional_metric(summary.cpu_freq_mhz, " MHz")),
]));
// Check if CPU temp should be highlighted due to alert
let cpu_temp_color = if let Some(temp) = summary.cpu_temp_c {
if temp >= 90.0 {
Color::Red // Critical
} else if temp >= 80.0 {
Color::Yellow // Warning
} else {
Color::White // Normal
}
} else {
Color::White // Normal
};
lines.push(Line::from(vec![
Span::raw("CPU temp: "),
Span::styled(
format_optional_metric(summary.cpu_temp_c, "°C"),
Style::default().fg(cpu_temp_color)
),
]));
if summary.gpu_load_percent.is_some() || summary.gpu_temp_c.is_some() {
// Check if GPU load should be highlighted due to alert
let gpu_load_color = if let Some(load) = summary.gpu_load_percent {
if load >= 95.0 {
Color::Red // Critical
} else if load >= 85.0 {
Color::Yellow // Warning
} else {
Color::White // Normal
}
} else {
Color::White // Normal
};
lines.push(Line::from(vec![
Span::styled("GPU load: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format_optional_percent(summary.gpu_load_percent),
Style::default().fg(gpu_load_color)
),
]));
// Check if GPU temp should be highlighted due to alert
let gpu_temp_color = if let Some(temp) = summary.gpu_temp_c {
if temp >= 85.0 {
Color::Red // Critical
} else if temp >= 75.0 {
Color::Yellow // Warning
} else {
Color::White // Normal
}
} else {
Color::White // Normal
};
lines.push(Line::from(vec![
Span::styled("GPU temp: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format_optional_metric(summary.gpu_temp_c, "°C"),
Style::default().fg(gpu_temp_color)
),
]));
}
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum PerfSeverity {
Ok,
Warning,
Critical,
}
fn format_optional_metric(value: Option<f32>, unit: &str) -> String {
match value {
Some(number) => format!("{:.1}{}", number, unit),
None => "".to_string(),
}
}
fn format_optional_percent(value: Option<f32>) -> String {
match value {
Some(number) => format!("{:.0}%", number),
None => "".to_string(),
}
}
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
let block = Block::default()
.title("CPU / Memory")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightMagenta))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(message))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
}
pub(crate) fn evaluate_performance(summary: &ServiceSummary) -> (PerfSeverity, Option<String>) {
let mem_percent = if summary.system_memory_total_mb > 0.0 {
(summary.system_memory_used_mb / summary.system_memory_total_mb) * 100.0
} else if summary.memory_quota_mb > 0.0 {
(summary.memory_used_mb / summary.memory_quota_mb) * 100.0
} else {
0.0
};
let mut severity = PerfSeverity::Ok;
let mut reason: Option<String> = None;
let mut consider = |level: PerfSeverity, message: String| {
if level > severity {
severity = level;
reason = Some(message);
}
};
if mem_percent >= 95.0 {
consider(PerfSeverity::Critical, format!("RAM {:.0}%", mem_percent));
} else if mem_percent >= 80.0 {
consider(PerfSeverity::Warning, format!("RAM {:.0}%", mem_percent));
}
let load = summary.cpu_load_5;
if load >= 4.0 {
consider(PerfSeverity::Critical, format!("CPU load {:.2}", load));
} else if load >= 2.0 {
consider(PerfSeverity::Warning, format!("CPU load {:.2}", load));
}
if let Some(temp) = summary.cpu_temp_c {
if temp >= 90.0 {
consider(PerfSeverity::Critical, format!("CPU temp {:.0}°C", temp));
} else if temp >= 80.0 {
consider(PerfSeverity::Warning, format!("CPU temp {:.0}°C", temp));
}
}
if let Some(load) = summary.gpu_load_percent {
if load >= 95.0 {
consider(PerfSeverity::Critical, format!("GPU load {:.0}%", load));
} else if load >= 85.0 {
consider(PerfSeverity::Warning, format!("GPU load {:.0}%", load));
}
}
if let Some(temp) = summary.gpu_temp_c {
if temp >= 85.0 {
consider(PerfSeverity::Critical, format!("GPU temp {:.0}°C", temp));
} else if temp >= 75.0 {
consider(PerfSeverity::Warning, format!("GPU temp {:.0}°C", temp));
}
}
if severity == PerfSeverity::Ok {
(PerfSeverity::Ok, None)
} else {
(severity, reason)
}
}

View File

@ -1,8 +1,9 @@
pub mod alerts;
pub mod backup;
pub mod dashboard;
pub mod memory;
pub mod storage;
pub mod services;
pub mod storage;
pub mod system;
pub mod widget;
pub use dashboard::render;

View File

@ -1,11 +1,10 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{ServiceStatus, ServiceSummary};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@ -16,11 +15,12 @@ pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
render_placeholder(
frame,
area,
"Services",
&format!("Host {} has no service metrics yet", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
None => render_placeholder(frame, area, "Services", "No hosts configured"),
}
}
@ -32,77 +32,38 @@ fn render_metrics(
) {
let summary = &metrics.summary;
let color = summary_color(summary);
let disk_summary = format_disk_summary(summary.disk_used_gb, summary.disk_total_gb);
let title = format!(
"Services • ok:{} warn:{} fail:{} • Disk: {}",
summary.healthy, summary.degraded, summary.failed, disk_summary
"Services • ok:{} warn:{} fail:{}",
summary.healthy, summary.degraded, summary.failed
);
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(inner);
let mut summary_lines = Vec::new();
summary_lines.push(Line::from(vec![
Span::styled(
"Service memory: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format_memory(summary)),
]));
let disk_text = if summary.disk_total_gb > 0.0 {
format!(
"{:.1} / {:.1} GiB",
summary.disk_used_gb, summary.disk_total_gb
)
let widget_status = if summary.failed > 0 {
StatusLevel::Error
} else if summary.degraded > 0 {
StatusLevel::Warning
} else {
"".to_string()
StatusLevel::Ok
};
summary_lines.push(Line::from(vec![
Span::styled(
"Disk usage: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(disk_text),
]));
summary_lines.push(Line::from(vec![
Span::styled(
"Services tracked: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(metrics.services.len().to_string()),
]));
frame.render_widget(
Paragraph::new(summary_lines)
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
chunks[0],
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Service".to_string(), "Memory".to_string(), "Disk".to_string(), "Description".to_string()]
);
if metrics.services.is_empty() {
frame.render_widget(
Paragraph::new("No services reported")
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
chunks[1],
data.add_row(
None,
"",
vec![
WidgetValue::new("No services reported"),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
render_widget_data(frame, area, data);
return;
}
@ -113,41 +74,27 @@ fn render_metrics(
.then_with(|| a.name.cmp(&b.name))
});
let header = Row::new(vec![
Cell::from(""),
Cell::from("Service"),
Cell::from("Memory"),
Cell::from("Disk"),
])
.style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
for svc in services {
let status_level = match svc.status {
ServiceStatus::Running => StatusLevel::Ok,
ServiceStatus::Degraded => StatusLevel::Warning,
ServiceStatus::Restarting => StatusLevel::Warning,
ServiceStatus::Stopped => StatusLevel::Error,
};
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new(svc.name.clone()),
WidgetValue::new(format_memory_value(svc.memory_used_mb, svc.memory_quota_mb)),
WidgetValue::new(format_disk_value(svc.disk_used_gb)),
WidgetValue::new(svc.description.as_deref().unwrap_or("")),
],
);
}
let rows = services.into_iter().map(|svc| {
let row_style = status_style(&svc.status);
Row::new(vec![
Cell::from(status_symbol(&svc.status)),
Cell::from(format_service_name(&svc.name)),
Cell::from(format_memory_value(svc.memory_used_mb, svc.memory_quota_mb)),
Cell::from(format_disk_value(svc.disk_used_gb)),
])
.style(row_style)
});
let table = Table::new(rows)
.header(header)
.style(Style::default().fg(Color::White))
.widths(&[
Constraint::Length(1),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(8),
])
.column_spacing(2);
frame.render_widget(table, chunks[1]);
render_widget_data(frame, area, data);
}
fn status_weight(status: &ServiceStatus) -> i32 {
@ -159,21 +106,12 @@ fn status_weight(status: &ServiceStatus) -> i32 {
}
}
fn status_symbol(status: &ServiceStatus) -> &'static str {
fn status_symbol(status: &ServiceStatus) -> (&'static str, Color) {
match status {
ServiceStatus::Running => "",
ServiceStatus::Degraded => "!",
ServiceStatus::Restarting => "",
ServiceStatus::Stopped => "",
}
}
fn status_style(status: &ServiceStatus) -> Style {
match status {
ServiceStatus::Running => Style::default().fg(Color::White),
ServiceStatus::Degraded => Style::default().fg(Color::Yellow),
ServiceStatus::Restarting => Style::default().fg(Color::Yellow),
ServiceStatus::Stopped => Style::default().fg(Color::Red),
ServiceStatus::Running => ("", Color::Green),
ServiceStatus::Degraded => ("!", Color::Yellow),
ServiceStatus::Restarting => ("", Color::Yellow),
ServiceStatus::Stopped => ("", Color::Red),
}
}
@ -187,17 +125,6 @@ fn summary_color(summary: &ServiceSummary) -> Color {
}
}
fn format_memory(summary: &ServiceSummary) -> String {
if summary.memory_quota_mb > 0.0 {
format!(
"{:.1}/{:.1} MiB",
summary.memory_used_mb, summary.memory_quota_mb
)
} else {
format!("{:.1} MiB used", summary.memory_used_mb)
}
}
fn format_memory_value(used: f32, quota: f32) -> String {
if quota > 0.05 {
format!("{:.1}/{:.1} MiB", used, quota)
@ -208,20 +135,11 @@ fn format_memory_value(used: f32, quota: f32) -> String {
}
}
fn format_disk_summary(used: f32, total: f32) -> String {
if total > 0.05 {
format!("{:.1}/{:.1} GiB", used, total)
} else if used > 0.05 {
format!("{:.1} GiB", used)
} else {
"".to_string()
}
}
fn format_disk_value(used: f32) -> String {
if used >= 1.0 {
format!("{:.1} GiB", used)
} else if used >= 0.001 { // 1 MB or more
} else if used >= 0.001 {
// 1 MB or more
format!("{:.0} MiB", used * 1024.0)
} else if used > 0.0 {
format!("<1 MiB")
@ -230,28 +148,3 @@ fn format_disk_value(used: f32) -> String {
}
}
fn format_service_name(name: &str) -> String {
let mut truncated = String::with_capacity(10);
for ch in name.chars().take(10) {
truncated.push(ch);
}
truncated
}
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
let block = Block::default()
.title("Services")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(message))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
}

View File

@ -1,11 +1,10 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::SmartMetrics;
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@ -16,11 +15,12 @@ pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
render_placeholder(
frame,
area,
"Storage",
&format!("Host {} has no SMART data yet", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
None => render_placeholder(frame, area, "Storage", "No hosts configured"),
}
}
@ -31,99 +31,71 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet
metrics.summary.healthy, metrics.summary.warning, metrics.summary.critical
);
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
let issue_count = metrics.issues.len();
let body_constraints = if issue_count > 0 {
vec![Constraint::Min(1), Constraint::Length(2)]
let widget_status = if metrics.summary.critical > 0 {
StatusLevel::Error
} else if metrics.summary.warning > 0 {
StatusLevel::Warning
} else {
vec![Constraint::Min(1)]
StatusLevel::Ok
};
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Drive".to_string(), "Temp".to_string(), "Wear".to_string(), "Spare".to_string(), "Hours".to_string(), "Capacity".to_string(), "Usage".to_string()]
);
let body_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(body_constraints)
.split(inner);
if metrics.drives.is_empty() {
frame.render_widget(
Paragraph::new("No drives reported")
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
body_chunks[0],
data.add_row(
None,
"",
vec![
WidgetValue::new("No drives reported"),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
} else {
let header = Row::new(vec![
Cell::from("Drive"),
Cell::from("Temp"),
Cell::from("Wear"),
Cell::from("Spare"),
Cell::from("Hours"),
Cell::from("Capacity"),
Cell::from("Usage"),
])
.style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
for drive in &metrics.drives {
let status_level = drive_status_level(metrics, &drive.name);
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new(drive.name.clone()),
WidgetValue::new(format_temperature(drive.temperature_c)),
WidgetValue::new(format_percent(drive.wear_level)),
WidgetValue::new(format_percent(drive.available_spare)),
WidgetValue::new(drive.power_on_hours.to_string()),
WidgetValue::new(format_capacity(drive.capacity_gb)),
WidgetValue::new(format_usage(drive.used_gb, drive.capacity_gb)),
],
);
}
let rows = metrics.drives.iter().map(|drive| {
Row::new(vec![
Cell::from(format_drive_name(&drive.name)),
Cell::from(format_temperature(drive.temperature_c)),
Cell::from(format_percent(drive.wear_level)),
Cell::from(format_percent(drive.available_spare)),
Cell::from(drive.power_on_hours.to_string()),
Cell::from(format_capacity(drive.capacity_gb)),
Cell::from(format_usage(drive.used_gb, drive.capacity_gb)),
])
});
let table = Table::new(rows)
.header(header)
.style(Style::default().fg(Color::White))
.widths(&[
Constraint::Length(10), // Drive name
Constraint::Length(8), // Temp
Constraint::Length(8), // Wear
Constraint::Length(8), // Spare
Constraint::Length(10), // Hours
Constraint::Length(10), // Capacity
Constraint::Min(8), // Usage
])
.column_spacing(2);
frame.render_widget(table, body_chunks[0]);
if let Some(issue) = metrics.issues.first() {
data.add_row(
Some(WidgetStatus::new(StatusLevel::Warning)),
"",
vec![
WidgetValue::new(format!("Issue: {}", issue)),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
}
}
if issue_count > 0 {
let issue_line = Line::from(vec![
Span::styled("Issue: ", Style::default().fg(Color::Yellow)),
Span::styled(
metrics.issues[0].clone(),
Style::default().fg(Color::Yellow),
),
]);
frame.render_widget(
Paragraph::new(issue_line)
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
body_chunks[1],
);
}
render_widget_data(frame, area, data);
}
fn smart_status_color(status: &str) -> Color {
@ -150,13 +122,6 @@ fn format_percent(value: f32) -> String {
}
}
fn format_drive_name(name: &str) -> String {
let mut truncated = String::with_capacity(10);
for ch in name.chars().take(10) {
truncated.push(ch);
}
truncated
}
fn format_capacity(value: Option<f32>) -> String {
match value {
@ -178,19 +143,22 @@ fn format_usage(used: Option<f32>, capacity: Option<f32>) -> String {
}
}
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
let block = Block::default()
.title("Storage")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightCyan))
.style(Style::default().fg(Color::White));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(message))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
fn drive_status_level(metrics: &SmartMetrics, drive_name: &str) -> StatusLevel {
if metrics.summary.critical > 0
|| metrics.issues.iter().any(|issue| {
issue.to_lowercase().contains(&drive_name.to_lowercase())
&& issue.to_lowercase().contains("fail")
})
{
StatusLevel::Error
} else if metrics.summary.warning > 0
|| metrics
.issues
.iter()
.any(|issue| issue.to_lowercase().contains(&drive_name.to_lowercase()))
{
StatusLevel::Warning
} else {
StatusLevel::Ok
}
}

217
dashboard/src/ui/system.rs Normal file
View File

@ -0,0 +1,217 @@
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{ServiceMetrics, ServiceSummary};
use crate::ui::widget::{
combined_color, render_placeholder, render_combined_widget_data, status_color_for_cpu_load, status_color_from_metric,
status_color_from_percentage, WidgetDataSet, WidgetStatus, WidgetValue, StatusLevel,
};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
Some(data) => {
if let Some(metrics) = data.services.as_ref() {
render_metrics(frame, data, metrics, area);
} else {
render_placeholder(
frame,
area,
"System",
&format!("Host {} awaiting service metrics", data.name),
);
}
}
None => render_placeholder(frame, area, "System", "No hosts configured"),
}
}
fn render_metrics(
frame: &mut Frame,
_host: &HostDisplayData,
metrics: &ServiceMetrics,
area: Rect,
) {
let summary = &metrics.summary;
let system_total = if summary.system_memory_total_mb > 0.0 {
summary.system_memory_total_mb
} else {
summary.memory_quota_mb
};
let system_used = if summary.system_memory_used_mb > 0.0 {
summary.system_memory_used_mb
} else {
summary.memory_used_mb
};
let usage_ratio = if system_total > 0.0 {
(system_used / system_total) * 100.0
} else {
0.0
};
let (perf_severity, _reason) = evaluate_performance(summary);
let border_color = match perf_severity {
PerfSeverity::Critical => Color::Red,
PerfSeverity::Warning => Color::Yellow,
PerfSeverity::Ok => Color::Green,
};
let memory_color = status_color_from_percentage(usage_ratio, 80.0, 95.0);
let cpu_load_color = status_color_for_cpu_load(summary.cpu_load_5);
let cpu_temp_color = status_color_from_metric(summary.cpu_temp_c, 80.0, 90.0);
let gpu_load_color = summary
.gpu_load_percent
.map(|value| status_color_from_percentage(value, 85.0, 95.0))
.unwrap_or(Color::Green);
let gpu_temp_color = summary
.gpu_temp_c
.map(|value| status_color_from_metric(Some(value), 75.0, 85.0))
.unwrap_or(Color::Green);
let cpu_icon_color = combined_color(&[cpu_load_color, cpu_temp_color]);
let gpu_icon_color = combined_color(&[gpu_load_color, gpu_temp_color]);
// Memory dataset
let memory_status = status_level_from_color(memory_color);
let mut memory_dataset = WidgetDataSet::new(vec!["Memory usage".to_string()], Some(WidgetStatus::new(memory_status)));
memory_dataset.add_row(
Some(WidgetStatus::new(memory_status)),
"",
vec![WidgetValue::new(format!("{:.1} / {:.1}", system_used, system_total))],
);
// CPU dataset
let cpu_status = status_level_from_color(cpu_icon_color);
let mut cpu_dataset = WidgetDataSet::new(vec!["CPU load".to_string(), "CPU temp".to_string(), "CPU freq".to_string()], Some(WidgetStatus::new(cpu_status)));
cpu_dataset.add_row(
Some(WidgetStatus::new(cpu_status)),
"",
vec![
WidgetValue::new(format!("{:.2}{:.2}{:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15)),
WidgetValue::new(format_optional_metric(summary.cpu_temp_c, "°C")),
WidgetValue::new(format_optional_metric(summary.cpu_freq_mhz, " MHz")),
],
);
// GPU dataset
let gpu_status = status_level_from_color(gpu_icon_color);
let mut gpu_dataset = WidgetDataSet::new(vec!["GPU load".to_string(), "GPU temp".to_string()], Some(WidgetStatus::new(gpu_status)));
gpu_dataset.add_row(
Some(WidgetStatus::new(gpu_status)),
"",
vec![
WidgetValue::new(summary
.gpu_load_percent
.map(|value| format_optional_percent(Some(value)))
.unwrap_or_else(|| "".to_string())),
WidgetValue::new(summary
.gpu_temp_c
.map(|value| format_optional_metric(Some(value), "°C"))
.unwrap_or_else(|| "".to_string())),
],
);
// Determine overall widget status based on worst case
let overall_status_level = match perf_severity {
PerfSeverity::Critical => StatusLevel::Error,
PerfSeverity::Warning => StatusLevel::Warning,
PerfSeverity::Ok => StatusLevel::Ok,
};
let overall_status = Some(WidgetStatus::new(overall_status_level));
// Render all three datasets in a single combined widget
render_combined_widget_data(frame, area, "CPU / Memory".to_string(), overall_status, vec![memory_dataset, cpu_dataset, gpu_dataset]);
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum PerfSeverity {
Ok,
Warning,
Critical,
}
fn format_optional_metric(value: Option<f32>, unit: &str) -> String {
match value {
Some(number) => format!("{:.1}{}", number, unit),
None => "".to_string(),
}
}
fn format_optional_percent(value: Option<f32>) -> String {
match value {
Some(number) => format!("{:.0}%", number),
None => "".to_string(),
}
}
fn status_level_from_color(color: Color) -> StatusLevel {
match color {
Color::Red => StatusLevel::Error,
Color::Yellow => StatusLevel::Warning,
_ => StatusLevel::Ok,
}
}
pub(crate) fn evaluate_performance(summary: &ServiceSummary) -> (PerfSeverity, Option<String>) {
let mem_percent = if summary.system_memory_total_mb > 0.0 {
(summary.system_memory_used_mb / summary.system_memory_total_mb) * 100.0
} else if summary.memory_quota_mb > 0.0 {
(summary.memory_used_mb / summary.memory_quota_mb) * 100.0
} else {
0.0
};
let mut severity = PerfSeverity::Ok;
let mut reason: Option<String> = None;
let mut consider = |level: PerfSeverity, message: String| {
if level > severity {
severity = level;
reason = Some(message);
}
};
if mem_percent >= 95.0 {
consider(PerfSeverity::Critical, format!("RAM {:.0}%", mem_percent));
} else if mem_percent >= 80.0 {
consider(PerfSeverity::Warning, format!("RAM {:.0}%", mem_percent));
}
let load = summary.cpu_load_5;
if load >= 4.0 {
consider(PerfSeverity::Critical, format!("CPU load {:.2}", load));
} else if load >= 2.0 {
consider(PerfSeverity::Warning, format!("CPU load {:.2}", load));
}
if let Some(temp) = summary.cpu_temp_c {
if temp >= 90.0 {
consider(PerfSeverity::Critical, format!("CPU temp {:.0}°C", temp));
} else if temp >= 80.0 {
consider(PerfSeverity::Warning, format!("CPU temp {:.0}°C", temp));
}
}
if let Some(load) = summary.gpu_load_percent {
if load >= 95.0 {
consider(PerfSeverity::Critical, format!("GPU load {:.0}%", load));
} else if load >= 85.0 {
consider(PerfSeverity::Warning, format!("GPU load {:.0}%", load));
}
}
if let Some(temp) = summary.gpu_temp_c {
if temp >= 85.0 {
consider(PerfSeverity::Critical, format!("GPU temp {:.0}°C", temp));
} else if temp >= 75.0 {
consider(PerfSeverity::Warning, format!("GPU temp {:.0}°C", temp));
}
}
if severity == PerfSeverity::Ok {
(PerfSeverity::Ok, None)
} else {
(severity, reason)
}
}

447
dashboard/src/ui/widget.rs Normal file
View File

@ -0,0 +1,447 @@
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use ratatui::Frame;
pub fn heading_row_style() -> Style {
neutral_text_style().add_modifier(Modifier::BOLD)
}
fn neutral_text_style() -> Style {
Style::default()
}
fn neutral_title_span(title: &str) -> Span<'static> {
Span::styled(
title.to_string(),
neutral_text_style().add_modifier(Modifier::BOLD),
)
}
fn neutral_border_style(color: Color) -> Style {
Style::default().fg(color)
}
pub fn status_color_from_percentage(value: f32, warn: f32, crit: f32) -> Color {
if value >= crit {
Color::Red
} else if value >= warn {
Color::Yellow
} else {
Color::Green
}
}
pub fn status_color_from_metric(value: Option<f32>, warn: f32, crit: f32) -> Color {
match value {
Some(v) if v >= crit => Color::Red,
Some(v) if v >= warn => Color::Yellow,
_ => Color::Green,
}
}
pub fn status_color_for_cpu_load(load: f32) -> Color {
if load >= 4.0 {
Color::Red
} else if load >= 2.0 {
Color::Yellow
} else {
Color::Green
}
}
pub fn combined_color(colors: &[Color]) -> Color {
if colors.iter().any(|&c| c == Color::Red) {
Color::Red
} else if colors.iter().any(|&c| c == Color::Yellow) {
Color::Yellow
} else {
Color::Green
}
}
pub fn render_placeholder(frame: &mut Frame, area: Rect, title: &str, message: &str) {
let block = Block::default()
.title(neutral_title_span(title))
.borders(Borders::ALL)
.border_style(neutral_border_style(Color::Gray));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(message))
.wrap(Wrap { trim: true })
.style(neutral_text_style()),
inner,
);
}
pub fn render_widget_data(frame: &mut Frame, area: Rect, data: WidgetData) {
render_combined_widget_data(frame, area, data.title, data.status, vec![data.dataset]);
}
pub fn render_combined_widget_data(frame: &mut Frame, area: Rect, title: String, status: Option<WidgetStatus>, datasets: Vec<WidgetDataSet>) {
if datasets.is_empty() {
return;
}
// Create border and title - determine color from widget status
let border_color = status.as_ref()
.map(|s| s.status.to_color())
.unwrap_or(Color::Reset);
let block = Block::default()
.title(neutral_title_span(&title))
.borders(Borders::ALL)
.border_style(neutral_border_style(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
// Split multi-row datasets into single-row datasets when wrapping is needed
let split_datasets = split_multirow_datasets_with_area(datasets, inner);
let mut current_y = inner.y;
for dataset in split_datasets.iter() {
if current_y >= inner.y + inner.height {
break; // No more space
}
current_y += render_dataset_with_wrapping(frame, dataset, inner, current_y);
}
}
fn split_multirow_datasets_with_area(datasets: Vec<WidgetDataSet>, inner: Rect) -> Vec<WidgetDataSet> {
let mut result = Vec::new();
for dataset in datasets {
if dataset.rows.len() <= 1 {
// Single row or empty - keep as is
result.push(dataset);
} else {
// Multiple rows - check if wrapping is needed using actual available width
if dataset_needs_wrapping_with_width(&dataset, inner.width) {
// Split into separate datasets for individual wrapping
for row in dataset.rows {
let single_row_dataset = WidgetDataSet {
colnames: dataset.colnames.clone(),
status: dataset.status.clone(),
rows: vec![row],
};
result.push(single_row_dataset);
}
} else {
// No wrapping needed - keep as single dataset
result.push(dataset);
}
}
}
result
}
fn dataset_needs_wrapping_with_width(dataset: &WidgetDataSet, available_width: u16) -> bool {
// Calculate column widths
let mut column_widths = Vec::new();
for (col_index, colname) in dataset.colnames.iter().enumerate() {
let mut max_width = colname.chars().count() as u16;
// Check data rows for this column width
for row in &dataset.rows {
if let Some(widget_value) = row.values.get(col_index) {
let data_width = widget_value.data.chars().count() as u16;
max_width = max_width.max(data_width);
}
}
let column_width = (max_width + 1).min(25).max(6);
column_widths.push(column_width);
}
// Calculate total width needed
let status_col_width = 1u16;
let col_spacing = 1u16;
let mut total_width = status_col_width + col_spacing;
for &col_width in &column_widths {
total_width += col_width + col_spacing;
}
total_width > available_width
}
fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inner: Rect, start_y: u16) -> u16 {
if dataset.colnames.is_empty() || dataset.rows.is_empty() {
return 0;
}
// Calculate column widths
let mut column_widths = Vec::new();
for (col_index, colname) in dataset.colnames.iter().enumerate() {
let mut max_width = colname.chars().count() as u16;
// Check data rows for this column width
for row in &dataset.rows {
if let Some(widget_value) = row.values.get(col_index) {
let data_width = widget_value.data.chars().count() as u16;
max_width = max_width.max(data_width);
}
}
let column_width = (max_width + 1).min(25).max(6);
column_widths.push(column_width);
}
let status_col_width = 1u16;
let col_spacing = 1u16;
let available_width = inner.width;
// Determine how many columns fit
let mut total_width = status_col_width + col_spacing;
let mut cols_that_fit = 0;
for &col_width in &column_widths {
let new_total = total_width + col_width + col_spacing;
if new_total <= available_width {
total_width = new_total;
cols_that_fit += 1;
} else {
break;
}
}
if cols_that_fit == 0 {
cols_that_fit = 1; // Always show at least one column
}
let mut current_y = start_y;
let mut col_start = 0;
let mut is_continuation = false;
// Render wrapped sections
while col_start < dataset.colnames.len() {
let col_end = (col_start + cols_that_fit).min(dataset.colnames.len());
let section_colnames = &dataset.colnames[col_start..col_end];
let section_widths = &column_widths[col_start..col_end];
// Render header for this section
let mut header_cells = vec![];
// Status cell
if is_continuation {
header_cells.push(Cell::from(""));
} else {
header_cells.push(Cell::from(""));
}
// Column headers
for colname in section_colnames {
header_cells.push(Cell::from(Line::from(vec![Span::styled(
colname.clone(),
heading_row_style(),
)])));
}
let header_row = Row::new(header_cells).style(heading_row_style());
// Build constraint widths for this section
let mut constraints = vec![Constraint::Length(status_col_width)];
for &width in section_widths {
constraints.push(Constraint::Length(width));
}
let header_table = Table::new(vec![header_row])
.widths(&constraints)
.column_spacing(col_spacing)
.style(neutral_text_style());
frame.render_widget(header_table, Rect {
x: inner.x,
y: current_y,
width: inner.width,
height: 1,
});
current_y += 1;
// Render data rows for this section
for row in &dataset.rows {
if current_y >= inner.y + inner.height {
break;
}
let mut cells = vec![];
// Status cell (only show on first section)
if col_start == 0 {
match &row.status {
Some(s) => {
let color = s.status.to_color();
let icon = s.status.to_icon();
cells.push(Cell::from(Line::from(vec![Span::styled(
icon.to_string(),
Style::default().fg(color),
)])));
},
None => cells.push(Cell::from("")),
}
} else {
cells.push(Cell::from(""));
}
// Data cells for this section
for col_idx in col_start..col_end {
if let Some(widget_value) = row.values.get(col_idx) {
let content = &widget_value.data;
if content.is_empty() {
cells.push(Cell::from(""));
} else {
cells.push(Cell::from(Line::from(vec![Span::styled(
content.to_string(),
neutral_text_style(),
)])));
}
} else {
cells.push(Cell::from(""));
}
}
let data_row = Row::new(cells);
let data_table = Table::new(vec![data_row])
.widths(&constraints)
.column_spacing(col_spacing)
.style(neutral_text_style());
frame.render_widget(data_table, Rect {
x: inner.x,
y: current_y,
width: inner.width,
height: 1,
});
current_y += 1;
}
col_start = col_end;
is_continuation = true;
}
current_y - start_y
}
#[derive(Clone)]
pub struct WidgetData {
pub title: String,
pub status: Option<WidgetStatus>,
pub dataset: WidgetDataSet,
}
#[derive(Clone)]
pub struct WidgetDataSet {
pub colnames: Vec<String>,
pub status: Option<WidgetStatus>,
pub rows: Vec<WidgetRow>,
}
#[derive(Clone)]
pub struct WidgetRow {
pub status: Option<WidgetStatus>,
pub values: Vec<WidgetValue>,
}
#[derive(Clone)]
pub struct WidgetValue {
pub data: String,
}
#[derive(Clone, Copy, Debug)]
pub enum StatusLevel {
Ok,
Warning,
Error,
Unknown,
}
#[derive(Clone)]
pub struct WidgetStatus {
pub status: StatusLevel,
}
impl WidgetData {
pub fn new(title: impl Into<String>, status: Option<WidgetStatus>, colnames: Vec<String>) -> Self {
Self {
title: title.into(),
status: status.clone(),
dataset: WidgetDataSet {
colnames,
status,
rows: Vec::new(),
},
}
}
pub fn add_row(&mut self, status: Option<WidgetStatus>, _description: impl Into<String>, values: Vec<WidgetValue>) -> &mut Self {
self.dataset.rows.push(WidgetRow {
status,
values,
});
self
}
}
impl WidgetDataSet {
pub fn new(colnames: Vec<String>, status: Option<WidgetStatus>) -> Self {
Self {
colnames,
status,
rows: Vec::new(),
}
}
pub fn add_row(&mut self, status: Option<WidgetStatus>, _description: impl Into<String>, values: Vec<WidgetValue>) -> &mut Self {
self.rows.push(WidgetRow {
status,
values,
});
self
}
}
impl WidgetValue {
pub fn new(data: impl Into<String>) -> Self {
Self {
data: data.into(),
}
}
}
impl WidgetStatus {
pub fn new(status: StatusLevel) -> Self {
Self {
status,
}
}
}
impl StatusLevel {
pub fn to_color(self) -> Color {
match self {
StatusLevel::Ok => Color::Green,
StatusLevel::Warning => Color::Yellow,
StatusLevel::Error => Color::Red,
StatusLevel::Unknown => Color::Reset, // Terminal default
}
}
pub fn to_icon(self) -> &'static str {
match self {
StatusLevel::Ok => "",
StatusLevel::Warning => "!",
StatusLevel::Error => "",
StatusLevel::Unknown => "?",
}
}
}