Restructure into workspace with dashboard and agent

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

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"

488
dashboard/src/app.rs Normal file
View File

@@ -0,0 +1,488 @@
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::Shutdown => {
self.should_quit = true;
self.status = "Shutting down…".to_string();
}
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>,
},
Shutdown,
}

19
dashboard/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)
}

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()]
}

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

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,
}

View File

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

477
dashboard/src/main.rs Normal file
View File

@@ -0,0 +1,477 @@
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 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_json::Value;
use tokio::sync::mpsc::{
error::TryRecvError, unbounded_channel, UnboundedReceiver, UnboundedSender,
};
use tokio::task::{spawn_blocking, JoinHandle};
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();
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
}
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>) -> 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<()> {
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(())
}
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"
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
[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"
enabled = true
[[hosts.hosts]]
name = "labbox"
enabled = true
"#;

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

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

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

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
dashboard/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
dashboard/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);
}

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