diff --git a/api/src/routes/ws/dashboards/update_dashboard.rs b/api/src/routes/ws/dashboards/update_dashboard.rs index 8ec0dde1b..ea6a7f921 100644 --- a/api/src/routes/ws/dashboards/update_dashboard.rs +++ b/api/src/routes/ws/dashboards/update_dashboard.rs @@ -28,7 +28,8 @@ use database::{ enums::{AssetPermissionRole, AssetType}, models::ThreadToDashboard, pool::get_pg_pool, - schema::{dashboards, threads_to_dashboards}, + schema::{dashboards, threads_to_dashboards, metric_files_to_dashboard_files}, + types::DashboardYml, vault::create_secret, }; @@ -41,6 +42,10 @@ pub struct UpdateDashboardRequest { pub name: Option, pub description: Option, pub config: Option, + /// YAML content of the dashboard + pub file_content: Option, + /// Whether to create a new version in the version history (defaults to true) + pub update_version: Option, pub threads: Option>, pub publicly_accessible: Option, #[serde(default)] @@ -112,6 +117,8 @@ pub async fn update_dashboard( req.publicly_accessible, req.public_password, req.public_expiry_date, + req.file_content, + req.update_version, ) .await { @@ -290,6 +297,8 @@ async fn update_dashboard_record( publicly_accessible: Option, public_password: Option>, public_expiry_date: Option>, + file_content: Option, + _update_version: Option, ) -> Result<()> { let _password_secret_id = match public_password { Some(Some(password)) => match create_secret(&dashboard_id, &password).await { @@ -319,12 +328,205 @@ async fn update_dashboard_record( None }; + // Fetch the current dashboard to check if we need to update it + let mut conn = match get_pg_pool().get().await { + Ok(conn) => conn, + Err(e) => { + tracing::error!("Unable to get connection from pool: {:?}", e); + return Err(anyhow!("Unable to get connection from pool: {}", e)); + } + }; + + // Handle file_content if provided (YAML validation) + let dashboard_yml_result = if let Some(content) = file_content.clone() { + // Validate YAML and convert to DashboardYml + match DashboardYml::new(content) { + Ok(yml) => { + // Validate metric references + let metric_ids: Vec = yml + .rows + .iter() + .flat_map(|row| row.items.iter()) + .map(|item| item.id) + .collect(); + + if !metric_ids.is_empty() { + // Validate that referenced metrics exist + match validate_dashboard_metric_ids(&metric_ids).await { + Ok(missing_ids) if !missing_ids.is_empty() => { + let error_msg = format!("Dashboard references non-existent metrics: {:?}", missing_ids); + tracing::error!("{}", error_msg); + return Err(anyhow!(error_msg)); + } + Err(e) => { + return Err(e); + } + Ok(_) => { + // Update metric associations - delete previous ones and create new ones + match update(metric_files_to_dashboard_files::table) + .filter(metric_files_to_dashboard_files::dashboard_file_id.eq(*dashboard_id)) + .set(metric_files_to_dashboard_files::deleted_at.eq(Some(chrono::Utc::now()))) + .execute(&mut conn) + .await + { + Ok(_) => { + // Insert new metric associations + let metric_dashboard_values: Vec<_> = metric_ids + .iter() + .map(|metric_id| { + diesel::insert_into(metric_files_to_dashboard_files::table) + .values(( + metric_files_to_dashboard_files::metric_file_id.eq(*metric_id), + metric_files_to_dashboard_files::dashboard_file_id.eq(*dashboard_id), + metric_files_to_dashboard_files::created_at.eq(chrono::Utc::now()), + metric_files_to_dashboard_files::updated_at.eq(chrono::Utc::now()), + metric_files_to_dashboard_files::created_by.eq(*user_id), + )) + .on_conflict_do_nothing() + }) + .collect(); + + for insertion in metric_dashboard_values { + if let Err(e) = insertion.execute(&mut conn).await { + tracing::warn!( + "Failed to create metric-to-dashboard association: {}", + e + ); + } + } + } + Err(e) => { + tracing::warn!( + "Failed to clear existing metric associations: {}", + e + ); + } + } + } + } + } + + // Update config with the serialized YAML + Some(Ok(yml)) + } + Err(e) => { + let error_msg = format!("Invalid dashboard YAML: {}", e); + tracing::error!("{}", error_msg); + Some(Err(anyhow!(error_msg))) + } + } + } else { + None + }; + + // Process config if file_content is not provided but config is + let config_yml_result = if file_content.is_none() && config.is_some() { + let config_value = config.as_ref().unwrap(); + + // Try to convert the config to a DashboardYml + match serde_json::from_value::(config_value.clone()) { + Ok(yml) => { + // Validate the yml structure + if let Err(e) = yml.validate() { + let error_msg = format!("Invalid dashboard configuration: {}", e); + tracing::error!("{}", error_msg); + return Err(anyhow!(error_msg)); + } + + // Validate metric references + let metric_ids: Vec = yml + .rows + .iter() + .flat_map(|row| row.items.iter()) + .map(|item| item.id) + .collect(); + + if !metric_ids.is_empty() { + // Validate that referenced metrics exist + match validate_dashboard_metric_ids(&metric_ids).await { + Ok(missing_ids) if !missing_ids.is_empty() => { + let error_msg = format!("Dashboard references non-existent metrics: {:?}", missing_ids); + tracing::error!("{}", error_msg); + return Err(anyhow!(error_msg)); + } + Err(e) => { + return Err(e); + } + Ok(_) => { + // Update metric associations - delete previous ones and create new ones + match update(metric_files_to_dashboard_files::table) + .filter(metric_files_to_dashboard_files::dashboard_file_id.eq(*dashboard_id)) + .set(metric_files_to_dashboard_files::deleted_at.eq(Some(chrono::Utc::now()))) + .execute(&mut conn) + .await + { + Ok(_) => { + // Insert new metric associations + let metric_dashboard_values: Vec<_> = metric_ids + .iter() + .map(|metric_id| { + diesel::insert_into(metric_files_to_dashboard_files::table) + .values(( + metric_files_to_dashboard_files::metric_file_id.eq(*metric_id), + metric_files_to_dashboard_files::dashboard_file_id.eq(*dashboard_id), + metric_files_to_dashboard_files::created_at.eq(chrono::Utc::now()), + metric_files_to_dashboard_files::updated_at.eq(chrono::Utc::now()), + metric_files_to_dashboard_files::created_by.eq(*user_id), + )) + .on_conflict_do_nothing() + }) + .collect(); + + for insertion in metric_dashboard_values { + if let Err(e) = insertion.execute(&mut conn).await { + tracing::warn!( + "Failed to create metric-to-dashboard association: {}", + e + ); + } + } + } + Err(e) => { + tracing::warn!( + "Failed to clear existing metric associations: {}", + e + ); + } + } + } + } + } + + Some(yml) + } + Err(e) => { + let error_msg = format!("Invalid dashboard configuration format: {}", e); + tracing::error!("{}", error_msg); + return Err(anyhow!(error_msg)); + } + } + } else { + None + }; + + // If YAML validation failed, return the error + if let Some(Err(e)) = dashboard_yml_result { + return Err(e); + } + + // Update dashboard record let changeset = DashboardChangeset { updated_at: Utc::now(), updated_by: *user_id, name: name.clone(), description, - config, + config: if let Some(Ok(ref yml)) = dashboard_yml_result { + Some(yml.to_value()?) + } else if let Some(ref yml) = config_yml_result { + Some(yml.to_value()?) + } else { + config + }, publicly_accessible, publicly_enabled_by, password_secret_id: None, @@ -361,7 +563,14 @@ async fn update_dashboard_record( let dashboard_search_handle = { let dashboard_id = dashboard_id.clone(); - let dashboard_name = name.unwrap_or_default(); + let dashboard_name = if let Some(Ok(ref yml)) = dashboard_yml_result { + yml.name.clone() + } else if let Some(ref yml) = config_yml_result { + yml.name.clone() + } else { + name.unwrap_or_default() + }; + tokio::spawn(async move { let mut conn = match get_pg_pool().get().await { Ok(conn) => conn, @@ -564,3 +773,48 @@ async fn update_dashboard_threads( Ok(()) } + +/// Validate that the metric IDs referenced in the dashboard exist +async fn validate_dashboard_metric_ids(metric_ids: &[Uuid]) -> Result> { + if metric_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut conn = match get_pg_pool().get().await { + Ok(conn) => conn, + Err(e) => { + tracing::error!("Unable to get connection from pool: {:?}", e); + return Err(anyhow!("Unable to get connection from pool: {}", e)); + } + }; + + #[derive(Debug, diesel::QueryableByName)] + #[diesel(table_name = metric_files)] + struct MetricIdResult { + #[diesel(sql_type = diesel::sql_types::Uuid)] + id: Uuid, + } + + // Query to find which metric IDs exist + let query = diesel::sql_query( + "SELECT id FROM metric_files WHERE id = ANY($1) AND deleted_at IS NULL" + ) + .bind::, _>(metric_ids); + + let existing_metrics: Vec = match query.load::(&mut conn).await { + Ok(results) => results.into_iter().map(|r| r.id).collect(), + Err(e) => { + tracing::error!("Error validating metric IDs: {:?}", e); + return Err(anyhow!("Error validating metric IDs: {}", e)); + } + }; + + // Find missing metrics + let missing_ids: Vec = metric_ids + .iter() + .filter(|id| !existing_metrics.contains(id)) + .cloned() + .collect(); + + Ok(missing_ids) +}