diff --git a/agent/src/collectors/service.rs b/agent/src/collectors/service.rs index 5752f30..7fc2298 100644 --- a/agent/src/collectors/service.rs +++ b/agent/src/collectors/service.rs @@ -17,6 +17,14 @@ pub struct ServiceCollector { pub interval: Duration, pub services: Vec, pub timeout_ms: u64, + pub cpu_tracking: std::sync::Arc>>, +} + +#[derive(Debug, Clone)] +pub(crate) struct CpuSample { + utime: u64, + stime: u64, + timestamp: std::time::Instant, } impl ServiceCollector { @@ -26,6 +34,7 @@ impl ServiceCollector { interval: Duration::from_millis(interval_ms), services, timeout_ms: 10000, // 10 second timeout for service checks + cpu_tracking: std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), } } @@ -151,12 +160,61 @@ impl ServiceCollector { // Convert pages to MB (assuming 4KB pages) let memory_mb = (rss_pages * 4) as f32 / 1024.0; - // For CPU, we'd need to track over time - simplified to 0 for now - // TODO: Implement proper CPU percentage calculation - let cpu_percent = 0.0; + // Calculate CPU percentage + let cpu_percent = self.calculate_cpu_usage(pid, &stat_fields).await.unwrap_or(0.0); Ok((memory_mb, cpu_percent)) } + + async fn calculate_cpu_usage(&self, pid: u32, stat_fields: &[&str]) -> Result { + // Parse CPU time fields from /proc/pid/stat + let utime: u64 = stat_fields[13].parse().map_err(|e| CollectorError::ParseError { + message: format!("Failed to parse utime: {}", e), + })?; + let stime: u64 = stat_fields[14].parse().map_err(|e| CollectorError::ParseError { + message: format!("Failed to parse stime: {}", e), + })?; + + let now = std::time::Instant::now(); + let current_sample = CpuSample { + utime, + stime, + timestamp: now, + }; + + let mut cpu_tracking = self.cpu_tracking.lock().await; + + let cpu_percent = if let Some(previous_sample) = cpu_tracking.get(&pid) { + let time_delta = now.duration_since(previous_sample.timestamp).as_secs_f32(); + if time_delta > 0.1 { // At least 100ms between samples + let utime_delta = current_sample.utime.saturating_sub(previous_sample.utime); + let stime_delta = current_sample.stime.saturating_sub(previous_sample.stime); + let total_delta = utime_delta + stime_delta; + + // Convert from jiffies to CPU percentage + // sysconf(_SC_CLK_TCK) is typically 100 on Linux + let hz = 100.0; // Clock ticks per second + let cpu_time_used = total_delta as f32 / hz; + let cpu_percent = (cpu_time_used / time_delta) * 100.0; + + // Cap at reasonable values + cpu_percent.min(999.9) + } else { + 0.0 // Too soon for accurate measurement + } + } else { + 0.0 // First measurement, no baseline + }; + + // Store current sample for next calculation + cpu_tracking.insert(pid, current_sample); + + // Clean up old entries (processes that no longer exist) + let cutoff = now - Duration::from_secs(300); // 5 minutes + cpu_tracking.retain(|_, sample| sample.timestamp > cutoff); + + Ok(cpu_percent) + } async fn get_service_disk_usage(&self, service: &str) -> Result { // Only check the most likely path to avoid multiple du calls diff --git a/dashboard/src/ui/services.rs b/dashboard/src/ui/services.rs index 8defb36..a01e18e 100644 --- a/dashboard/src/ui/services.rs +++ b/dashboard/src/ui/services.rs @@ -48,7 +48,7 @@ fn render_metrics( let mut data = WidgetData::new( title, Some(WidgetStatus::new(widget_status)), - vec!["Service".to_string(), "Memory".to_string(), "Disk".to_string()] + vec!["Service".to_string(), "Memory".to_string(), "CPU".to_string(), "Disk".to_string()] ); @@ -60,6 +60,7 @@ fn render_metrics( "No services reported".to_string(), "".to_string(), "".to_string(), + "".to_string(), ], ); render_widget_data(frame, area, data); @@ -94,6 +95,7 @@ fn render_metrics( 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), ], ); @@ -140,6 +142,16 @@ fn format_memory_value(used: f32, quota: f32) -> String { } } +fn format_cpu_value(cpu_percent: f32) -> String { + if cpu_percent >= 0.1 { + format!("{:.1}%", cpu_percent) + } else if cpu_percent > 0.0 { + "<0.1%".to_string() + } else { + "—".to_string() + } +} + fn format_disk_value(used: f32) -> String { if used >= 1.0 { format!("{:.1} GiB", used)