From 7c841ed39a525121062c6ea3bb991c33ecdaeb15 Mon Sep 17 00:00:00 2001 From: Patrick Sullivan Date: Tue, 3 Dec 2024 19:55:03 -0600 Subject: [PATCH] feat: divide dashboards "HTTP Layer" and "Service Layer" logic (#5220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR only refactors code and should not result in any behavioral changes. This PR follows #5206 and attempts to refactor dashboards in a similar way to how that PR refactors folders. This PR aims to divide our dashboard code into distinct "HTTP Layer" and "Service Layer"s - where the "HTTP Layer" contains only logic and models related to serializing/deserializing for the REST HTTP API, and the "Service Layer" contains only domain models/logic and is agnostic to the REST HTTP API. This PR only partially achieves the above goals. Specifically, it makes following changes... 1. Move all HTTP responses out of the `service` module and and into the `http/request` module. 2. Introduce a new `DashboardError` in the `service` module to model all types of "domain logic" errors that occur when interacting with folders. 3. Remove the `service/db/folders` module since, after the migration of folders from the `meta` table to the `folders` table, this module mostly just acted as a wrapper around the `infra/table/folders` module. This PR is still missing the following changes. These changes will be saved for a subsequent PR in order to keep the scope of this PR limited: - Introduce new `...Response` and `...Request` types in the `http/request/models` module whose role is specifically to define serialization/deserialization definitions for the REST HTTP API. ⚠️ Notably, at this point the "HTTP Layer", "Service Layer", and "DB Layer" models for dashboards are still tightly coupled together since all still use the shared JSON serialization/deserialization logic defined on the shared `Dashboard` struct in `config::meta`. Decoupling the models and de/serialization logic at each layer will take more effort than folders since the `Dashboard` struct is large and complicated. I plan to start on this decoupling in the follow-up PR, though that PR likely won't complete the process. ## Overall design goals The design goals here are the same as in #5206 . Please see that PR's description for a detailed explanation. ## Immediate motivation and next steps Motivations and next steps are generally the same as in #5206 . Please see that PR's description for a detailed explanation. ## Summary by CodeRabbit ## Release Notes - **New Features** - Enhanced dashboard creation and update processes with improved error handling and response management. - Added support for parsing dashboard data across multiple versions. - **Bug Fixes** - Streamlined error handling for dashboard operations, ensuring more consistent and informative responses. - **Chores** - Refactored dashboard management logic to remove unnecessary complexity, improving maintainability. --- src/cli/basic/cli.rs | 4 +- src/common/migration/dashboards.rs | 53 ++- src/handler/http/request/dashboards/mod.rs | 138 ++++++-- src/service/dashboards/mod.rs | 371 +++++++++------------ src/service/dashboards/reports.rs | 3 +- src/service/db/dashboards/mod.rs | 290 ---------------- 6 files changed, 325 insertions(+), 534 deletions(-) diff --git a/src/cli/basic/cli.rs b/src/cli/basic/cli.rs index 4f64470ac8..edec397bbf 100644 --- a/src/cli/basic/cli.rs +++ b/src/cli/basic/cli.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . use config::utils::file::set_permission; -use infra::file_list as infra_file_list; +use infra::{file_list as infra_file_list, table}; use crate::{ cli::data::{ @@ -172,7 +172,7 @@ pub async fn cli() -> Result { db::alerts::alert::reset().await?; } "dashboard" => { - db::dashboards::reset().await?; + table::dashboards::delete_all().await?; } "report" => { db::dashboards::reports::reset().await?; diff --git a/src/common/migration/dashboards.rs b/src/common/migration/dashboards.rs index 2d5da4bfb8..5f58f68c4b 100644 --- a/src/common/migration/dashboards.rs +++ b/src/common/migration/dashboards.rs @@ -27,7 +27,7 @@ use infra::{ }, }; -use crate::service::{db::dashboards::get_query_variables, stream::save_stream_settings}; +use crate::service::stream::save_stream_settings; type SettingsCache = HashMap<(String, String, StreamType), StreamSettings>; @@ -143,3 +143,54 @@ pub async fn run() -> Result<(), anyhow::Error> { Ok(()) } + +macro_rules! _get_variables { + ($map:ident, $dash:ident) => { + if let Some(vars) = &$dash.variables { + for v in vars.list.iter() { + if let Some(ref qd) = v.query_data { + $map.entry((qd.stream.clone(), qd.stream_type)) + .or_default() + .push(qd.field.clone()); + } + } + } + }; +} + +fn get_query_variables( + dashboard: Option<&Dashboard>, +) -> HashMap<(String, StreamType), Vec> { + let mut map: HashMap<(String, StreamType), Vec> = HashMap::new(); + let dashboard = if let Some(d) = dashboard { + d + } else { + return map; + }; + match dashboard.version { + 1 => { + let dash = dashboard.v1.as_ref().unwrap(); + _get_variables!(map, dash); + } + 2 => { + let dash = dashboard.v2.as_ref().unwrap(); + _get_variables!(map, dash); + } + 3 => { + let dash = dashboard.v3.as_ref().unwrap(); + _get_variables!(map, dash); + } + 4 => { + let dash = dashboard.v4.as_ref().unwrap(); + _get_variables!(map, dash); + } + 5 => { + let dash = dashboard.v5.as_ref().unwrap(); + _get_variables!(map, dash); + } + _ => { + unreachable!("we only have 5 dashboard versions") + } + } + map +} diff --git a/src/handler/http/request/dashboards/mod.rs b/src/handler/http/request/dashboards/mod.rs index 5e4009b11f..8b49e39a0e 100644 --- a/src/handler/http/request/dashboards/mod.rs +++ b/src/handler/http/request/dashboards/mod.rs @@ -13,16 +13,38 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{collections::HashMap, io::Error}; +use std::collections::HashMap; use actix_web::{delete, get, http, post, put, web, HttpRequest, HttpResponse, Responder}; -use config::meta::dashboards::{ListDashboardsParams, MoveDashboard}; +use config::{ + meta::dashboards::{v1, v2, v3, v4, v5, Dashboard, ListDashboardsParams, MoveDashboard}, + utils::json, +}; use serde::Deserialize; -use crate::{common::meta::http::HttpResponse as MetaHttpResponse, service::dashboards}; +use crate::{ + common::meta::http::HttpResponse as MetaHttpResponse, + service::dashboards::{self, DashboardError}, +}; pub mod reports; +impl From for HttpResponse { + fn from(value: DashboardError) -> Self { + match value { + DashboardError::InfraError(err) => MetaHttpResponse::internal_error(err), + DashboardError::DashboardNotFound => MetaHttpResponse::not_found("Dashboard not found"), + DashboardError::UpdateMissingHash => MetaHttpResponse::internal_error("Request to update existing dashboard with missing or invalid hash value. BUG"), + DashboardError::UpdateConflictingHash => MetaHttpResponse::conflict("Conflict: Failed to save due to concurrent changes. Please refresh the page after backing up your work to avoid losing changes."), + DashboardError::PutMissingTitle => MetaHttpResponse::internal_error("Dashboard should have title"), + DashboardError::MoveMissingFolderParam => MetaHttpResponse::bad_request("Please specify from & to folder from dashboard movement"), + DashboardError::MoveDestinationFolderNotFound => MetaHttpResponse::not_found("Folder not found"), + DashboardError::CreateFolderNotFound => MetaHttpResponse::not_found("Folder not found"), + DashboardError::CreateDefaultFolder => MetaHttpResponse::internal_error("Error saving default folder"), + } + } +} + /// CreateDashboard #[utoipa::path( context_path = "/api", @@ -52,14 +74,17 @@ pub async fn create_dashboard( path: web::Path, body: web::Bytes, req: HttpRequest, -) -> Result { +) -> impl Responder { let org_id = path.into_inner(); let folder = get_folder(req); - let resp = match dashboards::create_dashboard(&org_id, &folder, body).await { - Ok(resp) => resp, - Err(_) => HttpResponse::InternalServerError().into(), + let dashboard = match parse_dashboard(body) { + Ok(dashboard) => dashboard, + Err(err) => return MetaHttpResponse::bad_request(err), }; - Ok(resp) + match dashboards::create_dashboard(&org_id, &folder, dashboard).await { + Ok(dashboard) => HttpResponse::Ok().json(dashboard), + Err(err) => err.into(), + } } /// UpdateDashboard @@ -94,7 +119,14 @@ async fn update_dashboard( let query = web::Query::>::from_query(req.query_string()).unwrap(); let folder = crate::common::utils::http::get_folder(&query); let hash = query.get("hash").map(|h| h.as_str()); - dashboards::update_dashboard(&org_id, &dashboard_id, &folder, body, hash).await + let dashboard = match parse_dashboard(body) { + Ok(dashboard) => dashboard, + Err(err) => return MetaHttpResponse::bad_request(err), + }; + match dashboards::update_dashboard(&org_id, &dashboard_id, &folder, dashboard, hash).await { + Ok(dashboard) => HttpResponse::Ok().json(dashboard), + Err(err) => err.into(), + } } /// HTTP URL query component that contains parameters for listing dashboards. @@ -164,11 +196,16 @@ impl ListQuery { #[get("/{org_id}/dashboards")] async fn list_dashboards(org_id: web::Path, req: HttpRequest) -> impl Responder { let Ok(query) = web::Query::::from_query(req.query_string()) else { - return Ok(HttpResponse::BadRequest().into()); + return MetaHttpResponse::bad_request("Error parsing query parameters"); }; - let params = query.into_inner().into(&org_id.into_inner()); - dashboards::list_dashboards(params).await + match dashboards::list_dashboards(params).await { + Ok(dashboards) => { + let list = config::meta::dashboards::Dashboards { dashboards }; + HttpResponse::Ok().json(list) + } + Err(err) => err.into(), + } } /// GetDashboard @@ -192,7 +229,10 @@ async fn list_dashboards(org_id: web::Path, req: HttpRequest) -> impl Re async fn get_dashboard(path: web::Path<(String, String)>, req: HttpRequest) -> impl Responder { let (org_id, dashboard_id) = path.into_inner(); let folder = get_folder(req); - dashboards::get_dashboard(&org_id, &dashboard_id, &folder).await + match dashboards::get_dashboard(&org_id, &dashboard_id, &folder).await { + Ok(dashboard) => HttpResponse::Ok().json(dashboard), + Err(err) => err.into(), + } } /// DeleteDashboard @@ -217,7 +257,13 @@ async fn get_dashboard(path: web::Path<(String, String)>, req: HttpRequest) -> i async fn delete_dashboard(path: web::Path<(String, String)>, req: HttpRequest) -> impl Responder { let (org_id, dashboard_id) = path.into_inner(); let folder_id = get_folder(req); - dashboards::delete_dashboard(&org_id, &dashboard_id, &folder_id).await + match dashboards::delete_dashboard(&org_id, &dashboard_id, &folder_id).await { + Ok(()) => HttpResponse::Ok().json(MetaHttpResponse::message( + http::StatusCode::OK.into(), + "Dashboard deleted".to_string(), + )), + Err(err) => err.into(), + } } /// MoveDashboard @@ -249,26 +295,58 @@ async fn delete_dashboard(path: web::Path<(String, String)>, req: HttpRequest) - async fn move_dashboard( path: web::Path<(String, String)>, folder: web::Json, -) -> Result { +) -> impl Responder { let (org_id, dashboard_id) = path.into_inner(); - if folder.from.is_empty() || folder.to.is_empty() { - return Ok( - HttpResponse::InternalServerError().json(MetaHttpResponse::message( - http::StatusCode::BAD_REQUEST.into(), - "Please specify from & to folder from dashboard movement".to_string(), - )), - ); - }; - - let resp = - match dashboards::move_dashboard(&org_id, &dashboard_id, &folder.from, &folder.to).await { - Ok(resp) => resp, - Err(_) => HttpResponse::InternalServerError().into(), - }; - Ok(resp) + match dashboards::move_dashboard(&org_id, &dashboard_id, &folder.from, &folder.to).await { + Ok(()) => HttpResponse::Ok().json(MetaHttpResponse::message( + http::StatusCode::OK.into(), + "Dashboard moved".to_string(), + )), + Err(err) => err.into(), + } } fn get_folder(req: HttpRequest) -> String { let query = web::Query::>::from_query(req.query_string()).unwrap(); crate::common::utils::http::get_folder(&query) } + +/// Parses the bytes into a dashboard with the given version. +fn parse_dashboard(bytes: web::Bytes) -> Result { + let d_version: DashboardVersion = json::from_slice(&bytes)?; + let version = d_version.version; + + let dash = match version { + 1 => { + let inner: v1::Dashboard = json::from_slice(&bytes)?; + inner.into() + } + 2 => { + let inner: v2::Dashboard = json::from_slice(&bytes)?; + inner.into() + } + 3 => { + let inner: v3::Dashboard = json::from_slice(&bytes)?; + inner.into() + } + 4 => { + let inner: v4::Dashboard = json::from_slice(&bytes)?; + inner.into() + } + _ => { + let inner: v5::Dashboard = json::from_slice(&bytes)?; + inner.into() + } + }; + Ok(dash) +} + +#[derive(Clone, Debug, Deserialize)] +struct DashboardVersion { + #[serde(default = "default_version")] + pub version: i32, +} + +fn default_version() -> i32 { + 1 +} diff --git a/src/service/dashboards/mod.rs b/src/service/dashboards/mod.rs index ec3255fb67..aab05245bb 100644 --- a/src/service/dashboards/mod.rs +++ b/src/service/dashboards/mod.rs @@ -13,120 +13,139 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::io; - -use actix_web::{http, web, HttpResponse}; use config::{ ider, meta::{ - dashboards::{Dashboards, ListDashboardsParams}, + dashboards::{Dashboard, ListDashboardsParams}, folder::{Folder, DEFAULT_FOLDER}, }, - utils::json, }; use infra::table; use super::folders; -use crate::{ - common::{ - meta::{authz::Authz, http::HttpResponse as MetaHttpResponse}, - utils::auth::{remove_ownership, set_ownership}, - }, - service::db::{self}, +use crate::common::{ + meta::authz::Authz, + utils::auth::{remove_ownership, set_ownership}, }; pub mod reports; -#[tracing::instrument(skip(body))] +/// An error that occurs interacting with dashboards. +#[derive(Debug, thiserror::Error)] +pub enum DashboardError { + /// An error that occurs while interacting with the database through the + /// [infra] crate. + #[error("InfraError# {0}")] + InfraError(#[from] infra::errors::Error), + + /// Error that occurs when trying to access a dashboard that cannot be + /// found. + #[error("dashboard not found")] + DashboardNotFound, + + /// Error that occurs when trying to update a dashboard using a missing hash + /// value or a hash value that cannot be parsed as an [i64]. + #[error("tried to update dashboard using hash that cannot be parsed")] + UpdateMissingHash, + + /// Error that occurs when trying to update a dashboard using a stale hash. + /// This occurs when the two clients attempt to update the dashboard + /// concurrently. + #[error("tried to update dashboard using conflicting hash")] + UpdateConflictingHash, + + /// Error that occurs when trying to create an update a dashboard but not + /// title is provided. + #[error("dashboard cannot have empty title")] + PutMissingTitle, + + /// Error that occurs when trying to move a dashboard but either the source + /// or destination folder is not specified. + #[error("missing source or destination folder for dashboard move")] + MoveMissingFolderParam, + + /// Error that occurs when trying to move a dashboard to a destination + /// folder that cannot be found. + #[error("error moving dashboard to folder that cannot be found")] + MoveDestinationFolderNotFound, + + /// Error that occurs when trying to create a dashboard in a folder that + /// cannot be found. + #[error("error creating dashboard in folder that cannot be found")] + CreateFolderNotFound, + + /// Error that occurs when trying to create a destination folder for a new + /// dashboard. + #[error("error creating default folder")] + CreateDefaultFolder, +} + +#[tracing::instrument(skip(dashboard))] pub async fn create_dashboard( org_id: &str, folder_id: &str, - body: web::Bytes, -) -> Result { + dashboard: Dashboard, +) -> Result { // NOTE: Overwrite whatever `dashboard_id` the client has sent us // If folder is default folder & doesn't exist then create it if table::folders::exists(org_id, folder_id).await? { let dashboard_id = ider::generate(); - match save_dashboard(org_id, &dashboard_id, folder_id, body, None).await { - Ok(res) => { - set_ownership( - org_id, - "dashboards", - Authz { - obj_id: dashboard_id, - parent_type: "folders".to_owned(), - parent: folder_id.to_owned(), - }, - ) - .await; - Ok(res) - } - Err(error) => Ok( - HttpResponse::InternalServerError().json(MetaHttpResponse::message( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - error.to_string(), - )), - ), - } + let saved = put(org_id, &dashboard_id, folder_id, dashboard, None).await?; + set_ownership( + org_id, + "dashboards", + Authz { + obj_id: dashboard_id, + parent_type: "folders".to_owned(), + parent: folder_id.to_owned(), + }, + ) + .await; + Ok(saved) } else if folder_id == DEFAULT_FOLDER { let folder = Folder { folder_id: DEFAULT_FOLDER.to_string(), name: DEFAULT_FOLDER.to_string(), description: DEFAULT_FOLDER.to_string(), }; - folders::save_folder(org_id, folder, true).await?; + folders::save_folder(org_id, folder, true) + .await + .map_err(|_| DashboardError::CreateDefaultFolder)?; let dashboard_id = ider::generate(); - match save_dashboard(org_id, &dashboard_id, folder_id, body, None).await { - Ok(res) => { - set_ownership( - org_id, - "dashboards", - Authz { - obj_id: dashboard_id, - parent_type: "folders".to_owned(), - parent: folder_id.to_owned(), - }, - ) - .await; - Ok(res) - } - Err(error) => Ok( - HttpResponse::InternalServerError().json(MetaHttpResponse::message( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - error.to_string(), - )), - ), - } + let saved = put(org_id, &dashboard_id, folder_id, dashboard, None).await?; + set_ownership( + org_id, + "dashboards", + Authz { + obj_id: dashboard_id, + parent_type: "folders".to_owned(), + parent: folder_id.to_owned(), + }, + ) + .await; + Ok(saved) } else { - Ok(HttpResponse::NotFound().json(MetaHttpResponse::error( - http::StatusCode::NOT_FOUND.into(), - "folder not found".to_string(), - ))) + Err(DashboardError::CreateFolderNotFound) } } -#[tracing::instrument(skip(body))] +#[tracing::instrument(skip(dashboard))] pub async fn update_dashboard( org_id: &str, dashboard_id: &str, folder_id: &str, - body: web::Bytes, + dashboard: Dashboard, hash: Option<&str>, -) -> Result { - // Store new dashboard in the database - save_dashboard(org_id, dashboard_id, folder_id, body, hash).await +) -> Result { + put(org_id, dashboard_id, folder_id, dashboard, hash).await } #[tracing::instrument] -pub async fn list_dashboards(params: ListDashboardsParams) -> Result { - let resp = match db::dashboards::list(params).await { - Ok(dashboards) => HttpResponse::Ok().json(Dashboards { dashboards }), - Err(error) => HttpResponse::InternalServerError().json(MetaHttpResponse::error( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - error.to_string(), - )), - }; - Ok(resp) +pub async fn list_dashboards( + params: ListDashboardsParams, +) -> Result, DashboardError> { + let dashboards = table::dashboards::list(params).await?; + Ok(dashboards) } #[tracing::instrument] @@ -134,16 +153,10 @@ pub async fn get_dashboard( org_id: &str, dashboard_id: &str, folder_id: &str, -) -> Result { - let resp = match db::dashboards::get(org_id, dashboard_id, folder_id).await { - Ok(Some(dash)) => HttpResponse::Ok().json(dash), - Ok(None) => Response::NotFound("Dashboard".to_string()).into(), - Err(error) => HttpResponse::InternalServerError().json(MetaHttpResponse::error( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - error.to_string(), - )), - }; - Ok(resp) +) -> Result { + table::dashboards::get(org_id, folder_id, dashboard_id) + .await? + .ok_or(DashboardError::DashboardNotFound) } #[tracing::instrument] @@ -151,72 +164,23 @@ pub async fn delete_dashboard( org_id: &str, dashboard_id: &str, folder_id: &str, -) -> Result { - match db::dashboards::get(org_id, dashboard_id, folder_id).await { - Ok(Some(_)) => {} // Dashboard exists. Continue with deleting. - Ok(None) => { - return Ok(HttpResponse::NotFound().json(MetaHttpResponse::error( - http::StatusCode::NOT_FOUND.into(), - "Dashboard not found".to_string(), - ))); - } - Err(error) => { - return Ok( - HttpResponse::InternalServerError().json(MetaHttpResponse::error( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - error.to_string(), - )), - ) - } +) -> Result<(), DashboardError> { + match table::dashboards::get(org_id, folder_id, dashboard_id).await? { + Some(_) => {} // Dashboard exists. Continue with deleting. + None => return Err(DashboardError::DashboardNotFound), }; - - match db::dashboards::delete(org_id, dashboard_id, folder_id).await { - Ok(_) => { - remove_ownership( - org_id, - "dashboards", - Authz { - obj_id: dashboard_id.to_owned(), - parent_type: "folders".to_owned(), - parent: folder_id.to_owned(), - }, - ) - .await; - Ok(HttpResponse::Ok().json(MetaHttpResponse::message( - http::StatusCode::OK.into(), - "Dashboard deleted".to_string(), - ))) - } - Err(error) => Ok( - HttpResponse::InternalServerError().json(MetaHttpResponse::error( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - error.to_string(), - )), - ), - } -} - -async fn save_dashboard( - org_id: &str, - dashboard_id: &str, - folder_id: &str, - body: web::Bytes, - hash: Option<&str>, -) -> Result { - match db::dashboards::put(org_id, dashboard_id, folder_id, body, hash).await { - Ok(dashboard) => { - tracing::info!(dashboard_id, "Dashboard updated"); - Ok(HttpResponse::Ok().json(dashboard)) - } - Err(error) => { - tracing::error!(%error, dashboard_id, "Failed to store the dashboard"); - if error.to_string().contains("Conflict") { - Ok(Response::Conflict(error).into()) - } else { - Ok(Response::InternalServerError(error).into()) - } - } - } + table::dashboards::delete(org_id, folder_id, dashboard_id).await?; + remove_ownership( + org_id, + "dashboards", + Authz { + obj_id: dashboard_id.to_owned(), + parent_type: "folders".to_owned(), + parent: folder_id.to_owned(), + }, + ) + .await; + Ok(()) } #[tracing::instrument] @@ -225,69 +189,56 @@ pub async fn move_dashboard( dashboard_id: &str, from_folder: &str, to_folder: &str, -) -> Result { - if let Ok(Some(dashboard)) = db::dashboards::get(org_id, dashboard_id, from_folder).await { - // make sure the destination folder exists - if !table::folders::exists(org_id, to_folder).await? { - return Ok(Response::NotFound("Destination Folder".to_string()).into()); - } +) -> Result<(), DashboardError> { + if from_folder.is_empty() || to_folder.is_empty() { + return Err(DashboardError::MoveMissingFolderParam); + }; - let dash = if dashboard.version == 1 { - json::to_vec(&dashboard.v1.unwrap()).unwrap() - } else if dashboard.version == 2 { - json::to_vec(&dashboard.v2.unwrap()).unwrap() - } else if dashboard.version == 3 { - json::to_vec(&dashboard.v3.unwrap()).unwrap() - } else if dashboard.version == 4 { - json::to_vec(&dashboard.v4.unwrap()).unwrap() - } else { - json::to_vec(&dashboard.v5.unwrap()).unwrap() + let Some(dashboard) = table::dashboards::get(org_id, from_folder, dashboard_id).await? else { + return Err(DashboardError::DashboardNotFound); + }; + + // make sure the destination folder exists + if !table::folders::exists(org_id, to_folder).await? { + return Err(DashboardError::MoveDestinationFolderNotFound); + }; + + // add the dashboard to the destination folder + put(org_id, dashboard_id, to_folder, dashboard, None).await?; + + // delete the dashboard from the source folder + let _ = table::dashboards::delete(org_id, from_folder, dashboard_id).await; + + Ok(()) +} + +#[tracing::instrument(skip(dashboard))] +async fn put( + org_id: &str, + dashboard_id: &str, + folder_id: &str, + mut dashboard: Dashboard, + hash: Option<&str>, +) -> Result { + if let Some(existing_dash) = table::dashboards::get(org_id, folder_id, dashboard_id).await? { + let existing_dash_hash = existing_dash.hash; + + let Some(Ok(hash_val)) = hash.map(|hash_str| hash_str.parse::()) else { + return Err(DashboardError::UpdateMissingHash); }; - - // add the dashboard to the destination folder - if let Err(error) = - db::dashboards::put(org_id, dashboard_id, to_folder, dash.into(), None).await - { - return Ok(Response::InternalServerError(error).into()); + if hash_val.to_string() != existing_dash_hash { + return Err(DashboardError::UpdateConflictingHash); } + }; - // delete the dashboard from the source folder - let _ = db::dashboards::delete(org_id, dashboard_id, from_folder).await; - Ok(Response::OkMessage("Dashboard moved successfully".to_string()).into()) - } else { - Ok(Response::NotFound("Dashboard".to_string()).into()) - } -} + let title = dashboard + .title() + .map(|t| t.trim().to_string()) + .and_then(|t| if t.is_empty() { None } else { Some(t) }) + .ok_or_else(|| DashboardError::PutMissingTitle)?; + dashboard.set_title(title); -#[derive(Debug)] -enum Response { - OkMessage(String), - NotFound(String), - Conflict(anyhow::Error), - InternalServerError(anyhow::Error), -} - -impl From for HttpResponse { - fn from(resp: Response) -> Self { - match resp { - Response::OkMessage(message) => Self::Ok().json(MetaHttpResponse::message( - http::StatusCode::OK.into(), - message, - )), - Response::NotFound(entity) => Self::NotFound().json(MetaHttpResponse::error( - http::StatusCode::NOT_FOUND.into(), - format!("{entity} not found"), - )), - Response::Conflict(err) => Self::Conflict().json(MetaHttpResponse::error( - http::StatusCode::CONFLICT.into(), - err.to_string(), - )), - Response::InternalServerError(err) => { - Self::InternalServerError().json(MetaHttpResponse::error( - http::StatusCode::INTERNAL_SERVER_ERROR.into(), - err.to_string(), - )) - } - } - } + dashboard.set_dashboard_id(dashboard_id.to_owned()); + let dash = table::dashboards::put(org_id, folder_id, dashboard).await?; + Ok(dash) } diff --git a/src/service/dashboards/reports.rs b/src/service/dashboards/reports.rs index 5d9e65e6a4..20fcbff3f7 100644 --- a/src/service/dashboards/reports.rs +++ b/src/service/dashboards/reports.rs @@ -32,6 +32,7 @@ use config::{ }; use cron::Schedule; use futures::{future::try_join_all, StreamExt}; +use infra::table; use lettre::{ message::{header::ContentType, MultiPart, SinglePart}, AsyncTransport, Message, @@ -141,7 +142,7 @@ pub async fn save( // Supports only one tab for now let tab_id = &dashboard.tabs[0]; tasks.push(async move { - let maybe_dashboard = db::dashboards::get(org_id, dash_id, folder).await?; + let maybe_dashboard = table::dashboards::get(org_id, folder, dash_id).await?; // Check if the tab_id exists if let Some(dashboard) = maybe_dashboard.and_then(|d| d.v3) { let mut tab_found = false; diff --git a/src/service/db/dashboards/mod.rs b/src/service/db/dashboards/mod.rs index 6318e625c7..136e7fa4fa 100644 --- a/src/service/db/dashboards/mod.rs +++ b/src/service/db/dashboards/mod.rs @@ -13,294 +13,4 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use actix_web::web; -use config::{ - meta::{ - dashboards::{v1, v2, v3, v4, v5, Dashboard, DashboardVersion, ListDashboardsParams}, - stream::{DistinctField, StreamType}, - }, - utils::json, -}; -use hashbrown::HashMap; -use infra::table::{ - dashboards, - distinct_values::{self, DistinctFieldRecord, OriginType}, -}; - -use crate::service::stream::save_stream_settings; - pub mod reports; - -async fn add_distinct_field_entry( - dashboard_id: &str, - org: &str, - stream: &str, - stype: String, - field: &str, -) -> Result<(), anyhow::Error> { - let record = DistinctFieldRecord::new( - OriginType::Dashboard, - dashboard_id, - org, - stream, - stype, - field, - ); - distinct_values::add(record) - .await - .map_err(|e| anyhow::anyhow!("error in adding distinct value record : {e}")) -} - -async fn remove_distinct_field_entry( - dashboard_id: &str, - org: &str, - stream: &str, - stype: String, - field: &str, -) -> Result<(), anyhow::Error> { - let record = DistinctFieldRecord::new( - OriginType::Dashboard, - dashboard_id, - org, - stream, - stype, - field, - ); - distinct_values::remove(record) - .await - .map_err(|e| anyhow::anyhow!("error in removing distinct value record : {e}")) -} - -async fn update_distinct_variables( - org_id: &str, - old_dash: Option, - new_dash: &Dashboard, -) -> Result<(), anyhow::Error> { - let mut old_variables = get_query_variables(old_dash.as_ref()); - let new_variables = get_query_variables(Some(new_dash)); - - let dashboard_id = new_dash.dashboard_id().unwrap(); - - if !new_variables.is_empty() { - for ((name, typ), fields) in new_variables.into_iter() { - let mut stream_settings = infra::schema::get_settings(org_id, &name, typ) - .await - .unwrap_or_default(); - // we only store distinct values for logs and traces - - // if anything else, we can ignore. - if !matches!(typ, StreamType::Logs | StreamType::Traces) { - continue; - } - // get entry from previous variables corresponding to this stream - let old_fields = old_variables - .remove(&(name.to_owned(), typ)) - .unwrap_or_default(); - let mut _new_added = false; - - for f in fields.iter() { - // we ignore full text search no matter what - if stream_settings.full_text_search_keys.contains(f) { - continue; - } - // we add entry for all the fields, because we need mappings for each individual - // origin-stream-field mapping. The duplicates are handled in add function, so - // we can call it for each field without issues - add_distinct_field_entry(dashboard_id, org_id, &name, typ.to_string(), f).await?; - let _temp = DistinctField { - name: f.to_owned(), - added_ts: chrono::Utc::now().timestamp_micros(), - }; - if !stream_settings.distinct_value_fields.contains(&_temp) { - stream_settings.distinct_value_fields.push(_temp); - _new_added = true; - } - } - // here we check if any of the fields used in previous version are no longer used - // if so, remove their entry. - for f in old_fields { - if !fields.contains(&f) { - remove_distinct_field_entry(dashboard_id, org_id, &name, typ.to_string(), &f) - .await?; - } - } - if _new_added { - save_stream_settings(org_id, &name, typ, stream_settings).await?; - } - } - - // finally, whatever stream remains in the old variables - // it has all corresponding fields removed, so remove those as well - for ((name, typ), fields) in old_variables.into_iter() { - for f in fields { - remove_distinct_field_entry(dashboard_id, org_id, &name, typ.to_string(), &f) - .await?; - } - } - } else { - // I guess all the variables were removed from the dashboard. - // we can batch remove all entries belonging to this dashboard. - distinct_values::batch_remove(OriginType::Dashboard, dashboard_id).await? - } - Ok(()) -} - -macro_rules! _get_variables { - ($map:ident, $dash:ident) => { - if let Some(vars) = &$dash.variables { - for v in vars.list.iter() { - if let Some(ref qd) = v.query_data { - $map.entry((qd.stream.clone(), qd.stream_type)) - .or_default() - .push(qd.field.clone()); - } - } - } - }; -} - -pub fn get_query_variables( - dashboard: Option<&Dashboard>, -) -> HashMap<(String, StreamType), Vec> { - let mut map: HashMap<(String, StreamType), Vec> = HashMap::new(); - let dashboard = if let Some(d) = dashboard { - d - } else { - return map; - }; - match dashboard.version { - 1 => { - let dash = dashboard.v1.as_ref().unwrap(); - _get_variables!(map, dash); - } - 2 => { - let dash = dashboard.v2.as_ref().unwrap(); - _get_variables!(map, dash); - } - 3 => { - let dash = dashboard.v3.as_ref().unwrap(); - _get_variables!(map, dash); - } - 4 => { - let dash = dashboard.v4.as_ref().unwrap(); - _get_variables!(map, dash); - } - 5 => { - let dash = dashboard.v5.as_ref().unwrap(); - _get_variables!(map, dash); - } - _ => { - unreachable!("we only have 5 dashboard versions") - } - } - map -} - -#[tracing::instrument] -pub(crate) async fn get( - org_id: &str, - dashboard_id: &str, - folder_id: &str, -) -> Result, anyhow::Error> { - let dash = dashboards::get(org_id, folder_id, dashboard_id).await?; - Ok(dash) -} - -#[tracing::instrument(skip(body))] -pub(crate) async fn put( - org_id: &str, - dashboard_id: &str, - folder_id: &str, - body: web::Bytes, - hash: Option<&str>, -) -> Result { - let mut old_version = None; - if let Some(existing_dash) = get(org_id, dashboard_id, folder_id).await? { - let existing_dash_hash = existing_dash.hash.clone(); - let Some(Ok(hash_val)) = hash.map(|hash_str| hash_str.parse::()) else { - return Err(anyhow::anyhow!( - "Request to update existing dashboard with missing or invalid hash value. BUG" - )); - }; - if hash_val.to_string() != existing_dash_hash { - return Err(anyhow::anyhow!( - "Conflict: Failed to save due to concurrent changes. Please refresh the page after backing up your work to avoid losing changes." - )); - } - old_version = Some(existing_dash); - }; - - let d_version: DashboardVersion = json::from_slice(&body)?; - let mut dash = parse_dashboard(body, d_version.version)?; - update_distinct_variables(org_id, old_version, &dash).await?; - let title = dash - .title() - .map(|t| t.trim().to_string()) - .and_then(|t| if t.is_empty() { None } else { Some(t) }) - .ok_or_else(|| anyhow::anyhow!("Dashboard should have title"))?; - dash.set_title(title); - - dash.set_dashboard_id(dashboard_id.to_owned()); - let dash = dashboards::put(org_id, folder_id, dash).await?; - Ok(dash) -} - -#[tracing::instrument] -pub(crate) async fn list(params: ListDashboardsParams) -> Result, anyhow::Error> { - let ds = dashboards::list(params).await?; - Ok(ds) -} - -#[tracing::instrument] -pub(crate) async fn delete( - org_id: &str, - dashboard_id: &str, - folder_id: &str, -) -> Result<(), anyhow::Error> { - distinct_values::batch_remove(OriginType::Dashboard, dashboard_id).await?; - dashboards::delete(org_id, folder_id, dashboard_id).await?; - Ok(()) -} - -#[tracing::instrument] -pub async fn reset() -> Result<(), anyhow::Error> { - let dashboards = dashboards::list_all().await?; - let ids: Vec<_> = dashboards - .iter() - .map(|(_, d)| d.dashboard_id().unwrap()) - .collect(); - for id in ids { - distinct_values::batch_remove(OriginType::Dashboard, id).await?; - } - dashboards::delete_all().await?; - Ok(()) -} - -/// Parses the bytes into a dashboard with the given version. -pub(crate) fn parse_dashboard( - bytes: web::Bytes, - version: i32, -) -> Result { - let dash = match version { - 1 => { - let inner: v1::Dashboard = json::from_slice(&bytes)?; - inner.into() - } - 2 => { - let inner: v2::Dashboard = json::from_slice(&bytes)?; - inner.into() - } - 3 => { - let inner: v3::Dashboard = json::from_slice(&bytes)?; - inner.into() - } - 4 => { - let inner: v4::Dashboard = json::from_slice(&bytes)?; - inner.into() - } - _ => { - let inner: v5::Dashboard = json::from_slice(&bytes)?; - inner.into() - } - }; - Ok(dash) -}