Restructure into workspace with dashboard and agent

This commit is contained in:
Christoffer Martinsson 2025-10-11 13:56:58 +02:00
parent 65d31514a1
commit 82afe3d4f1
23 changed files with 405 additions and 59 deletions

114
Cargo.lock generated
View File

@ -1,5 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
# This file is automatically generated by Cargo.
version = 4
[[package]]
@ -237,6 +238,7 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"cm-dashboard-shared",
"crossterm",
"ratatui",
"serde",
@ -249,6 +251,33 @@ dependencies = [
"zmq",
]
[[package]]
name = "cm-dashboard-agent"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"cm-dashboard-shared",
"rand",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zmq",
]
[[package]]
name = "cm-dashboard-shared"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@ -386,6 +415,17 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.3"
@ -511,7 +551,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom",
"getrandom 0.3.3",
"libc",
]
@ -624,7 +664,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -710,6 +750,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
@ -734,6 +783,36 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "ratatui"
version = "0.24.0"
@ -1364,7 +1443,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1459,6 +1538,15 @@ dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@ -1660,6 +1748,26 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "zerocopy"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeromq-src"
version = "0.2.6+4.3.4"

View File

@ -1,19 +1,8 @@
[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"
[workspace]
members = [
"dashboard",
"agent",
"shared"
]
resolver = "2"
default-members = ["dashboard"]

View File

@ -30,7 +30,7 @@ CM Dashboard is a Rust-powered terminal UI for real-time monitoring of CMTEC inf
## Requirements
- Rust toolchain 1.75+ (install via [`rustup`](https://rustup.rs))
- Network access to the CMTEC metrics gossip agents (default `tcp://<host>:6130`)
- Network access to the CMTEC metrics gossip agents (default `tcp://<host>:6130`; install `zeromq`/`libzmq` on the host)
- Configuration files under `config/` describing hosts and dashboard preferences
## Installation
@ -46,7 +46,7 @@ cargo build --release
The optimized binary is available at `target/release/cm-dashboard`. To install into your Cargo bin directory:
```bash
cargo install --path .
cargo install --path dashboard
```
## Configuration
@ -56,7 +56,7 @@ On first launch, the dashboard will create `config/dashboard.toml` and `config/h
You can also generate starter configuration files manually with the built-in helper:
```bash
cargo run -- init-config
cargo run -p cm-dashboard -- init-config
# or, once installed
cm-dashboard init-config --dir ./config --force
```
@ -107,13 +107,13 @@ Adjust the host list and `data_source.zmq.endpoints` to match your CMTEC gossip
## Getting Started
```bash
cargo run -- --config config/dashboard.toml
cargo run -p cm-dashboard -- --config config/dashboard.toml
# specify a single host
cargo run -- --host srv01
cargo run -p cm-dashboard -- --host srv01
# override ZMQ endpoints at runtime
cargo run -- --zmq-endpoint tcp://srv01:6130,tcp://labbox:6130
cargo run -p cm-dashboard -- --zmq-endpoint tcp://srv01:6130,tcp://labbox:6130
# increase logging verbosity
cargo run -- -v
cargo run -p cm-dashboard -- -v
```
### Keyboard Shortcuts
@ -126,10 +126,20 @@ cargo run -- -v
| `r` | Update status message |
| `q` / `Esc` | Quit |
## Agent
The metrics agent publishes SMART/service/backup data to the gossip network. Run it on each host (or under systemd/NixOS) and point the dashboard at its endpoint. Example:
```bash
cargo run -p cm-dashboard-agent -- --hostname srv01 --bind tcp://*:6130 --interval-ms 5000
```
Use `--disable-*` flags to skip collectors when a host doesnt expose those metrics.
## Development
- Format: `cargo fmt`
- Check: `cargo check`
- Run: `cargo run`
- Check workspace: `cargo check`
- Build release binaries: `cargo build --release`
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.

18
agent/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "cm-dashboard-agent"
version = "0.1.0"
edition = "2021"
[dependencies]
cm-dashboard-shared = { path = "../shared" }
anyhow = "1.0"
clap = { version = "4.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tracing-appender = "0.2"
zmq = "0.10"
tokio = { version = "1.0", features = ["full"] }
rand = "0.8"

182
agent/src/main.rs Normal file
View File

@ -0,0 +1,182 @@
use std::thread;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use clap::{ArgAction, Parser};
use cm_dashboard_shared::envelope::{AgentType, MetricsEnvelope};
use rand::Rng;
use serde_json::json;
use tracing::info;
use tracing_subscriber::EnvFilter;
use zmq::{Context as ZmqContext, SocketType};
#[derive(Parser, Debug)]
#[command(
name = "cm-dashboard-agent",
version,
about = "CM Dashboard metrics agent"
)]
struct Cli {
/// Hostname to advertise in metric envelopes
#[arg(long, value_name = "HOSTNAME")]
hostname: String,
/// Bind endpoint for PUB socket (default tcp://*:6130)
#[arg(long, default_value = "tcp://*:6130", value_name = "ENDPOINT")]
bind: String,
/// Publish interval in milliseconds
#[arg(long, default_value_t = 5000)]
interval_ms: u64,
/// Disable smart metrics publisher
#[arg(long, action = ArgAction::SetTrue)]
disable_smart: bool,
/// Disable service metrics publisher
#[arg(long, action = ArgAction::SetTrue)]
disable_service: bool,
/// Disable backup metrics publisher
#[arg(long, action = ArgAction::SetTrue)]
disable_backup: bool,
/// Increase logging verbosity (-v, -vv)
#[arg(short, long, action = ArgAction::Count)]
verbose: u8,
}
fn main() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.verbose)?;
let context = ZmqContext::new();
let socket = context
.socket(SocketType::PUB)
.context("failed to create ZMQ PUB socket")?;
socket
.bind(&cli.bind)
.with_context(|| format!("failed to bind to {}", cli.bind))?;
info!(endpoint = %cli.bind, host = %cli.hostname, "agent started");
let interval = Duration::from_millis(cli.interval_ms.max(100));
let mut rng = rand::thread_rng();
loop {
let now = Utc::now();
let timestamp = now.timestamp() as u64;
let timestamp_rfc3339 = now.to_rfc3339();
if !cli.disable_smart {
let envelope = MetricsEnvelope {
hostname: cli.hostname.clone(),
agent_type: AgentType::Smart,
timestamp,
metrics: json!({
"status": "Healthy",
"drives": [{
"name": "nvme0n1",
"temperature_c": rng.gen_range(30.0..60.0),
"wear_level": rng.gen_range(1.0..10.0),
"power_on_hours": rng.gen_range(1000..20000),
"available_spare": rng.gen_range(90.0..100.0)
}],
"summary": {
"healthy": 1,
"warning": 0,
"critical": 0,
"capacity_total_gb": 1024,
"capacity_used_gb": rng.gen_range(100.0..800.0)
},
"issues": [],
"timestamp": timestamp_rfc3339
}),
};
publish(&socket, &envelope)?;
}
if !cli.disable_service {
let envelope = MetricsEnvelope {
hostname: cli.hostname.clone(),
agent_type: AgentType::Service,
timestamp,
metrics: json!({
"summary": {
"healthy": 5,
"degraded": 0,
"failed": 0,
"memory_used_mb": rng.gen_range(512.0..2048.0),
"memory_quota_mb": 4096.0
},
"services": [
{
"name": "example",
"status": "Running",
"memory_used_mb": rng.gen_range(128.0..512.0),
"memory_quota_mb": 1024.0,
"cpu_percent": rng.gen_range(0.0..75.0),
"sandbox_limit": null
}
],
"timestamp": timestamp_rfc3339
}),
};
publish(&socket, &envelope)?;
}
if !cli.disable_backup {
let envelope = MetricsEnvelope {
hostname: cli.hostname.clone(),
agent_type: AgentType::Backup,
timestamp,
metrics: json!({
"overall_status": "Healthy",
"backup": {
"last_success": timestamp_rfc3339,
"last_failure": null,
"size_gb": rng.gen_range(100.0..500.0),
"snapshot_count": rng.gen_range(10..40)
},
"service": {
"enabled": true,
"pending_jobs": 0,
"last_message": "Backups up-to-date"
},
"timestamp": timestamp_rfc3339
}),
};
publish(&socket, &envelope)?;
}
thread::sleep(interval);
}
}
fn publish(socket: &zmq::Socket, envelope: &MetricsEnvelope) -> Result<()> {
let serialized = serde_json::to_vec(envelope)?;
socket.send(serialized, 0)?;
Ok(())
}
fn init_tracing(verbosity: u8) -> Result<()> {
let level = match verbosity {
0 => "info",
1 => "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));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.compact()
.try_init()
.map_err(|err| anyhow!(err))?;
Ok(())
}

20
dashboard/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "cm-dashboard"
version = "0.1.0"
edition = "2021"
[dependencies]
cm-dashboard-shared = { path = "../shared" }
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"

View File

@ -220,6 +220,10 @@ impl App {
pub fn handle_app_event(&mut self, event: AppEvent) {
match event {
AppEvent::Shutdown => {
self.should_quit = true;
self.status = "Shutting down…".to_string();
}
AppEvent::MetricsUpdated {
host,
smart,
@ -480,4 +484,5 @@ pub enum AppEvent {
error: String,
timestamp: DateTime<Utc>,
},
Shutdown,
}

View File

@ -13,17 +13,17 @@ use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics};
use anyhow::{anyhow, Context, Result};
use chrono::{TimeZone, Utc};
use clap::{ArgAction, Parser, Subcommand};
use cm_dashboard_shared::envelope::{AgentType, MetricsEnvelope};
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 tokio::task::{spawn_blocking, JoinHandle};
use tracing::{debug, warn};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::EnvFilter;
@ -100,13 +100,19 @@ async fn main() -> Result<()> {
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 zmq_task = if let Some(context) = app.zmq_context() {
Some(spawn_metrics_task(context, event_tx.clone()))
} else {
None
};
let mut terminal = setup_terminal()?;
let result = run_app(&mut terminal, &mut app, &mut event_rx);
teardown_terminal(terminal)?;
let _ = event_tx.send(AppEvent::Shutdown);
if let Some(handle) = zmq_task {
handle.abort();
}
result
}
@ -200,14 +206,14 @@ fn prepare_log_writer() -> Result<tracing_appender::non_blocking::NonBlocking> {
Ok(non_blocking)
}
fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender<AppEvent>) {
fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender<AppEvent>) -> JoinHandle<()> {
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<()> {
@ -332,23 +338,6 @@ fn handle_zmq_message(
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)?;
@ -441,13 +430,11 @@ const DASHBOARD_TEMPLATE: &str = r#"# CM Dashboard configuration
[[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]
@ -482,11 +469,9 @@ const HOSTS_TEMPLATE: &str = r#"# Optional separate hosts configuration
[[hosts.hosts]]
name = "srv01"
base_url = "http://srv01.local"
enabled = true
[[hosts.hosts]]
name = "labbox"
base_url = "http://labbox.local"
enabled = true
"#;

9
shared/Cargo.toml Normal file
View File

@ -0,0 +1,9 @@
[package]
name = "cm-dashboard-shared"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }

19
shared/src/envelope.rs Normal file
View File

@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentType {
Smart,
Service,
Backup,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsEnvelope {
pub hostname: String,
pub agent_type: AgentType,
pub timestamp: u64,
#[serde(default)]
pub metrics: Value,
}

1
shared/src/lib.rs Normal file
View File

@ -0,0 +1 @@
pub mod envelope;