Implement real-time process monitoring and fix UI hardcoded data
This commit addresses several key issues identified during development: Major Changes: - Replace hardcoded top CPU/RAM process display with real system data - Add intelligent process monitoring to CpuCollector using ps command - Fix disk metrics permission issues in systemd collector - Optimize service collection to focus on status, memory, and disk only - Update dashboard widgets to display live process information Process Monitoring Implementation: - Added collect_top_cpu_process() and collect_top_ram_process() methods - Implemented ps-based monitoring with accurate CPU percentages - Added filtering to prevent self-monitoring artifacts (ps commands) - Enhanced error handling and validation for process data - Dashboard now shows realistic values like "claude (PID 2974) 11.0%" Service Collection Optimization: - Removed CPU monitoring from systemd collector for efficiency - Enhanced service directory permission error logging - Simplified services widget to show essential metrics only - Fixed service-to-directory mapping accuracy UI and Dashboard Improvements: - Reorganized dashboard layout with btop-inspired multi-panel design - Updated system panel to include real top CPU/RAM process display - Enhanced widget formatting and data presentation - Removed placeholder/hardcoded data throughout the interface Technical Details: - Updated agent/src/collectors/cpu.rs with process monitoring - Modified dashboard/src/ui/mod.rs for real-time process display - Enhanced systemd collector error handling and disk metrics - Updated CLAUDE.md documentation with implementation details
This commit is contained in:
@@ -4,18 +4,17 @@ 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"
|
||||
gethostname = "0.4"
|
||||
cm-dashboard-shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
zmq = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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
|
||||
|
||||
[data_source]
|
||||
kind = "zmq"
|
||||
|
||||
[data_source.zmq]
|
||||
endpoints = [
|
||||
"tcp://srv01:6130", # srv01
|
||||
"tcp://cmbox:6130", # cmbox
|
||||
"tcp://simonbox:6130", # simonbox
|
||||
"tcp://steambox:6130", # steambox
|
||||
"tcp://labbox:6130", # labbox
|
||||
]
|
||||
|
||||
[filesystem]
|
||||
# cache_dir = "/var/lib/cm-dashboard/cache"
|
||||
# history_dir = "/var/lib/cm-dashboard/history"
|
||||
@@ -1,12 +0,0 @@
|
||||
# Optional separate hosts configuration
|
||||
|
||||
[hosts]
|
||||
# default_host = "srv01"
|
||||
|
||||
[[hosts.hosts]]
|
||||
name = "srv01"
|
||||
enabled = true
|
||||
|
||||
[[hosts.hosts]]
|
||||
name = "labbox"
|
||||
enabled = true
|
||||
@@ -1,647 +1,276 @@
|
||||
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 gethostname::gethostname;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
Terminal,
|
||||
};
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, error, debug, warn};
|
||||
|
||||
use crate::config;
|
||||
use crate::data::config::{AppConfig, DataSourceKind, HostTarget, ZmqConfig, DEFAULT_HOSTS};
|
||||
use crate::data::history::MetricsHistory;
|
||||
use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics, SystemMetrics};
|
||||
use crate::config::DashboardConfig;
|
||||
use crate::communication::{ZmqConsumer, ZmqCommandSender, AgentCommand};
|
||||
use crate::metrics::MetricStore;
|
||||
use crate::ui::TuiApp;
|
||||
|
||||
// Host connection timeout - if no data received for this duration, mark as timeout
|
||||
// Keep-alive mechanism: agents send data every 5 seconds, timeout after 15 seconds
|
||||
const HOST_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 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>,
|
||||
pub struct Dashboard {
|
||||
config: DashboardConfig,
|
||||
zmq_consumer: ZmqConsumer,
|
||||
zmq_command_sender: ZmqCommandSender,
|
||||
metric_store: MetricStore,
|
||||
tui_app: Option<TuiApp>,
|
||||
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
|
||||
headless: bool,
|
||||
initial_commands_sent: std::collections::HashSet<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>,
|
||||
connection_status: ConnectionStatus,
|
||||
smart: Option<SmartMetrics>,
|
||||
services: Option<ServiceMetrics>,
|
||||
system: Option<SystemMetrics>,
|
||||
backup: Option<BackupMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum ConnectionStatus {
|
||||
#[default]
|
||||
Unknown,
|
||||
Connected,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Top-level application state container.
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
options: AppOptions,
|
||||
#[allow(dead_code)]
|
||||
config: Option<AppConfig>,
|
||||
#[allow(dead_code)]
|
||||
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();
|
||||
impl Dashboard {
|
||||
pub async fn new(config_path: Option<String>, headless: bool) -> Result<Self> {
|
||||
info!("Initializing dashboard");
|
||||
|
||||
// Load configuration
|
||||
let config = if let Some(path) = config_path {
|
||||
DashboardConfig::load_from_file(&path)?
|
||||
} else {
|
||||
DashboardConfig::default()
|
||||
};
|
||||
|
||||
// Initialize ZMQ consumer
|
||||
let mut zmq_consumer = match ZmqConsumer::new(&config.zmq).await {
|
||||
Ok(consumer) => consumer,
|
||||
Err(e) => {
|
||||
error!("Failed to initialize ZMQ consumer: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize ZMQ command sender
|
||||
let zmq_command_sender = match ZmqCommandSender::new(&config.zmq) {
|
||||
Ok(sender) => sender,
|
||||
Err(e) => {
|
||||
error!("Failed to initialize ZMQ command sender: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to predefined hosts
|
||||
let hosts = if config.hosts.predefined_hosts.is_empty() {
|
||||
vec![
|
||||
"localhost".to_string(),
|
||||
"cmbox".to_string(),
|
||||
"labbox".to_string(),
|
||||
"simonbox".to_string(),
|
||||
"steambox".to_string(),
|
||||
"srv01".to_string(),
|
||||
]
|
||||
} else {
|
||||
config.hosts.predefined_hosts.clone()
|
||||
};
|
||||
|
||||
// Try to connect to hosts but don't fail if none are available
|
||||
match zmq_consumer.connect_to_predefined_hosts(&hosts).await {
|
||||
Ok(_) => info!("Successfully connected to ZMQ hosts"),
|
||||
Err(e) => {
|
||||
warn!("Failed to connect to hosts (this is normal if no agents are running): {}", e);
|
||||
info!("Dashboard will start anyway and connect when agents become available");
|
||||
}
|
||||
}
|
||||
|
||||
let status = Self::build_initial_status(options.host.as_ref(), active_config_path.as_ref());
|
||||
|
||||
|
||||
// Initialize metric store
|
||||
let metric_store = MetricStore::new(10000, 24); // 10k metrics, 24h retention
|
||||
|
||||
// Initialize TUI components only if not headless
|
||||
let (tui_app, terminal) = if headless {
|
||||
info!("Running in headless mode (no TUI)");
|
||||
(None, None)
|
||||
} else {
|
||||
// Initialize TUI app
|
||||
let tui_app = TuiApp::new();
|
||||
|
||||
// Setup terminal
|
||||
if let Err(e) = enable_raw_mode() {
|
||||
error!("Failed to enable raw mode: {}", e);
|
||||
error!("This usually means the dashboard is being run without a proper terminal (TTY)");
|
||||
error!("Try running with --headless flag or in a proper terminal");
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
let mut stdout = io::stdout();
|
||||
if let Err(e) = execute!(stdout, EnterAlternateScreen) {
|
||||
error!("Failed to enter alternate screen: {}", e);
|
||||
let _ = disable_raw_mode();
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = match Terminal::new(backend) {
|
||||
Ok(term) => term,
|
||||
Err(e) => {
|
||||
error!("Failed to create terminal: {}", e);
|
||||
let _ = disable_raw_mode();
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
(Some(tui_app), Some(terminal))
|
||||
};
|
||||
|
||||
info!("Dashboard initialization complete");
|
||||
|
||||
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,
|
||||
zmq_consumer,
|
||||
zmq_command_sender,
|
||||
metric_store,
|
||||
tui_app,
|
||||
terminal,
|
||||
headless,
|
||||
initial_commands_sent: std::collections::HashSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn on_tick(&mut self) {
|
||||
self.tick_count = self.tick_count.saturating_add(1);
|
||||
self.last_tick = Instant::now();
|
||||
|
||||
// Check for host connection timeouts
|
||||
self.check_host_timeouts();
|
||||
|
||||
let host_count = self.hosts.len();
|
||||
let retention = self.history.retention();
|
||||
self.status = format!(
|
||||
"Monitoring • hosts: {} • tick: {:?} • retention: {:?}",
|
||||
host_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::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
|
||||
|
||||
/// Send a command to a specific agent
|
||||
pub async fn send_command(&mut self, hostname: &str, command: AgentCommand) -> Result<()> {
|
||||
self.zmq_command_sender.send_command(hostname, command).await
|
||||
}
|
||||
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn status_text(&self) -> &str {
|
||||
&self.status
|
||||
/// Send a command to all connected hosts
|
||||
pub async fn broadcast_command(&mut self, command: AgentCommand) -> Result<Vec<String>> {
|
||||
let connected_hosts = self.metric_store.get_connected_hosts(Duration::from_secs(30));
|
||||
self.zmq_command_sender.broadcast_command(&connected_hosts, command).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn zmq_connected(&self) -> bool {
|
||||
self.zmq_connected
|
||||
}
|
||||
|
||||
pub fn tick_rate(&self) -> Duration {
|
||||
self.options.tick_rate()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn config(&self) -> Option<&AppConfig> {
|
||||
self.config.as_ref()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn active_config_path(&self) -> Option<&PathBuf> {
|
||||
self.active_config_path.as_ref()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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]))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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)
|
||||
.and_then(|state| {
|
||||
// Only show hosts that have successfully connected at least once
|
||||
if state.last_success.is_some() {
|
||||
Some(HostDisplayData {
|
||||
name: host.name.clone(),
|
||||
last_success: state.last_success.clone(),
|
||||
last_error: state.last_error.clone(),
|
||||
connection_status: state.connection_status.clone(),
|
||||
smart: state.smart.clone(),
|
||||
services: state.services.clone(),
|
||||
system: state.system.clone(),
|
||||
backup: state.backup.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
info!("Starting dashboard main loop");
|
||||
|
||||
let mut last_metrics_check = Instant::now();
|
||||
let metrics_check_interval = Duration::from_millis(100); // Check for metrics every 100ms
|
||||
|
||||
loop {
|
||||
// Handle terminal events (keyboard input) only if not headless
|
||||
if !self.headless {
|
||||
match event::poll(Duration::from_millis(50)) {
|
||||
Ok(true) => {
|
||||
match event::read() {
|
||||
Ok(Event::Key(key)) => {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
info!("Quit key pressed, exiting dashboard");
|
||||
break;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
debug!("Navigate left");
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
if let Err(e) = tui_app.handle_input(Event::Key(key)) {
|
||||
error!("Error handling left navigation: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
debug!("Navigate right");
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
if let Err(e) = tui_app.handle_input(Event::Key(key)) {
|
||||
error!("Error handling right navigation: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
debug!("Refresh requested");
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
if let Err(e) = tui_app.handle_input(Event::Key(key)) {
|
||||
error!("Error handling refresh: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Other events (mouse, resize, etc.)
|
||||
Err(e) => {
|
||||
error!("Error reading terminal event: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.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(),
|
||||
connection_status: state.connection_status.clone(),
|
||||
smart: state.smart.clone(),
|
||||
services: state.services.clone(),
|
||||
system: state.system.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 zmq_endpoints(&self) -> &[String] {
|
||||
&self.zmq_endpoints
|
||||
}
|
||||
|
||||
pub fn handle_app_event(&mut self, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::Shutdown => {
|
||||
self.should_quit = true;
|
||||
self.status = "Shutting down…".to_string();
|
||||
}
|
||||
Ok(false) => {} // No events available (timeout)
|
||||
Err(e) => {
|
||||
error!("Error polling for terminal events: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::MetricsUpdated {
|
||||
host,
|
||||
smart,
|
||||
services,
|
||||
system,
|
||||
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;
|
||||
state.connection_status = ConnectionStatus::Connected;
|
||||
|
||||
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();
|
||||
|
||||
// Check for new metrics
|
||||
if last_metrics_check.elapsed() >= metrics_check_interval {
|
||||
if let Ok(Some(metric_message)) = self.zmq_consumer.receive_metrics().await {
|
||||
debug!("Received metrics from {}: {} metrics",
|
||||
metric_message.hostname, metric_message.metrics.len());
|
||||
|
||||
// No more need for dashboard-side description caching since agent handles it
|
||||
// Check if this is the first time we've seen this host
|
||||
let is_new_host = !self.initial_commands_sent.contains(&metric_message.hostname);
|
||||
|
||||
self.history.record_services(service_metrics);
|
||||
state.services = Some(snapshot);
|
||||
}
|
||||
|
||||
if let Some(system_metrics) = system {
|
||||
// Convert timestamp format (u64 to DateTime<Utc>)
|
||||
let system_snapshot = SystemMetrics {
|
||||
summary: system_metrics.summary,
|
||||
timestamp: system_metrics.timestamp,
|
||||
};
|
||||
self.history.record_system(system_snapshot.clone());
|
||||
state.system = Some(system_snapshot);
|
||||
}
|
||||
|
||||
if let Some(mut backup_metrics) = backup {
|
||||
if backup_metrics.timestamp != timestamp {
|
||||
backup_metrics.timestamp = timestamp;
|
||||
if is_new_host {
|
||||
info!("First contact with host {}, sending initial CollectNow command", metric_message.hostname);
|
||||
|
||||
// Send CollectNow command for immediate refresh
|
||||
if let Err(e) = self.send_command(&metric_message.hostname, AgentCommand::CollectNow).await {
|
||||
error!("Failed to send initial CollectNow command to {}: {}", metric_message.hostname, e);
|
||||
} else {
|
||||
info!("✓ Sent initial CollectNow command to {}", metric_message.hostname);
|
||||
self.initial_commands_sent.insert(metric_message.hostname.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Update metric store
|
||||
self.metric_store.update_metrics(&metric_message.hostname, metric_message.metrics);
|
||||
|
||||
// Update TUI with new hosts and metrics (only if not headless)
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
let connected_hosts = self.metric_store.get_connected_hosts(Duration::from_secs(30));
|
||||
tui_app.update_hosts(connected_hosts);
|
||||
tui_app.update_metrics(&self.metric_store);
|
||||
}
|
||||
let snapshot = backup_metrics.clone();
|
||||
self.history.record_backup(backup_metrics);
|
||||
state.backup = Some(snapshot);
|
||||
}
|
||||
last_metrics_check = Instant::now();
|
||||
}
|
||||
|
||||
// Render TUI (only if not headless)
|
||||
if !self.headless {
|
||||
if let (Some(ref mut terminal), Some(ref mut tui_app)) = (&mut self.terminal, &mut self.tui_app) {
|
||||
if let Err(e) = terminal.draw(|frame| {
|
||||
tui_app.render(frame, &self.metric_store);
|
||||
}) {
|
||||
error!("Error rendering TUI: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small sleep to prevent excessive CPU usage
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
info!("Dashboard main loop ended");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
self.status = format!(
|
||||
"Metrics update • host: {} • at {}",
|
||||
host,
|
||||
timestamp.format("%H:%M:%S")
|
||||
impl Drop for Dashboard {
|
||||
fn drop(&mut self) {
|
||||
// Restore terminal (only if not headless)
|
||||
if !self.headless {
|
||||
let _ = disable_raw_mode();
|
||||
if let Some(ref mut terminal) = self.terminal {
|
||||
let _ = execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
}
|
||||
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")));
|
||||
state.connection_status = ConnectionStatus::Error;
|
||||
|
||||
self.status = format!("Fetch failed • host: {} • {}", host, error);
|
||||
let _ = terminal.show_cursor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_host_timeouts(&mut self) {
|
||||
let now = Utc::now();
|
||||
|
||||
for (_host_name, state) in self.host_states.iter_mut() {
|
||||
if let Some(last_success) = state.last_success {
|
||||
let duration_since_last = now.signed_duration_since(last_success);
|
||||
|
||||
if duration_since_last > chrono::Duration::from_std(HOST_CONNECTION_TIMEOUT).unwrap() {
|
||||
// Host has timed out (missed keep-alive)
|
||||
if !matches!(state.connection_status, ConnectionStatus::Timeout) {
|
||||
state.connection_status = ConnectionStatus::Timeout;
|
||||
state.last_error = Some(format!("Keep-alive timeout (no data for {}s)", duration_since_last.num_seconds()));
|
||||
}
|
||||
} else {
|
||||
// Host is connected
|
||||
state.connection_status = ConnectionStatus::Connected;
|
||||
}
|
||||
} else {
|
||||
// No data ever received from this host
|
||||
state.connection_status = ConnectionStatus::Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let detected = Self::local_hostname();
|
||||
match (host, config_path, detected.as_ref()) {
|
||||
(Some(host), Some(path), _) => {
|
||||
format!("Ready • host: {} • config: {}", host, path.display())
|
||||
}
|
||||
(Some(host), None, _) => format!("Ready • host: {}", host),
|
||||
(None, Some(path), Some(local)) => format!(
|
||||
"Ready • host: {} (auto) • config: {}",
|
||||
local,
|
||||
path.display()
|
||||
),
|
||||
(None, Some(path), None) => format!("Ready • config: {}", path.display()),
|
||||
(None, None, Some(local)) => format!("Ready • host: {} (auto)", local),
|
||||
(None, None, None) => "Ready • no host selected".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_hosts(host: Option<&String>, _config: Option<&AppConfig>) -> Vec<HostTarget> {
|
||||
let mut targets = Vec::new();
|
||||
|
||||
// Use default hosts for auto-discovery
|
||||
|
||||
if let Some(filter) = host {
|
||||
// If specific host requested, only connect to that one
|
||||
return vec![HostTarget::from_name(filter.clone())];
|
||||
}
|
||||
|
||||
let local_host = Self::local_hostname();
|
||||
|
||||
// Always use auto-discovery - skip config files
|
||||
if let Some(local) = local_host.as_ref() {
|
||||
targets.push(HostTarget::from_name(local.clone()));
|
||||
}
|
||||
|
||||
// Add all default hosts for auto-discovery
|
||||
for hostname in DEFAULT_HOSTS {
|
||||
if targets
|
||||
.iter()
|
||||
.any(|existing| existing.name.eq_ignore_ascii_case(hostname))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
targets.push(HostTarget::from_name(hostname.to_string()));
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
targets.push(HostTarget::from_name("localhost".to_string()));
|
||||
}
|
||||
|
||||
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 connected_hosts(&self) -> Vec<&HostTarget> {
|
||||
self.hosts
|
||||
.iter()
|
||||
.filter(|host| {
|
||||
self.host_states
|
||||
.get(&host.name)
|
||||
.map(|state| state.last_success.is_some())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn select_previous_host(&mut self) {
|
||||
let connected = self.connected_hosts();
|
||||
if connected.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current host in connected list
|
||||
let current_host = self.hosts.get(self.active_host_index);
|
||||
if let Some(current) = current_host {
|
||||
if let Some(current_pos) = connected.iter().position(|h| h.name == current.name) {
|
||||
let new_pos = if current_pos == 0 {
|
||||
connected.len().saturating_sub(1)
|
||||
} else {
|
||||
current_pos - 1
|
||||
};
|
||||
let new_host = connected[new_pos];
|
||||
// Find this host's index in the full hosts list
|
||||
if let Some(new_index) = self.hosts.iter().position(|h| h.name == new_host.name) {
|
||||
self.active_host_index = new_index;
|
||||
}
|
||||
} else {
|
||||
// Current host not connected, switch to first connected host
|
||||
if let Some(new_index) = self.hosts.iter().position(|h| h.name == connected[0].name) {
|
||||
self.active_host_index = new_index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
let connected = self.connected_hosts();
|
||||
if connected.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current host in connected list
|
||||
let current_host = self.hosts.get(self.active_host_index);
|
||||
if let Some(current) = current_host {
|
||||
if let Some(current_pos) = connected.iter().position(|h| h.name == current.name) {
|
||||
let new_pos = (current_pos + 1) % connected.len();
|
||||
let new_host = connected[new_pos];
|
||||
// Find this host's index in the full hosts list
|
||||
if let Some(new_index) = self.hosts.iter().position(|h| h.name == new_host.name) {
|
||||
self.active_host_index = new_index;
|
||||
}
|
||||
} else {
|
||||
// Current host not connected, switch to first connected host
|
||||
if let Some(new_index) = self.hosts.iter().position(|h| h.name == connected[0].name) {
|
||||
self.active_host_index = new_index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// Generate endpoints for all default hosts
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
// Always include localhost
|
||||
endpoints.push("tcp://127.0.0.1:6130".to_string());
|
||||
|
||||
// Add endpoint for each default host
|
||||
for host in DEFAULT_HOSTS {
|
||||
endpoints.push(format!("tcp://{}:6130", host));
|
||||
}
|
||||
|
||||
endpoints
|
||||
} else {
|
||||
zmq_config.endpoints.clone()
|
||||
};
|
||||
|
||||
(endpoints, zmq_config.subscribe.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn local_hostname() -> Option<String> {
|
||||
let raw = gethostname();
|
||||
let value = raw.to_string_lossy().trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostDisplayData {
|
||||
pub name: String,
|
||||
pub last_success: Option<DateTime<Utc>>,
|
||||
pub last_error: Option<String>,
|
||||
pub connection_status: ConnectionStatus,
|
||||
pub smart: Option<SmartMetrics>,
|
||||
pub services: Option<ServiceMetrics>,
|
||||
pub system: Option<SystemMetrics>,
|
||||
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>,
|
||||
system: Option<SystemMetrics>,
|
||||
backup: Option<BackupMetrics>,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
MetricsFailed {
|
||||
host: String,
|
||||
error: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
}
|
||||
204
dashboard/src/communication/mod.rs
Normal file
204
dashboard/src/communication/mod.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use anyhow::Result;
|
||||
use cm_dashboard_shared::{MetricMessage, MessageEnvelope, MessageType};
|
||||
use tracing::{info, error, debug, warn};
|
||||
use zmq::{Context, Socket, SocketType};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::ZmqConfig;
|
||||
|
||||
/// Commands that can be sent to agents
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub enum AgentCommand {
|
||||
/// Request immediate metric collection
|
||||
CollectNow,
|
||||
/// Change collection interval
|
||||
SetInterval { seconds: u64 },
|
||||
/// Enable/disable a collector
|
||||
ToggleCollector { name: String, enabled: bool },
|
||||
/// Request status/health check
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// ZMQ consumer for receiving metrics from agents
|
||||
pub struct ZmqConsumer {
|
||||
subscriber: Socket,
|
||||
config: ZmqConfig,
|
||||
connected_hosts: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl ZmqConsumer {
|
||||
pub async fn new(config: &ZmqConfig) -> Result<Self> {
|
||||
let context = Context::new();
|
||||
|
||||
// Create subscriber socket
|
||||
let subscriber = context.socket(SocketType::SUB)?;
|
||||
|
||||
// Set socket options
|
||||
subscriber.set_rcvtimeo(1000)?; // 1 second timeout for non-blocking receives
|
||||
subscriber.set_subscribe(b"")?; // Subscribe to all messages
|
||||
|
||||
info!("ZMQ consumer initialized");
|
||||
|
||||
Ok(Self {
|
||||
subscriber,
|
||||
config: config.clone(),
|
||||
connected_hosts: std::collections::HashSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Connect to a specific host's agent
|
||||
pub async fn connect_to_host(&mut self, hostname: &str, port: u16) -> Result<()> {
|
||||
let address = format!("tcp://{}:{}", hostname, port);
|
||||
|
||||
match self.subscriber.connect(&address) {
|
||||
Ok(()) => {
|
||||
info!("Connected to agent at {}", address);
|
||||
self.connected_hosts.insert(hostname.to_string());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to connect to agent at {}: {}", address, e);
|
||||
Err(anyhow::anyhow!("Failed to connect to {}: {}", address, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to predefined hosts
|
||||
pub async fn connect_to_predefined_hosts(&mut self, hosts: &[String]) -> Result<()> {
|
||||
let default_port = self.config.subscriber_ports[0];
|
||||
|
||||
for hostname in hosts {
|
||||
// Try to connect, but don't fail if some hosts are unreachable
|
||||
if let Err(e) = self.connect_to_host(hostname, default_port).await {
|
||||
warn!("Could not connect to {}: {}", hostname, e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Connected to {} out of {} configured hosts",
|
||||
self.connected_hosts.len(), hosts.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get list of newly connected hosts since last check
|
||||
pub fn get_newly_connected_hosts(&self) -> Vec<String> {
|
||||
// For now, return all connected hosts (could be enhanced with state tracking)
|
||||
self.connected_hosts.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Receive metrics from any connected agent (non-blocking)
|
||||
pub async fn receive_metrics(&mut self) -> Result<Option<MetricMessage>> {
|
||||
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
|
||||
Ok(data) => {
|
||||
debug!("Received {} bytes from ZMQ", data.len());
|
||||
|
||||
// Deserialize envelope
|
||||
let envelope: MessageEnvelope = serde_json::from_slice(&data)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to deserialize envelope: {}", e))?;
|
||||
|
||||
// Check message type
|
||||
match envelope.message_type {
|
||||
MessageType::Metrics => {
|
||||
let metrics = envelope.decode_metrics()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode metrics: {}", e))?;
|
||||
|
||||
debug!("Received {} metrics from {}",
|
||||
metrics.metrics.len(), metrics.hostname);
|
||||
|
||||
Ok(Some(metrics))
|
||||
}
|
||||
MessageType::Heartbeat => {
|
||||
debug!("Received heartbeat");
|
||||
Ok(None) // Don't return heartbeats as metrics
|
||||
}
|
||||
_ => {
|
||||
debug!("Received non-metrics message: {:?}", envelope.message_type);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(zmq::Error::EAGAIN) => {
|
||||
// No message available (non-blocking mode)
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("ZMQ receive error: {}", e);
|
||||
Err(anyhow::anyhow!("ZMQ receive error: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of connected hosts
|
||||
pub fn get_connected_hosts(&self) -> Vec<String> {
|
||||
self.connected_hosts.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Check if connected to any hosts
|
||||
pub fn has_connections(&self) -> bool {
|
||||
!self.connected_hosts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// ZMQ command sender for sending commands to agents
|
||||
pub struct ZmqCommandSender {
|
||||
context: Context,
|
||||
config: ZmqConfig,
|
||||
}
|
||||
|
||||
impl ZmqCommandSender {
|
||||
pub fn new(config: &ZmqConfig) -> Result<Self> {
|
||||
let context = Context::new();
|
||||
|
||||
info!("ZMQ command sender initialized");
|
||||
|
||||
Ok(Self {
|
||||
context,
|
||||
config: config.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a command to a specific agent
|
||||
pub async fn send_command(&self, hostname: &str, command: AgentCommand) -> Result<()> {
|
||||
// Create a new PUSH socket for this command (ZMQ best practice)
|
||||
let socket = self.context.socket(SocketType::PUSH)?;
|
||||
|
||||
// Set socket options
|
||||
socket.set_linger(1000)?; // Wait up to 1 second on close
|
||||
socket.set_sndtimeo(5000)?; // 5 second send timeout
|
||||
|
||||
// Connect to agent's command port (6131)
|
||||
let address = format!("tcp://{}:6131", hostname);
|
||||
socket.connect(&address)?;
|
||||
|
||||
// Serialize command
|
||||
let serialized = serde_json::to_vec(&command)?;
|
||||
|
||||
// Send command
|
||||
socket.send(&serialized, 0)?;
|
||||
|
||||
info!("Sent command {:?} to agent at {}", command, hostname);
|
||||
|
||||
// Socket will be automatically closed when dropped
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a command to all connected hosts
|
||||
pub async fn broadcast_command(&self, hosts: &[String], command: AgentCommand) -> Result<Vec<String>> {
|
||||
let mut failed_hosts = Vec::new();
|
||||
|
||||
for hostname in hosts {
|
||||
if let Err(e) = self.send_command(hostname, command.clone()).await {
|
||||
error!("Failed to send command to {}: {}", hostname, e);
|
||||
failed_hosts.push(hostname.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if failed_hosts.is_empty() {
|
||||
info!("Successfully broadcast command {:?} to {} hosts", command, hosts.len());
|
||||
} else {
|
||||
warn!("Failed to send command to {} hosts: {:?}", failed_hosts.len(), failed_hosts);
|
||||
}
|
||||
|
||||
Ok(failed_hosts)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#![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)
|
||||
}
|
||||
173
dashboard/src/config/mod.rs
Normal file
173
dashboard/src/config/mod.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// Main dashboard configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DashboardConfig {
|
||||
pub zmq: ZmqConfig,
|
||||
pub ui: UiConfig,
|
||||
pub hosts: HostsConfig,
|
||||
pub metrics: MetricsConfig,
|
||||
pub widgets: WidgetsConfig,
|
||||
}
|
||||
|
||||
/// ZMQ consumer configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZmqConfig {
|
||||
pub subscriber_ports: Vec<u16>,
|
||||
pub connection_timeout_ms: u64,
|
||||
pub reconnect_interval_ms: u64,
|
||||
}
|
||||
|
||||
/// UI configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UiConfig {
|
||||
pub refresh_rate_ms: u64,
|
||||
pub theme: String,
|
||||
pub preserve_layout: bool,
|
||||
}
|
||||
|
||||
/// Hosts configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HostsConfig {
|
||||
pub auto_discovery: bool,
|
||||
pub predefined_hosts: Vec<String>,
|
||||
pub default_host: Option<String>,
|
||||
}
|
||||
|
||||
/// Metrics configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetricsConfig {
|
||||
pub history_retention_hours: u64,
|
||||
pub max_metrics_per_host: usize,
|
||||
}
|
||||
|
||||
/// Widget configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WidgetsConfig {
|
||||
pub cpu: WidgetConfig,
|
||||
pub memory: WidgetConfig,
|
||||
pub storage: WidgetConfig,
|
||||
pub services: WidgetConfig,
|
||||
pub backup: WidgetConfig,
|
||||
}
|
||||
|
||||
/// Individual widget configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WidgetConfig {
|
||||
pub enabled: bool,
|
||||
pub metrics: Vec<String>,
|
||||
}
|
||||
|
||||
impl DashboardConfig {
|
||||
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: DashboardConfig = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DashboardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zmq: ZmqConfig::default(),
|
||||
ui: UiConfig::default(),
|
||||
hosts: HostsConfig::default(),
|
||||
metrics: MetricsConfig::default(),
|
||||
widgets: WidgetsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ZmqConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscriber_ports: vec![6130],
|
||||
connection_timeout_ms: 15000,
|
||||
reconnect_interval_ms: 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
refresh_rate_ms: 100,
|
||||
theme: "default".to_string(),
|
||||
preserve_layout: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HostsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_discovery: true,
|
||||
predefined_hosts: vec![
|
||||
"cmbox".to_string(),
|
||||
"labbox".to_string(),
|
||||
"simonbox".to_string(),
|
||||
"steambox".to_string(),
|
||||
"srv01".to_string(),
|
||||
],
|
||||
default_host: Some("cmbox".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MetricsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
history_retention_hours: 24,
|
||||
max_metrics_per_host: 10000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WidgetsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu: WidgetConfig {
|
||||
enabled: true,
|
||||
metrics: vec![
|
||||
"cpu_load_1min".to_string(),
|
||||
"cpu_load_5min".to_string(),
|
||||
"cpu_load_15min".to_string(),
|
||||
"cpu_temperature_celsius".to_string(),
|
||||
],
|
||||
},
|
||||
memory: WidgetConfig {
|
||||
enabled: true,
|
||||
metrics: vec![
|
||||
"memory_usage_percent".to_string(),
|
||||
"memory_total_gb".to_string(),
|
||||
"memory_available_gb".to_string(),
|
||||
],
|
||||
},
|
||||
storage: WidgetConfig {
|
||||
enabled: true,
|
||||
metrics: vec![
|
||||
"disk_nvme0_temperature_celsius".to_string(),
|
||||
"disk_nvme0_wear_percent".to_string(),
|
||||
"disk_nvme0_usage_percent".to_string(),
|
||||
],
|
||||
},
|
||||
services: WidgetConfig {
|
||||
enabled: true,
|
||||
metrics: vec![
|
||||
"service_ssh_status".to_string(),
|
||||
"service_ssh_memory_mb".to_string(),
|
||||
],
|
||||
},
|
||||
backup: WidgetConfig {
|
||||
enabled: true,
|
||||
metrics: vec![
|
||||
"backup_status".to_string(),
|
||||
"backup_last_run_timestamp".to_string(),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
#![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
|
||||
}
|
||||
|
||||
/// Default hosts for auto-discovery
|
||||
pub const DEFAULT_HOSTS: &[&str] = &[
|
||||
"cmbox", "labbox", "simonbox", "steambox", "srv01"
|
||||
];
|
||||
|
||||
fn default_data_source_config() -> DataSourceConfig {
|
||||
DataSourceConfig::default()
|
||||
}
|
||||
|
||||
fn default_zmq_endpoints() -> Vec<String> {
|
||||
// Default endpoints include localhost and all known CMTEC hosts
|
||||
let mut endpoints = vec!["tcp://127.0.0.1:6130".to_string()];
|
||||
|
||||
for host in DEFAULT_HOSTS {
|
||||
endpoints.push(format!("tcp://{}:6130", host));
|
||||
}
|
||||
|
||||
endpoints
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics, SystemMetrics};
|
||||
|
||||
/// 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)>,
|
||||
system: VecDeque<(DateTime<Utc>, SystemMetrics)>,
|
||||
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),
|
||||
system: 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_system(&mut self, metrics: SystemMetrics) {
|
||||
let entry = (Utc::now(), metrics);
|
||||
Self::push_with_limit(&mut self.system, 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);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
#![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,
|
||||
pub capacity_gb: Option<f32>,
|
||||
pub used_gb: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub description: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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 SystemMetrics {
|
||||
pub summary: SystemSummary,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemSummary {
|
||||
pub cpu_load_1: f32,
|
||||
pub cpu_load_5: f32,
|
||||
pub cpu_load_15: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_status: Option<String>,
|
||||
pub memory_used_mb: f32,
|
||||
pub memory_total_mb: f32,
|
||||
pub memory_usage_percent: f32,
|
||||
#[serde(default)]
|
||||
pub memory_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub cpu_temp_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cpu_cstate: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub logged_in_users: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub top_cpu_process: Option<String>,
|
||||
#[serde(default)]
|
||||
pub top_ram_process: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
#[serde(default)]
|
||||
pub services_status: Option<String>,
|
||||
pub memory_used_mb: f32,
|
||||
pub memory_quota_mb: f32,
|
||||
#[serde(default)]
|
||||
pub system_memory_used_mb: f32,
|
||||
#[serde(default)]
|
||||
pub system_memory_total_mb: f32,
|
||||
#[serde(default)]
|
||||
pub memory_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub disk_used_gb: f32,
|
||||
#[serde(default)]
|
||||
pub disk_total_gb: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_load_1: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_load_5: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_load_15: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cpu_cstate: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub cpu_temp_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gpu_load_percent: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub gpu_temp_c: Option<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>,
|
||||
#[serde(default)]
|
||||
pub disk_used_gb: f32,
|
||||
#[serde(default)]
|
||||
pub disk_quota_gb: f32,
|
||||
#[serde(default)]
|
||||
pub is_sandboxed: bool,
|
||||
#[serde(default)]
|
||||
pub is_sandbox_excluded: bool,
|
||||
#[serde(default)]
|
||||
pub description: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sub_service: Option<String>,
|
||||
#[serde(default)]
|
||||
pub latency_ms: 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: String,
|
||||
pub backup: BackupInfo,
|
||||
pub service: BackupServiceInfo,
|
||||
#[serde(default)]
|
||||
pub disk: Option<BackupDiskInfo>,
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub latest_archive_size_gb: Option<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 struct BackupDiskInfo {
|
||||
pub device: String,
|
||||
pub health: String,
|
||||
pub total_gb: f32,
|
||||
pub used_gb: f32,
|
||||
pub usage_percent: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum BackupStatus {
|
||||
Healthy,
|
||||
Warning,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod config;
|
||||
pub mod history;
|
||||
pub mod metrics;
|
||||
1
dashboard/src/hosts/mod.rs
Normal file
1
dashboard/src/hosts/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: Implement hosts module
|
||||
@@ -1,550 +1,88 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tracing::{info, error};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
mod data;
|
||||
mod communication;
|
||||
mod metrics;
|
||||
mod ui;
|
||||
mod hosts;
|
||||
mod utils;
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Stdout};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, OnceLock,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use app::Dashboard;
|
||||
|
||||
use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics, SystemMetrics};
|
||||
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"
|
||||
)]
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cm-dashboard")]
|
||||
#[command(about = "CM Dashboard TUI with individual metric consumption")]
|
||||
#[command(version)]
|
||||
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) for dashboard tick rate
|
||||
#[arg(long, default_value_t = 250)]
|
||||
tick_rate: u64,
|
||||
|
||||
/// Increase logging verbosity (-v, -vv)
|
||||
#[arg(short, long, action = ArgAction::Count)]
|
||||
#[arg(short, long, action = clap::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,
|
||||
},
|
||||
|
||||
/// Configuration file path
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
|
||||
/// Run in headless mode (no TUI, just logging)
|
||||
#[arg(long)]
|
||||
headless: 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 shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let zmq_task = if let Some(context) = app.zmq_context() {
|
||||
Some(spawn_metrics_task(
|
||||
context,
|
||||
event_tx.clone(),
|
||||
shutdown_flag.clone(),
|
||||
))
|
||||
|
||||
// Setup logging - only if headless or verbose
|
||||
if cli.headless || cli.verbose > 0 {
|
||||
let log_level = match cli.verbose {
|
||||
0 => "warn", // Only warnings and errors when not verbose
|
||||
1 => "info",
|
||||
2 => "debug",
|
||||
_ => "trace",
|
||||
};
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive(log_level.parse()?))
|
||||
.init();
|
||||
} else {
|
||||
None
|
||||
// No logging output when running TUI mode
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive("off".parse()?))
|
||||
.init();
|
||||
}
|
||||
|
||||
if cli.headless || cli.verbose > 0 {
|
||||
info!("CM Dashboard starting with individual metrics architecture...");
|
||||
}
|
||||
|
||||
// Create and run dashboard
|
||||
let mut dashboard = Dashboard::new(cli.config, cli.headless).await?;
|
||||
|
||||
// Setup graceful shutdown
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
let mut terminal = setup_terminal()?;
|
||||
let result = run_app(&mut terminal, &mut app, &mut event_rx);
|
||||
teardown_terminal(terminal)?;
|
||||
shutdown_flag.store(true, Ordering::Relaxed);
|
||||
let _ = event_tx.send(AppEvent::Shutdown);
|
||||
if let Some(handle) = zmq_task {
|
||||
if let Err(join_error) = handle.await {
|
||||
warn!(%join_error, "ZMQ metrics task ended unexpectedly");
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
// Run dashboard with graceful shutdown
|
||||
tokio::select! {
|
||||
result = dashboard.run() => {
|
||||
if let Err(e) = result {
|
||||
error!("Dashboard error: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
} 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>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
match spawn_blocking(move || metrics_blocking_loop(context, sender, shutdown)).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>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let zmq_context = NativeZmqContext::new();
|
||||
let socket = zmq_context
|
||||
.socket(zmq::SUB)
|
||||
.context("failed to create ZMQ SUB socket")?;
|
||||
|
||||
socket
|
||||
.set_linger(0)
|
||||
.context("failed to configure ZMQ linger")?;
|
||||
socket
|
||||
.set_rcvtimeo(1_000)
|
||||
.context("failed to configure ZMQ receive timeout")?;
|
||||
|
||||
let mut connected_endpoints = 0;
|
||||
for endpoint in context.endpoints() {
|
||||
debug!(%endpoint, "attempting to connect to ZMQ endpoint");
|
||||
match socket.connect(endpoint) {
|
||||
Ok(()) => {
|
||||
debug!(%endpoint, "successfully connected to ZMQ endpoint");
|
||||
connected_endpoints += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%endpoint, %error, "failed to connect to ZMQ endpoint, continuing with others");
|
||||
}
|
||||
_ = ctrl_c => {
|
||||
info!("Shutdown signal received");
|
||||
}
|
||||
}
|
||||
|
||||
if connected_endpoints == 0 {
|
||||
return Err(anyhow!("failed to connect to any ZMQ endpoints"));
|
||||
if cli.headless || cli.verbose > 0 {
|
||||
info!("Dashboard shutdown complete");
|
||||
}
|
||||
|
||||
debug!("connected to {}/{} ZMQ endpoints", connected_endpoints, context.endpoints().len());
|
||||
|
||||
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")?;
|
||||
}
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
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) => {
|
||||
if error == zmq::Error::EAGAIN {
|
||||
continue;
|
||||
}
|
||||
warn!(%error, "ZMQ receive error");
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("ZMQ metrics worker shutting down");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
system: 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),
|
||||
system: None,
|
||||
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::System => match serde_json::from_value::<SystemMetrics>(payload.clone()) {
|
||||
Ok(metrics) => {
|
||||
let _ = sender.send(AppEvent::MetricsUpdated {
|
||||
host,
|
||||
smart: None,
|
||||
services: None,
|
||||
system: Some(metrics),
|
||||
backup: None,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "failed to parse system metrics");
|
||||
let _ = sender.send(AppEvent::MetricsFailed {
|
||||
host,
|
||||
error: format!("system 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,
|
||||
system: 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 = "storage"
|
||||
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
|
||||
"#;
|
||||
}
|
||||
142
dashboard/src/metrics/mod.rs
Normal file
142
dashboard/src/metrics/mod.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub mod store;
|
||||
pub mod subscription;
|
||||
|
||||
pub use store::MetricStore;
|
||||
pub use subscription::SubscriptionManager;
|
||||
|
||||
/// Widget types that can subscribe to metrics
|
||||
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||
pub enum WidgetType {
|
||||
Cpu,
|
||||
Memory,
|
||||
Storage,
|
||||
Services,
|
||||
Backup,
|
||||
Hosts,
|
||||
Alerts,
|
||||
}
|
||||
|
||||
/// Metric subscription entry
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetricSubscription {
|
||||
pub widget_type: WidgetType,
|
||||
pub metric_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Historical metric data point
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetricDataPoint {
|
||||
pub metric: Metric,
|
||||
pub received_at: Instant,
|
||||
}
|
||||
|
||||
/// Metric filtering and selection utilities
|
||||
pub mod filter {
|
||||
use super::*;
|
||||
|
||||
/// Filter metrics by widget type subscription
|
||||
pub fn filter_metrics_for_widget<'a>(
|
||||
metrics: &'a [Metric],
|
||||
subscriptions: &[String],
|
||||
) -> Vec<&'a Metric> {
|
||||
metrics
|
||||
.iter()
|
||||
.filter(|metric| subscriptions.contains(&metric.name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get metrics by pattern matching
|
||||
pub fn filter_metrics_by_pattern<'a>(
|
||||
metrics: &'a [Metric],
|
||||
pattern: &str,
|
||||
) -> Vec<&'a Metric> {
|
||||
if pattern.is_empty() {
|
||||
return metrics.iter().collect();
|
||||
}
|
||||
|
||||
metrics
|
||||
.iter()
|
||||
.filter(|metric| metric.name.contains(pattern))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Aggregate status from multiple metrics
|
||||
pub fn aggregate_widget_status(metrics: &[&Metric]) -> Status {
|
||||
if metrics.is_empty() {
|
||||
return Status::Unknown;
|
||||
}
|
||||
|
||||
let statuses: Vec<Status> = metrics.iter().map(|m| m.status).collect();
|
||||
Status::aggregate(&statuses)
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget metric subscription definitions
|
||||
pub mod subscriptions {
|
||||
/// CPU widget metric subscriptions
|
||||
pub const CPU_WIDGET_METRICS: &[&str] = &[
|
||||
"cpu_load_1min",
|
||||
"cpu_load_5min",
|
||||
"cpu_load_15min",
|
||||
"cpu_temperature_celsius",
|
||||
"cpu_frequency_mhz",
|
||||
];
|
||||
|
||||
/// Memory widget metric subscriptions
|
||||
pub const MEMORY_WIDGET_METRICS: &[&str] = &[
|
||||
"memory_usage_percent",
|
||||
"memory_total_gb",
|
||||
"memory_used_gb",
|
||||
"memory_available_gb",
|
||||
"memory_swap_total_gb",
|
||||
"memory_swap_used_gb",
|
||||
"disk_tmp_size_mb",
|
||||
"disk_tmp_total_mb",
|
||||
"disk_tmp_usage_percent",
|
||||
];
|
||||
|
||||
/// Storage widget metric subscriptions
|
||||
pub const STORAGE_WIDGET_METRICS: &[&str] = &[
|
||||
"disk_nvme0_temperature_celsius",
|
||||
"disk_nvme0_wear_percent",
|
||||
"disk_nvme0_spare_percent",
|
||||
"disk_nvme0_hours",
|
||||
"disk_nvme0_capacity_gb",
|
||||
"disk_nvme0_usage_gb",
|
||||
"disk_nvme0_usage_percent",
|
||||
];
|
||||
|
||||
/// Services widget metric subscriptions
|
||||
/// Note: Individual service metrics are dynamically discovered
|
||||
/// Pattern: "service_{name}_status" and "service_{name}_memory_mb"
|
||||
pub const SERVICES_WIDGET_METRICS: &[&str] = &[
|
||||
// Individual service metrics will be matched by pattern in the widget
|
||||
// e.g., "service_sshd_status", "service_nginx_status", etc.
|
||||
];
|
||||
|
||||
/// Backup widget metric subscriptions
|
||||
pub const BACKUP_WIDGET_METRICS: &[&str] = &[
|
||||
"backup_status",
|
||||
"backup_last_run_timestamp",
|
||||
"backup_size_gb",
|
||||
"backup_duration_minutes",
|
||||
];
|
||||
|
||||
/// Get all metric subscriptions for a widget type
|
||||
pub fn get_widget_subscriptions(widget_type: super::WidgetType) -> &'static [&'static str] {
|
||||
match widget_type {
|
||||
super::WidgetType::Cpu => CPU_WIDGET_METRICS,
|
||||
super::WidgetType::Memory => MEMORY_WIDGET_METRICS,
|
||||
super::WidgetType::Storage => STORAGE_WIDGET_METRICS,
|
||||
super::WidgetType::Services => SERVICES_WIDGET_METRICS,
|
||||
super::WidgetType::Backup => BACKUP_WIDGET_METRICS,
|
||||
super::WidgetType::Hosts => &[], // Hosts widget doesn't subscribe to specific metrics
|
||||
super::WidgetType::Alerts => &[], // Alerts widget aggregates from all metrics
|
||||
}
|
||||
}
|
||||
}
|
||||
230
dashboard/src/metrics/store.rs
Normal file
230
dashboard/src/metrics/store.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{MetricDataPoint, WidgetType, subscriptions};
|
||||
|
||||
/// Central metric storage for the dashboard
|
||||
pub struct MetricStore {
|
||||
/// Current metrics: hostname -> metric_name -> metric
|
||||
current_metrics: HashMap<String, HashMap<String, Metric>>,
|
||||
/// Historical metrics for trending
|
||||
historical_metrics: HashMap<String, Vec<MetricDataPoint>>,
|
||||
/// Last update timestamp per host
|
||||
last_update: HashMap<String, Instant>,
|
||||
/// Configuration
|
||||
max_metrics_per_host: usize,
|
||||
history_retention: Duration,
|
||||
}
|
||||
|
||||
impl MetricStore {
|
||||
pub fn new(max_metrics_per_host: usize, history_retention_hours: u64) -> Self {
|
||||
Self {
|
||||
current_metrics: HashMap::new(),
|
||||
historical_metrics: HashMap::new(),
|
||||
last_update: HashMap::new(),
|
||||
max_metrics_per_host,
|
||||
history_retention: Duration::from_secs(history_retention_hours * 3600),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update metrics for a specific host
|
||||
pub fn update_metrics(&mut self, hostname: &str, metrics: Vec<Metric>) {
|
||||
let now = Instant::now();
|
||||
|
||||
debug!("Updating {} metrics for host {}", metrics.len(), hostname);
|
||||
|
||||
// Get or create host entry
|
||||
let host_metrics = self.current_metrics
|
||||
.entry(hostname.to_string())
|
||||
.or_insert_with(HashMap::new);
|
||||
|
||||
// Get or create historical entry
|
||||
let host_history = self.historical_metrics
|
||||
.entry(hostname.to_string())
|
||||
.or_insert_with(Vec::new);
|
||||
|
||||
// Update current metrics and add to history
|
||||
for metric in metrics {
|
||||
let metric_name = metric.name.clone();
|
||||
|
||||
// Store current metric
|
||||
host_metrics.insert(metric_name.clone(), metric.clone());
|
||||
|
||||
// Add to history
|
||||
host_history.push(MetricDataPoint {
|
||||
metric,
|
||||
received_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Update last update timestamp
|
||||
self.last_update.insert(hostname.to_string(), now);
|
||||
|
||||
// Get metrics count before cleanup
|
||||
let metrics_count = host_metrics.len();
|
||||
|
||||
// Cleanup old history and enforce limits
|
||||
self.cleanup_host_data(hostname);
|
||||
|
||||
info!("Updated metrics for {}: {} current metrics",
|
||||
hostname, metrics_count);
|
||||
}
|
||||
|
||||
/// Get current metric for a specific host
|
||||
pub fn get_metric(&self, hostname: &str, metric_name: &str) -> Option<&Metric> {
|
||||
self.current_metrics
|
||||
.get(hostname)?
|
||||
.get(metric_name)
|
||||
}
|
||||
|
||||
/// Get all current metrics for a host
|
||||
pub fn get_host_metrics(&self, hostname: &str) -> Option<&HashMap<String, Metric>> {
|
||||
self.current_metrics.get(hostname)
|
||||
}
|
||||
|
||||
/// Get all current metrics for a host as a vector
|
||||
pub fn get_metrics_for_host(&self, hostname: &str) -> Vec<&Metric> {
|
||||
if let Some(metrics_map) = self.current_metrics.get(hostname) {
|
||||
metrics_map.values().collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metrics for a specific widget type
|
||||
pub fn get_metrics_for_widget(&self, hostname: &str, widget_type: WidgetType) -> Vec<&Metric> {
|
||||
let subscriptions = subscriptions::get_widget_subscriptions(widget_type);
|
||||
|
||||
if let Some(host_metrics) = self.get_host_metrics(hostname) {
|
||||
subscriptions
|
||||
.iter()
|
||||
.filter_map(|&metric_name| host_metrics.get(metric_name))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get aggregated status for a widget
|
||||
pub fn get_widget_status(&self, hostname: &str, widget_type: WidgetType) -> Status {
|
||||
let metrics = self.get_metrics_for_widget(hostname, widget_type);
|
||||
|
||||
if metrics.is_empty() {
|
||||
Status::Unknown
|
||||
} else {
|
||||
let statuses: Vec<Status> = metrics.iter().map(|m| m.status).collect();
|
||||
Status::aggregate(&statuses)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of all hosts with metrics
|
||||
pub fn get_hosts(&self) -> Vec<String> {
|
||||
self.current_metrics.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get connected hosts (hosts with recent updates)
|
||||
pub fn get_connected_hosts(&self, timeout: Duration) -> Vec<String> {
|
||||
let now = Instant::now();
|
||||
|
||||
self.last_update
|
||||
.iter()
|
||||
.filter_map(|(hostname, &last_update)| {
|
||||
if now.duration_since(last_update) <= timeout {
|
||||
Some(hostname.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get last update timestamp for a host
|
||||
pub fn get_last_update(&self, hostname: &str) -> Option<Instant> {
|
||||
self.last_update.get(hostname).copied()
|
||||
}
|
||||
|
||||
/// Check if host is considered connected
|
||||
pub fn is_host_connected(&self, hostname: &str, timeout: Duration) -> bool {
|
||||
if let Some(&last_update) = self.last_update.get(hostname) {
|
||||
Instant::now().duration_since(last_update) <= timeout
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get metric value as specific type (helper function)
|
||||
pub fn get_metric_value_f32(&self, hostname: &str, metric_name: &str) -> Option<f32> {
|
||||
self.get_metric(hostname, metric_name)?
|
||||
.value
|
||||
.as_f32()
|
||||
}
|
||||
|
||||
/// Get metric value as string (helper function)
|
||||
pub fn get_metric_value_string(&self, hostname: &str, metric_name: &str) -> Option<String> {
|
||||
Some(self.get_metric(hostname, metric_name)?
|
||||
.value
|
||||
.as_string())
|
||||
}
|
||||
|
||||
/// Get historical data for a metric
|
||||
pub fn get_metric_history(&self, hostname: &str, metric_name: &str) -> Vec<&MetricDataPoint> {
|
||||
if let Some(history) = self.historical_metrics.get(hostname) {
|
||||
history
|
||||
.iter()
|
||||
.filter(|dp| dp.metric.name == metric_name)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup old data and enforce limits
|
||||
fn cleanup_host_data(&mut self, hostname: &str) {
|
||||
let now = Instant::now();
|
||||
|
||||
// Cleanup historical data
|
||||
if let Some(history) = self.historical_metrics.get_mut(hostname) {
|
||||
// Remove old entries
|
||||
history.retain(|dp| now.duration_since(dp.received_at) <= self.history_retention);
|
||||
|
||||
// Enforce size limit
|
||||
if history.len() > self.max_metrics_per_host {
|
||||
let excess = history.len() - self.max_metrics_per_host;
|
||||
history.drain(0..excess);
|
||||
warn!("Trimmed {} old metrics for host {} (size limit: {})",
|
||||
excess, hostname, self.max_metrics_per_host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
pub fn get_stats(&self) -> MetricStoreStats {
|
||||
let total_current_metrics: usize = self.current_metrics
|
||||
.values()
|
||||
.map(|host_metrics| host_metrics.len())
|
||||
.sum();
|
||||
|
||||
let total_historical_metrics: usize = self.historical_metrics
|
||||
.values()
|
||||
.map(|history| history.len())
|
||||
.sum();
|
||||
|
||||
MetricStoreStats {
|
||||
total_hosts: self.current_metrics.len(),
|
||||
total_current_metrics,
|
||||
total_historical_metrics,
|
||||
connected_hosts: self.get_connected_hosts(Duration::from_secs(30)).len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metric store statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetricStoreStats {
|
||||
pub total_hosts: usize,
|
||||
pub total_current_metrics: usize,
|
||||
pub total_historical_metrics: usize,
|
||||
pub connected_hosts: usize,
|
||||
}
|
||||
177
dashboard/src/metrics/subscription.rs
Normal file
177
dashboard/src/metrics/subscription.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::{WidgetType, MetricSubscription, subscriptions};
|
||||
|
||||
/// Manages metric subscriptions for widgets
|
||||
pub struct SubscriptionManager {
|
||||
/// Widget subscriptions: widget_type -> metric_names
|
||||
widget_subscriptions: HashMap<WidgetType, Vec<String>>,
|
||||
/// All subscribed metric names (for efficient filtering)
|
||||
all_subscribed_metrics: HashSet<String>,
|
||||
/// Active hosts
|
||||
active_hosts: HashSet<String>,
|
||||
}
|
||||
|
||||
impl SubscriptionManager {
|
||||
pub fn new() -> Self {
|
||||
let mut manager = Self {
|
||||
widget_subscriptions: HashMap::new(),
|
||||
all_subscribed_metrics: HashSet::new(),
|
||||
active_hosts: HashSet::new(),
|
||||
};
|
||||
|
||||
// Initialize default subscriptions
|
||||
manager.initialize_default_subscriptions();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
/// Initialize default widget subscriptions
|
||||
fn initialize_default_subscriptions(&mut self) {
|
||||
// Subscribe CPU widget to CPU metrics
|
||||
self.subscribe_widget(
|
||||
WidgetType::Cpu,
|
||||
subscriptions::CPU_WIDGET_METRICS.iter().map(|&s| s.to_string()).collect()
|
||||
);
|
||||
|
||||
// Subscribe Memory widget to memory metrics
|
||||
self.subscribe_widget(
|
||||
WidgetType::Memory,
|
||||
subscriptions::MEMORY_WIDGET_METRICS.iter().map(|&s| s.to_string()).collect()
|
||||
);
|
||||
|
||||
// Subscribe Storage widget to storage metrics
|
||||
self.subscribe_widget(
|
||||
WidgetType::Storage,
|
||||
subscriptions::STORAGE_WIDGET_METRICS.iter().map(|&s| s.to_string()).collect()
|
||||
);
|
||||
|
||||
// Subscribe Services widget to service metrics
|
||||
self.subscribe_widget(
|
||||
WidgetType::Services,
|
||||
subscriptions::SERVICES_WIDGET_METRICS.iter().map(|&s| s.to_string()).collect()
|
||||
);
|
||||
|
||||
// Subscribe Backup widget to backup metrics
|
||||
self.subscribe_widget(
|
||||
WidgetType::Backup,
|
||||
subscriptions::BACKUP_WIDGET_METRICS.iter().map(|&s| s.to_string()).collect()
|
||||
);
|
||||
|
||||
info!("Initialized default widget subscriptions for {} widgets",
|
||||
self.widget_subscriptions.len());
|
||||
}
|
||||
|
||||
/// Subscribe a widget to specific metrics
|
||||
pub fn subscribe_widget(&mut self, widget_type: WidgetType, metric_names: Vec<String>) {
|
||||
debug!("Subscribing {:?} widget to {} metrics", widget_type, metric_names.len());
|
||||
|
||||
// Update widget subscriptions
|
||||
self.widget_subscriptions.insert(widget_type, metric_names.clone());
|
||||
|
||||
// Update global subscription set
|
||||
for metric_name in metric_names {
|
||||
self.all_subscribed_metrics.insert(metric_name);
|
||||
}
|
||||
|
||||
debug!("Total subscribed metrics: {}", self.all_subscribed_metrics.len());
|
||||
}
|
||||
|
||||
/// Get metrics subscribed by a specific widget
|
||||
pub fn get_widget_subscriptions(&self, widget_type: WidgetType) -> Vec<String> {
|
||||
self.widget_subscriptions
|
||||
.get(&widget_type)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all subscribed metric names
|
||||
pub fn get_all_subscribed_metrics(&self) -> Vec<String> {
|
||||
self.all_subscribed_metrics.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Check if a metric is subscribed by any widget
|
||||
pub fn is_metric_subscribed(&self, metric_name: &str) -> bool {
|
||||
self.all_subscribed_metrics.contains(metric_name)
|
||||
}
|
||||
|
||||
/// Add a host to active hosts list
|
||||
pub fn add_host(&mut self, hostname: String) {
|
||||
if self.active_hosts.insert(hostname.clone()) {
|
||||
info!("Added host to subscription manager: {}", hostname);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a host from active hosts list
|
||||
pub fn remove_host(&mut self, hostname: &str) {
|
||||
if self.active_hosts.remove(hostname) {
|
||||
info!("Removed host from subscription manager: {}", hostname);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of active hosts
|
||||
pub fn get_active_hosts(&self) -> Vec<String> {
|
||||
self.active_hosts.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get subscription statistics
|
||||
pub fn get_stats(&self) -> SubscriptionStats {
|
||||
SubscriptionStats {
|
||||
total_widgets_subscribed: self.widget_subscriptions.len(),
|
||||
total_metric_subscriptions: self.all_subscribed_metrics.len(),
|
||||
active_hosts: self.active_hosts.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update widget subscription dynamically
|
||||
pub fn update_widget_subscription(&mut self, widget_type: WidgetType, metric_names: Vec<String>) {
|
||||
// Remove old subscriptions from global set
|
||||
if let Some(old_subscriptions) = self.widget_subscriptions.get(&widget_type) {
|
||||
for old_metric in old_subscriptions {
|
||||
// Only remove if no other widget subscribes to it
|
||||
let still_subscribed = self.widget_subscriptions
|
||||
.iter()
|
||||
.filter(|(&wt, _)| wt != widget_type)
|
||||
.any(|(_, metrics)| metrics.contains(old_metric));
|
||||
|
||||
if !still_subscribed {
|
||||
self.all_subscribed_metrics.remove(old_metric);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new subscriptions
|
||||
self.subscribe_widget(widget_type, metric_names);
|
||||
|
||||
debug!("Updated subscription for {:?} widget", widget_type);
|
||||
}
|
||||
|
||||
/// Get widgets that subscribe to a specific metric
|
||||
pub fn get_widgets_for_metric(&self, metric_name: &str) -> Vec<WidgetType> {
|
||||
self.widget_subscriptions
|
||||
.iter()
|
||||
.filter_map(|(&widget_type, metrics)| {
|
||||
if metrics.contains(&metric_name.to_string()) {
|
||||
Some(widget_type)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubscriptionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscription manager statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubscriptionStats {
|
||||
pub total_widgets_subscribed: usize,
|
||||
pub total_metric_subscriptions: usize,
|
||||
pub active_hosts: usize,
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::BackupMetrics;
|
||||
use crate::ui::widget::{render_placeholder, render_widget_data, status_level_from_agent_status, connection_status_message, WidgetData, WidgetStatus, StatusLevel};
|
||||
use crate::app::ConnectionStatus;
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
match host {
|
||||
Some(data) => {
|
||||
match (&data.connection_status, data.backup.as_ref()) {
|
||||
(ConnectionStatus::Connected, Some(metrics)) => {
|
||||
render_metrics(frame, data, metrics, area);
|
||||
}
|
||||
(ConnectionStatus::Connected, None) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"Backups",
|
||||
&format!("Host {} awaiting backup metrics", data.name),
|
||||
);
|
||||
}
|
||||
(status, _) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"Backups",
|
||||
&format!("Host {}: {}", data.name, connection_status_message(status, &data.last_error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => render_placeholder(frame, area, "Backups", "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMetrics, area: Rect) {
|
||||
let widget_status = status_level_from_agent_status(Some(&metrics.overall_status));
|
||||
|
||||
let mut data = WidgetData::new(
|
||||
"Backups",
|
||||
Some(WidgetStatus::new(widget_status)),
|
||||
vec!["Backup".to_string(), "Status".to_string(), "Details".to_string()]
|
||||
);
|
||||
|
||||
// Latest backup
|
||||
let (latest_status, latest_time) = if let Some(last_success) = metrics.backup.last_success.as_ref() {
|
||||
let hours_ago = chrono::Utc::now().signed_duration_since(*last_success).num_hours();
|
||||
let time_str = if hours_ago < 24 {
|
||||
format!("{}h ago", hours_ago)
|
||||
} else {
|
||||
format!("{}d ago", hours_ago / 24)
|
||||
};
|
||||
(StatusLevel::Ok, time_str)
|
||||
} else {
|
||||
(StatusLevel::Warning, "Never".to_string())
|
||||
};
|
||||
|
||||
data.add_row(
|
||||
Some(WidgetStatus::new(latest_status)),
|
||||
vec![format!("Archives: {}, {:.1}GB total", metrics.backup.snapshot_count, metrics.backup.size_gb)],
|
||||
vec![
|
||||
"Latest".to_string(),
|
||||
latest_time,
|
||||
format!("{:.1}GB", metrics.backup.latest_archive_size_gb.unwrap_or(metrics.backup.size_gb)),
|
||||
],
|
||||
);
|
||||
|
||||
// Disk usage
|
||||
if let Some(disk) = &metrics.disk {
|
||||
let disk_status = match disk.health.as_str() {
|
||||
"ok" => StatusLevel::Ok,
|
||||
"failed" => StatusLevel::Error,
|
||||
_ => StatusLevel::Warning,
|
||||
};
|
||||
|
||||
data.add_row(
|
||||
Some(WidgetStatus::new(disk_status)),
|
||||
vec![],
|
||||
vec![
|
||||
"Disk".to_string(),
|
||||
disk.health.clone(),
|
||||
{
|
||||
let used_mb = disk.used_gb * 1000.0;
|
||||
let used_str = if used_mb < 1000.0 {
|
||||
format!("{:.0}MB", used_mb)
|
||||
} else {
|
||||
format!("{:.1}GB", disk.used_gb)
|
||||
};
|
||||
format!("{} ({}GB)", used_str, disk.total_gb.round() as u32)
|
||||
},
|
||||
],
|
||||
);
|
||||
} else {
|
||||
data.add_row(
|
||||
Some(WidgetStatus::new(StatusLevel::Unknown)),
|
||||
vec![],
|
||||
vec![
|
||||
"Disk".to_string(),
|
||||
"Unknown".to_string(),
|
||||
"—".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
render_widget_data(frame, area, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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::{hosts, backup, services, storage, system};
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
let host_summaries = app.host_display_data();
|
||||
let primary_host = app.active_host_display();
|
||||
|
||||
let title = if let Some(host) = primary_host.as_ref() {
|
||||
format!("CM Dashboard • {}", host.name)
|
||||
} else {
|
||||
"CM Dashboard".to_string()
|
||||
};
|
||||
|
||||
let root_block = Block::default().title(Span::styled(
|
||||
title,
|
||||
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 main_columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(outer);
|
||||
|
||||
let left_side = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(75), Constraint::Percentage(25)])
|
||||
.split(main_columns[0]);
|
||||
|
||||
let left_widgets = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
])
|
||||
.split(left_side[0]);
|
||||
|
||||
let services_area = main_columns[1];
|
||||
|
||||
system::render(frame, primary_host.as_ref(), left_widgets[0]);
|
||||
storage::render(frame, primary_host.as_ref(), left_widgets[1]);
|
||||
backup::render(frame, primary_host.as_ref(), left_widgets[2]);
|
||||
services::render(frame, primary_host.as_ref(), services_area);
|
||||
|
||||
hosts::render(frame, &host_summaries, left_side[1]);
|
||||
|
||||
if app.help_visible() {
|
||||
render_help(frame, size);
|
||||
}
|
||||
}
|
||||
|
||||
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: Refresh all metrics"),
|
||||
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]
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::{HostDisplayData, ConnectionStatus};
|
||||
// Removed: evaluate_performance and PerfSeverity no longer needed
|
||||
use crate::ui::widget::{render_widget_data, WidgetData, WidgetStatus, StatusLevel};
|
||||
|
||||
pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
|
||||
let (severity, _ok_count, _warn_count, _fail_count) = classify_hosts(hosts);
|
||||
|
||||
let title = "Hosts".to_string();
|
||||
|
||||
let widget_status = match severity {
|
||||
HostSeverity::Critical => StatusLevel::Error,
|
||||
HostSeverity::Warning => StatusLevel::Warning,
|
||||
HostSeverity::Healthy => StatusLevel::Ok,
|
||||
HostSeverity::Unknown => StatusLevel::Unknown,
|
||||
};
|
||||
|
||||
let mut data = WidgetData::new(
|
||||
title,
|
||||
Some(WidgetStatus::new(widget_status)),
|
||||
vec!["Host".to_string(), "Status".to_string(), "Timestamp".to_string()]
|
||||
);
|
||||
|
||||
if hosts.is_empty() {
|
||||
data.add_row(
|
||||
None,
|
||||
vec![],
|
||||
vec![
|
||||
"No hosts configured".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
for host in hosts {
|
||||
let (status_text, severity, _emphasize) = host_status(host);
|
||||
let status_level = match severity {
|
||||
HostSeverity::Critical => StatusLevel::Error,
|
||||
HostSeverity::Warning => StatusLevel::Warning,
|
||||
HostSeverity::Healthy => StatusLevel::Ok,
|
||||
HostSeverity::Unknown => StatusLevel::Unknown,
|
||||
};
|
||||
let update = latest_timestamp(host)
|
||||
.map(|ts| ts.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
data.add_row(
|
||||
Some(WidgetStatus::new(status_level)),
|
||||
vec![],
|
||||
vec![
|
||||
host.name.clone(),
|
||||
status_text,
|
||||
update,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render_widget_data(frame, area, data);
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
enum HostSeverity {
|
||||
Healthy,
|
||||
Warning,
|
||||
Critical,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn classify_hosts(hosts: &[HostDisplayData]) -> (HostSeverity, usize, usize, usize) {
|
||||
let mut ok = 0;
|
||||
let mut warn = 0;
|
||||
let mut fail = 0;
|
||||
|
||||
for host in hosts {
|
||||
let severity = host_severity(host);
|
||||
match severity {
|
||||
HostSeverity::Healthy => ok += 1,
|
||||
HostSeverity::Warning => warn += 1,
|
||||
HostSeverity::Critical => fail += 1,
|
||||
HostSeverity::Unknown => warn += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let highest = if fail > 0 {
|
||||
HostSeverity::Critical
|
||||
} else if warn > 0 {
|
||||
HostSeverity::Warning
|
||||
} else if ok > 0 {
|
||||
HostSeverity::Healthy
|
||||
} else {
|
||||
HostSeverity::Unknown
|
||||
};
|
||||
|
||||
(highest, ok, warn, fail)
|
||||
}
|
||||
|
||||
fn host_severity(host: &HostDisplayData) -> HostSeverity {
|
||||
// Check connection status first
|
||||
match host.connection_status {
|
||||
ConnectionStatus::Error => return HostSeverity::Critical,
|
||||
ConnectionStatus::Timeout => return HostSeverity::Warning,
|
||||
ConnectionStatus::Unknown => return HostSeverity::Unknown,
|
||||
ConnectionStatus::Connected => {}, // Continue with other checks
|
||||
}
|
||||
|
||||
if host.last_error.is_some() {
|
||||
return HostSeverity::Critical;
|
||||
}
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
if smart.summary.critical > 0 {
|
||||
return HostSeverity::Critical;
|
||||
}
|
||||
if smart.summary.warning > 0 || !smart.issues.is_empty() {
|
||||
return HostSeverity::Warning;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(services) = host.services.as_ref() {
|
||||
if services.summary.failed > 0 {
|
||||
return HostSeverity::Critical;
|
||||
}
|
||||
if services.summary.degraded > 0 {
|
||||
return HostSeverity::Warning;
|
||||
}
|
||||
|
||||
// TODO: Update to use agent-provided system statuses instead of evaluate_performance
|
||||
// let (perf_severity, _) = evaluate_performance(&services.summary);
|
||||
// match perf_severity {
|
||||
// PerfSeverity::Critical => return HostSeverity::Critical,
|
||||
// PerfSeverity::Warning => return HostSeverity::Warning,
|
||||
// PerfSeverity::Ok => {}
|
||||
// }
|
||||
}
|
||||
|
||||
if let Some(backup) = host.backup.as_ref() {
|
||||
match backup.overall_status.as_str() {
|
||||
"critical" => return HostSeverity::Critical,
|
||||
"warning" => return HostSeverity::Warning,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if host.smart.is_none() && host.services.is_none() && host.backup.is_none() {
|
||||
HostSeverity::Unknown
|
||||
} else {
|
||||
HostSeverity::Healthy
|
||||
}
|
||||
}
|
||||
|
||||
fn host_status(host: &HostDisplayData) -> (String, HostSeverity, bool) {
|
||||
// Check connection status first
|
||||
match host.connection_status {
|
||||
ConnectionStatus::Error => {
|
||||
let msg = if let Some(error) = &host.last_error {
|
||||
format!("Connection error: {}", error)
|
||||
} else {
|
||||
"Connection error".to_string()
|
||||
};
|
||||
return (msg, HostSeverity::Critical, true);
|
||||
},
|
||||
ConnectionStatus::Timeout => {
|
||||
let msg = if let Some(error) = &host.last_error {
|
||||
format!("Keep-alive timeout: {}", error)
|
||||
} else {
|
||||
"Keep-alive timeout".to_string()
|
||||
};
|
||||
return (msg, HostSeverity::Warning, true);
|
||||
},
|
||||
ConnectionStatus::Unknown => {
|
||||
return ("No data received".to_string(), HostSeverity::Unknown, true);
|
||||
},
|
||||
ConnectionStatus::Connected => {}, // Continue with other checks
|
||||
}
|
||||
|
||||
if let Some(error) = &host.last_error {
|
||||
return (format!("error: {}", error), HostSeverity::Critical, true);
|
||||
}
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
if smart.summary.critical > 0 {
|
||||
return (
|
||||
"critical: SMART critical".to_string(),
|
||||
HostSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let Some(issue) = smart.issues.first() {
|
||||
return (format!("warning: {}", issue), HostSeverity::Warning, true);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(services) = host.services.as_ref() {
|
||||
if services.summary.failed > 0 {
|
||||
return (
|
||||
format!("critical: {} failed svc", services.summary.failed),
|
||||
HostSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if services.summary.degraded > 0 {
|
||||
return (
|
||||
format!("warning: {} degraded svc", services.summary.degraded),
|
||||
HostSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Update to use agent-provided system statuses instead of evaluate_performance
|
||||
// let (perf_severity, reason) = evaluate_performance(&services.summary);
|
||||
// if let Some(reason_text) = reason {
|
||||
// match perf_severity {
|
||||
// PerfSeverity::Critical => {
|
||||
// return (
|
||||
// format!("critical: {}", reason_text),
|
||||
// HostSeverity::Critical,
|
||||
// true,
|
||||
// );
|
||||
// }
|
||||
// PerfSeverity::Warning => {
|
||||
// return (
|
||||
// format!("warning: {}", reason_text),
|
||||
// HostSeverity::Warning,
|
||||
// true,
|
||||
// );
|
||||
// }
|
||||
// PerfSeverity::Ok => {}
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
if let Some(backup) = host.backup.as_ref() {
|
||||
match backup.overall_status.as_str() {
|
||||
"critical" => {
|
||||
return (
|
||||
"critical: backup failed".to_string(),
|
||||
HostSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
"warning" => {
|
||||
return (
|
||||
"warning: backup warning".to_string(),
|
||||
HostSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if host.smart.is_none() && host.services.is_none() && host.backup.is_none() {
|
||||
let status = if host.last_success.is_none() {
|
||||
"pending: awaiting metrics"
|
||||
} else {
|
||||
"pending: no recent data"
|
||||
};
|
||||
|
||||
return (status.to_string(), HostSeverity::Warning, false);
|
||||
}
|
||||
|
||||
("ok".to_string(), HostSeverity::Healthy, false)
|
||||
}
|
||||
|
||||
|
||||
fn latest_timestamp(host: &HostDisplayData) -> Option<DateTime<Utc>> {
|
||||
let mut latest = host.last_success;
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
latest = Some(match latest {
|
||||
Some(current) => current.max(smart.timestamp),
|
||||
None => smart.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(services) = host.services.as_ref() {
|
||||
latest = Some(match latest {
|
||||
Some(current) => current.max(services.timestamp),
|
||||
None => services.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(backup) = host.backup.as_ref() {
|
||||
latest = Some(match latest {
|
||||
Some(current) => current.max(backup.timestamp),
|
||||
None => backup.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
latest
|
||||
}
|
||||
|
||||
121
dashboard/src/ui/input.rs
Normal file
121
dashboard/src/ui/input.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Input handling utilities for the dashboard
|
||||
pub struct InputHandler;
|
||||
|
||||
impl InputHandler {
|
||||
/// Check if the event is a quit command (q or Ctrl+C)
|
||||
pub fn is_quit_event(event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => true,
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the event is a refresh command (r)
|
||||
pub fn is_refresh_event(event: &Event) -> bool {
|
||||
matches!(event, Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('r'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if the event is a navigation command (arrow keys)
|
||||
pub fn get_navigation_direction(event: &Event) -> Option<NavigationDirection> {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Left),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Right),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Up,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Up),
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(NavigationDirection::Down),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the event is an Enter key press
|
||||
pub fn is_enter_event(event: &Event) -> bool {
|
||||
matches!(event, Event::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if the event is an Escape key press
|
||||
pub fn is_escape_event(event: &Event) -> bool {
|
||||
matches!(event, Event::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}))
|
||||
}
|
||||
|
||||
/// Extract character from key event
|
||||
pub fn get_char(event: &Event) -> Option<char> {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation directions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NavigationDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl NavigationDirection {
|
||||
/// Get the opposite direction
|
||||
pub fn opposite(&self) -> Self {
|
||||
match self {
|
||||
NavigationDirection::Up => NavigationDirection::Down,
|
||||
NavigationDirection::Down => NavigationDirection::Up,
|
||||
NavigationDirection::Left => NavigationDirection::Right,
|
||||
NavigationDirection::Right => NavigationDirection::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a horizontal direction
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
matches!(self, NavigationDirection::Left | NavigationDirection::Right)
|
||||
}
|
||||
|
||||
/// Check if this is a vertical direction
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, NavigationDirection::Up | NavigationDirection::Down)
|
||||
}
|
||||
}
|
||||
71
dashboard/src/ui/layout.rs
Normal file
71
dashboard/src/ui/layout.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Layout utilities for consistent dashboard design
|
||||
pub struct DashboardLayout;
|
||||
|
||||
impl DashboardLayout {
|
||||
/// Create the main dashboard layout (preserving legacy design)
|
||||
pub fn main_layout(area: Rect) -> [Rect; 3] {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Title bar
|
||||
Constraint::Min(0), // Main content
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
[chunks[0], chunks[1], chunks[2]]
|
||||
}
|
||||
|
||||
/// Create 2x2 grid layout for widgets (legacy layout)
|
||||
pub fn content_grid(area: Rect) -> [Rect; 4] {
|
||||
let horizontal_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
let left_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(horizontal_chunks[0]);
|
||||
|
||||
let right_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(horizontal_chunks[1]);
|
||||
|
||||
[
|
||||
left_chunks[0], // Top-left
|
||||
right_chunks[0], // Top-right
|
||||
left_chunks[1], // Bottom-left
|
||||
right_chunks[1], // Bottom-right
|
||||
]
|
||||
}
|
||||
|
||||
/// Create horizontal split layout
|
||||
pub fn horizontal_split(area: Rect, left_percentage: u16) -> [Rect; 2] {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(left_percentage),
|
||||
Constraint::Percentage(100 - left_percentage),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
[chunks[0], chunks[1]]
|
||||
}
|
||||
|
||||
/// Create vertical split layout
|
||||
pub fn vertical_split(area: Rect, top_percentage: u16) -> [Rect; 2] {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(top_percentage),
|
||||
Constraint::Percentage(100 - top_percentage),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
[chunks[0], chunks[1]]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,340 @@
|
||||
pub mod hosts;
|
||||
pub mod backup;
|
||||
pub mod dashboard;
|
||||
pub mod services;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
pub mod widget;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub use dashboard::render;
|
||||
pub mod widgets;
|
||||
pub mod layout;
|
||||
pub mod theme;
|
||||
pub mod input;
|
||||
|
||||
use widgets::{CpuWidget, MemoryWidget, ServicesWidget, Widget};
|
||||
use crate::metrics::{MetricStore, WidgetType};
|
||||
use cm_dashboard_shared::Metric;
|
||||
use theme::Theme;
|
||||
|
||||
/// Main TUI application
|
||||
pub struct TuiApp {
|
||||
/// CPU widget
|
||||
cpu_widget: CpuWidget,
|
||||
/// Memory widget
|
||||
memory_widget: MemoryWidget,
|
||||
/// Services widget
|
||||
services_widget: ServicesWidget,
|
||||
/// Current active host
|
||||
current_host: Option<String>,
|
||||
/// Available hosts
|
||||
available_hosts: Vec<String>,
|
||||
/// Host index for navigation
|
||||
host_index: usize,
|
||||
/// Last update time
|
||||
last_update: Option<Instant>,
|
||||
/// Should quit application
|
||||
should_quit: bool,
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cpu_widget: CpuWidget::new(),
|
||||
memory_widget: MemoryWidget::new(),
|
||||
services_widget: ServicesWidget::new(),
|
||||
current_host: None,
|
||||
available_hosts: Vec::new(),
|
||||
host_index: 0,
|
||||
last_update: None,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update widgets with metrics from store
|
||||
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
||||
if let Some(ref hostname) = self.current_host {
|
||||
// Update CPU widget
|
||||
let cpu_metrics = metric_store.get_metrics_for_widget(hostname, WidgetType::Cpu);
|
||||
self.cpu_widget.update_from_metrics(&cpu_metrics);
|
||||
|
||||
// Update Memory widget
|
||||
let memory_metrics = metric_store.get_metrics_for_widget(hostname, WidgetType::Memory);
|
||||
self.memory_widget.update_from_metrics(&memory_metrics);
|
||||
|
||||
// Update Services widget - get all metrics that start with "service_"
|
||||
let all_metrics = metric_store.get_metrics_for_host(hostname);
|
||||
let service_metrics: Vec<&Metric> = all_metrics.into_iter()
|
||||
.filter(|m| m.name.starts_with("service_"))
|
||||
.collect();
|
||||
self.services_widget.update_from_metrics(&service_metrics);
|
||||
|
||||
self.last_update = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Update available hosts
|
||||
pub fn update_hosts(&mut self, hosts: Vec<String>) {
|
||||
self.available_hosts = hosts;
|
||||
|
||||
// Set current host if none selected
|
||||
if self.current_host.is_none() && !self.available_hosts.is_empty() {
|
||||
self.current_host = Some(self.available_hosts[0].clone());
|
||||
self.host_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keyboard input
|
||||
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.navigate_host(-1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.navigate_host(1);
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
info!("Manual refresh requested");
|
||||
// Refresh will be handled by main loop
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate between hosts
|
||||
fn navigate_host(&mut self, direction: i32) {
|
||||
if self.available_hosts.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let len = self.available_hosts.len();
|
||||
if direction > 0 {
|
||||
self.host_index = (self.host_index + 1) % len;
|
||||
} else {
|
||||
self.host_index = if self.host_index == 0 { len - 1 } else { self.host_index - 1 };
|
||||
}
|
||||
|
||||
self.current_host = Some(self.available_hosts[self.host_index].clone());
|
||||
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
|
||||
}
|
||||
|
||||
/// Check if should quit
|
||||
pub fn should_quit(&self) -> bool {
|
||||
self.should_quit
|
||||
}
|
||||
|
||||
/// Get current host
|
||||
pub fn get_current_host(&self) -> Option<&str> {
|
||||
self.current_host.as_deref()
|
||||
}
|
||||
|
||||
/// Render the dashboard (real btop-style multi-panel layout)
|
||||
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
|
||||
let size = frame.size();
|
||||
|
||||
// Clear background to true black like btop
|
||||
frame.render_widget(
|
||||
Block::default().style(Style::default().bg(Theme::background())),
|
||||
size
|
||||
);
|
||||
|
||||
// Create real btop-style layout: multi-panel with borders
|
||||
// Top section: title bar
|
||||
// Middle section: split into left (mem + disks) and right (CPU + processes)
|
||||
// Bottom: status bar
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Title bar
|
||||
Constraint::Min(0), // Main content area
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// New layout: left panels | right services (100% height)
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(45), // Left side: system, backup
|
||||
Constraint::Percentage(55), // Right side: services (100% height)
|
||||
])
|
||||
.split(main_chunks[1]);
|
||||
|
||||
// Left side: system on top, backup on bottom (equal height)
|
||||
let left_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(50), // System section
|
||||
Constraint::Percentage(50), // Backup section
|
||||
])
|
||||
.split(content_chunks[0]);
|
||||
|
||||
// Render title bar
|
||||
self.render_btop_title(frame, main_chunks[0]);
|
||||
|
||||
// Render new panel layout
|
||||
self.render_system_panel(frame, left_chunks[0], metric_store);
|
||||
self.render_backup_panel(frame, left_chunks[1]);
|
||||
self.services_widget.render(frame, content_chunks[1]); // Services takes full right side
|
||||
|
||||
// Render status bar
|
||||
self.render_btop_status(frame, main_chunks[2], metric_store);
|
||||
}
|
||||
|
||||
/// Render btop-style minimal title
|
||||
fn render_btop_title(&self, frame: &mut Frame, area: Rect) {
|
||||
let title_text = if let Some(ref host) = self.current_host {
|
||||
format!("cm-dashboard • {}", host)
|
||||
} else {
|
||||
"cm-dashboard • disconnected".to_string()
|
||||
};
|
||||
|
||||
let title = Paragraph::new(title_text)
|
||||
.style(Style::default()
|
||||
.fg(Theme::primary_text())
|
||||
.bg(Theme::background()));
|
||||
|
||||
frame.render_widget(title, area);
|
||||
}
|
||||
|
||||
/// Render title bar (legacy)
|
||||
fn render_title_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
let title = if let Some(ref host) = self.current_host {
|
||||
format!("CM Dashboard • {}", host)
|
||||
} else {
|
||||
"CM Dashboard • No Host Connected".to_string()
|
||||
};
|
||||
|
||||
let title_block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(Theme::widget_border_style())
|
||||
.title_style(Theme::title_style());
|
||||
|
||||
frame.render_widget(title_block, area);
|
||||
}
|
||||
|
||||
/// Render btop-style minimal status bar
|
||||
fn render_btop_status(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let status_text = if let Some(ref hostname) = self.current_host {
|
||||
let connected = metric_store.is_host_connected(hostname, Duration::from_secs(30));
|
||||
let status = if connected { "●" } else { "○" };
|
||||
format!("{} [←→] host [q] quit", status)
|
||||
} else {
|
||||
"○ waiting for connection...".to_string()
|
||||
};
|
||||
|
||||
let status = Paragraph::new(status_text)
|
||||
.style(Style::default()
|
||||
.fg(Theme::muted_text())
|
||||
.bg(Theme::background()));
|
||||
|
||||
frame.render_widget(status, area);
|
||||
}
|
||||
|
||||
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let system_block = Block::default().title("system").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
|
||||
let inner_area = system_block.inner(area);
|
||||
frame.render_widget(system_block, area);
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(3), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]).split(inner_area);
|
||||
self.cpu_widget.render(frame, content_chunks[0]);
|
||||
self.memory_widget.render(frame, content_chunks[1]);
|
||||
self.render_top_cpu_process(frame, content_chunks[2], metric_store);
|
||||
self.render_top_ram_process(frame, content_chunks[3], metric_store);
|
||||
self.render_storage_section(frame, content_chunks[4]);
|
||||
}
|
||||
|
||||
fn render_backup_panel(&self, frame: &mut Frame, area: Rect) {
|
||||
let backup_block = Block::default().title("backup").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
|
||||
let inner_area = backup_block.inner(area);
|
||||
frame.render_widget(backup_block, area);
|
||||
let backup_text = Paragraph::new("Backup status and metrics").style(Style::default().fg(Theme::muted_text()).bg(Theme::background()));
|
||||
frame.render_widget(backup_text, inner_area);
|
||||
}
|
||||
|
||||
fn render_top_cpu_process(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let top_cpu_text = if let Some(ref hostname) = self.current_host {
|
||||
if let Some(metric) = metric_store.get_metric(hostname, "top_cpu_process") {
|
||||
format!("Top CPU: {}", metric.value.as_string())
|
||||
} else {
|
||||
"Top CPU: awaiting data...".to_string()
|
||||
}
|
||||
} else {
|
||||
"Top CPU: no host".to_string()
|
||||
};
|
||||
|
||||
let top_cpu_para = Paragraph::new(top_cpu_text).style(Style::default().fg(Theme::warning()).bg(Theme::background()));
|
||||
frame.render_widget(top_cpu_para, area);
|
||||
}
|
||||
|
||||
fn render_top_ram_process(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let top_ram_text = if let Some(ref hostname) = self.current_host {
|
||||
if let Some(metric) = metric_store.get_metric(hostname, "top_ram_process") {
|
||||
format!("Top RAM: {}", metric.value.as_string())
|
||||
} else {
|
||||
"Top RAM: awaiting data...".to_string()
|
||||
}
|
||||
} else {
|
||||
"Top RAM: no host".to_string()
|
||||
};
|
||||
|
||||
let top_ram_para = Paragraph::new(top_ram_text).style(Style::default().fg(Theme::info()).bg(Theme::background()));
|
||||
frame.render_widget(top_ram_para, area);
|
||||
}
|
||||
|
||||
fn render_storage_section(&self, frame: &mut Frame, area: Rect) {
|
||||
let storage_text = Paragraph::new("Storage: NVMe health and disk usage").style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
|
||||
frame.render_widget(storage_text, area);
|
||||
}
|
||||
|
||||
/// Render status bar (legacy)
|
||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
||||
let status_text = if let Some(ref hostname) = self.current_host {
|
||||
let connected = metric_store.is_host_connected(hostname, Duration::from_secs(30));
|
||||
let connection_status = if connected { "connected" } else { "disconnected" };
|
||||
|
||||
format!(
|
||||
"Keys: [←→] hosts [r]efresh [q]uit | Status: {} | Hosts: {}/{}",
|
||||
connection_status,
|
||||
self.host_index + 1,
|
||||
self.available_hosts.len()
|
||||
)
|
||||
} else {
|
||||
"Keys: [←→] hosts [r]efresh [q]uit | Status: No hosts | Waiting for connections...".to_string()
|
||||
};
|
||||
|
||||
let status_block = Block::default()
|
||||
.title(status_text)
|
||||
.style(Theme::status_bar_style());
|
||||
|
||||
frame.render_widget(status_block, area);
|
||||
}
|
||||
|
||||
/// Render placeholder widget
|
||||
fn render_placeholder(&self, frame: &mut Frame, area: Rect, name: &str) {
|
||||
let placeholder_block = Block::default()
|
||||
.title(format!("{} • awaiting implementation", name))
|
||||
.borders(Borders::ALL)
|
||||
.style(Theme::widget_border_inactive_style())
|
||||
.title_style(Style::default().fg(Theme::muted_text()));
|
||||
|
||||
frame.render_widget(placeholder_block, area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for input events with timeout
|
||||
pub fn check_for_input(timeout: Duration) -> Result<Option<Event>> {
|
||||
if event::poll(timeout)? {
|
||||
Ok(Some(event::read()?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::ServiceStatus;
|
||||
use crate::ui::widget::{render_placeholder, render_widget_data, status_level_from_agent_status, connection_status_message, WidgetData, WidgetStatus, StatusLevel};
|
||||
use crate::app::ConnectionStatus;
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
match host {
|
||||
Some(data) => {
|
||||
match (&data.connection_status, data.services.as_ref()) {
|
||||
(ConnectionStatus::Connected, Some(metrics)) => {
|
||||
render_metrics(frame, data, metrics, area);
|
||||
}
|
||||
(ConnectionStatus::Connected, None) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"Services",
|
||||
&format!("Host {} has no service metrics yet", data.name),
|
||||
);
|
||||
}
|
||||
(status, _) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"Services",
|
||||
&format!("Host {}: {}", data.name, connection_status_message(status, &data.last_error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => render_placeholder(frame, area, "Services", "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(
|
||||
frame: &mut Frame,
|
||||
_host: &HostDisplayData,
|
||||
metrics: &crate::data::metrics::ServiceMetrics,
|
||||
area: Rect,
|
||||
) {
|
||||
let summary = &metrics.summary;
|
||||
let title = "Services".to_string();
|
||||
|
||||
// Use agent-calculated services status
|
||||
let widget_status = status_level_from_agent_status(summary.services_status.as_ref());
|
||||
|
||||
let mut data = WidgetData::new(
|
||||
title,
|
||||
Some(WidgetStatus::new(widget_status)),
|
||||
vec!["Service".to_string(), "RAM".to_string(), "CPU".to_string(), "Disk".to_string()]
|
||||
);
|
||||
|
||||
|
||||
if metrics.services.is_empty() {
|
||||
data.add_row(
|
||||
None,
|
||||
vec![],
|
||||
vec![
|
||||
"No services reported".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
],
|
||||
);
|
||||
render_widget_data(frame, area, data);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut services = metrics.services.clone();
|
||||
services.sort_by(|a, b| {
|
||||
// First, determine the primary service name for grouping
|
||||
let primary_a = a.sub_service.as_ref().unwrap_or(&a.name);
|
||||
let primary_b = b.sub_service.as_ref().unwrap_or(&b.name);
|
||||
|
||||
// Sort by primary service name first
|
||||
match primary_a.cmp(primary_b) {
|
||||
std::cmp::Ordering::Equal => {
|
||||
// Same primary service, put parent service first, then sub-services alphabetically
|
||||
match (a.sub_service.as_ref(), b.sub_service.as_ref()) {
|
||||
(None, Some(_)) => std::cmp::Ordering::Less, // Parent comes before sub-services
|
||||
(Some(_), None) => std::cmp::Ordering::Greater, // Sub-services come after parent
|
||||
_ => a.name.cmp(&b.name), // Both same type, sort by name
|
||||
}
|
||||
}
|
||||
other => other, // Different primary services, sort alphabetically
|
||||
}
|
||||
});
|
||||
|
||||
for svc in services {
|
||||
let status_level = match svc.status {
|
||||
ServiceStatus::Running => StatusLevel::Ok,
|
||||
ServiceStatus::Degraded => StatusLevel::Warning,
|
||||
ServiceStatus::Restarting => StatusLevel::Warning,
|
||||
ServiceStatus::Stopped => StatusLevel::Error,
|
||||
};
|
||||
|
||||
// Service row with optional description(s)
|
||||
let description = if let Some(desc_vec) = &svc.description {
|
||||
desc_vec.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
if svc.sub_service.is_some() {
|
||||
// Sub-services (nginx sites) only show name and status, no memory/CPU/disk data
|
||||
// Add latency information for nginx sites if available
|
||||
let service_name_with_latency = if let Some(parent) = &svc.sub_service {
|
||||
if parent == "nginx" {
|
||||
// Use full site name instead of truncating at first dot
|
||||
let short_name = &svc.name;
|
||||
|
||||
match &svc.latency_ms {
|
||||
Some(latency) if *latency >= 2000.0 => format!("{} → unreachable", short_name), // Timeout (2s+)
|
||||
Some(latency) => format!("{} → {:.0}ms", short_name, latency),
|
||||
None => format!("{} → unreachable", short_name), // Connection failed
|
||||
}
|
||||
} else {
|
||||
svc.name.clone()
|
||||
}
|
||||
} else {
|
||||
svc.name.clone()
|
||||
};
|
||||
|
||||
data.add_row_with_sub_service(
|
||||
Some(WidgetStatus::new(status_level)),
|
||||
description,
|
||||
vec![
|
||||
service_name_with_latency,
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
],
|
||||
svc.sub_service.clone(),
|
||||
);
|
||||
} else {
|
||||
// Regular services show all columns
|
||||
data.add_row(
|
||||
Some(WidgetStatus::new(status_level)),
|
||||
description,
|
||||
vec![
|
||||
svc.name.clone(),
|
||||
format_memory_value(svc.memory_used_mb, svc.memory_quota_mb),
|
||||
format_cpu_value(svc.cpu_percent),
|
||||
format_disk_value(svc.disk_used_gb, svc.disk_quota_gb),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render_widget_data(frame, area, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn format_bytes(mb: f32) -> String {
|
||||
if mb < 0.1 {
|
||||
"<1MB".to_string()
|
||||
} else if mb < 1.0 {
|
||||
format!("{:.0}kB", mb * 1000.0)
|
||||
} else if mb < 1000.0 {
|
||||
format!("{:.0}MB", mb)
|
||||
} else {
|
||||
format!("{:.1}GB", mb / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_memory_value(used: f32, quota: f32) -> String {
|
||||
let used_value = format_bytes(used);
|
||||
|
||||
if quota > 0.05 {
|
||||
let quota_gb = quota / 1000.0;
|
||||
// Format quota without decimals and use GB
|
||||
format!("{} ({}GB)", used_value, quota_gb as u32)
|
||||
} else {
|
||||
used_value
|
||||
}
|
||||
}
|
||||
|
||||
fn format_cpu_value(cpu_percent: f32) -> String {
|
||||
if cpu_percent >= 0.1 {
|
||||
format!("{:.1}%", cpu_percent)
|
||||
} else {
|
||||
"0.0%".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_disk_value(used: f32, quota: f32) -> String {
|
||||
let used_value = format_bytes(used * 1000.0); // Convert GB to MB for format_bytes
|
||||
|
||||
if quota > 0.05 {
|
||||
// Format quota without decimals and use GB (round to nearest GB)
|
||||
format!("{} ({}GB)", used_value, quota.round() as u32)
|
||||
} else {
|
||||
used_value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::SmartMetrics;
|
||||
use crate::ui::widget::{render_placeholder, render_widget_data, status_level_from_agent_status, connection_status_message, WidgetData, WidgetStatus, StatusLevel};
|
||||
use crate::app::ConnectionStatus;
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
match host {
|
||||
Some(data) => {
|
||||
match (&data.connection_status, data.smart.as_ref()) {
|
||||
(ConnectionStatus::Connected, Some(metrics)) => {
|
||||
render_metrics(frame, data, metrics, area);
|
||||
}
|
||||
(ConnectionStatus::Connected, None) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"Storage",
|
||||
&format!("Host {} has no SMART data yet", data.name),
|
||||
);
|
||||
}
|
||||
(status, _) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"Storage",
|
||||
&format!("Host {}: {}", data.name, connection_status_message(status, &data.last_error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => render_placeholder(frame, area, "Storage", "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMetrics, area: Rect) {
|
||||
let title = "Storage".to_string();
|
||||
|
||||
let widget_status = status_level_from_agent_status(Some(&metrics.status));
|
||||
|
||||
let mut data = WidgetData::new(
|
||||
title,
|
||||
Some(WidgetStatus::new(widget_status)),
|
||||
vec!["Name".to_string(), "Temp".to_string(), "Wear".to_string(), "Usage".to_string()]
|
||||
);
|
||||
|
||||
if metrics.drives.is_empty() {
|
||||
data.add_row(
|
||||
None,
|
||||
vec![],
|
||||
vec![
|
||||
"No drives reported".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
for drive in &metrics.drives {
|
||||
let status_level = drive_status_level(metrics, &drive.name);
|
||||
|
||||
// Use agent-provided descriptions (agent is source of truth)
|
||||
let mut description = drive.description.clone().unwrap_or_default();
|
||||
|
||||
// Add drive-specific issues as additional description lines
|
||||
for issue in &metrics.issues {
|
||||
if issue.to_lowercase().contains(&drive.name.to_lowercase()) {
|
||||
description.push(format!("Issue: {}", issue));
|
||||
}
|
||||
}
|
||||
|
||||
data.add_row(
|
||||
Some(WidgetStatus::new(status_level)),
|
||||
description,
|
||||
vec![
|
||||
drive.name.clone(),
|
||||
format_temperature(drive.temperature_c),
|
||||
format_percent(drive.wear_level),
|
||||
format_usage(drive.used_gb, drive.capacity_gb),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render_widget_data(frame, area, data);
|
||||
}
|
||||
|
||||
|
||||
fn format_temperature(value: f32) -> String {
|
||||
if value.abs() < f32::EPSILON {
|
||||
"—".to_string()
|
||||
} else {
|
||||
format!("{:.0}°C", value)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_percent(value: f32) -> String {
|
||||
if value.abs() < f32::EPSILON {
|
||||
"—".to_string()
|
||||
} else {
|
||||
format!("{:.0}%", value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn format_usage(used: Option<f32>, capacity: Option<f32>) -> String {
|
||||
match (used, capacity) {
|
||||
(Some(used_gb), Some(total_gb)) if used_gb > 0.0 && total_gb > 0.0 => {
|
||||
format!("{:.0}GB ({:.0}GB)", used_gb, total_gb)
|
||||
}
|
||||
(Some(used_gb), None) if used_gb > 0.0 => {
|
||||
format!("{:.0}GB", used_gb)
|
||||
}
|
||||
(None, Some(total_gb)) if total_gb > 0.0 => {
|
||||
format!("— ({:.0}GB)", total_gb)
|
||||
}
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_status_level(metrics: &SmartMetrics, drive_name: &str) -> StatusLevel {
|
||||
if metrics.summary.critical > 0
|
||||
|| metrics.issues.iter().any(|issue| {
|
||||
issue.to_lowercase().contains(&drive_name.to_lowercase())
|
||||
&& issue.to_lowercase().contains("fail")
|
||||
})
|
||||
{
|
||||
StatusLevel::Error
|
||||
} else if metrics.summary.warning > 0
|
||||
|| metrics
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.to_lowercase().contains(&drive_name.to_lowercase()))
|
||||
{
|
||||
StatusLevel::Warning
|
||||
} else {
|
||||
StatusLevel::Ok
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::SystemMetrics;
|
||||
use crate::ui::widget::{
|
||||
render_placeholder, render_combined_widget_data,
|
||||
status_level_from_agent_status, connection_status_message, WidgetDataSet, WidgetStatus, StatusLevel,
|
||||
};
|
||||
use crate::app::ConnectionStatus;
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
match host {
|
||||
Some(data) => {
|
||||
match (&data.connection_status, data.system.as_ref()) {
|
||||
(ConnectionStatus::Connected, Some(metrics)) => {
|
||||
render_metrics(frame, data, metrics, area);
|
||||
}
|
||||
(ConnectionStatus::Connected, None) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"System",
|
||||
&format!("Host {} awaiting system metrics", data.name),
|
||||
);
|
||||
}
|
||||
(status, _) => {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
"System",
|
||||
&format!("Host {}: {}", data.name, connection_status_message(status, &data.last_error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => render_placeholder(frame, area, "System", "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(
|
||||
frame: &mut Frame,
|
||||
_host: &HostDisplayData,
|
||||
metrics: &SystemMetrics,
|
||||
area: Rect,
|
||||
) {
|
||||
let summary = &metrics.summary;
|
||||
|
||||
// Use agent-calculated statuses
|
||||
let memory_status = status_level_from_agent_status(summary.memory_status.as_ref());
|
||||
let cpu_status = status_level_from_agent_status(summary.cpu_status.as_ref());
|
||||
|
||||
// Determine overall widget status based on worst case from agent statuses
|
||||
let overall_status_level = match (memory_status, cpu_status) {
|
||||
(StatusLevel::Error, _) | (_, StatusLevel::Error) => StatusLevel::Error,
|
||||
(StatusLevel::Warning, _) | (_, StatusLevel::Warning) => StatusLevel::Warning,
|
||||
(StatusLevel::Ok, StatusLevel::Ok) => StatusLevel::Ok,
|
||||
_ => StatusLevel::Unknown,
|
||||
};
|
||||
let overall_status = Some(WidgetStatus::new(overall_status_level));
|
||||
|
||||
// Single dataset with RAM, CPU load, CPU temp as columns
|
||||
let mut system_dataset = WidgetDataSet::new(
|
||||
vec!["RAM usage".to_string(), "CPU load".to_string(), "CPU temp".to_string()],
|
||||
overall_status.clone()
|
||||
);
|
||||
|
||||
// Use agent-provided C-states and logged-in users as description
|
||||
let mut description_lines = Vec::new();
|
||||
|
||||
// Add C-state (now only highest C-state from agent)
|
||||
if let Some(cstates) = &summary.cpu_cstate {
|
||||
for cstate_line in cstates.iter() {
|
||||
description_lines.push(cstate_line.clone()); // Agent already includes "C-State:" prefix
|
||||
}
|
||||
}
|
||||
|
||||
// Add logged-in users to description
|
||||
if let Some(users) = &summary.logged_in_users {
|
||||
if !users.is_empty() {
|
||||
let user_line = if users.len() == 1 {
|
||||
format!("Logged in: {}", users[0])
|
||||
} else {
|
||||
format!("Logged in: {} users ({})", users.len(), users.join(", "))
|
||||
};
|
||||
description_lines.push(user_line);
|
||||
}
|
||||
}
|
||||
|
||||
// Add top CPU process
|
||||
if let Some(cpu_proc) = &summary.top_cpu_process {
|
||||
description_lines.push(format!("Top CPU: {}", cpu_proc));
|
||||
}
|
||||
|
||||
// Add top RAM process
|
||||
if let Some(ram_proc) = &summary.top_ram_process {
|
||||
description_lines.push(format!("Top RAM: {}", ram_proc));
|
||||
}
|
||||
|
||||
system_dataset.add_row(
|
||||
overall_status.clone(),
|
||||
description_lines,
|
||||
vec![
|
||||
format_system_memory_value(summary.memory_used_mb, summary.memory_total_mb),
|
||||
format!("{:.2} • {:.2} • {:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15),
|
||||
format_optional_metric(summary.cpu_temp_c, "°C"),
|
||||
],
|
||||
);
|
||||
|
||||
// Render single dataset
|
||||
render_combined_widget_data(frame, area, "System".to_string(), overall_status, vec![system_dataset]);
|
||||
}
|
||||
|
||||
fn format_optional_metric(value: Option<f32>, unit: &str) -> String {
|
||||
match value {
|
||||
Some(number) => format!("{:.1}{}", number, unit),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(mb: f32) -> String {
|
||||
if mb < 0.1 {
|
||||
"<1MB".to_string()
|
||||
} else if mb < 1.0 {
|
||||
format!("{:.0}kB", mb * 1000.0)
|
||||
} else if mb < 1000.0 {
|
||||
format!("{:.0}MB", mb)
|
||||
} else {
|
||||
format!("{:.1}GB", mb / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_system_memory_value(used_mb: f32, total_mb: f32) -> String {
|
||||
let used_value = format_bytes(used_mb);
|
||||
let total_gb = total_mb / 1000.0;
|
||||
// Format total as GB without decimals
|
||||
format!("{} ({}GB)", used_value, total_gb as u32)
|
||||
}
|
||||
|
||||
134
dashboard/src/ui/theme.rs
Normal file
134
dashboard/src/ui/theme.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use ratatui::style::{Color, Style, Modifier};
|
||||
use cm_dashboard_shared::Status;
|
||||
|
||||
/// Color theme for the dashboard - btop dark theme
|
||||
pub struct Theme;
|
||||
|
||||
impl Theme {
|
||||
/// Get color for status level (btop-style)
|
||||
pub fn status_color(status: Status) -> Color {
|
||||
match status {
|
||||
Status::Ok => Self::success(),
|
||||
Status::Warning => Self::warning(),
|
||||
Status::Critical => Self::error(),
|
||||
Status::Unknown => Self::muted_text(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get style for status level
|
||||
pub fn status_style(status: Status) -> Style {
|
||||
Style::default().fg(Self::status_color(status))
|
||||
}
|
||||
|
||||
/// Primary text color (btop bright text)
|
||||
pub fn primary_text() -> Color {
|
||||
Color::Rgb(255, 255, 255) // Pure white
|
||||
}
|
||||
|
||||
/// Secondary text color (btop muted text)
|
||||
pub fn secondary_text() -> Color {
|
||||
Color::Rgb(180, 180, 180) // Light gray
|
||||
}
|
||||
|
||||
/// Muted text color (btop dimmed text)
|
||||
pub fn muted_text() -> Color {
|
||||
Color::Rgb(120, 120, 120) // Medium gray
|
||||
}
|
||||
|
||||
/// Border color (btop muted borders)
|
||||
pub fn border() -> Color {
|
||||
Color::Rgb(100, 100, 100) // Muted gray like btop
|
||||
}
|
||||
|
||||
/// Secondary border color (btop blue)
|
||||
pub fn border_secondary() -> Color {
|
||||
Color::Rgb(100, 149, 237) // Cornflower blue
|
||||
}
|
||||
|
||||
/// Background color (btop true black)
|
||||
pub fn background() -> Color {
|
||||
Color::Black // True black like btop
|
||||
}
|
||||
|
||||
/// Highlight color (btop selection)
|
||||
pub fn highlight() -> Color {
|
||||
Color::Rgb(58, 150, 221) // Bright blue
|
||||
}
|
||||
|
||||
/// Success color (btop green)
|
||||
pub fn success() -> Color {
|
||||
Color::Rgb(40, 167, 69) // Success green
|
||||
}
|
||||
|
||||
/// Warning color (btop orange/yellow)
|
||||
pub fn warning() -> Color {
|
||||
Color::Rgb(255, 193, 7) // Warning amber
|
||||
}
|
||||
|
||||
/// Error color (btop red)
|
||||
pub fn error() -> Color {
|
||||
Color::Rgb(220, 53, 69) // Error red
|
||||
}
|
||||
|
||||
/// Info color (btop blue)
|
||||
pub fn info() -> Color {
|
||||
Color::Rgb(23, 162, 184) // Info cyan-blue
|
||||
}
|
||||
|
||||
/// CPU usage colors (btop CPU gradient)
|
||||
pub fn cpu_color(percentage: u16) -> Color {
|
||||
match percentage {
|
||||
0..=25 => Color::Rgb(46, 160, 67), // Green
|
||||
26..=50 => Color::Rgb(255, 206, 84), // Yellow
|
||||
51..=75 => Color::Rgb(255, 159, 67), // Orange
|
||||
76..=100 => Color::Rgb(255, 69, 58), // Red
|
||||
_ => Color::Rgb(255, 69, 58), // Red for >100%
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory usage colors (btop memory gradient)
|
||||
pub fn memory_color(percentage: u16) -> Color {
|
||||
match percentage {
|
||||
0..=60 => Color::Rgb(52, 199, 89), // Green
|
||||
61..=80 => Color::Rgb(255, 214, 10), // Yellow
|
||||
81..=95 => Color::Rgb(255, 149, 0), // Orange
|
||||
96..=100 => Color::Rgb(255, 59, 48), // Red
|
||||
_ => Color::Rgb(255, 59, 48), // Red for >100%
|
||||
}
|
||||
}
|
||||
|
||||
/// Get gauge color based on percentage (btop-style gradient)
|
||||
pub fn gauge_color(percentage: u16, warning_threshold: u16, critical_threshold: u16) -> Color {
|
||||
if percentage >= critical_threshold {
|
||||
Self::error()
|
||||
} else if percentage >= warning_threshold {
|
||||
Self::warning()
|
||||
} else {
|
||||
Self::success()
|
||||
}
|
||||
}
|
||||
|
||||
/// Title style (btop widget titles)
|
||||
pub fn title_style() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::primary_text())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Widget border style (btop default borders)
|
||||
pub fn widget_border_style() -> Style {
|
||||
Style::default().fg(Self::border())
|
||||
}
|
||||
|
||||
/// Inactive widget border style
|
||||
pub fn widget_border_inactive_style() -> Style {
|
||||
Style::default().fg(Self::muted_text())
|
||||
}
|
||||
|
||||
/// Status bar style (btop bottom bar)
|
||||
pub fn status_bar_style() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::secondary_text())
|
||||
.bg(Self::background())
|
||||
}
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
use ratatui::layout::{Constraint, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
|
||||
pub fn heading_row_style() -> Style {
|
||||
neutral_text_style().add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn neutral_text_style() -> Style {
|
||||
Style::default()
|
||||
}
|
||||
|
||||
fn neutral_title_span(title: &str) -> Span<'static> {
|
||||
Span::styled(
|
||||
title.to_string(),
|
||||
neutral_text_style().add_modifier(Modifier::BOLD),
|
||||
)
|
||||
}
|
||||
|
||||
fn neutral_border_style(color: Color) -> Style {
|
||||
Style::default().fg(color)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
pub fn status_level_from_agent_status(agent_status: Option<&String>) -> StatusLevel {
|
||||
match agent_status.map(|s| s.as_str()) {
|
||||
Some("critical") => StatusLevel::Error,
|
||||
Some("warning") => StatusLevel::Warning,
|
||||
Some("ok") => StatusLevel::Ok,
|
||||
Some("unknown") => StatusLevel::Unknown,
|
||||
_ => StatusLevel::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connection_status_message(connection_status: &crate::app::ConnectionStatus, last_error: &Option<String>) -> String {
|
||||
use crate::app::ConnectionStatus;
|
||||
match connection_status {
|
||||
ConnectionStatus::Connected => "Connected".to_string(),
|
||||
ConnectionStatus::Timeout => {
|
||||
if let Some(error) = last_error {
|
||||
format!("Timeout: {}", error)
|
||||
} else {
|
||||
"Keep-alive timeout".to_string()
|
||||
}
|
||||
},
|
||||
ConnectionStatus::Error => {
|
||||
if let Some(error) = last_error {
|
||||
format!("Error: {}", error)
|
||||
} else {
|
||||
"Connection error".to_string()
|
||||
}
|
||||
},
|
||||
ConnectionStatus::Unknown => "No data received".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub fn render_placeholder(frame: &mut Frame, area: Rect, title: &str, message: &str) {
|
||||
let block = Block::default()
|
||||
.title(neutral_title_span(title))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(neutral_border_style(Color::Gray));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(message))
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(neutral_text_style()),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_last_sub_service_in_group(rows: &[WidgetRow], current_idx: usize, parent_service: &Option<String>) -> bool {
|
||||
if let Some(parent) = parent_service {
|
||||
// Look ahead to see if there are any more sub-services for this parent
|
||||
for i in (current_idx + 1)..rows.len() {
|
||||
if let Some(ref other_parent) = rows[i].sub_service {
|
||||
if other_parent == parent {
|
||||
return false; // Found another sub-service for same parent
|
||||
}
|
||||
}
|
||||
}
|
||||
true // No more sub-services found for this parent
|
||||
} else {
|
||||
false // Not a sub-service
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_widget_data(frame: &mut Frame, area: Rect, data: WidgetData) {
|
||||
render_combined_widget_data(frame, area, data.title, data.status, vec![data.dataset]);
|
||||
}
|
||||
|
||||
pub fn render_combined_widget_data(frame: &mut Frame, area: Rect, title: String, status: Option<WidgetStatus>, datasets: Vec<WidgetDataSet>) {
|
||||
if datasets.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create border and title - determine color from widget status
|
||||
let border_color = status.as_ref()
|
||||
.map(|s| s.status.to_color())
|
||||
.unwrap_or(Color::Reset);
|
||||
let block = Block::default()
|
||||
.title(neutral_title_span(&title))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(neutral_border_style(border_color));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// Split multi-row datasets into single-row datasets when wrapping is needed
|
||||
let split_datasets = split_multirow_datasets_with_area(datasets, inner);
|
||||
|
||||
let mut current_y = inner.y;
|
||||
|
||||
for dataset in split_datasets.iter() {
|
||||
if current_y >= inner.y + inner.height {
|
||||
break; // No more space
|
||||
}
|
||||
|
||||
current_y += render_dataset_with_wrapping(frame, dataset, inner, current_y);
|
||||
}
|
||||
}
|
||||
|
||||
fn split_multirow_datasets_with_area(datasets: Vec<WidgetDataSet>, inner: Rect) -> Vec<WidgetDataSet> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for dataset in datasets {
|
||||
if dataset.rows.len() <= 1 {
|
||||
// Single row or empty - keep as is
|
||||
result.push(dataset);
|
||||
} else {
|
||||
// Multiple rows - check if wrapping is needed using actual available width
|
||||
if dataset_needs_wrapping_with_width(&dataset, inner.width) {
|
||||
// Split into separate datasets for individual wrapping
|
||||
for row in dataset.rows {
|
||||
let single_row_dataset = WidgetDataSet {
|
||||
colnames: dataset.colnames.clone(),
|
||||
status: dataset.status.clone(),
|
||||
rows: vec![row],
|
||||
};
|
||||
result.push(single_row_dataset);
|
||||
}
|
||||
} else {
|
||||
// No wrapping needed - keep as single dataset
|
||||
result.push(dataset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn dataset_needs_wrapping_with_width(dataset: &WidgetDataSet, available_width: u16) -> bool {
|
||||
// Calculate column widths
|
||||
let mut column_widths = Vec::new();
|
||||
for (col_index, colname) in dataset.colnames.iter().enumerate() {
|
||||
let mut max_width = colname.chars().count() as u16;
|
||||
|
||||
// Check data rows for this column width
|
||||
for row in &dataset.rows {
|
||||
if let Some(widget_value) = row.values.get(col_index) {
|
||||
let data_width = widget_value.chars().count() as u16;
|
||||
max_width = max_width.max(data_width);
|
||||
}
|
||||
}
|
||||
|
||||
let column_width = (max_width + 1).min(25).max(6);
|
||||
column_widths.push(column_width);
|
||||
}
|
||||
|
||||
// Calculate total width needed
|
||||
let status_col_width = 1u16;
|
||||
let col_spacing = 1u16;
|
||||
let mut total_width = status_col_width + col_spacing;
|
||||
|
||||
for &col_width in &column_widths {
|
||||
total_width += col_width + col_spacing;
|
||||
}
|
||||
|
||||
total_width > available_width
|
||||
}
|
||||
|
||||
fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inner: Rect, start_y: u16) -> u16 {
|
||||
if dataset.colnames.is_empty() || dataset.rows.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
let mut column_widths = Vec::new();
|
||||
for (col_index, colname) in dataset.colnames.iter().enumerate() {
|
||||
let mut max_width = colname.chars().count() as u16;
|
||||
|
||||
// Check data rows for this column width
|
||||
for row in &dataset.rows {
|
||||
if let Some(widget_value) = row.values.get(col_index) {
|
||||
let data_width = widget_value.chars().count() as u16;
|
||||
max_width = max_width.max(data_width);
|
||||
}
|
||||
}
|
||||
|
||||
let column_width = (max_width + 1).min(25).max(6);
|
||||
column_widths.push(column_width);
|
||||
}
|
||||
|
||||
let status_col_width = 1u16;
|
||||
let col_spacing = 1u16;
|
||||
let available_width = inner.width;
|
||||
|
||||
// Determine how many columns fit
|
||||
let mut total_width = status_col_width + col_spacing;
|
||||
let mut cols_that_fit = 0;
|
||||
|
||||
for &col_width in &column_widths {
|
||||
let new_total = total_width + col_width + col_spacing;
|
||||
if new_total <= available_width {
|
||||
total_width = new_total;
|
||||
cols_that_fit += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if cols_that_fit == 0 {
|
||||
cols_that_fit = 1; // Always show at least one column
|
||||
}
|
||||
|
||||
let mut current_y = start_y;
|
||||
let mut col_start = 0;
|
||||
let mut is_continuation = false;
|
||||
|
||||
// Render wrapped sections
|
||||
while col_start < dataset.colnames.len() {
|
||||
let col_end = (col_start + cols_that_fit).min(dataset.colnames.len());
|
||||
let section_colnames = &dataset.colnames[col_start..col_end];
|
||||
let section_widths = &column_widths[col_start..col_end];
|
||||
|
||||
// Render header for this section
|
||||
let mut header_cells = vec![];
|
||||
|
||||
// Status cell
|
||||
if is_continuation {
|
||||
header_cells.push(Cell::from("↳"));
|
||||
} else {
|
||||
header_cells.push(Cell::from(""));
|
||||
}
|
||||
|
||||
// Column headers
|
||||
for colname in section_colnames {
|
||||
header_cells.push(Cell::from(Line::from(vec![Span::styled(
|
||||
colname.clone(),
|
||||
heading_row_style(),
|
||||
)])));
|
||||
}
|
||||
|
||||
let header_row = Row::new(header_cells).style(heading_row_style());
|
||||
|
||||
// Build constraint widths for this section
|
||||
let mut constraints = vec![Constraint::Length(status_col_width)];
|
||||
for &width in section_widths {
|
||||
constraints.push(Constraint::Length(width));
|
||||
}
|
||||
|
||||
let header_table = Table::new(vec![header_row])
|
||||
.widths(&constraints)
|
||||
.column_spacing(col_spacing)
|
||||
.style(neutral_text_style());
|
||||
|
||||
frame.render_widget(header_table, Rect {
|
||||
x: inner.x,
|
||||
y: current_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
});
|
||||
current_y += 1;
|
||||
|
||||
// Render data rows for this section
|
||||
for (row_idx, row) in dataset.rows.iter().enumerate() {
|
||||
if current_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this is a sub-service - if so, render as full-width row
|
||||
if row.sub_service.is_some() && col_start == 0 {
|
||||
// Sub-service: render as full-width spanning row
|
||||
let is_last_sub_service = is_last_sub_service_in_group(&dataset.rows, row_idx, &row.sub_service);
|
||||
let tree_char = if is_last_sub_service { "└─" } else { "├─" };
|
||||
let service_name = row.values.get(0).cloned().unwrap_or_default();
|
||||
|
||||
let status_icon = match &row.status {
|
||||
Some(s) => {
|
||||
let color = s.status.to_color();
|
||||
let icon = s.status.to_icon();
|
||||
Span::styled(icon.to_string(), Style::default().fg(color))
|
||||
},
|
||||
None => Span::raw(""),
|
||||
};
|
||||
|
||||
let full_content = format!("{} {}", tree_char, service_name);
|
||||
let full_cell = Cell::from(Line::from(vec![
|
||||
status_icon,
|
||||
Span::raw(" "),
|
||||
Span::styled(full_content, neutral_text_style()),
|
||||
]));
|
||||
|
||||
let full_row = Row::new(vec![full_cell]);
|
||||
let full_constraints = vec![Constraint::Length(inner.width)];
|
||||
let full_table = Table::new(vec![full_row])
|
||||
.widths(&full_constraints)
|
||||
.style(neutral_text_style());
|
||||
|
||||
frame.render_widget(full_table, Rect {
|
||||
x: inner.x,
|
||||
y: current_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
});
|
||||
} else if row.sub_service.is_none() {
|
||||
// Regular service: render with columns as normal
|
||||
let mut cells = vec![];
|
||||
|
||||
// Status cell (only show on first section)
|
||||
if col_start == 0 {
|
||||
match &row.status {
|
||||
Some(s) => {
|
||||
let color = s.status.to_color();
|
||||
let icon = s.status.to_icon();
|
||||
cells.push(Cell::from(Line::from(vec![Span::styled(
|
||||
icon.to_string(),
|
||||
Style::default().fg(color),
|
||||
)])));
|
||||
},
|
||||
None => cells.push(Cell::from("")),
|
||||
}
|
||||
} else {
|
||||
cells.push(Cell::from(""));
|
||||
}
|
||||
|
||||
// Data cells for this section
|
||||
for col_idx in col_start..col_end {
|
||||
if let Some(content) = row.values.get(col_idx) {
|
||||
if content.is_empty() {
|
||||
cells.push(Cell::from(""));
|
||||
} else {
|
||||
cells.push(Cell::from(Line::from(vec![Span::styled(
|
||||
content.to_string(),
|
||||
neutral_text_style(),
|
||||
)])));
|
||||
}
|
||||
} else {
|
||||
cells.push(Cell::from(""));
|
||||
}
|
||||
}
|
||||
|
||||
let data_row = Row::new(cells);
|
||||
let data_table = Table::new(vec![data_row])
|
||||
.widths(&constraints)
|
||||
.column_spacing(col_spacing)
|
||||
.style(neutral_text_style());
|
||||
|
||||
frame.render_widget(data_table, Rect {
|
||||
x: inner.x,
|
||||
y: current_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
current_y += 1;
|
||||
|
||||
// Render description rows if any exist
|
||||
for description in &row.description {
|
||||
if current_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
// Render description as a single cell spanning the entire width
|
||||
let desc_cell = Cell::from(Line::from(vec![Span::styled(
|
||||
format!(" {}", description),
|
||||
Style::default().fg(Color::Blue),
|
||||
)]));
|
||||
|
||||
let desc_row = Row::new(vec![desc_cell]);
|
||||
let desc_constraints = vec![Constraint::Length(inner.width)];
|
||||
let desc_table = Table::new(vec![desc_row])
|
||||
.widths(&desc_constraints)
|
||||
.style(neutral_text_style());
|
||||
|
||||
frame.render_widget(desc_table, Rect {
|
||||
x: inner.x,
|
||||
y: current_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
});
|
||||
current_y += 1;
|
||||
}
|
||||
}
|
||||
|
||||
col_start = col_end;
|
||||
is_continuation = true;
|
||||
}
|
||||
|
||||
current_y - start_y
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WidgetData {
|
||||
pub title: String,
|
||||
pub status: Option<WidgetStatus>,
|
||||
pub dataset: WidgetDataSet,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WidgetDataSet {
|
||||
pub colnames: Vec<String>,
|
||||
pub status: Option<WidgetStatus>,
|
||||
pub rows: Vec<WidgetRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WidgetRow {
|
||||
pub status: Option<WidgetStatus>,
|
||||
pub values: Vec<String>,
|
||||
pub description: Vec<String>,
|
||||
pub sub_service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum StatusLevel {
|
||||
Ok,
|
||||
Warning,
|
||||
Error,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WidgetStatus {
|
||||
pub status: StatusLevel,
|
||||
}
|
||||
|
||||
impl WidgetData {
|
||||
pub fn new(title: impl Into<String>, status: Option<WidgetStatus>, colnames: Vec<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
status: status.clone(),
|
||||
dataset: WidgetDataSet {
|
||||
colnames,
|
||||
status,
|
||||
rows: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_row(&mut self, status: Option<WidgetStatus>, description: Vec<String>, values: Vec<String>) -> &mut Self {
|
||||
self.add_row_with_sub_service(status, description, values, None)
|
||||
}
|
||||
|
||||
pub fn add_row_with_sub_service(&mut self, status: Option<WidgetStatus>, description: Vec<String>, values: Vec<String>, sub_service: Option<String>) -> &mut Self {
|
||||
self.dataset.rows.push(WidgetRow {
|
||||
status,
|
||||
values,
|
||||
description,
|
||||
sub_service,
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetDataSet {
|
||||
pub fn new(colnames: Vec<String>, status: Option<WidgetStatus>) -> Self {
|
||||
Self {
|
||||
colnames,
|
||||
status,
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_row(&mut self, status: Option<WidgetStatus>, description: Vec<String>, values: Vec<String>) -> &mut Self {
|
||||
self.add_row_with_sub_service(status, description, values, None)
|
||||
}
|
||||
|
||||
pub fn add_row_with_sub_service(&mut self, status: Option<WidgetStatus>, description: Vec<String>, values: Vec<String>, sub_service: Option<String>) -> &mut Self {
|
||||
self.rows.push(WidgetRow {
|
||||
status,
|
||||
values,
|
||||
description,
|
||||
sub_service,
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl WidgetStatus {
|
||||
pub fn new(status: StatusLevel) -> Self {
|
||||
Self {
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusLevel {
|
||||
pub fn to_color(self) -> Color {
|
||||
match self {
|
||||
StatusLevel::Ok => Color::Green,
|
||||
StatusLevel::Warning => Color::Yellow,
|
||||
StatusLevel::Error => Color::Red,
|
||||
StatusLevel::Unknown => Color::Reset, // Terminal default
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_icon(self) -> &'static str {
|
||||
match self {
|
||||
StatusLevel::Ok => "✔",
|
||||
StatusLevel::Warning => "!",
|
||||
StatusLevel::Error => "✖",
|
||||
StatusLevel::Unknown => "?",
|
||||
}
|
||||
}
|
||||
}
|
||||
196
dashboard/src/ui/widgets/cpu.rs
Normal file
196
dashboard/src/ui/widgets/cpu.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
text::{Line, Span},
|
||||
Frame,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
/// CPU widget displaying load, temperature, and frequency
|
||||
pub struct CpuWidget {
|
||||
/// CPU load averages (1, 5, 15 minutes)
|
||||
load_1min: Option<f32>,
|
||||
load_5min: Option<f32>,
|
||||
load_15min: Option<f32>,
|
||||
/// CPU temperature in Celsius
|
||||
temperature: Option<f32>,
|
||||
/// CPU frequency in MHz
|
||||
frequency: Option<f32>,
|
||||
/// Aggregated status
|
||||
status: Status,
|
||||
/// Last update indicator
|
||||
has_data: bool,
|
||||
}
|
||||
|
||||
impl CpuWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
load_1min: None,
|
||||
load_5min: None,
|
||||
load_15min: None,
|
||||
temperature: None,
|
||||
frequency: None,
|
||||
status: Status::Unknown,
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status color for display (btop-style)
|
||||
fn get_status_color(&self) -> Color {
|
||||
Theme::status_color(self.status)
|
||||
}
|
||||
|
||||
/// Format load average for display
|
||||
fn format_load(&self) -> String {
|
||||
match (self.load_1min, self.load_5min, self.load_15min) {
|
||||
(Some(l1), Some(l5), Some(l15)) => {
|
||||
format!("{:.2} {:.2} {:.2}", l1, l5, l15)
|
||||
}
|
||||
_ => "— — —".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format temperature for display
|
||||
fn format_temperature(&self) -> String {
|
||||
match self.temperature {
|
||||
Some(temp) => format!("{:.1}°C", temp),
|
||||
None => "—°C".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format frequency for display
|
||||
fn format_frequency(&self) -> String {
|
||||
match self.frequency {
|
||||
Some(freq) => format!("{:.1} MHz", freq),
|
||||
None => "— MHz".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get load percentage for gauge (based on load_1min)
|
||||
fn get_load_percentage(&self) -> u16 {
|
||||
match self.load_1min {
|
||||
Some(load) => {
|
||||
// Assume 8-core system, so 100% = load of 8.0
|
||||
let percentage = (load / 8.0 * 100.0).min(100.0).max(0.0);
|
||||
percentage as u16
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create btop-style dotted bar pattern (like real btop)
|
||||
fn create_btop_dotted_bar(&self, percentage: u16, width: usize) -> String {
|
||||
let filled = (width * percentage as usize) / 100;
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
// Real btop uses these patterns:
|
||||
// High usage: ████████ (solid blocks)
|
||||
// Medium usage: :::::::: (colons)
|
||||
// Low usage: ........ (dots)
|
||||
// Empty: (spaces)
|
||||
|
||||
let pattern = if percentage >= 75 {
|
||||
"█" // High usage - solid blocks
|
||||
} else if percentage >= 25 {
|
||||
":" // Medium usage - colons like btop
|
||||
} else if percentage > 0 {
|
||||
"." // Low usage - dots like btop
|
||||
} else {
|
||||
" " // No usage - spaces
|
||||
};
|
||||
|
||||
let filled_chars = pattern.repeat(filled);
|
||||
let empty_chars = " ".repeat(empty);
|
||||
|
||||
filled_chars + &empty_chars
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for CpuWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
debug!("CPU widget updating with {} metrics", metrics.len());
|
||||
|
||||
// Reset status aggregation
|
||||
let mut statuses = Vec::new();
|
||||
|
||||
for metric in metrics {
|
||||
match metric.name.as_str() {
|
||||
"cpu_load_1min" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.load_1min = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"cpu_load_5min" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.load_5min = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"cpu_load_15min" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.load_15min = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"cpu_temperature_celsius" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.temperature = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"cpu_frequency_mhz" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.frequency = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status
|
||||
self.status = if statuses.is_empty() {
|
||||
Status::Unknown
|
||||
} else {
|
||||
Status::aggregate(&statuses)
|
||||
};
|
||||
|
||||
self.has_data = !metrics.is_empty();
|
||||
|
||||
debug!("CPU widget updated: load={:?}, temp={:?}, freq={:?}, status={:?}",
|
||||
self.load_1min, self.temperature, self.frequency, self.status);
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
|
||||
let cpu_title = Paragraph::new("CPU:").style(Style::default().fg(Theme::primary_text()).bg(Theme::background()));
|
||||
frame.render_widget(cpu_title, content_chunks[0]);
|
||||
let overall_usage = self.get_load_percentage();
|
||||
let cpu_usage_text = format!("Usage: {} {:>3}%", self.create_btop_dotted_bar(overall_usage, 20), overall_usage);
|
||||
let cpu_usage_para = Paragraph::new(cpu_usage_text).style(Style::default().fg(Theme::cpu_color(overall_usage)).bg(Theme::background()));
|
||||
frame.render_widget(cpu_usage_para, content_chunks[1]);
|
||||
let load_freq_text = format!("Load: {} • {}", self.format_load(), self.format_frequency());
|
||||
let load_freq_para = Paragraph::new(load_freq_text).style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
|
||||
frame.render_widget(load_freq_para, content_chunks[2]);
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"CPU"
|
||||
}
|
||||
|
||||
fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CpuWidget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
258
dashboard/src/ui/widgets/memory.rs
Normal file
258
dashboard/src/ui/widgets/memory.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use cm_dashboard_shared::{Metric, MetricValue, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
text::{Line, Span},
|
||||
Frame,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
/// Memory widget displaying usage, totals, and swap information
|
||||
pub struct MemoryWidget {
|
||||
/// Memory usage percentage
|
||||
usage_percent: Option<f32>,
|
||||
/// Total memory in GB
|
||||
total_gb: Option<f32>,
|
||||
/// Used memory in GB
|
||||
used_gb: Option<f32>,
|
||||
/// Available memory in GB
|
||||
available_gb: Option<f32>,
|
||||
/// Total swap in GB
|
||||
swap_total_gb: Option<f32>,
|
||||
/// Used swap in GB
|
||||
swap_used_gb: Option<f32>,
|
||||
/// /tmp directory size in MB
|
||||
tmp_size_mb: Option<f32>,
|
||||
/// /tmp total size in MB
|
||||
tmp_total_mb: Option<f32>,
|
||||
/// /tmp usage percentage
|
||||
tmp_usage_percent: Option<f32>,
|
||||
/// Aggregated status
|
||||
status: Status,
|
||||
/// Last update indicator
|
||||
has_data: bool,
|
||||
}
|
||||
|
||||
impl MemoryWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
usage_percent: None,
|
||||
total_gb: None,
|
||||
used_gb: None,
|
||||
available_gb: None,
|
||||
swap_total_gb: None,
|
||||
swap_used_gb: None,
|
||||
tmp_size_mb: None,
|
||||
tmp_total_mb: None,
|
||||
tmp_usage_percent: None,
|
||||
status: Status::Unknown,
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status color for display (btop-style)
|
||||
fn get_status_color(&self) -> Color {
|
||||
Theme::status_color(self.status)
|
||||
}
|
||||
|
||||
/// Format memory usage for display
|
||||
fn format_memory_usage(&self) -> String {
|
||||
match (self.used_gb, self.total_gb) {
|
||||
(Some(used), Some(total)) => {
|
||||
format!("{:.1}/{:.1} GB", used, total)
|
||||
}
|
||||
_ => "—/— GB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format swap usage for display
|
||||
fn format_swap_usage(&self) -> String {
|
||||
match (self.swap_used_gb, self.swap_total_gb) {
|
||||
(Some(used), Some(total)) => {
|
||||
if total > 0.0 {
|
||||
format!("{:.1}/{:.1} GB", used, total)
|
||||
} else {
|
||||
"No swap".to_string()
|
||||
}
|
||||
}
|
||||
_ => "—/— GB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format /tmp usage for display
|
||||
fn format_tmp_usage(&self) -> String {
|
||||
match (self.tmp_size_mb, self.tmp_total_mb) {
|
||||
(Some(used), Some(total)) => {
|
||||
format!("{:.1}/{:.0} MB", used, total)
|
||||
}
|
||||
_ => "—/— MB".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get memory usage percentage for gauge
|
||||
fn get_memory_percentage(&self) -> u16 {
|
||||
match self.usage_percent {
|
||||
Some(percent) => percent.min(100.0).max(0.0) as u16,
|
||||
None => {
|
||||
// Calculate from used/total if percentage not available
|
||||
match (self.used_gb, self.total_gb) {
|
||||
(Some(used), Some(total)) if total > 0.0 => {
|
||||
let percent = (used / total * 100.0).min(100.0).max(0.0);
|
||||
percent as u16
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get swap usage percentage
|
||||
fn get_swap_percentage(&self) -> u16 {
|
||||
match (self.swap_used_gb, self.swap_total_gb) {
|
||||
(Some(used), Some(total)) if total > 0.0 => {
|
||||
let percent = (used / total * 100.0).min(100.0).max(0.0);
|
||||
percent as u16
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create btop-style dotted bar pattern (same as CPU)
|
||||
fn create_btop_dotted_bar(&self, percentage: u16, width: usize) -> String {
|
||||
let filled = (width * percentage as usize) / 100;
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
// Real btop uses these patterns:
|
||||
// High usage: ████████ (solid blocks)
|
||||
// Medium usage: :::::::: (colons)
|
||||
// Low usage: ........ (dots)
|
||||
// Empty: (spaces)
|
||||
|
||||
let pattern = if percentage >= 75 {
|
||||
"█" // High usage - solid blocks
|
||||
} else if percentage >= 25 {
|
||||
":" // Medium usage - colons like btop
|
||||
} else if percentage > 0 {
|
||||
"." // Low usage - dots like btop
|
||||
} else {
|
||||
" " // No usage - spaces
|
||||
};
|
||||
|
||||
let filled_chars = pattern.repeat(filled);
|
||||
let empty_chars = " ".repeat(empty);
|
||||
|
||||
filled_chars + &empty_chars
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MemoryWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
debug!("Memory widget updating with {} metrics", metrics.len());
|
||||
|
||||
// Reset status aggregation
|
||||
let mut statuses = Vec::new();
|
||||
|
||||
for metric in metrics {
|
||||
match metric.name.as_str() {
|
||||
"memory_usage_percent" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.usage_percent = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"memory_total_gb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.total_gb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"memory_used_gb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.used_gb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"memory_available_gb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.available_gb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"memory_swap_total_gb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.swap_total_gb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"memory_swap_used_gb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.swap_used_gb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"disk_tmp_size_mb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.tmp_size_mb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"disk_tmp_total_mb" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.tmp_total_mb = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
"disk_tmp_usage_percent" => {
|
||||
if let Some(value) = metric.value.as_f32() {
|
||||
self.tmp_usage_percent = Some(value);
|
||||
statuses.push(metric.status);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status
|
||||
self.status = if statuses.is_empty() {
|
||||
Status::Unknown
|
||||
} else {
|
||||
Status::aggregate(&statuses)
|
||||
};
|
||||
|
||||
self.has_data = !metrics.is_empty();
|
||||
|
||||
debug!("Memory widget updated: usage={:?}%, total={:?}GB, swap_total={:?}GB, tmp={:?}/{:?}MB, status={:?}",
|
||||
self.usage_percent, self.total_gb, self.swap_total_gb, self.tmp_size_mb, self.tmp_total_mb, self.status);
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).split(area);
|
||||
let mem_title = Paragraph::new("Memory:").style(Style::default().fg(Theme::primary_text()).bg(Theme::background()));
|
||||
frame.render_widget(mem_title, content_chunks[0]);
|
||||
let memory_percentage = self.get_memory_percentage();
|
||||
let mem_usage_text = format!("Usage: {} {:>3}%", self.create_btop_dotted_bar(memory_percentage, 20), memory_percentage);
|
||||
let mem_usage_para = Paragraph::new(mem_usage_text).style(Style::default().fg(Theme::memory_color(memory_percentage)).bg(Theme::background()));
|
||||
frame.render_widget(mem_usage_para, content_chunks[1]);
|
||||
let mem_details_text = format!("Used: {} • Total: {}", self.used_gb.map_or("—".to_string(), |v| format!("{:.1}GB", v)), self.total_gb.map_or("—".to_string(), |v| format!("{:.1}GB", v)));
|
||||
let mem_details_para = Paragraph::new(mem_details_text).style(Style::default().fg(Theme::secondary_text()).bg(Theme::background()));
|
||||
frame.render_widget(mem_details_para, content_chunks[2]);
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"Memory"
|
||||
}
|
||||
|
||||
fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemoryWidget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
25
dashboard/src/ui/widgets/mod.rs
Normal file
25
dashboard/src/ui/widgets/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use cm_dashboard_shared::Metric;
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
|
||||
pub mod cpu;
|
||||
pub mod memory;
|
||||
pub mod services;
|
||||
|
||||
pub use cpu::CpuWidget;
|
||||
pub use memory::MemoryWidget;
|
||||
pub use services::ServicesWidget;
|
||||
|
||||
/// Widget trait for UI components that display metrics
|
||||
pub trait Widget {
|
||||
/// Update widget with new metrics data
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]);
|
||||
|
||||
/// Render the widget to a terminal frame
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect);
|
||||
|
||||
/// Get widget name for display
|
||||
fn get_name(&self) -> &str;
|
||||
|
||||
/// Check if widget has data to display
|
||||
fn has_data(&self) -> bool;
|
||||
}
|
||||
193
dashboard/src/ui/widgets/services.rs
Normal file
193
dashboard/src/ui/widgets/services.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
/// Services widget displaying individual systemd service statuses
|
||||
pub struct ServicesWidget {
|
||||
/// Individual service statuses
|
||||
services: HashMap<String, ServiceInfo>,
|
||||
/// Aggregated status
|
||||
status: Status,
|
||||
/// Last update indicator
|
||||
has_data: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServiceInfo {
|
||||
status: String,
|
||||
memory_mb: Option<f32>,
|
||||
disk_gb: Option<f32>,
|
||||
widget_status: Status,
|
||||
}
|
||||
|
||||
impl ServicesWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
services: HashMap::new(),
|
||||
status: Status::Unknown,
|
||||
has_data: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status color for display (btop-style)
|
||||
fn get_status_color(&self) -> Color {
|
||||
Theme::status_color(self.status)
|
||||
}
|
||||
|
||||
/// Extract service name from metric name
|
||||
fn extract_service_name(metric_name: &str) -> Option<String> {
|
||||
if metric_name.starts_with("service_") {
|
||||
if let Some(end_pos) = metric_name.rfind("_status")
|
||||
.or_else(|| metric_name.rfind("_memory_mb"))
|
||||
.or_else(|| metric_name.rfind("_disk_gb")) {
|
||||
let service_name = &metric_name[8..end_pos]; // Remove "service_" prefix
|
||||
return Some(service_name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Format service info for display
|
||||
fn format_service_info(&self, name: &str, info: &ServiceInfo) -> String {
|
||||
let status_icon = match info.widget_status {
|
||||
Status::Ok => "✅",
|
||||
Status::Warning => "⚠️",
|
||||
Status::Critical => "❌",
|
||||
Status::Unknown => "❓",
|
||||
};
|
||||
|
||||
let memory_str = if let Some(memory) = info.memory_mb {
|
||||
format!(" Mem:{:.1}MB", memory)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let disk_str = if let Some(disk) = info.disk_gb {
|
||||
format!(" Disk:{:.1}GB", disk)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
format!("{} {} ({}){}{}", status_icon, name, info.status, memory_str, disk_str)
|
||||
}
|
||||
|
||||
/// Format service info in clean service list format
|
||||
fn format_btop_process_line(&self, name: &str, info: &ServiceInfo, _index: usize) -> String {
|
||||
let memory_str = info.memory_mb.map_or("0M".to_string(), |m| format!("{:.0}M", m));
|
||||
let disk_str = info.disk_gb.map_or("0G".to_string(), |d| format!("{:.1}G", d));
|
||||
|
||||
// Truncate long service names to fit layout
|
||||
let short_name = if name.len() > 23 {
|
||||
format!("{}...", &name[..20])
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Status with color indicator
|
||||
let status_str = match info.widget_status {
|
||||
Status::Ok => "✅ active",
|
||||
Status::Warning => "⚠️ inactive",
|
||||
Status::Critical => "❌ failed",
|
||||
Status::Unknown => "❓ unknown",
|
||||
};
|
||||
|
||||
format!("{:<25} {:<10} {:<8} {:<8}",
|
||||
short_name,
|
||||
status_str,
|
||||
memory_str,
|
||||
disk_str)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ServicesWidget {
|
||||
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
||||
debug!("Services widget updating with {} metrics", metrics.len());
|
||||
|
||||
// Don't clear existing services - preserve data between metric batches
|
||||
|
||||
// Process individual service metrics
|
||||
for metric in metrics {
|
||||
if let Some(service_name) = Self::extract_service_name(&metric.name) {
|
||||
let service_info = self.services.entry(service_name).or_insert(ServiceInfo {
|
||||
status: "unknown".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
widget_status: Status::Unknown,
|
||||
});
|
||||
|
||||
if metric.name.ends_with("_status") {
|
||||
service_info.status = metric.value.as_string();
|
||||
service_info.widget_status = metric.status;
|
||||
} else if metric.name.ends_with("_memory_mb") {
|
||||
if let Some(memory) = metric.value.as_f32() {
|
||||
service_info.memory_mb = Some(memory);
|
||||
}
|
||||
} else if metric.name.ends_with("_disk_gb") {
|
||||
if let Some(disk) = metric.value.as_f32() {
|
||||
service_info.disk_gb = Some(disk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status from all services
|
||||
let statuses: Vec<Status> = self.services.values()
|
||||
.map(|info| info.widget_status)
|
||||
.collect();
|
||||
|
||||
self.status = if statuses.is_empty() {
|
||||
Status::Unknown
|
||||
} else {
|
||||
Status::aggregate(&statuses)
|
||||
};
|
||||
|
||||
self.has_data = !self.services.is_empty();
|
||||
|
||||
debug!("Services widget updated: {} services, status={:?}",
|
||||
self.services.len(), self.status);
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let services_block = Block::default().title("services").borders(Borders::ALL).style(Style::default().fg(Theme::border()).bg(Theme::background())).title_style(Style::default().fg(Theme::primary_text()));
|
||||
let inner_area = services_block.inner(area);
|
||||
frame.render_widget(services_block, area);
|
||||
let content_chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Min(0)]).split(inner_area);
|
||||
let header = format!("{:<25} {:<10} {:<8} {:<8}", "Service:", "Status:", "MemB", "DiskGB");
|
||||
let header_para = Paragraph::new(header).style(Style::default().fg(Theme::muted_text()).bg(Theme::background()));
|
||||
frame.render_widget(header_para, content_chunks[0]);
|
||||
if self.services.is_empty() { let empty_text = Paragraph::new("No process data").style(Style::default().fg(Theme::muted_text()).bg(Theme::background())); frame.render_widget(empty_text, content_chunks[1]); return; }
|
||||
let mut services: Vec<_> = self.services.iter().collect();
|
||||
services.sort_by(|(_, a), (_, b)| b.memory_mb.unwrap_or(0.0).partial_cmp(&a.memory_mb.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let available_lines = content_chunks[1].height as usize;
|
||||
let service_chunks = Layout::default().direction(Direction::Vertical).constraints(vec![Constraint::Length(1); available_lines.min(services.len())]).split(content_chunks[1]);
|
||||
for (i, (name, info)) in services.iter().take(available_lines).enumerate() {
|
||||
let service_line = self.format_btop_process_line(name, info, i);
|
||||
let color = match info.widget_status { Status::Ok => Theme::primary_text(), Status::Warning => Theme::warning(), Status::Critical => Theme::error(), Status::Unknown => Theme::muted_text(), };
|
||||
let service_para = Paragraph::new(service_line).style(Style::default().fg(color).bg(Theme::background()));
|
||||
frame.render_widget(service_para, service_chunks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"Services"
|
||||
}
|
||||
|
||||
fn has_data(&self) -> bool {
|
||||
self.has_data
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServicesWidget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
1
dashboard/src/utils/mod.rs
Normal file
1
dashboard/src/utils/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: Implement utils module
|
||||
Reference in New Issue
Block a user