diff --git a/Cargo.lock b/Cargo.lock index c704985..9578eb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5f9b694..5bf83d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/README.md b/README.md index 9eb918e..b44fcf7 100644 --- a/README.md +++ b/README.md @@ -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://:6130`) +- Network access to the CMTEC metrics gossip agents (default `tcp://: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 doesn’t 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. diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000..9c9c6ac --- /dev/null +++ b/agent/Cargo.toml @@ -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" diff --git a/agent/src/main.rs b/agent/src/main.rs new file mode 100644 index 0000000..7679b79 --- /dev/null +++ b/agent/src/main.rs @@ -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(()) +} diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml new file mode 100644 index 0000000..4eb451d --- /dev/null +++ b/dashboard/Cargo.toml @@ -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" diff --git a/src/app.rs b/dashboard/src/app.rs similarity index 98% rename from src/app.rs rename to dashboard/src/app.rs index 92924da..247ce64 100644 --- a/src/app.rs +++ b/dashboard/src/app.rs @@ -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, }, + Shutdown, } diff --git a/src/config.rs b/dashboard/src/config.rs similarity index 100% rename from src/config.rs rename to dashboard/src/config.rs diff --git a/src/data/config.rs b/dashboard/src/data/config.rs similarity index 100% rename from src/data/config.rs rename to dashboard/src/data/config.rs diff --git a/src/data/history.rs b/dashboard/src/data/history.rs similarity index 100% rename from src/data/history.rs rename to dashboard/src/data/history.rs diff --git a/src/data/metrics.rs b/dashboard/src/data/metrics.rs similarity index 100% rename from src/data/metrics.rs rename to dashboard/src/data/metrics.rs diff --git a/src/data/mod.rs b/dashboard/src/data/mod.rs similarity index 100% rename from src/data/mod.rs rename to dashboard/src/data/mod.rs diff --git a/src/main.rs b/dashboard/src/main.rs similarity index 95% rename from src/main.rs rename to dashboard/src/main.rs index 9c9a9c3..d32b6e4 100644 --- a/src/main.rs +++ b/dashboard/src/main.rs @@ -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 { Ok(non_blocking) } -fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender) { +fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender) -> 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) -> 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 "#; diff --git a/src/ui/alerts.rs b/dashboard/src/ui/alerts.rs similarity index 100% rename from src/ui/alerts.rs rename to dashboard/src/ui/alerts.rs diff --git a/src/ui/backup.rs b/dashboard/src/ui/backup.rs similarity index 100% rename from src/ui/backup.rs rename to dashboard/src/ui/backup.rs diff --git a/src/ui/dashboard.rs b/dashboard/src/ui/dashboard.rs similarity index 100% rename from src/ui/dashboard.rs rename to dashboard/src/ui/dashboard.rs diff --git a/src/ui/memory.rs b/dashboard/src/ui/memory.rs similarity index 100% rename from src/ui/memory.rs rename to dashboard/src/ui/memory.rs diff --git a/src/ui/mod.rs b/dashboard/src/ui/mod.rs similarity index 100% rename from src/ui/mod.rs rename to dashboard/src/ui/mod.rs diff --git a/src/ui/nvme.rs b/dashboard/src/ui/nvme.rs similarity index 100% rename from src/ui/nvme.rs rename to dashboard/src/ui/nvme.rs diff --git a/src/ui/services.rs b/dashboard/src/ui/services.rs similarity index 100% rename from src/ui/services.rs rename to dashboard/src/ui/services.rs diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..20e256f --- /dev/null +++ b/shared/Cargo.toml @@ -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"] } diff --git a/shared/src/envelope.rs b/shared/src/envelope.rs new file mode 100644 index 0000000..a509afc --- /dev/null +++ b/shared/src/envelope.rs @@ -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, +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..788ab12 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1 @@ +pub mod envelope;