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:
2025-10-16 23:55:05 +02:00
parent 7a664ef0fb
commit 8a36472a3d
81 changed files with 7702 additions and 9608 deletions

View File

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

View File

@@ -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"

View File

@@ -1,12 +0,0 @@
# Optional separate hosts configuration
[hosts]
# default_host = "srv01"
[[hosts.hosts]]
name = "srv01"
enabled = true
[[hosts.hosts]]
name = "labbox"
enabled = true

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
// TODO: Implement hosts module

View File

@@ -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
"#;
}

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

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

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

View File

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

View File

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

View File

@@ -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
View 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)
}
}

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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())
}
}

View File

@@ -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 => "?",
}
}
}

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

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

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

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

View File

@@ -0,0 +1 @@
// TODO: Implement utils module