Restructure into workspace with dashboard and agent
This commit is contained in:
20
dashboard/Cargo.toml
Normal file
20
dashboard/Cargo.toml
Normal 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
488
dashboard/src/app.rs
Normal 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
19
dashboard/src/config.rs
Normal 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
dashboard/src/data/config.rs
Normal file
138
dashboard/src/data/config.rs
Normal 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
dashboard/src/data/history.rs
Normal file
54
dashboard/src/data/history.rs
Normal 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
dashboard/src/data/metrics.rs
Normal file
96
dashboard/src/data/metrics.rs
Normal 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
dashboard/src/data/mod.rs
Normal file
3
dashboard/src/data/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod history;
|
||||
pub mod metrics;
|
||||
477
dashboard/src/main.rs
Normal file
477
dashboard/src/main.rs
Normal 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
|
||||
"#;
|
||||
51
dashboard/src/ui/alerts.rs
Normal file
51
dashboard/src/ui/alerts.rs
Normal 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
dashboard/src/ui/backup.rs
Normal file
62
dashboard/src/ui/backup.rs
Normal 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
dashboard/src/ui/dashboard.rs
Normal file
190
dashboard/src/ui/dashboard.rs
Normal 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
dashboard/src/ui/memory.rs
Normal file
56
dashboard/src/ui/memory.rs
Normal 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
8
dashboard/src/ui/mod.rs
Normal 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
58
dashboard/src/ui/nvme.rs
Normal 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
dashboard/src/ui/services.rs
Normal file
54
dashboard/src/ui/services.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user