Christoffer Martinsson c6e8749ddd Implement logged-in users monitoring and improve widget formatting
Agent improvements:
- Add get_logged_in_users() function to SystemCollector using 'who' command
- Collect unique, sorted list of currently logged-in users
- Include logged_in_users field in system metrics JSON output
- Change C-state formatting to show 2 states per row instead of 4

Dashboard improvements:
- Update Backups widget to show "Archives: XX, ..." format
- System widget ready to display logged-in users with proper formatting

The System widget will now show:
- C-states formatted as 2 per row for better readability
- Logged-in users displayed as "Logged in: user" or "Logged in: X users (user1, user2)"
2025-10-14 19:23:26 +02:00

343 lines
12 KiB
Rust

use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
use tokio::fs;
use tokio::process::Command;
use tracing::debug;
use super::{Collector, CollectorError, CollectorOutput, AgentType};
pub struct SystemCollector {
enabled: bool,
interval: Duration,
}
impl SystemCollector {
pub fn new(enabled: bool, interval_ms: u64) -> Self {
Self {
enabled,
interval: Duration::from_millis(interval_ms),
}
}
async fn get_cpu_load(&self) -> Result<(f32, f32, f32), CollectorError> {
let output = Command::new("/run/current-system/sw/bin/uptime")
.output()
.await
.map_err(|e| CollectorError::CommandFailed {
command: "uptime".to_string(),
message: e.to_string()
})?;
let uptime_str = String::from_utf8_lossy(&output.stdout);
// Parse load averages from uptime output
// Format with comma decimals: "... load average: 3,30, 3,17, 2,84"
if let Some(load_part) = uptime_str.split("load average:").nth(1) {
// Use regex or careful parsing for comma decimal separator locale
let load_str = load_part.trim();
// Split on ", " to separate the three load values
let loads: Vec<&str> = load_str.split(", ").collect();
if loads.len() >= 3 {
let load_1 = loads[0].trim().replace(',', ".").parse::<f32>()
.map_err(|_| CollectorError::ParseError { message: "Failed to parse 1min load".to_string() })?;
let load_5 = loads[1].trim().replace(',', ".").parse::<f32>()
.map_err(|_| CollectorError::ParseError { message: "Failed to parse 5min load".to_string() })?;
let load_15 = loads[2].trim().replace(',', ".").parse::<f32>()
.map_err(|_| CollectorError::ParseError { message: "Failed to parse 15min load".to_string() })?;
return Ok((load_1, load_5, load_15));
}
}
Err(CollectorError::ParseError { message: "Failed to parse load averages".to_string() })
}
async fn get_cpu_temperature(&self) -> Option<f32> {
// Try to find CPU-specific thermal zones first (x86_pkg_temp, coretemp, etc.)
for i in 0..10 {
let type_path = format!("/sys/class/thermal/thermal_zone{}/type", i);
let temp_path = format!("/sys/class/thermal/thermal_zone{}/temp", i);
if let (Ok(zone_type), Ok(temp_str)) = (
fs::read_to_string(&type_path).await,
fs::read_to_string(&temp_path).await,
) {
let zone_type = zone_type.trim();
if let Ok(temp_millic) = temp_str.trim().parse::<f32>() {
let temp_c = temp_millic / 1000.0;
// Look for reasonable temperatures first
if temp_c > 20.0 && temp_c < 150.0 {
// Prefer CPU package temperature zones
if zone_type == "x86_pkg_temp" || zone_type.contains("coretemp") {
debug!("Found CPU temperature: {}°C from {} ({})", temp_c, temp_path, zone_type);
return Some(temp_c);
}
}
}
}
}
// Fallback: try any reasonable temperature if no CPU-specific zone found
for i in 0..10 {
let temp_path = format!("/sys/class/thermal/thermal_zone{}/temp", i);
if let Ok(temp_str) = fs::read_to_string(&temp_path).await {
if let Ok(temp_millic) = temp_str.trim().parse::<f32>() {
let temp_c = temp_millic / 1000.0;
if temp_c > 20.0 && temp_c < 150.0 {
debug!("Found fallback temperature: {}°C from {}", temp_c, temp_path);
return Some(temp_c);
}
}
}
}
None
}
async fn get_memory_info(&self) -> Result<(f32, f32), CollectorError> {
let meminfo = fs::read_to_string("/proc/meminfo")
.await
.map_err(|e| CollectorError::IoError { message: format!("Failed to read /proc/meminfo: {}", e) })?;
let mut total_kb = 0;
let mut available_kb = 0;
for line in meminfo.lines() {
if line.starts_with("MemTotal:") {
if let Some(value) = line.split_whitespace().nth(1) {
total_kb = value.parse::<u64>().unwrap_or(0);
}
} else if line.starts_with("MemAvailable:") {
if let Some(value) = line.split_whitespace().nth(1) {
available_kb = value.parse::<u64>().unwrap_or(0);
}
}
}
if total_kb == 0 {
return Err(CollectorError::ParseError { message: "Could not parse total memory".to_string() });
}
let total_mb = total_kb as f32 / 1024.0;
let used_mb = total_mb - (available_kb as f32 / 1024.0);
Ok((used_mb, total_mb))
}
async fn get_logged_in_users(&self) -> Option<Vec<String>> {
// Get currently logged-in users using 'who' command
let output = Command::new("who")
.output()
.await
.ok()?;
let who_output = String::from_utf8_lossy(&output.stdout);
let mut users = Vec::new();
for line in who_output.lines() {
if let Some(username) = line.split_whitespace().next() {
if !username.is_empty() && !users.contains(&username.to_string()) {
users.push(username.to_string());
}
}
}
if users.is_empty() {
None
} else {
users.sort();
Some(users)
}
}
async fn get_cpu_cstate_info(&self) -> Option<Vec<String>> {
// Read C-state information to show all sleep state distributions
let mut cstate_times: Vec<(String, u64)> = Vec::new();
let mut total_time = 0u64;
// Check if C-state information is available
if let Ok(mut entries) = fs::read_dir("/sys/devices/system/cpu/cpu0/cpuidle").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let state_path = entry.path();
let name_path = state_path.join("name");
let time_path = state_path.join("time");
if let (Ok(name), Ok(time_str)) = (
fs::read_to_string(&name_path).await,
fs::read_to_string(&time_path).await
) {
let name = name.trim().to_string();
if let Ok(time) = time_str.trim().parse::<u64>() {
total_time += time;
cstate_times.push((name, time));
}
}
}
if total_time > 0 && !cstate_times.is_empty() {
// Sort by C-state order: POLL, C1, C1E, C3, C6, C7s, C8, C9, C10
cstate_times.sort_by(|a, b| {
let order_a = match a.0.as_str() {
"POLL" => 0,
"C1" => 1,
"C1E" => 2,
"C3" => 3,
"C6" => 4,
"C7s" => 5,
"C8" => 6,
"C9" => 7,
"C10" => 8,
_ => 99,
};
let order_b = match b.0.as_str() {
"POLL" => 0,
"C1" => 1,
"C1E" => 2,
"C3" => 3,
"C6" => 4,
"C7s" => 5,
"C8" => 6,
"C9" => 7,
"C10" => 8,
_ => 99,
};
order_a.cmp(&order_b)
});
// Format C-states as description lines (2 C-states per row)
let mut result = Vec::new();
let mut current_line = Vec::new();
for (name, time) in cstate_times {
let percent = (time as f32 / total_time as f32) * 100.0;
if percent >= 0.1 { // Only show states with at least 0.1% time
current_line.push(format!("{}: {:.1}%", name, percent));
// Split into rows when we have 2 items
if current_line.len() == 2 {
result.push(current_line.join(", "));
current_line.clear();
}
}
}
// Add remaining items as final line
if !current_line.is_empty() {
result.push(current_line.join(", "));
}
return Some(result);
}
}
None
}
fn determine_cpu_status(&self, cpu_load_5: f32) -> String {
if cpu_load_5 >= 10.0 {
"critical".to_string()
} else if cpu_load_5 >= 9.0 {
"warning".to_string()
} else {
"ok".to_string()
}
}
fn determine_cpu_temp_status(&self, temp_c: f32) -> String {
if temp_c >= 100.0 {
"critical".to_string()
} else if temp_c >= 100.0 {
"warning".to_string()
} else {
"ok".to_string()
}
}
fn determine_memory_status(&self, usage_percent: f32) -> String {
if usage_percent >= 95.0 {
"critical".to_string()
} else if usage_percent >= 80.0 {
"warning".to_string()
} else {
"ok".to_string()
}
}
}
#[async_trait]
impl Collector for SystemCollector {
fn name(&self) -> &str {
"system"
}
fn agent_type(&self) -> AgentType {
AgentType::System
}
fn collect_interval(&self) -> Duration {
self.interval
}
async fn collect(&self) -> Result<CollectorOutput, CollectorError> {
if !self.enabled {
return Err(CollectorError::ConfigError { message: "SystemCollector disabled".to_string() });
}
// Get CPU load averages
let (cpu_load_1, cpu_load_5, cpu_load_15) = self.get_cpu_load().await?;
let cpu_status = self.determine_cpu_status(cpu_load_5);
// Get CPU temperature (optional)
let cpu_temp_c = self.get_cpu_temperature().await;
let cpu_temp_status = cpu_temp_c.map(|temp| self.determine_cpu_temp_status(temp));
// Get memory information
let (memory_used_mb, memory_total_mb) = self.get_memory_info().await?;
let memory_usage_percent = (memory_used_mb / memory_total_mb) * 100.0;
let memory_status = self.determine_memory_status(memory_usage_percent);
// Get C-state information (optional)
let cpu_cstate_info = self.get_cpu_cstate_info().await;
// Get logged-in users (optional)
let logged_in_users = self.get_logged_in_users().await;
let mut system_metrics = json!({
"summary": {
"cpu_load_1": cpu_load_1,
"cpu_load_5": cpu_load_5,
"cpu_load_15": cpu_load_15,
"cpu_status": cpu_status,
"memory_used_mb": memory_used_mb,
"memory_total_mb": memory_total_mb,
"memory_usage_percent": memory_usage_percent,
"memory_status": memory_status,
},
"timestamp": chrono::Utc::now().timestamp() as u64,
});
// Add optional metrics if available
if let Some(temp) = cpu_temp_c {
system_metrics["summary"]["cpu_temp_c"] = json!(temp);
if let Some(status) = cpu_temp_status {
system_metrics["summary"]["cpu_temp_status"] = json!(status);
}
}
if let Some(cstates) = cpu_cstate_info {
system_metrics["summary"]["cpu_cstate"] = json!(cstates);
}
if let Some(users) = logged_in_users {
system_metrics["summary"]["logged_in_users"] = json!(users);
}
debug!("System metrics collected: CPU load {:.2}, Memory {:.1}%",
cpu_load_5, memory_usage_percent);
Ok(CollectorOutput {
agent_type: AgentType::System,
data: system_metrics,
})
}
}