diff --git a/CLAUDE.md b/CLAUDE.md index 0b20d97..bad4c2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, // ["srv01:6130", "cmbox:6130"] - collectors: Vec>, // 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. diff --git a/README.md b/README.md index 1c1d37b..4c51796 100644 --- a/README.md +++ b/README.md @@ -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 doesn’t 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 diff --git a/agent/src/collectors/service.rs b/agent/src/collectors/service.rs index 2cbdca5..c6162b0 100644 --- a/agent/src/collectors/service.rs +++ b/agent/src/collectors/service.rs @@ -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 { // 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 { 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) } diff --git a/agent/src/collectors/smart.rs b/agent/src/collectors/smart.rs index 8864864..a5bd955 100644 --- a/agent/src/collectors/smart.rs +++ b/agent/src/collectors/smart.rs @@ -89,7 +89,10 @@ impl SmartCollector { Ok(SmartDeviceData::from_smartctl_output(device, smart_output)) } - async fn get_drive_usage(&self, device: &str) -> Result<(Option, Option), CollectorError> { + async fn get_drive_usage( + &self, + device: &str, + ) -> Result<(Option, Option), 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 { // 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()); diff --git a/dashboard/config/dashboard.toml b/dashboard/config/dashboard.toml index 16fd41d..0f4d09d 100644 --- a/dashboard/config/dashboard.toml +++ b/dashboard/config/dashboard.toml @@ -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" diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index 78bf2b0..daa0f02 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -41,7 +41,9 @@ struct HostRuntimeState { #[derive(Debug)] pub struct App { options: AppOptions, + #[allow(dead_code)] config: Option, + #[allow(dead_code)] active_config_path: Option, hosts: Vec, 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 } diff --git a/dashboard/src/data/metrics.rs b/dashboard/src/data/metrics.rs index 09bd25b..9fa1c8f 100644 --- a/dashboard/src/data/metrics.rs +++ b/dashboard/src/data/metrics.rs @@ -80,6 +80,8 @@ pub struct ServiceInfo { pub sandbox_limit: Option, #[serde(default)] pub disk_used_gb: f32, + #[serde(default)] + pub description: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/dashboard/src/ui/alerts.rs b/dashboard/src/ui/alerts.rs index f1603b4..d9b5f36 100644 --- a/dashboard/src/ui/alerts.rs +++ b/dashboard/src/ui/alerts.rs @@ -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> { latest } + +fn severity_symbol(severity: AlertSeverity) -> &'static str { + match severity { + AlertSeverity::Critical => "βœ–", + AlertSeverity::Warning => "!", + AlertSeverity::Healthy => "βœ”", + AlertSeverity::Unknown => "?", + } +} diff --git a/dashboard/src/ui/backup.rs b/dashboard/src/ui/backup.rs index 593f563..61060b3 100644 --- a/dashboard/src/ui/backup.rs +++ b/dashboard/src/ui/backup.rs @@ -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>) -> 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 + } + } } } diff --git a/dashboard/src/ui/dashboard.rs b/dashboard/src/ui/dashboard.rs index 6ee4775..0d8a70a 100644 --- a/dashboard/src/ui/dashboard.rs +++ b/dashboard/src/ui/dashboard.rs @@ -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 = 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, diff --git a/dashboard/src/ui/memory.rs b/dashboard/src/ui/memory.rs deleted file mode 100644 index b53f0e1..0000000 --- a/dashboard/src/ui/memory.rs +++ /dev/null @@ -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, unit: &str) -> String { - match value { - Some(number) => format!("{:.1}{}", number, unit), - None => "β€”".to_string(), - } -} - -fn format_optional_percent(value: Option) -> 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) { - 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 = 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) - } -} diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 23427bc..a9baa6b 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -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; diff --git a/dashboard/src/ui/services.rs b/dashboard/src/ui/services.rs index 4e5a01f..391d310 100644 --- a/dashboard/src/ui/services.rs +++ b/dashboard/src/ui/services.rs @@ -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, - ); -} - - diff --git a/dashboard/src/ui/storage.rs b/dashboard/src/ui/storage.rs index 41aa78c..731c183 100644 --- a/dashboard/src/ui/storage.rs +++ b/dashboard/src/ui/storage.rs @@ -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) -> String { match value { @@ -178,19 +143,22 @@ fn format_usage(used: Option, capacity: Option) -> 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 + } } diff --git a/dashboard/src/ui/system.rs b/dashboard/src/ui/system.rs new file mode 100644 index 0000000..9817e2e --- /dev/null +++ b/dashboard/src/ui/system.rs @@ -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, unit: &str) -> String { + match value { + Some(number) => format!("{:.1}{}", number, unit), + None => "β€”".to_string(), + } +} + +fn format_optional_percent(value: Option) -> 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) { + 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 = 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) + } +} diff --git a/dashboard/src/ui/widget.rs b/dashboard/src/ui/widget.rs new file mode 100644 index 0000000..4704fce --- /dev/null +++ b/dashboard/src/ui/widget.rs @@ -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, 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, datasets: Vec) { + 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, inner: Rect) -> Vec { + 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, + pub dataset: WidgetDataSet, +} + +#[derive(Clone)] +pub struct WidgetDataSet { + pub colnames: Vec, + pub status: Option, + pub rows: Vec, +} + +#[derive(Clone)] +pub struct WidgetRow { + pub status: Option, + pub values: Vec, +} + +#[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, status: Option, colnames: Vec) -> Self { + Self { + title: title.into(), + status: status.clone(), + dataset: WidgetDataSet { + colnames, + status, + rows: Vec::new(), + }, + } + } + + pub fn add_row(&mut self, status: Option, _description: impl Into, values: Vec) -> &mut Self { + self.dataset.rows.push(WidgetRow { + status, + values, + }); + self + } +} + +impl WidgetDataSet { + pub fn new(colnames: Vec, status: Option) -> Self { + Self { + colnames, + status, + rows: Vec::new(), + } + } + + pub fn add_row(&mut self, status: Option, _description: impl Into, values: Vec) -> &mut Self { + self.rows.push(WidgetRow { + status, + values, + }); + self + } +} + +impl WidgetValue { + pub fn new(data: impl Into) -> 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 => "?", + } + } +}