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

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