Christoffer Martinsson 8a36472a3d 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
2025-10-16 23:55:05 +02:00

276 lines
11 KiB
Rust

use anyhow::Result;
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::DashboardConfig;
use crate::communication::{ZmqConsumer, ZmqCommandSender, AgentCommand};
use crate::metrics::MetricStore;
use crate::ui::TuiApp;
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 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");
}
}
// 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 {
config,
zmq_consumer,
zmq_command_sender,
metric_store,
tui_app,
terminal,
headless,
initial_commands_sent: std::collections::HashSet::new(),
})
}
/// 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
}
/// 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
}
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;
}
}
}
Ok(false) => {} // No events available (timeout)
Err(e) => {
error!("Error polling for terminal events: {}", e);
break;
}
}
}
// 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());
// Check if this is the first time we've seen this host
let is_new_host = !self.initial_commands_sent.contains(&metric_message.hostname);
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);
}
}
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(())
}
}
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
);
let _ = terminal.show_cursor();
}
}
}
}