From bd6c14c8c1d7c331ce2c7ed3f04b8d1e77eaaa97 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sun, 12 Oct 2025 16:01:56 +0200 Subject: [PATCH] Testing --- agent/src/collectors/service.rs | 91 +++++++++++++++++++++++++++++---- dashboard/src/app.rs | 21 +++++++- dashboard/src/data/metrics.rs | 2 +- dashboard/src/ui/alerts.rs | 18 +++---- dashboard/src/ui/backup.rs | 30 +++++------ dashboard/src/ui/services.rs | 41 ++++++--------- dashboard/src/ui/storage.rs | 50 +++++++++--------- dashboard/src/ui/system.rs | 24 ++++----- dashboard/src/ui/widget.rs | 55 +++++++++++++------- 9 files changed, 216 insertions(+), 116 deletions(-) diff --git a/agent/src/collectors/service.rs b/agent/src/collectors/service.rs index 0d483d6..80748ea 100644 --- a/agent/src/collectors/service.rs +++ b/agent/src/collectors/service.rs @@ -449,7 +449,7 @@ impl ServiceCollector { } } - async fn get_service_description_throttled(&self, service: &str) -> Option { + async fn get_service_description_throttled(&self, service: &str) -> Option> { // Simple time-based throttling - only run expensive descriptions every ~30 seconds // Use a hash of the current time to spread out when different services get described let now = std::time::SystemTime::now() @@ -472,14 +472,14 @@ impl ServiceCollector { } } - async fn get_service_description(&self, service: &str) -> Option { + async fn get_service_description(&self, service: &str) -> Option> { match service { - "sshd" | "ssh" => self.get_ssh_active_users().await, - "nginx" => self.get_web_server_connections().await, // Use same method for now - "apache2" | "httpd" => self.get_web_server_connections().await, - "docker" => self.get_docker_containers().await, - "postgresql" | "postgres" => self.get_postgres_connections().await, - "mysql" | "mariadb" => self.get_mysql_connections().await, + "sshd" | "ssh" => self.get_ssh_active_users().await.map(|s| vec![s]), + "nginx" => self.get_nginx_sites().await, + "apache2" | "httpd" => self.get_web_server_connections().await.map(|s| vec![s]), + "docker" => self.get_docker_containers().await.map(|s| vec![s]), + "postgresql" | "postgres" => self.get_postgres_connections().await.map(|s| vec![s]), + "mysql" | "mariadb" => self.get_mysql_connections().await.map(|s| vec![s]), _ => None, } } @@ -620,6 +620,79 @@ impl ServiceCollector { None } } + + async fn get_nginx_sites(&self) -> Option> { + // Check enabled sites in sites-enabled directory + let sites_enabled_dir = "/etc/nginx/sites-enabled"; + + let mut entries = match fs::read_dir(sites_enabled_dir).await { + Ok(entries) => entries, + Err(_) => return None, + }; + + let mut sites = Vec::new(); + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + + // Skip if it's not a file or is a symlink to default + if !path.is_file() { + continue; + } + + let filename = path.file_name()?.to_string_lossy(); + + // Skip default site unless it's the only one + if filename == "default" { + continue; + } + + // Try to extract server names from the config file + if let Ok(config_content) = fs::read_to_string(&path).await { + let mut server_names = Vec::new(); + + for line in config_content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("server_name") { + // Extract server names from "server_name example.com www.example.com;" + if let Some(names_part) = trimmed.strip_prefix("server_name") { + let names_clean = names_part.trim().trim_end_matches(';'); + for name in names_clean.split_whitespace() { + if name != "_" && !name.is_empty() { + server_names.push(name.to_string()); + break; // Only take the first valid server name + } + } + } + } + } + + if !server_names.is_empty() { + sites.push(server_names[0].clone()); + } else { + // Fallback to filename if no server_name found + sites.push(filename.to_string()); + } + } else { + // Fallback to filename if can't read config + sites.push(filename.to_string()); + } + } + + // If no sites found, check for default + if sites.is_empty() { + let default_path = format!("{}/default", sites_enabled_dir); + if fs::metadata(&default_path).await.is_ok() { + sites.push("default".to_string()); + } + } + + if sites.is_empty() { + None + } else { + Some(sites) + } + } } #[async_trait] @@ -755,7 +828,7 @@ struct ServiceData { sandbox_limit: Option, disk_used_gb: f32, #[serde(skip_serializing_if = "Option::is_none")] - description: Option, + description: Option>, } #[derive(Debug, Clone, Serialize)] diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index daa0f02..3ab0f4c 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -35,6 +35,7 @@ struct HostRuntimeState { smart: Option, services: Option, backup: Option, + service_description_cache: HashMap>, // service_name -> last_known_descriptions } /// Top-level application state container. @@ -259,7 +260,25 @@ impl App { if service_metrics.timestamp != timestamp { service_metrics.timestamp = timestamp; } - let snapshot = service_metrics.clone(); + let mut snapshot = service_metrics.clone(); + + // Update description cache and fill in missing descriptions + for service in &mut snapshot.services { + // If service has a new description, cache it + if let Some(ref description) = service.description { + if !description.is_empty() { + state.service_description_cache.insert(service.name.clone(), description.clone()); + } + } + + // If service has no description but we have a cached one, use it + if service.description.is_none() || service.description.as_ref().map_or(true, |d| d.is_empty()) { + if let Some(cached_description) = state.service_description_cache.get(&service.name) { + service.description = Some(cached_description.clone()); + } + } + } + self.history.record_services(service_metrics); state.services = Some(snapshot); } diff --git a/dashboard/src/data/metrics.rs b/dashboard/src/data/metrics.rs index 9fa1c8f..3329e02 100644 --- a/dashboard/src/data/metrics.rs +++ b/dashboard/src/data/metrics.rs @@ -81,7 +81,7 @@ pub struct ServiceInfo { #[serde(default)] pub disk_used_gb: f32, #[serde(default)] - pub description: Option, + pub description: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/dashboard/src/ui/alerts.rs b/dashboard/src/ui/alerts.rs index d9b5f36..4bc07f4 100644 --- a/dashboard/src/ui/alerts.rs +++ b/dashboard/src/ui/alerts.rs @@ -5,7 +5,7 @@ use ratatui::Frame; use crate::app::HostDisplayData; use crate::ui::system::{evaluate_performance, PerfSeverity}; -use crate::ui::widget::{render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel}; +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); @@ -41,11 +41,11 @@ pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) { if hosts.is_empty() { data.add_row( None, - "", + vec![], vec![ - WidgetValue::new("No hosts configured"), - WidgetValue::new(""), - WidgetValue::new(""), + "No hosts configured".to_string(), + "".to_string(), + "".to_string(), ], ); } else { @@ -63,11 +63,11 @@ pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) { data.add_row( Some(WidgetStatus::new(status_level)), - "", + vec![], vec![ - WidgetValue::new(host.name.clone()), - WidgetValue::new(status_text), - WidgetValue::new(update), + host.name.clone(), + status_text, + update, ], ); } diff --git a/dashboard/src/ui/backup.rs b/dashboard/src/ui/backup.rs index 61060b3..c259cd6 100644 --- a/dashboard/src/ui/backup.rs +++ b/dashboard/src/ui/backup.rs @@ -4,7 +4,7 @@ use ratatui::Frame; use crate::app::HostDisplayData; use crate::data::metrics::{BackupMetrics, BackupStatus}; -use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel}; +use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, StatusLevel}; pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { match host { @@ -41,36 +41,36 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMe let repo_status = repo_status_level(metrics); data.add_row( Some(WidgetStatus::new(repo_status)), - "", + vec![], vec![ - WidgetValue::new("Repo"), - WidgetValue::new(format!( + "Repo".to_string(), + format!( "Snapshots: {} • Size: {:.1} GiB", metrics.backup.snapshot_count, metrics.backup.size_gb - )), + ), ], ); let service_status = service_status_level(metrics); data.add_row( Some(WidgetStatus::new(service_status)), - "", + vec![], vec![ - WidgetValue::new("Service"), - WidgetValue::new(format!( + "Service".to_string(), + format!( "Enabled: {} • Pending jobs: {}", metrics.service.enabled, metrics.service.pending_jobs - )), + ), ], ); if let Some(last_failure) = metrics.backup.last_failure.as_ref() { data.add_row( Some(WidgetStatus::new(StatusLevel::Error)), - "", + vec![], vec![ - WidgetValue::new("Last failure"), - WidgetValue::new(format_timestamp(Some(last_failure))), + "Last failure".to_string(), + format_timestamp(Some(last_failure)), ], ); } @@ -85,10 +85,10 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMe data.add_row( Some(WidgetStatus::new(status_level)), - "", + vec![], vec![ - WidgetValue::new("Last message"), - WidgetValue::new(message.clone()), + "Last message".to_string(), + message.clone(), ], ); } diff --git a/dashboard/src/ui/services.rs b/dashboard/src/ui/services.rs index 1e5762c..8defb36 100644 --- a/dashboard/src/ui/services.rs +++ b/dashboard/src/ui/services.rs @@ -4,7 +4,7 @@ use ratatui::Frame; use crate::app::HostDisplayData; use crate::data::metrics::{ServiceStatus, ServiceSummary}; -use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel}; +use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, StatusLevel}; pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { match host { @@ -55,11 +55,11 @@ fn render_metrics( if metrics.services.is_empty() { data.add_row( None, - "", + vec![], vec![ - WidgetValue::new("No services reported"), - WidgetValue::new(""), - WidgetValue::new(""), + "No services reported".to_string(), + "".to_string(), + "".to_string(), ], ); render_widget_data(frame, area, data); @@ -81,31 +81,22 @@ fn render_metrics( ServiceStatus::Stopped => StatusLevel::Error, }; - // Main service row + // Service row with optional description(s) + let description = if let Some(desc_vec) = &svc.description { + desc_vec.clone() + } else { + vec![] + }; + data.add_row( Some(WidgetStatus::new(status_level)), - "", + description, vec![ - WidgetValue::new(svc.name.clone()), - WidgetValue::new(format_memory_value(svc.memory_used_mb, svc.memory_quota_mb)), - WidgetValue::new(format_disk_value(svc.disk_used_gb)), + svc.name.clone(), + format_memory_value(svc.memory_used_mb, svc.memory_quota_mb), + format_disk_value(svc.disk_used_gb), ], ); - - // Description row (indented) if description exists - if let Some(description) = &svc.description { - if !description.trim().is_empty() { - data.add_row( - None, - "", - vec![ - WidgetValue::new(format!(" {}", description)), - WidgetValue::new(""), - WidgetValue::new(""), - ], - ); - } - } } render_widget_data(frame, area, data); diff --git a/dashboard/src/ui/storage.rs b/dashboard/src/ui/storage.rs index 731c183..99df0f9 100644 --- a/dashboard/src/ui/storage.rs +++ b/dashboard/src/ui/storage.rs @@ -4,7 +4,7 @@ use ratatui::Frame; use crate::app::HostDisplayData; use crate::data::metrics::SmartMetrics; -use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel}; +use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, StatusLevel}; pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { match host { @@ -49,15 +49,15 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet if metrics.drives.is_empty() { data.add_row( None, - "", + vec![], vec![ - WidgetValue::new("No drives reported"), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), + "No drives reported".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), ], ); } else { @@ -65,15 +65,15 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet let status_level = drive_status_level(metrics, &drive.name); data.add_row( Some(WidgetStatus::new(status_level)), - "", + vec![], vec![ - WidgetValue::new(drive.name.clone()), - WidgetValue::new(format_temperature(drive.temperature_c)), - WidgetValue::new(format_percent(drive.wear_level)), - WidgetValue::new(format_percent(drive.available_spare)), - WidgetValue::new(drive.power_on_hours.to_string()), - WidgetValue::new(format_capacity(drive.capacity_gb)), - WidgetValue::new(format_usage(drive.used_gb, drive.capacity_gb)), + drive.name.clone(), + format_temperature(drive.temperature_c), + format_percent(drive.wear_level), + format_percent(drive.available_spare), + drive.power_on_hours.to_string(), + format_capacity(drive.capacity_gb), + format_usage(drive.used_gb, drive.capacity_gb), ], ); } @@ -81,15 +81,15 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet if let Some(issue) = metrics.issues.first() { data.add_row( Some(WidgetStatus::new(StatusLevel::Warning)), - "", + vec![], vec![ - WidgetValue::new(format!("Issue: {}", issue)), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), - WidgetValue::new(""), + format!("Issue: {}", issue), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), ], ); } diff --git a/dashboard/src/ui/system.rs b/dashboard/src/ui/system.rs index 9817e2e..d968188 100644 --- a/dashboard/src/ui/system.rs +++ b/dashboard/src/ui/system.rs @@ -6,7 +6,7 @@ use crate::app::HostDisplayData; use crate::data::metrics::{ServiceMetrics, ServiceSummary}; use crate::ui::widget::{ combined_color, render_placeholder, render_combined_widget_data, status_color_for_cpu_load, status_color_from_metric, - status_color_from_percentage, WidgetDataSet, WidgetStatus, WidgetValue, StatusLevel, + status_color_from_percentage, WidgetDataSet, WidgetStatus, StatusLevel, }; pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) { @@ -77,8 +77,8 @@ fn render_metrics( let mut memory_dataset = WidgetDataSet::new(vec!["Memory usage".to_string()], Some(WidgetStatus::new(memory_status))); memory_dataset.add_row( Some(WidgetStatus::new(memory_status)), - "", - vec![WidgetValue::new(format!("{:.1} / {:.1}", system_used, system_total))], + vec![], + vec![format!("{:.1} / {:.1}", system_used, system_total)], ); // CPU dataset @@ -86,11 +86,11 @@ fn render_metrics( let mut cpu_dataset = WidgetDataSet::new(vec!["CPU load".to_string(), "CPU temp".to_string(), "CPU freq".to_string()], Some(WidgetStatus::new(cpu_status))); cpu_dataset.add_row( Some(WidgetStatus::new(cpu_status)), - "", + vec![], vec![ - WidgetValue::new(format!("{:.2} • {:.2} • {:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15)), - WidgetValue::new(format_optional_metric(summary.cpu_temp_c, "°C")), - WidgetValue::new(format_optional_metric(summary.cpu_freq_mhz, " MHz")), + format!("{:.2} • {:.2} • {:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15), + format_optional_metric(summary.cpu_temp_c, "°C"), + format_optional_metric(summary.cpu_freq_mhz, " MHz"), ], ); @@ -99,16 +99,16 @@ fn render_metrics( let mut gpu_dataset = WidgetDataSet::new(vec!["GPU load".to_string(), "GPU temp".to_string()], Some(WidgetStatus::new(gpu_status))); gpu_dataset.add_row( Some(WidgetStatus::new(gpu_status)), - "", + vec![], vec![ - WidgetValue::new(summary + summary .gpu_load_percent .map(|value| format_optional_percent(Some(value))) - .unwrap_or_else(|| "—".to_string())), - WidgetValue::new(summary + .unwrap_or_else(|| "—".to_string()), + summary .gpu_temp_c .map(|value| format_optional_metric(Some(value), "°C")) - .unwrap_or_else(|| "—".to_string())), + .unwrap_or_else(|| "—".to_string()), ], ); diff --git a/dashboard/src/ui/widget.rs b/dashboard/src/ui/widget.rs index 4704fce..95ec4c4 100644 --- a/dashboard/src/ui/widget.rs +++ b/dashboard/src/ui/widget.rs @@ -152,7 +152,7 @@ fn dataset_needs_wrapping_with_width(dataset: &WidgetDataSet, available_width: u // 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.data.chars().count() as u16; + let data_width = widget_value.chars().count() as u16; max_width = max_width.max(data_width); } } @@ -186,7 +186,7 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne // 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.data.chars().count() as u16; + let data_width = widget_value.chars().count() as u16; max_width = max_width.max(data_width); } } @@ -293,8 +293,7 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne // Data cells for this section for col_idx in col_start..col_end { - if let Some(widget_value) = row.values.get(col_idx) { - let content = &widget_value.data; + if let Some(content) = row.values.get(col_idx) { if content.is_empty() { cells.push(Cell::from("")); } else { @@ -321,6 +320,33 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne 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; @@ -349,12 +375,8 @@ pub struct WidgetDataSet { #[derive(Clone)] pub struct WidgetRow { pub status: Option, - pub values: Vec, -} - -#[derive(Clone)] -pub struct WidgetValue { - pub data: String, + pub values: Vec, + pub description: Vec, } #[derive(Clone, Copy, Debug)] @@ -383,10 +405,11 @@ impl WidgetData { } } - pub fn add_row(&mut self, status: Option, _description: impl Into, values: Vec) -> &mut Self { + pub fn add_row(&mut self, status: Option, description: Vec, values: Vec) -> &mut Self { self.dataset.rows.push(WidgetRow { status, values, + description, }); self } @@ -401,22 +424,16 @@ impl WidgetDataSet { } } - pub fn add_row(&mut self, status: Option, _description: impl Into, values: Vec) -> &mut Self { + pub fn add_row(&mut self, status: Option, description: Vec, values: Vec) -> &mut Self { self.rows.push(WidgetRow { status, values, + description, }); self } } -impl WidgetValue { - pub fn new(data: impl Into) -> Self { - Self { - data: data.into(), - } - } -} impl WidgetStatus { pub fn new(status: StatusLevel) -> Self {