From 656cb5943bdae9775cfd53f8a346192801346632 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 11 Oct 2025 13:36:46 +0200 Subject: [PATCH] Switch dashboard to ZMQ gossip data source --- .gitignore | 2 + AGENTS.md | 3 + CLAUDE.md | 620 ++++++++++++ Cargo.lock | 1693 +++++++++++++++++++++++++++++++++ Cargo.toml | 19 + README.md | 136 ++- config/dashboard.example.toml | 44 + config/dashboard.toml | 39 + config/hosts.example.toml | 12 + config/hosts.toml | 14 + src/app.rs | 483 ++++++++++ src/config.rs | 19 + src/data/config.rs | 138 +++ src/data/history.rs | 54 ++ src/data/metrics.rs | 96 ++ src/data/mod.rs | 3 + src/main.rs | 492 ++++++++++ src/ui/alerts.rs | 51 + src/ui/backup.rs | 62 ++ src/ui/dashboard.rs | 190 ++++ src/ui/memory.rs | 56 ++ src/ui/mod.rs | 8 + src/ui/nvme.rs | 58 ++ src/ui/services.rs | 54 ++ 24 files changed, 4344 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 config/dashboard.example.toml create mode 100644 config/dashboard.toml create mode 100644 config/hosts.example.toml create mode 100644 config/hosts.toml create mode 100644 src/app.rs create mode 100644 src/config.rs create mode 100644 src/data/config.rs create mode 100644 src/data/history.rs create mode 100644 src/data/metrics.rs create mode 100644 src/data/mod.rs create mode 100644 src/main.rs create mode 100644 src/ui/alerts.rs create mode 100644 src/ui/backup.rs create mode 100644 src/ui/dashboard.rs create mode 100644 src/ui/memory.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/nvme.rs create mode 100644 src/ui/services.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..232ed82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +logs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..111304b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Guide + +Agents working in this repo must follow the instructions in `CLAUDE.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..737ed06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,620 @@ +# 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 +- **Keyboard-driven interface** for power users +- **Integration** with existing monitoring APIs (ports 6127, 6128, 6129) + +### Key Features +- **NVMe health monitoring** with wear prediction +- **RAM optimization tracking** (tmpfs, zram, kernel metrics) +- **Service resource monitoring** with sandboxed limits +- **Backup status** with detailed metrics and history +- **Email notification integration** +- **Historical data tracking** and trend analysis + +## Technical Architecture + +### Technology Stack +- **Language**: Rust πŸ¦€ +- **TUI Framework**: ratatui (modern tui-rs fork) +- **Async Runtime**: tokio +- **HTTP Client**: reqwest +- **Serialization**: serde +- **CLI**: clap +- **Error Handling**: anyhow +- **Time**: chrono + +### Dependencies +```toml +[dependencies] +ratatui = "0.24" # Modern TUI framework +crossterm = "0.27" # Cross-platform terminal handling +tokio = { version = "1.0", features = ["full"] } # Async runtime +reqwest = { version = "0.11", features = ["json"] } # HTTP client +serde = { version = "1.0", features = ["derive"] } # JSON parsing +clap = { version = "4.0", features = ["derive"] } # CLI args +anyhow = "1.0" # Error handling +chrono = "0.4" # Time handling +``` + +## Project Structure + +``` +cm-dashboard/ +β”œβ”€β”€ Cargo.toml +β”œβ”€β”€ README.md +β”œβ”€β”€ CLAUDE.md # This file +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ main.rs # Entry point & CLI +β”‚ β”œβ”€β”€ app.rs # Main application state +β”‚ β”œβ”€β”€ ui/ +β”‚ β”‚ β”œβ”€β”€ mod.rs +β”‚ β”‚ β”œβ”€β”€ dashboard.rs # Main dashboard layout +β”‚ β”‚ β”œβ”€β”€ nvme.rs # NVMe health widget +β”‚ β”‚ β”œβ”€β”€ services.rs # Services status widget +β”‚ β”‚ β”œβ”€β”€ memory.rs # RAM optimization widget +β”‚ β”‚ β”œβ”€β”€ backup.rs # Backup status widget +β”‚ β”‚ └── alerts.rs # Alerts/notifications widget +β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ β”œβ”€β”€ mod.rs +β”‚ β”‚ β”œβ”€β”€ client.rs # HTTP client wrapper +β”‚ β”‚ β”œβ”€β”€ smart.rs # Smart metrics API (port 6127) +β”‚ β”‚ β”œβ”€β”€ service.rs # Service metrics API (port 6128) +β”‚ β”‚ └── backup.rs # Backup metrics API (port 6129) +β”‚ β”œβ”€β”€ data/ +β”‚ β”‚ β”œβ”€β”€ mod.rs +β”‚ β”‚ β”œβ”€β”€ metrics.rs # Data structures +β”‚ β”‚ β”œβ”€β”€ history.rs # Historical data storage +β”‚ β”‚ └── config.rs # Host configuration +β”‚ └── config.rs # Application configuration +β”œβ”€β”€ config/ +β”‚ β”œβ”€β”€ hosts.toml # Host definitions +β”‚ └── dashboard.toml # Dashboard layout config +└── docs/ + β”œβ”€β”€ API.md # API integration documentation + └── 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 + - Memory consumption vs limits + - Disk usage per service + +3. **Backup Metrics API** (port 6129) + - Backup status and history + - Repository statistics + - Service integration status + +### Data Structures +```rust +#[derive(Deserialize, Debug)] +pub struct SmartMetrics { + pub status: String, + pub drives: Vec, + pub summary: DriveSummary, + pub issues: Vec, + pub timestamp: u64, +} + +#[derive(Deserialize, Debug)] +pub struct ServiceMetrics { + pub summary: ServiceSummary, + pub services: Vec, + pub timestamp: u64, +} + +#[derive(Deserialize, Debug)] +pub struct BackupMetrics { + pub overall_status: String, + pub backup: BackupInfo, + pub service: BackupServiceInfo, + pub timestamp: u64, +} +``` + +## Dashboard Layout Design + +### Main Dashboard View +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ“Š CMTEC Infrastructure Dashboard srv01 β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ πŸ’Ύ 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 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ πŸ”§ 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 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ πŸ“§ 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 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +Keys: [h]osts [r]efresh [s]ettings [a]lerts [←→] navigate [q]uit +``` + +### Multi-Host View +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ–₯️ CMTEC Host Overview β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Host β”‚ NVMe Wear β”‚ RAM Usage β”‚ Services β”‚ Last Alert β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ srv01 β”‚ 4% βœ… β”‚ 32% βœ… β”‚ 8/8 βœ… β”‚ 04:00 Backup OK β”‚ +β”‚ cmbox β”‚ 12% βœ… β”‚ 45% βœ… β”‚ 3/3 βœ… β”‚ Yesterday Email test β”‚ +β”‚ labbox β”‚ 8% βœ… β”‚ 28% βœ… β”‚ 2/2 βœ… β”‚ 2h ago NVMe temp OK β”‚ +β”‚ simonbox β”‚ 15% βœ… β”‚ 67% ⚠️ β”‚ 4/4 βœ… β”‚ Gaming session active β”‚ +β”‚ steambox β”‚ 23% βœ… β”‚ 78% ⚠️ β”‚ 2/2 βœ… β”‚ High RAM usage β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +Keys: [Enter] details [r]efresh [s]ort [f]ilter [q]uit +``` + +## Development Phases + +### 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 + +**Deliverables:** +- Working TUI that connects to srv01 +- Real-time display of basic metrics +- Keyboard navigation + +### Phase 2: Core Features (Week 3-4) +- [ ] All widget implementations +- [ ] Multi-host configuration +- [ ] Historical data storage +- [ ] Alert system integration +- [ ] Configuration management + +**Deliverables:** +- Full-featured dashboard +- Multi-host monitoring +- Historical trending +- Configuration file support + +### Phase 3: Advanced Features (Week 5-6) +- [ ] Predictive analytics +- [ ] Custom alert rules +- [ ] Export capabilities +- [ ] Performance optimizations +- [ ] Error handling & resilience + +**Deliverables:** +- Production-ready dashboard +- Advanced monitoring features +- Comprehensive error handling +- Performance benchmarks + +### Phase 4: Polish & Documentation (Week 7-8) +- [ ] Code documentation +- [ ] User documentation +- [ ] Installation scripts +- [ ] Testing suite +- [ ] Release preparation + +**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 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c704985 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1693 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "cm-dashboard" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm", + "ratatui", + "serde", + "serde_json", + "tokio", + "toml", + "tracing", + "tracing-appender", + "tracing-subscriber", + "zmq", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dircpy" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88521b0517f5f9d51d11925d8ab4523497dcf947073fa3231a311b63941131c" +dependencies = [ + "jwalk", + "log", + "walkdir", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2735847566356cd2179a2a38264839308f7079fa96e6bd5a42d740460e003c56" +dependencies = [ + "crossbeam", + "rayon", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "ratatui" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" +dependencies = [ + "bitflags 2.9.4", + "cassowary", + "crossterm", + "indoc", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3160422bbd54dd5ecfdca71e5fd59b7b8fe2b1697ab2baf64f6d05dcc66d298" + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio 1.0.4", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zeromq-src" +version = "0.2.6+4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc120b771270365d5ed0dfb4baf1005f2243ae1ae83703265cb3504070f4160b" +dependencies = [ + "cc", + "dircpy", +] + +[[package]] +name = "zmq" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd3091dd571fb84a9b3e5e5c6a807d186c411c812c8618786c3c30e5349234e7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "zmq-sys", +] + +[[package]] +name = "zmq-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8351dc72494b4d7f5652a681c33634063bbad58046c1689e75270908fdc864" +dependencies = [ + "libc", + "system-deps", + "zeromq-src", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5f9b694 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cm-dashboard" +version = "0.1.0" +edition = "2021" + +[dependencies] +ratatui = "0.24" +crossterm = "0.27" +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.0", features = ["derive"] } +anyhow = "1.0" +chrono = { version = "0.4", features = ["serde"] } +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +tracing-appender = "0.2" +zmq = "0.10" diff --git a/README.md b/README.md index 04a1d62..43adbaa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,135 @@ -# cm-dashboard +# CM Dashboard -Linux TUI dashboard for host health overview \ No newline at end of file +CM Dashboard is a Rust-powered terminal UI for real-time monitoring of CMTEC infrastructure hosts. It aggregates SMART, service, and backup metrics from the existing CMTEC APIs and presents them in an efficient, keyboard-driven interface built with `ratatui`. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CM Dashboard β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ NVMe Health β”‚ Services β”‚ Memory Optimization β”‚ +β”‚ Host: srv01 β”‚ Host: srv01 β”‚ Host: srv01 β”‚ +β”‚ Status: Healthy β”‚ Services healthy: 5 β”‚ Memory used: 2048 / β”‚ +β”‚ Drives healthy/warn/crit: β”‚ Degraded: 1 Failed: 0 β”‚ 4096 MiB (50.0%) β”‚ +β”‚ 4/0/0 β”‚ CPU top service: 71.3% β”‚ Last update: 12:34: β”‚ +β”‚ Capacity used: 512.0 / β”‚ Total memory: 1536 / 2048 β”‚ 56 β”‚ +β”‚ 2048.0 GiB β”‚ MiB β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Backups β”‚ Alerts β”‚ +β”‚ Host: srv01 β”‚ srv01: OK β”‚ +β”‚ Status: Healthy β”‚ labbox: smart warning β”‚ +β”‚ Last success: 2024-02-01 03:12:45 β”‚ β”‚ +β”‚ Snapshots: 17 β€’ Size: 512.0 GiB β”‚ β”‚ +β”‚ Pending jobs: 0 (enabled: true) β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ Status β”‚ β”‚ +β”‚ Active host: srv01 (1/3) β”‚ History retention β‰ˆ 3600s β”‚ +β”‚ Config: config/dashboard.tomlβ”‚ Default host: labbox β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Requirements + +- Rust toolchain 1.75+ (install via [`rustup`](https://rustup.rs)) +- Access to CMTEC monitoring APIs on ports 6127, 6128, and 6129 from the machine running the dashboard +- Configuration files under `config/` describing hosts and dashboard preferences + +## Installation + +Clone the repository and build with Cargo: + +```bash +git clone https://github.com/cmtec/cm-dashboard.git +cd cm-dashboard +cargo build --release +``` + +The optimized binary is available at `target/release/cm-dashboard`. To install into your Cargo bin directory: + +```bash +cargo install --path . +``` + +## Configuration + +On first launch, the dashboard will create `config/dashboard.toml` and `config/hosts.toml` automatically if they do not exist. + +You can also generate starter configuration files manually with the built-in helper: + +```bash +cargo run -- init-config +# or, once installed +cm-dashboard init-config --dir ./config --force +``` + +This produces `config/dashboard.toml` and `config/hosts.toml`. The primary dashboard config looks like: + +```toml +[hosts] +default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +enabled = true + +[[hosts.hosts]] +name = "labbox" +enabled = true + +[dashboard] +tick_rate_ms = 250 +history_duration_minutes = 60 + +[[dashboard.widgets]] +id = "nvme" +enabled = true + +[[dashboard.widgets]] +id = "alerts" +enabled = true + +[data_source] +kind = "zmq" + +[data_source.zmq] +endpoints = ["tcp://127.0.0.1:6130"] +``` + +Adjust the host list and `data_source.zmq.endpoints` to match your CMTEC gossip network. If you prefer to manage hosts separately, edit the generated `hosts.toml` file. + +## Features + +- Rotating host selection with left/right arrows (`←`, `β†’`, `h`, `l`, `Tab`) +- Live NVMe, service, memory, backup, and alert summaries per active host +- Structured logging with `tracing` (`-v`/`-vv` to increase verbosity) +- Help overlay (`?`) outlining keyboard shortcuts +- Config-driven host discovery via `config/dashboard.toml` + +## Getting Started + +```bash +cargo run -- --config config/dashboard.toml +# specify a single host +cargo run -- --host srv01 +# override ZMQ endpoints at runtime +cargo run -- --zmq-endpoint tcp://srv01:6130,tcp://labbox:6130 +# increase logging verbosity +cargo run -- -v +``` + +### Keyboard Shortcuts + +| Key | Action | +| --- | --- | +| `←` / `h` | Previous host | +| `β†’` / `l` / `Tab` | Next host | +| `?` | Toggle help overlay | +| `r` | Update status message | +| `q` / `Esc` | Quit | + +## Development + +- Format: `cargo fmt` +- Check: `cargo check` +- Run: `cargo run` + +The dashboard subscribes to the CMTEC ZMQ gossip network (default `tcp://127.0.0.1:6130`). Received metrics are cached per host and retained in an in-memory ring buffer for future trend analysis. diff --git a/config/dashboard.example.toml b/config/dashboard.example.toml new file mode 100644 index 0000000..338b958 --- /dev/null +++ b/config/dashboard.example.toml @@ -0,0 +1,44 @@ +# CM Dashboard configuration template + +[hosts] +# default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +enabled = true +# metadata = { rack = "R1" } + +[[hosts.hosts]] +name = "labbox" +enabled = true + +[dashboard] +tick_rate_ms = 250 +history_duration_minutes = 60 + +[[dashboard.widgets]] +id = "nvme" +enabled = true + +[[dashboard.widgets]] +id = "services" +enabled = true + +[[dashboard.widgets]] +id = "backup" +enabled = true + +[[dashboard.widgets]] +id = "alerts" +enabled = true + +[data_source] +kind = "zmq" + +[data_source.zmq] +endpoints = ["tcp://127.0.0.1:6130"] +# subscribe = "" + +[filesystem] +# cache_dir = "/var/lib/cm-dashboard/cache" +# history_dir = "/var/lib/cm-dashboard/history" diff --git a/config/dashboard.toml b/config/dashboard.toml new file mode 100644 index 0000000..53272da --- /dev/null +++ b/config/dashboard.toml @@ -0,0 +1,39 @@ +# CM Dashboard configuration + +[hosts] +# default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +base_url = "http://srv01.local" +enabled = true +# metadata = { rack = "R1" } + +[[hosts.hosts]] +name = "labbox" +base_url = "http://labbox.local" +enabled = true + +[dashboard] +tick_rate_ms = 250 +history_duration_minutes = 60 + +[[dashboard.widgets]] +id = "nvme" +enabled = true + +[[dashboard.widgets]] +id = "services" +enabled = true + +[[dashboard.widgets]] +id = "backup" +enabled = true + +[[dashboard.widgets]] +id = "alerts" +enabled = true + +[filesystem] +# cache_dir = "/var/lib/cm-dashboard/cache" +# history_dir = "/var/lib/cm-dashboard/history" diff --git a/config/hosts.example.toml b/config/hosts.example.toml new file mode 100644 index 0000000..b59e156 --- /dev/null +++ b/config/hosts.example.toml @@ -0,0 +1,12 @@ +# Hosts configuration template (optional if you want a separate hosts file) + +[hosts] +# default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +enabled = true + +[[hosts.hosts]] +name = "labbox" +enabled = true diff --git a/config/hosts.toml b/config/hosts.toml new file mode 100644 index 0000000..dd5c7d2 --- /dev/null +++ b/config/hosts.toml @@ -0,0 +1,14 @@ +# Optional separate hosts configuration + +[hosts] +# default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +base_url = "http://srv01.local" +enabled = true + +[[hosts.hosts]] +name = "labbox" +base_url = "http://labbox.local" +enabled = true diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..92924da --- /dev/null +++ b/src/app.rs @@ -0,0 +1,483 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; + +use crate::config; +use crate::data::config::{AppConfig, DataSourceKind, HostTarget, ZmqConfig}; +use crate::data::history::MetricsHistory; +use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics}; + +/// Shared application settings derived from the CLI arguments. +#[derive(Debug, Clone)] +pub struct AppOptions { + pub config: Option, + pub host: Option, + pub tick_rate: Duration, + pub verbosity: u8, + pub zmq_endpoints_override: Vec, +} + +impl AppOptions { + pub fn tick_rate(&self) -> Duration { + self.tick_rate + } +} + +#[derive(Debug, Default)] +struct HostRuntimeState { + last_success: Option>, + last_error: Option, + smart: Option, + services: Option, + backup: Option, +} + +/// Top-level application state container. +#[derive(Debug)] +pub struct App { + options: AppOptions, + config: Option, + active_config_path: Option, + hosts: Vec, + history: MetricsHistory, + host_states: HashMap, + zmq_endpoints: Vec, + zmq_subscription: Option, + zmq_connected: bool, + active_host_index: usize, + show_help: bool, + should_quit: bool, + last_tick: Instant, + tick_count: u64, + status: String, +} + +impl App { + pub fn new(options: AppOptions) -> Result { + let (config, active_config_path) = Self::load_configuration(options.config.as_ref())?; + + let hosts = Self::select_hosts(options.host.as_ref(), config.as_ref()); + let history_capacity = Self::history_capacity_hint(config.as_ref()); + let history = MetricsHistory::with_capacity(history_capacity); + let host_states = hosts + .iter() + .map(|host| (host.name.clone(), HostRuntimeState::default())) + .collect::>(); + + let (mut zmq_endpoints, zmq_subscription) = Self::resolve_zmq_config(config.as_ref()); + if !options.zmq_endpoints_override.is_empty() { + zmq_endpoints = options.zmq_endpoints_override.clone(); + } + + let status = Self::build_initial_status(options.host.as_ref(), active_config_path.as_ref()); + + Ok(Self { + options, + config, + active_config_path, + hosts, + history, + host_states, + zmq_endpoints, + zmq_subscription, + zmq_connected: false, + active_host_index: 0, + show_help: false, + should_quit: false, + last_tick: Instant::now(), + tick_count: 0, + status, + }) + } + + pub fn on_tick(&mut self) { + self.tick_count = self.tick_count.saturating_add(1); + self.last_tick = Instant::now(); + let host_count = self.hosts.len(); + let retention = self.history.retention(); + self.status = format!( + "Monitoring β€’ hosts: {} β€’ ticks: {} β€’ refresh: {:?} β€’ retention: {:?}", + host_count, self.tick_count, self.options.tick_rate, retention + ); + } + + pub fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { + self.should_quit = true; + self.status = "Exiting…".to_string(); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + self.status = "Manual refresh requested".to_string(); + } + KeyCode::Left | KeyCode::Char('h') => { + self.select_previous_host(); + } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { + self.select_next_host(); + } + KeyCode::Char('?') => { + self.show_help = !self.show_help; + } + _ => {} + } + } + + pub fn should_quit(&self) -> bool { + self.should_quit + } + + pub fn status_text(&self) -> &str { + &self.status + } + + pub fn zmq_connected(&self) -> bool { + self.zmq_connected + } + + pub fn tick_rate(&self) -> Duration { + self.options.tick_rate() + } + + pub fn config(&self) -> Option<&AppConfig> { + self.config.as_ref() + } + + pub fn active_config_path(&self) -> Option<&PathBuf> { + self.active_config_path.as_ref() + } + + pub fn hosts(&self) -> &[HostTarget] { + &self.hosts + } + + pub fn active_host_info(&self) -> Option<(usize, &HostTarget)> { + if self.hosts.is_empty() { + None + } else { + let index = self + .active_host_index + .min(self.hosts.len().saturating_sub(1)); + Some((index, &self.hosts[index])) + } + } + + pub fn history(&self) -> &MetricsHistory { + &self.history + } + + pub fn host_display_data(&self) -> Vec { + self.hosts + .iter() + .filter_map(|host| { + self.host_states + .get(&host.name) + .map(|state| HostDisplayData { + name: host.name.clone(), + last_success: state.last_success.clone(), + last_error: state.last_error.clone(), + smart: state.smart.clone(), + services: state.services.clone(), + backup: state.backup.clone(), + }) + }) + .collect() + } + + pub fn active_host_display(&self) -> Option { + self.active_host_info().and_then(|(_, host)| { + self.host_states + .get(&host.name) + .map(|state| HostDisplayData { + name: host.name.clone(), + last_success: state.last_success.clone(), + last_error: state.last_error.clone(), + smart: state.smart.clone(), + services: state.services.clone(), + backup: state.backup.clone(), + }) + }) + } + + pub fn zmq_context(&self) -> Option { + if self.zmq_endpoints.is_empty() { + return None; + } + + Some(ZmqContext::new( + self.zmq_endpoints.clone(), + self.zmq_subscription.clone(), + )) + } + + pub fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::MetricsUpdated { + host, + smart, + services, + backup, + timestamp, + } => { + self.zmq_connected = true; + self.ensure_host_entry(&host); + let state = self.host_states.entry(host.clone()).or_default(); + state.last_success = Some(timestamp); + state.last_error = None; + + if let Some(mut smart_metrics) = smart { + if smart_metrics.timestamp != timestamp { + smart_metrics.timestamp = timestamp; + } + let snapshot = smart_metrics.clone(); + self.history.record_smart(smart_metrics); + state.smart = Some(snapshot); + } + + if let Some(mut service_metrics) = services { + if service_metrics.timestamp != timestamp { + service_metrics.timestamp = timestamp; + } + let snapshot = service_metrics.clone(); + self.history.record_services(service_metrics); + state.services = Some(snapshot); + } + + if let Some(mut backup_metrics) = backup { + if backup_metrics.timestamp != timestamp { + backup_metrics.timestamp = timestamp; + } + let snapshot = backup_metrics.clone(); + self.history.record_backup(backup_metrics); + state.backup = Some(snapshot); + } + + self.status = format!( + "Metrics update β€’ host: {} β€’ at {}", + host, + timestamp.format("%H:%M:%S") + ); + } + AppEvent::MetricsFailed { + host, + error, + timestamp, + } => { + self.zmq_connected = false; + self.ensure_host_entry(&host); + let state = self.host_states.entry(host.clone()).or_default(); + state.last_error = Some(format!("{} at {}", error, timestamp.format("%H:%M:%S"))); + + self.status = format!("Fetch failed β€’ host: {} β€’ {}", host, error); + } + } + } + + pub fn help_visible(&self) -> bool { + self.show_help + } + + fn ensure_host_entry(&mut self, host: &str) { + if !self.host_states.contains_key(host) { + self.host_states + .insert(host.to_string(), HostRuntimeState::default()); + } + + if self.hosts.iter().any(|entry| entry.name == host) { + return; + } + + self.hosts.push(HostTarget::from_name(host.to_string())); + if self.hosts.len() == 1 { + self.active_host_index = 0; + } + } + + fn load_configuration(path: Option<&PathBuf>) -> Result<(Option, Option)> { + if let Some(explicit) = path { + let config = config::load_from_path(explicit)?; + return Ok((Some(config), Some(explicit.clone()))); + } + + let default_path = PathBuf::from("config/dashboard.toml"); + if default_path.exists() { + let config = config::load_from_path(&default_path)?; + return Ok((Some(config), Some(default_path))); + } + + Ok((None, None)) + } + + fn build_initial_status(host: Option<&String>, config_path: Option<&PathBuf>) -> String { + match (host, config_path) { + (Some(host), Some(path)) => { + format!("Ready β€’ host: {} β€’ config: {}", host, path.display()) + } + (Some(host), None) => format!("Ready β€’ host: {}", host), + (None, Some(path)) => format!("Ready β€’ config: {}", path.display()), + (None, None) => "Ready β€’ no host selected".to_string(), + } + } + + fn select_hosts(host: Option<&String>, config: Option<&AppConfig>) -> Vec { + let mut targets = Vec::new(); + + let Some(config) = config else { + return targets; + }; + + let host_filter = host.map(|value| value.to_lowercase()); + + for entry in &config.hosts.hosts { + if !entry.enabled { + continue; + } + + if let Some(filter) = &host_filter { + if entry.name.to_lowercase() != *filter { + continue; + } + } + + targets.push(entry.clone()); + } + + if targets.is_empty() { + if let Some(default_host) = &config.hosts.default_host { + if host_filter.is_none() { + if let Some(entry) = config.hosts.hosts.iter().find(|candidate| { + candidate.enabled && candidate.name.eq_ignore_ascii_case(default_host) + }) { + targets.push(entry.clone()); + } + } + } + } + + targets + } + + fn history_capacity_hint(config: Option<&AppConfig>) -> usize { + const DEFAULT_CAPACITY: usize = 120; + const SAMPLE_SECONDS: u64 = 30; + + let Some(config) = config else { + return DEFAULT_CAPACITY; + }; + + let minutes = config.dashboard.history_duration_minutes.max(1); + let total_seconds = minutes.saturating_mul(60); + let samples = total_seconds / SAMPLE_SECONDS; + usize::try_from(samples.max(1)).unwrap_or(DEFAULT_CAPACITY) + } + + fn select_previous_host(&mut self) { + if self.hosts.is_empty() { + return; + } + + self.active_host_index = if self.active_host_index == 0 { + self.hosts.len().saturating_sub(1) + } else { + self.active_host_index - 1 + }; + self.status = format!( + "Active host switched to {} ({}/{})", + self.hosts[self.active_host_index].name, + self.active_host_index + 1, + self.hosts.len() + ); + } + + fn select_next_host(&mut self) { + if self.hosts.is_empty() { + return; + } + + self.active_host_index = (self.active_host_index + 1) % self.hosts.len(); + self.status = format!( + "Active host switched to {} ({}/{})", + self.hosts[self.active_host_index].name, + self.active_host_index + 1, + self.hosts.len() + ); + } + + fn resolve_zmq_config(config: Option<&AppConfig>) -> (Vec, Option) { + let default = ZmqConfig::default(); + let zmq_config = config + .and_then(|cfg| { + if cfg.data_source.kind == DataSourceKind::Zmq { + Some(cfg.data_source.zmq.clone()) + } else { + None + } + }) + .unwrap_or(default); + + let endpoints = if zmq_config.endpoints.is_empty() { + ZmqConfig::default().endpoints + } else { + zmq_config.endpoints.clone() + }; + + (endpoints, zmq_config.subscribe.clone()) + } +} + +#[derive(Debug, Clone)] +pub struct HostDisplayData { + pub name: String, + pub last_success: Option>, + pub last_error: Option, + pub smart: Option, + pub services: Option, + pub backup: Option, +} + +#[derive(Debug, Clone)] +pub struct ZmqContext { + endpoints: Vec, + subscription: Option, +} + +impl ZmqContext { + pub fn new(endpoints: Vec, subscription: Option) -> Self { + Self { + endpoints, + subscription, + } + } + + pub fn endpoints(&self) -> &[String] { + &self.endpoints + } + + pub fn subscription(&self) -> Option<&str> { + self.subscription.as_deref() + } +} + +#[derive(Debug)] +pub enum AppEvent { + MetricsUpdated { + host: String, + smart: Option, + services: Option, + backup: Option, + timestamp: DateTime, + }, + MetricsFailed { + host: String, + error: String, + timestamp: DateTime, + }, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dca7418 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::data::config::AppConfig; + +/// Load application configuration from a TOML file. +pub fn load_from_path(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read configuration file at {}", path.display()))?; + + let config = toml::from_str::(&raw) + .with_context(|| format!("failed to parse configuration file {}", path.display()))?; + + Ok(config) +} diff --git a/src/data/config.rs b/src/data/config.rs new file mode 100644 index 0000000..21c18cc --- /dev/null +++ b/src/data/config.rs @@ -0,0 +1,138 @@ +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct HostsConfig { + pub default_host: Option, + #[serde(default)] + pub hosts: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HostTarget { + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub metadata: HashMap, +} + +impl HostTarget { + pub fn from_name(name: String) -> Self { + Self { + name, + enabled: true, + metadata: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DashboardConfig { + #[serde(default = "default_tick_rate_ms")] + pub tick_rate_ms: u64, + #[serde(default)] + pub history_duration_minutes: u64, + #[serde(default)] + pub widgets: Vec, +} + +impl Default for DashboardConfig { + fn default() -> Self { + Self { + tick_rate_ms: default_tick_rate_ms(), + history_duration_minutes: 60, + widgets: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WidgetConfig { + pub id: String, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub options: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AppFilesystem { + pub cache_dir: Option, + pub history_dir: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub hosts: HostsConfig, + #[serde(default)] + pub dashboard: DashboardConfig, + #[serde(default = "default_data_source_config")] + pub data_source: DataSourceConfig, + #[serde(default)] + pub filesystem: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DataSourceConfig { + #[serde(default = "default_data_source_kind")] + pub kind: DataSourceKind, + #[serde(default)] + pub zmq: ZmqConfig, +} + +impl Default for DataSourceConfig { + fn default() -> Self { + Self { + kind: DataSourceKind::Zmq, + zmq: ZmqConfig::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DataSourceKind { + Zmq, +} + +fn default_data_source_kind() -> DataSourceKind { + DataSourceKind::Zmq +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZmqConfig { + #[serde(default = "default_zmq_endpoints")] + pub endpoints: Vec, + #[serde(default)] + pub subscribe: Option, +} + +impl Default for ZmqConfig { + fn default() -> Self { + Self { + endpoints: default_zmq_endpoints(), + subscribe: None, + } + } +} + +const fn default_true() -> bool { + true +} + +const fn default_tick_rate_ms() -> u64 { + 500 +} + +fn default_data_source_config() -> DataSourceConfig { + DataSourceConfig::default() +} + +fn default_zmq_endpoints() -> Vec { + vec!["tcp://127.0.0.1:6130".to_string()] +} diff --git a/src/data/history.rs b/src/data/history.rs new file mode 100644 index 0000000..a90fa17 --- /dev/null +++ b/src/data/history.rs @@ -0,0 +1,54 @@ +#![allow(dead_code)] + +use std::collections::VecDeque; +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics}; + +/// Ring buffer for retaining recent samples for trend analysis. +#[derive(Debug)] +pub struct MetricsHistory { + capacity: usize, + smart: VecDeque<(DateTime, SmartMetrics)>, + services: VecDeque<(DateTime, ServiceMetrics)>, + backups: VecDeque<(DateTime, BackupMetrics)>, +} + +impl MetricsHistory { + pub fn with_capacity(capacity: usize) -> Self { + Self { + capacity, + smart: VecDeque::with_capacity(capacity), + services: VecDeque::with_capacity(capacity), + backups: VecDeque::with_capacity(capacity), + } + } + + pub fn record_smart(&mut self, metrics: SmartMetrics) { + let entry = (Utc::now(), metrics); + Self::push_with_limit(&mut self.smart, entry, self.capacity); + } + + pub fn record_services(&mut self, metrics: ServiceMetrics) { + let entry = (Utc::now(), metrics); + Self::push_with_limit(&mut self.services, entry, self.capacity); + } + + pub fn record_backup(&mut self, metrics: BackupMetrics) { + let entry = (Utc::now(), metrics); + Self::push_with_limit(&mut self.backups, entry, self.capacity); + } + + pub fn retention(&self) -> Duration { + Duration::from_secs((self.capacity as u64) * 30) + } + + fn push_with_limit(deque: &mut VecDeque, item: T, capacity: usize) { + if deque.len() == capacity { + deque.pop_front(); + } + deque.push_back(item); + } +} diff --git a/src/data/metrics.rs b/src/data/metrics.rs new file mode 100644 index 0000000..165ab45 --- /dev/null +++ b/src/data/metrics.rs @@ -0,0 +1,96 @@ +#![allow(dead_code)] + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmartMetrics { + pub status: String, + pub drives: Vec, + pub summary: DriveSummary, + pub issues: Vec, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DriveInfo { + pub name: String, + pub temperature_c: f32, + pub wear_level: f32, + pub power_on_hours: u64, + pub available_spare: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DriveSummary { + pub healthy: usize, + pub warning: usize, + pub critical: usize, + pub capacity_total_gb: f32, + pub capacity_used_gb: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceMetrics { + pub summary: ServiceSummary, + pub services: Vec, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceSummary { + pub healthy: usize, + pub degraded: usize, + pub failed: usize, + pub memory_used_mb: f32, + pub memory_quota_mb: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceInfo { + pub name: String, + pub status: ServiceStatus, + pub memory_used_mb: f32, + pub memory_quota_mb: f32, + pub cpu_percent: f32, + pub sandbox_limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ServiceStatus { + Running, + Degraded, + Restarting, + Stopped, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupMetrics { + pub overall_status: BackupStatus, + pub backup: BackupInfo, + pub service: BackupServiceInfo, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupInfo { + pub last_success: Option>, + pub last_failure: Option>, + pub size_gb: f32, + pub snapshot_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupServiceInfo { + pub enabled: bool, + pub pending_jobs: u32, + pub last_message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BackupStatus { + Healthy, + Warning, + Failed, + Unknown, +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..b82d2d3 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod history; +pub mod metrics; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9c9a9c3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,492 @@ +mod app; +mod config; +mod data; +mod ui; + +use std::fs; +use std::io::{self, Stdout}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics}; +use anyhow::{anyhow, Context, Result}; +use chrono::{TimeZone, Utc}; +use clap::{ArgAction, Parser, Subcommand}; +use crossterm::event::{self, Event}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use crossterm::{execute, terminal}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use serde::Deserialize; +use serde_json::Value; +use tokio::sync::mpsc::{ + error::TryRecvError, unbounded_channel, UnboundedReceiver, UnboundedSender, +}; +use tokio::task::spawn_blocking; +use tracing::{debug, warn}; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::EnvFilter; +use zmq::{Context as NativeZmqContext, Message as NativeZmqMessage}; + +use crate::app::{App, AppEvent, AppOptions, ZmqContext}; + +static LOG_GUARD: OnceLock = OnceLock::new(); + +#[derive(Parser, Debug)] +#[command( + name = "cm-dashboard", + version, + about = "Infrastructure monitoring TUI for CMTEC" +)] +struct Cli { + #[command(subcommand)] + command: Option, + /// Optional path to configuration TOML file + #[arg(long, value_name = "FILE")] + config: Option, + + /// Limit dashboard to a single host + #[arg(short = 'H', long, value_name = "HOST")] + host: Option, + + /// Interval (ms) to refresh dashboard when idle + #[arg(long, default_value_t = 250)] + tick_rate: u64, + + /// Increase logging verbosity (-v, -vv) + #[arg(short, long, action = ArgAction::Count)] + verbose: u8, + + /// Override ZMQ endpoints (comma-separated) + #[arg(long, value_delimiter = ',', value_name = "ENDPOINT")] + zmq_endpoint: Vec, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Generate default configuration files + InitConfig { + #[arg(long, value_name = "DIR", default_value = "config")] + dir: PathBuf, + /// Overwrite existing files if they already exist + #[arg(long, action = ArgAction::SetTrue)] + force: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if let Some(Command::InitConfig { dir, force }) = cli.command.as_ref() { + init_tracing(cli.verbose)?; + generate_config_templates(dir, *force)?; + return Ok(()); + } + + ensure_default_config(&cli)?; + + let options = AppOptions { + config: cli.config, + host: cli.host, + tick_rate: Duration::from_millis(cli.tick_rate.max(16)), + verbosity: cli.verbose, + zmq_endpoints_override: cli.zmq_endpoint, + }; + + init_tracing(options.verbosity)?; + + let mut app = App::new(options)?; + let (event_tx, mut event_rx) = unbounded_channel(); + + if let Some(context) = app.zmq_context() { + spawn_metrics_task(context, event_tx.clone()); + } + + let mut terminal = setup_terminal()?; + let result = run_app(&mut terminal, &mut app, &mut event_rx); + teardown_terminal(terminal)?; + result +} + +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, terminal::EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn teardown_terminal(mut terminal: Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +fn run_app( + terminal: &mut Terminal>, + app: &mut App, + event_rx: &mut UnboundedReceiver, +) -> Result<()> { + let tick_rate = app.tick_rate(); + + while !app.should_quit() { + drain_app_events(app, event_rx); + terminal.draw(|frame| ui::render(frame, app))?; + + if event::poll(tick_rate)? { + if let Event::Key(key) = event::read()? { + app.handle_key_event(key); + } + } else { + app.on_tick(); + } + } + + Ok(()) +} + +fn drain_app_events(app: &mut App, receiver: &mut UnboundedReceiver) { + loop { + match receiver.try_recv() { + Ok(event) => app.handle_app_event(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => break, + } + } +} + +fn init_tracing(verbosity: u8) -> Result<()> { + let level = match verbosity { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + }; + + let env_filter = std::env::var("RUST_LOG") + .ok() + .and_then(|value| EnvFilter::try_new(value).ok()) + .unwrap_or_else(|| EnvFilter::new(level)); + + let writer = prepare_log_writer()?; + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(false) + .with_ansi(false) + .with_writer(writer) + .compact() + .try_init() + .map_err(|err| anyhow!(err))?; + + Ok(()) +} + +fn prepare_log_writer() -> Result { + let logs_dir = Path::new("logs"); + if !logs_dir.exists() { + fs::create_dir_all(logs_dir).with_context(|| { + format!("failed to create logs directory at {}", logs_dir.display()) + })?; + } + + let file_appender = tracing_appender::rolling::never(logs_dir, "cm-dashboard.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + LOG_GUARD.get_or_init(|| guard); + Ok(non_blocking) +} + +fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender) { + tokio::spawn(async move { + match spawn_blocking(move || metrics_blocking_loop(context, sender)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => warn!(%error, "ZMQ metrics worker exited with error"), + Err(join_error) => warn!(%join_error, "ZMQ metrics worker panicked"), + } + }); +} + +fn metrics_blocking_loop(context: ZmqContext, sender: UnboundedSender) -> Result<()> { + let zmq_context = NativeZmqContext::new(); + let socket = zmq_context + .socket(zmq::SUB) + .context("failed to create ZMQ SUB socket")?; + + for endpoint in context.endpoints() { + debug!(%endpoint, "connecting to ZMQ endpoint"); + socket + .connect(endpoint) + .with_context(|| format!("failed to connect to {endpoint}"))?; + } + + if let Some(prefix) = context.subscription() { + socket + .set_subscribe(prefix.as_bytes()) + .context("failed to set ZMQ subscription")?; + } else { + socket + .set_subscribe(b"") + .context("failed to subscribe to all ZMQ topics")?; + } + + loop { + match socket.recv_msg(0) { + Ok(message) => { + if let Err(error) = handle_zmq_message(&message, &sender) { + warn!(%error, "failed to handle ZMQ message"); + } + } + Err(error) => { + warn!(%error, "ZMQ receive error"); + std::thread::sleep(Duration::from_secs(1)); + } + } + } +} + +fn handle_zmq_message( + message: &NativeZmqMessage, + sender: &UnboundedSender, +) -> Result<()> { + let bytes = message.to_vec(); + + let envelope: MetricsEnvelope = + serde_json::from_slice(&bytes).with_context(|| "failed to deserialize metrics envelope")?; + let timestamp = Utc + .timestamp_opt(envelope.timestamp as i64, 0) + .single() + .unwrap_or_else(|| Utc::now()); + + let host = envelope.hostname.clone(); + + let mut payload = envelope.metrics; + if let Some(obj) = payload.as_object_mut() { + obj.entry("timestamp") + .or_insert_with(|| Value::String(timestamp.to_rfc3339())); + } + + match envelope.agent_type { + AgentType::Smart => match serde_json::from_value::(payload.clone()) { + Ok(metrics) => { + let _ = sender.send(AppEvent::MetricsUpdated { + host, + smart: Some(metrics), + services: None, + backup: None, + timestamp, + }); + } + Err(error) => { + warn!(%error, "failed to parse smart metrics"); + let _ = sender.send(AppEvent::MetricsFailed { + host, + error: format!("smart metrics parse error: {error:#}"), + timestamp, + }); + } + }, + AgentType::Service => match serde_json::from_value::(payload.clone()) { + Ok(metrics) => { + let _ = sender.send(AppEvent::MetricsUpdated { + host, + smart: None, + services: Some(metrics), + backup: None, + timestamp, + }); + } + Err(error) => { + warn!(%error, "failed to parse service metrics"); + let _ = sender.send(AppEvent::MetricsFailed { + host, + error: format!("service metrics parse error: {error:#}"), + timestamp, + }); + } + }, + AgentType::Backup => match serde_json::from_value::(payload.clone()) { + Ok(metrics) => { + let _ = sender.send(AppEvent::MetricsUpdated { + host, + smart: None, + services: None, + backup: Some(metrics), + timestamp, + }); + } + Err(error) => { + warn!(%error, "failed to parse backup metrics"); + let _ = sender.send(AppEvent::MetricsFailed { + host, + error: format!("backup metrics parse error: {error:#}"), + timestamp, + }); + } + }, + } + + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AgentType { + Smart, + Service, + Backup, +} + +#[derive(Debug, Deserialize)] +struct MetricsEnvelope { + hostname: String, + #[serde(rename = "agent_type")] + agent_type: AgentType, + timestamp: u64, + metrics: Value, +} + +fn ensure_default_config(cli: &Cli) -> Result<()> { + if let Some(path) = cli.config.as_ref() { + ensure_config_at(path, false)?; + } else { + let default_path = Path::new("config/dashboard.toml"); + if !default_path.exists() { + generate_config_templates(Path::new("config"), false)?; + println!("Created default configuration in ./config"); + } + } + + Ok(()) +} + +fn ensure_config_at(path: &Path, force: bool) -> Result<()> { + if path.exists() && !force { + return Ok(()); + } + + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + + write_template(path.to_path_buf(), DASHBOARD_TEMPLATE, force, "dashboard")?; + + let hosts_path = parent.join("hosts.toml"); + if !hosts_path.exists() || force { + write_template(hosts_path, HOSTS_TEMPLATE, force, "hosts")?; + } + println!( + "Created configuration templates in {} (dashboard: {})", + parent.display(), + path.display() + ); + } else { + return Err(anyhow!("invalid configuration path {}", path.display())); + } + + Ok(()) +} + +fn generate_config_templates(target_dir: &Path, force: bool) -> Result<()> { + if !target_dir.exists() { + fs::create_dir_all(target_dir) + .with_context(|| format!("failed to create directory {}", target_dir.display()))?; + } + + write_template( + target_dir.join("dashboard.toml"), + DASHBOARD_TEMPLATE, + force, + "dashboard", + )?; + write_template( + target_dir.join("hosts.toml"), + HOSTS_TEMPLATE, + force, + "hosts", + )?; + + println!( + "Configuration templates written to {}", + target_dir.display() + ); + + Ok(()) +} + +fn write_template(path: PathBuf, contents: &str, force: bool, name: &str) -> Result<()> { + if path.exists() && !force { + return Err(anyhow!( + "{} template already exists at {} (use --force to overwrite)", + name, + path.display() + )); + } + + fs::write(&path, contents) + .with_context(|| format!("failed to write {} template to {}", name, path.display()))?; + + Ok(()) +} + +const DASHBOARD_TEMPLATE: &str = r#"# CM Dashboard configuration + +[hosts] +# default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +base_url = "http://srv01.local" +enabled = true +# metadata = { rack = "R1" } + +[[hosts.hosts]] +name = "labbox" +base_url = "http://labbox.local" +enabled = true + +[dashboard] +tick_rate_ms = 250 +history_duration_minutes = 60 + +[[dashboard.widgets]] +id = "nvme" +enabled = true + +[[dashboard.widgets]] +id = "services" +enabled = true + +[[dashboard.widgets]] +id = "backup" +enabled = true + +[[dashboard.widgets]] +id = "alerts" +enabled = true + +[filesystem] +# cache_dir = "/var/lib/cm-dashboard/cache" +# history_dir = "/var/lib/cm-dashboard/history" +"#; + +const HOSTS_TEMPLATE: &str = r#"# Optional separate hosts configuration + +[hosts] +# default_host = "srv01" + +[[hosts.hosts]] +name = "srv01" +base_url = "http://srv01.local" +enabled = true + +[[hosts.hosts]] +name = "labbox" +base_url = "http://labbox.local" +enabled = true +"#; diff --git a/src/ui/alerts.rs b/src/ui/alerts.rs new file mode 100644 index 0000000..1a00f6c --- /dev/null +++ b/src/ui/alerts.rs @@ -0,0 +1,51 @@ +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; + +pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) { + let block = Block::default() + .title("Alerts") + .borders(Borders::ALL) + .style(Style::default().fg(Color::LightRed)); + + let mut lines = Vec::new(); + + if hosts.is_empty() { + lines.push(Line::from("No hosts configured")); + } else { + for host in hosts { + if let Some(error) = &host.last_error { + lines.push(Line::from(vec![ + Span::styled(&host.name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": "), + Span::styled(error, Style::default().fg(Color::Red)), + ])); + continue; + } + + if let Some(smart) = host.smart.as_ref() { + if let Some(issue) = smart.issues.first() { + lines.push(Line::from(vec![ + Span::styled(&host.name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": "), + Span::styled(issue, Style::default().fg(Color::Yellow)), + ])); + continue; + } + } + + lines.push(Line::from(vec![ + Span::styled(&host.name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": OK"), + ])); + } + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block); + + frame.render_widget(paragraph, area); +} diff --git a/src/ui/backup.rs b/src/ui/backup.rs new file mode 100644 index 0000000..365e3e3 --- /dev/null +++ b/src/ui/backup.rs @@ -0,0 +1,62 @@ +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; + +pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { + let block = Block::default() + .title("Backups") + .borders(Borders::ALL) + .style(Style::default().fg(Color::LightGreen)); + + let mut lines = Vec::new(); + + match host { + Some(data) => { + if let Some(metrics) = data.backup.as_ref() { + lines.push(Line::from(vec![ + Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(data.name.clone()), + ])); + lines.push(Line::from(format!("Status: {:?}", metrics.overall_status))); + + if let Some(last_success) = metrics.backup.last_success.as_ref() { + lines.push(Line::from(format!( + "Last success: {}", + last_success.format("%Y-%m-%d %H:%M:%S") + ))); + } + + if let Some(last_failure) = metrics.backup.last_failure.as_ref() { + lines.push(Line::from(vec![ + Span::styled("Last failure: ", Style::default().fg(Color::Red)), + Span::raw(last_failure.format("%Y-%m-%d %H:%M:%S").to_string()), + ])); + } + + lines.push(Line::from(format!( + "Snapshots: {} β€’ Size: {:.1} GiB", + metrics.backup.snapshot_count, metrics.backup.size_gb + ))); + + lines.push(Line::from(format!( + "Pending jobs: {} (enabled: {})", + metrics.service.pending_jobs, metrics.service.enabled + ))); + } else { + lines.push(Line::from(format!( + "Host {} awaiting backup metrics", + data.name + ))); + } + } + None => lines.push(Line::from("No hosts configured")), + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block); + + frame.render_widget(paragraph, area); +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs new file mode 100644 index 0000000..e0bdd38 --- /dev/null +++ b/src/ui/dashboard.rs @@ -0,0 +1,190 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::Frame; + +use crate::app::App; + +use super::{alerts, backup, memory, nvme, services}; + +pub fn render(frame: &mut Frame, app: &App) { + let host_summaries = app.host_display_data(); + let primary_host = app.active_host_display(); + + let root_block = Block::default().title(Span::styled( + "CM Dashboard", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + + let size = frame.size(); + frame.render_widget(root_block, size); + + let outer = inner_rect(size); + + let vertical_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(35), + Constraint::Percentage(35), + Constraint::Percentage(30), + ]) + .split(outer); + + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(vertical_chunks[0]); + + let middle = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(vertical_chunks[1]); + + let bottom = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(vertical_chunks[2]); + + nvme::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]); + + if app.help_visible() { + render_help(frame, size); + } +} + +fn render_status(frame: &mut Frame, app: &App, area: Rect) { + use ratatui::text::Line; + use ratatui::widgets::{Paragraph, Wrap}; + + let mut lines = Vec::new(); + lines.push(Line::from(app.status_text().to_string())); + + if app.zmq_connected() { + lines.push(Line::from(vec![ + Span::styled( + "Data source: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("ZMQ", Style::default().fg(Color::Green)), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled( + "Data source: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("ZMQ (disconnected)", Style::default().fg(Color::Red)), + ])); + } + + if let Some((index, host)) = app.active_host_info() { + lines.push(Line::from(format!( + "Active host: {} ({}/{})", + host.name, + index + 1, + app.hosts().len() + ))); + } else { + lines.push(Line::from("Active host: β€”")); + } + + if let Some(path) = app.active_config_path() { + lines.push(Line::from(vec![ + Span::styled("Config: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(path.display().to_string()), + ])); + } + + let retention = app.history().retention(); + lines.push(Line::from(format!( + "History retention β‰ˆ {}s", + retention.as_secs() + ))); + + if let Some(config) = app.config() { + if let Some(default_host) = &config.hosts.default_host { + lines.push(Line::from(format!("Default host: {}", default_host))); + } + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block( + Block::default() + .title(Span::styled( + "Status", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )) + .borders(ratatui::widgets::Borders::ALL), + ); + + frame.render_widget(paragraph, area); +} + +fn inner_rect(area: Rect) -> Rect { + Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + } +} + +fn render_help(frame: &mut Frame, area: Rect) { + use ratatui::text::Line; + use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + + let help_area = centered_rect(60, 40, area); + let lines = vec![ + Line::from("Keyboard Shortcuts"), + Line::from("←/β†’ or h/l: Switch active host"), + Line::from("r: Manual refresh status"), + Line::from("?: Toggle this help"), + Line::from("q / Esc: Quit dashboard"), + ]; + + let block = Block::default() + .title(Span::styled( + "Help", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block); + + frame.render_widget(Clear, help_area); + frame.render_widget(paragraph, help_area); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1]); + + horizontal[1] +} diff --git a/src/ui/memory.rs b/src/ui/memory.rs new file mode 100644 index 0000000..a5e3772 --- /dev/null +++ b/src/ui/memory.rs @@ -0,0 +1,56 @@ +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; + +pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { + let block = Block::default() + .title("Memory Optimization") + .borders(Borders::ALL) + .style(Style::default().fg(Color::LightMagenta)); + + let mut lines = Vec::new(); + + match host { + Some(data) => { + if let Some(metrics) = data.services.as_ref() { + let summary = &metrics.summary; + let usage_ratio = if summary.memory_quota_mb > 0.0 { + (summary.memory_used_mb / summary.memory_quota_mb) * 100.0 + } else { + 0.0 + }; + + lines.push(Line::from(vec![ + Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(data.name.clone()), + ])); + + lines.push(Line::from(format!( + "Memory used: {:.1} / {:.1} MiB ({:.1}%)", + summary.memory_used_mb, summary.memory_quota_mb, usage_ratio + ))); + + if let Some(last_success) = data.last_success.as_ref() { + lines.push(Line::from(format!( + "Last update: {}", + last_success.format("%H:%M:%S") + ))); + } + } else { + lines.push(Line::from(format!( + "Host {} awaiting service metrics", + data.name + ))); + } + } + None => lines.push(Line::from("No hosts configured")), + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block); + + frame.render_widget(paragraph, area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..16be3a9 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,8 @@ +pub mod alerts; +pub mod backup; +pub mod dashboard; +pub mod memory; +pub mod nvme; +pub mod services; + +pub use dashboard::render; diff --git a/src/ui/nvme.rs b/src/ui/nvme.rs new file mode 100644 index 0000000..30aa268 --- /dev/null +++ b/src/ui/nvme.rs @@ -0,0 +1,58 @@ +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; + +pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { + let block = Block::default() + .title("NVMe Health") + .borders(Borders::ALL) + .style(Style::default().fg(Color::LightCyan)); + + let mut lines = Vec::new(); + + match host { + Some(data) => { + if let Some(metrics) = data.smart.as_ref() { + lines.push(Line::from(vec![ + Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(data.name.clone()), + ])); + lines.push(Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(metrics.status.clone()), + ])); + lines.push(Line::from(format!( + "Drives healthy/warn/crit: {}/{}/{}", + metrics.summary.healthy, metrics.summary.warning, metrics.summary.critical + ))); + lines.push(Line::from(format!( + "Capacity used: {:.1} / {:.1} GiB", + metrics.summary.capacity_used_gb, metrics.summary.capacity_total_gb + ))); + + if let Some(issue) = metrics.issues.first() { + lines.push(Line::from(vec![ + Span::styled("Issue: ", Style::default().fg(Color::Yellow)), + Span::raw(issue.clone()), + ])); + } + } else { + lines.push(Line::from(format!( + "Host {} has no SMART data yet", + data.name + ))); + } + } + None => { + lines.push(Line::from("No hosts configured")); + } + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block); + + frame.render_widget(paragraph, area); +} diff --git a/src/ui/services.rs b/src/ui/services.rs new file mode 100644 index 0000000..9bcb71a --- /dev/null +++ b/src/ui/services.rs @@ -0,0 +1,54 @@ +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; + +pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { + let block = Block::default() + .title("Services") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Yellow)); + + let mut lines = Vec::new(); + + match host { + Some(data) => { + if let Some(metrics) = data.services.as_ref() { + let summary = &metrics.summary; + lines.push(Line::from(vec![ + Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(data.name.clone()), + ])); + lines.push(Line::from(format!( + "Services healthy/degraded/failed: {}/{}/{}", + summary.healthy, summary.degraded, summary.failed + ))); + lines.push(Line::from(format!( + "CPU top service: {:.1}%", + metrics + .services + .iter() + .map(|svc| svc.cpu_percent) + .fold(0.0_f32, f32::max) + ))); + lines.push(Line::from(format!( + "Total memory: {:.1} / {:.1} MiB", + summary.memory_used_mb, summary.memory_quota_mb + ))); + } else { + lines.push(Line::from(format!( + "Host {} has no service metrics yet", + data.name + ))); + } + } + None => lines.push(Line::from("No hosts configured")), + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block); + + frame.render_widget(paragraph, area); +}