Switch dashboard to ZMQ gossip data source

This commit is contained in:
Christoffer Martinsson 2025-10-11 13:36:46 +02:00
parent 100056b790
commit 656cb5943b
24 changed files with 4344 additions and 2 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
logs/

3
AGENTS.md Normal file
View File

@ -0,0 +1,3 @@
# Agent Guide
Agents working in this repo must follow the instructions in `CLAUDE.md`.

620
CLAUDE.md Normal file
View File

@ -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<DriveInfo>,
pub summary: DriveSummary,
pub issues: Vec<String>,
pub timestamp: u64,
}
#[derive(Deserialize, Debug)]
pub struct ServiceMetrics {
pub summary: ServiceSummary,
pub services: Vec<ServiceInfo>,
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<String>, // ["srv01:6130", "cmbox:6130"]
collectors: Vec<Box<dyn Collector>>, // SMART, Service, Backup
gossip_interval: Duration, // How often to broadcast
zmq_context: zmq::Context,
}
// Message format for metrics
#[derive(Serialize, Deserialize)]
struct MetricsMessage {
hostname: String,
agent_type: AgentType, // Smart, Service, Backup
timestamp: u64,
metrics: MetricsData,
hop_count: u8, // Prevent infinite loops
}
```
**Phase 2: Dashboard Integration**
- **ZMQ Subscriber**: Dashboard subscribes to gossip stream on srv01
- **Real-time updates**: WebSocket connection to TUI for live streaming
- **Historical storage**: Optional persistence layer for trending
**Phase 3: Migration Strategy**
- **Parallel deployment**: Run ZMQ agents alongside existing HTTP APIs
- **A/B comparison**: Validate metrics accuracy and performance
- **Gradual cutover**: Switch dashboard to ZMQ, then remove HTTP services
#### **Configuration Integration**
**Agent Configuration** (per-host):
```toml
[metrics_agent]
enabled = true
port = 6130
neighbors = ["srv01:6130", "cmbox:6130"] # Redundant connections
role = "agent" # or "dashboard" for srv01
[collectors]
smart_metrics = { enabled = true, interval_ms = 5000 }
service_metrics = { enabled = true, interval_ms = 2000 } # srv01 only
backup_metrics = { enabled = true, interval_ms = 30000 } # srv01 only
```
**Dashboard Configuration** (updated):
```toml
[data_source]
type = "zmq_gossip" # vs current "http_polling"
listen_port = 6130
buffer_size = 1000
real_time_updates = true
[legacy_support]
http_apis_enabled = true # For migration period
fallback_to_http = true # If ZMQ unavailable
```
#### **Performance Comparison**
| Metric | Current (HTTP) | Proposed (ZMQ) |
|--------|---------------|----------------|
| Collection latency | ~50ms | ~1ms |
| Network overhead | HTTP headers + JSON | Binary ZMQ frames |
| Resource per host | ~5MB (Python + HTTP) | ~1MB (Rust agent) |
| Update frequency | 5s polling | Real-time push |
| Network ports | 3 per host | 1 per host |
| Failure recovery | Manual retry | Auto-reconnect |
#### **Development Roadmap**
**Week 1-2**: Basic ZMQ agent
- Rust binary with ZMQ gossip protocol
- SMART metrics collection
- Configuration management
**Week 3-4**: Dashboard integration
- ZMQ subscriber in cm-dashboard
- Real-time TUI updates
- Parallel HTTP/ZMQ operation
**Week 5-6**: Production readiness
- Service/backup metrics support
- Error handling and resilience
- Performance benchmarking
**Week 7-8**: Migration and cleanup
- Switch dashboard to ZMQ-only
- Remove legacy HTTP APIs
- Documentation and deployment
### Potential Features
- **Plugin system** for custom widgets
- **REST API** for external integrations
- **Mobile companion app** for alerts
- **Grafana integration** for advanced graphing
- **Prometheus metrics export**
- **Custom scripting** for automated responses
- **Machine learning** for predictive analytics
- **Clustering support** for high availability
### Integration Opportunities
- **Home Assistant** integration
- **Slack/Discord** notifications
- **SNMP support** for network equipment
- **Docker/Kubernetes** container monitoring
- **Cloud metrics** integration (if needed)
## Success Metrics
### Technical Success
- **Zero crashes** during normal operation
- **Sub-second response** times for all operations
- **99.9% uptime** for monitoring (excluding network issues)
- **Minimal resource usage** as specified
### User Success
- **Faster problem detection** compared to Glance
- **Reduced time to resolution** for issues
- **Improved infrastructure awareness**
- **Enhanced operational efficiency**
---
## Development Log
### Project Initialization
- Repository created: `/home/cm/projects/cm-dashboard`
- Initial planning: TUI dashboard to replace Glance
- Technology selected: Rust + ratatui
- Architecture designed: Multi-host monitoring with existing API integration
### Current Status (HTTP-based)
- **Functional TUI**: Basic dashboard rendering with ratatui
- **HTTP API integration**: Connects to ports 6127, 6128, 6129
- **Multi-host support**: Configurable host management
- **Async architecture**: Tokio-based concurrent metrics fetching
- **Configuration system**: TOML-based host and dashboard configuration
### Proposed Evolution: ZMQ Agent System
**Rationale for Change**: The current HTTP polling approach has fundamental limitations:
1. **Latency**: 5-second refresh cycles miss rapid changes
2. **Resource overhead**: Python HTTP servers consume unnecessary resources
3. **Network complexity**: Multiple ports per host complicate firewall management
4. **Scalability**: Linear resource growth with host count
**Solution**: Peer-to-peer ZMQ gossip network with Rust agents provides:
- **Real-time streaming**: Sub-second metric propagation
- **Fault tolerance**: Network self-heals around failed hosts
- **Performance**: Native Rust speed vs interpreted Python
- **Simplicity**: Single port per host, no central coordination
### ZMQ Agent Development Plan
**Component 1: cm-metrics-agent** (New Rust binary)
```toml
[package]
name = "cm-metrics-agent"
version = "0.1.0"
[dependencies]
zmq = "0.10"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
smartmontools-rs = "0.1" # Or direct smartctl bindings
```
**Component 2: Dashboard Integration** (Update cm-dashboard)
- Add ZMQ subscriber mode alongside HTTP client
- Implement real-time metric streaming
- Provide migration path from HTTP to ZMQ
**Migration Strategy**:
1. **Phase 1**: Deploy agents alongside existing APIs
2. **Phase 2**: Switch dashboard to ZMQ mode
3. **Phase 3**: Remove HTTP APIs from NixOS configurations
**Performance Targets**:
- **Agent footprint**: < 2MB RAM, < 1% CPU
- **Metric latency**: < 100ms propagation across network
- **Network efficiency**: < 1KB/s per host steady state

1693
Cargo.lock generated Normal file
View File

@ -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",
]

19
Cargo.toml Normal file
View File

@ -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"

136
README.md
View File

@ -1,3 +1,135 @@
# cm-dashboard
# CM Dashboard
Linux TUI dashboard for host health overview
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.

View File

@ -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"

39
config/dashboard.toml Normal file
View File

@ -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"

12
config/hosts.example.toml Normal file
View File

@ -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

14
config/hosts.toml Normal file
View File

@ -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

483
src/app.rs Normal file
View File

@ -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<PathBuf>,
pub host: Option<String>,
pub tick_rate: Duration,
pub verbosity: u8,
pub zmq_endpoints_override: Vec<String>,
}
impl AppOptions {
pub fn tick_rate(&self) -> Duration {
self.tick_rate
}
}
#[derive(Debug, Default)]
struct HostRuntimeState {
last_success: Option<DateTime<Utc>>,
last_error: Option<String>,
smart: Option<SmartMetrics>,
services: Option<ServiceMetrics>,
backup: Option<BackupMetrics>,
}
/// Top-level application state container.
#[derive(Debug)]
pub struct App {
options: AppOptions,
config: Option<AppConfig>,
active_config_path: Option<PathBuf>,
hosts: Vec<HostTarget>,
history: MetricsHistory,
host_states: HashMap<String, HostRuntimeState>,
zmq_endpoints: Vec<String>,
zmq_subscription: Option<String>,
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<Self> {
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::<HashMap<_, _>>();
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<HostDisplayData> {
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<HostDisplayData> {
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<ZmqContext> {
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<AppConfig>, Option<PathBuf>)> {
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<HostTarget> {
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<String>, Option<String>) {
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<DateTime<Utc>>,
pub last_error: Option<String>,
pub smart: Option<SmartMetrics>,
pub services: Option<ServiceMetrics>,
pub backup: Option<BackupMetrics>,
}
#[derive(Debug, Clone)]
pub struct ZmqContext {
endpoints: Vec<String>,
subscription: Option<String>,
}
impl ZmqContext {
pub fn new(endpoints: Vec<String>, subscription: Option<String>) -> 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<SmartMetrics>,
services: Option<ServiceMetrics>,
backup: Option<BackupMetrics>,
timestamp: DateTime<Utc>,
},
MetricsFailed {
host: String,
error: String,
timestamp: DateTime<Utc>,
},
}

19
src/config.rs Normal file
View File

@ -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<AppConfig> {
let raw = fs::read_to_string(path)
.with_context(|| format!("failed to read configuration file at {}", path.display()))?;
let config = toml::from_str::<AppConfig>(&raw)
.with_context(|| format!("failed to parse configuration file {}", path.display()))?;
Ok(config)
}

138
src/data/config.rs Normal file
View File

@ -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<String>,
#[serde(default)]
pub hosts: Vec<HostTarget>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostTarget {
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
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<WidgetConfig>,
}
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<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppFilesystem {
pub cache_dir: Option<PathBuf>,
pub history_dir: Option<PathBuf>,
}
#[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<AppFilesystem>,
}
#[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<String>,
#[serde(default)]
pub subscribe: Option<String>,
}
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<String> {
vec!["tcp://127.0.0.1:6130".to_string()]
}

54
src/data/history.rs Normal file
View File

@ -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<Utc>, SmartMetrics)>,
services: VecDeque<(DateTime<Utc>, ServiceMetrics)>,
backups: VecDeque<(DateTime<Utc>, 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<T>(deque: &mut VecDeque<T>, item: T, capacity: usize) {
if deque.len() == capacity {
deque.pop_front();
}
deque.push_back(item);
}
}

96
src/data/metrics.rs Normal file
View File

@ -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<DriveInfo>,
pub summary: DriveSummary,
pub issues: Vec<String>,
pub timestamp: DateTime<Utc>,
}
#[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<ServiceInfo>,
pub timestamp: DateTime<Utc>,
}
#[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<f32>,
}
#[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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupInfo {
pub last_success: Option<DateTime<Utc>>,
pub last_failure: Option<DateTime<Utc>>,
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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BackupStatus {
Healthy,
Warning,
Failed,
Unknown,
}

3
src/data/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod config;
pub mod history;
pub mod metrics;

492
src/main.rs Normal file
View File

@ -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<WorkerGuard> = OnceLock::new();
#[derive(Parser, Debug)]
#[command(
name = "cm-dashboard",
version,
about = "Infrastructure monitoring TUI for CMTEC"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
/// Optional path to configuration TOML file
#[arg(long, value_name = "FILE")]
config: Option<PathBuf>,
/// Limit dashboard to a single host
#[arg(short = 'H', long, value_name = "HOST")]
host: Option<String>,
/// 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<String>,
}
#[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<Terminal<CrosstermBackend<Stdout>>> {
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<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
event_rx: &mut UnboundedReceiver<AppEvent>,
) -> 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<AppEvent>) {
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<tracing_appender::non_blocking::NonBlocking> {
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<AppEvent>) {
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<AppEvent>) -> 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<AppEvent>,
) -> 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::<SmartMetrics>(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::<ServiceMetrics>(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::<BackupMetrics>(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
"#;

51
src/ui/alerts.rs Normal file
View File

@ -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);
}

62
src/ui/backup.rs Normal file
View File

@ -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);
}

190
src/ui/dashboard.rs Normal file
View File

@ -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]
}

56
src/ui/memory.rs Normal file
View File

@ -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);
}

8
src/ui/mod.rs Normal file
View File

@ -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;

58
src/ui/nvme.rs Normal file
View File

@ -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);
}

54
src/ui/services.rs Normal file
View File

@ -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);
}