From 27b43f64d9ce686d9cd8265573ba2745c7417fa8 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 04:43:01 -0700 Subject: [PATCH 1/8] dashboard changes 1. add below fields to dashboard creation - a. tags - list of strings b. created - created datetime c. is favorite - true/false, default false 2. ensure title is unique 3. add API to get all tags - `GET /api/v1/dashboards/list_tags` --- src/handlers/http/modal/server.rs | 7 +++ src/handlers/http/users/dashboards.rs | 5 ++ src/users/dashboards.rs | 70 +++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index e92ae836d..af503069c 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -300,6 +300,13 @@ impl Server { .authorize(Action::ListDashboard), ), ) + .service( + web::resource("/list_tags").route( + web::get() + .to(dashboards::list_tags) + .authorize(Action::ListDashboard), + ), + ) .service( web::scope("/{dashboard_id}") .service( diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 93dc4fd7d..ceb1d0c45 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -145,6 +145,11 @@ pub async fn add_tile( Ok((web::Json(dashboard), StatusCode::OK)) } +pub async fn list_tags() -> Result { + let tags = DASHBOARDS.list_tags().await; + Ok((web::Json(tags), StatusCode::OK)) +} + #[derive(Debug, thiserror::Error)] pub enum DashboardError { #[error("Failed to connect to storage: {0}")] diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index b838a1938..3c7fb3c5e 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -52,12 +52,16 @@ pub struct Tile { pub other_fields: Option>, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] pub struct Dashboard { pub version: Option, pub title: String, pub author: Option, pub dashboard_id: Option, + pub created: Option>, pub modified: Option>, + pub tags: Option>, + pub is_favorite: Option, // whether the dashboard is marked as favorite, default is false dashboard_type: Option, pub tiles: Option>, } @@ -77,6 +81,11 @@ impl Dashboard { if self.tiles.is_none() { self.tiles = Some(Vec::new()); } + + // if is_favorite is None, set it to false, else set it to the current value + self.is_favorite = Some( + self.is_favorite.unwrap_or(false), // default to false if not set + ); } /// create a summary of the dashboard @@ -96,6 +105,13 @@ impl Dashboard { ); } + if let Some(created) = &self.created { + map.insert( + "created".to_string(), + serde_json::Value::String(created.to_string()), + ); + } + if let Some(modified) = &self.modified { map.insert( "modified".to_string(), @@ -110,6 +126,22 @@ impl Dashboard { ); } + if let Some(tags) = &self.tags { + map.insert( + "tags".to_string(), + serde_json::Value::Array( + tags.iter() + .map(|tag| serde_json::Value::String(tag.clone())) + .collect(), + ), + ); + } + + map.insert( + "is_favorite".to_string(), + serde_json::Value::Bool(self.is_favorite.unwrap_or(false)), + ); + map } } @@ -175,6 +207,16 @@ impl Dashboards { let dashboard_id = dashboard .dashboard_id .ok_or(DashboardError::Metadata("Dashboard ID must be provided"))?; + + // ensure the dashboard has unique title + let dashboards = self.0.read().await; + let has_duplicate = dashboards + .iter() + .any(|d| d.title == dashboard.title && d.dashboard_id != dashboard.dashboard_id); + + if has_duplicate { + return Err(DashboardError::Metadata("Dashboard title must be unique")); + } let path = dashboard_path(user_id, &format!("{dashboard_id}.json")); let store = PARSEABLE.storage.get_object_store(); @@ -194,6 +236,7 @@ impl Dashboards { user_id: &str, dashboard: &mut Dashboard, ) -> Result<(), DashboardError> { + dashboard.created = Some(Utc::now()); dashboard.set_metadata(user_id, None); self.save_dashboard(user_id, dashboard).await?; @@ -211,10 +254,12 @@ impl Dashboards { dashboard_id: Ulid, dashboard: &mut Dashboard, ) -> Result<(), DashboardError> { - self.ensure_dashboard_ownership(dashboard_id, user_id) + let existing_dashboard = self + .ensure_dashboard_ownership(dashboard_id, user_id) .await?; dashboard.set_metadata(user_id, Some(dashboard_id)); + dashboard.created = existing_dashboard.created; self.save_dashboard(user_id, dashboard).await?; let mut dashboards = self.0.write().await; @@ -288,6 +333,20 @@ impl Dashboards { self.0.read().await.clone() } + /// List tags from all dashboards + /// This function returns a list of unique tags from all dashboards + pub async fn list_tags(&self) -> Vec { + let dashboards = self.0.read().await; + let mut tags = dashboards + .iter() + .filter_map(|d| d.tags.as_ref()) + .flat_map(|t| t.iter().cloned()) + .collect::>(); + tags.sort(); + tags.dedup(); + tags + } + /// Ensure the user is the owner of the dashboard /// This function is called when updating or deleting a dashboard /// check if the user is the owner of the dashboard @@ -296,10 +355,13 @@ impl Dashboards { &self, dashboard_id: Ulid, user_id: &str, - ) -> Result<(), DashboardError> { + ) -> Result { self.get_dashboard_by_user(dashboard_id, user_id) .await - .ok_or_else(|| DashboardError::Unauthorized) - .map(|_| ()) + .ok_or_else(|| { + DashboardError::Metadata( + "Dashboard does not exist or you do not have permission to access it", + ) + }) } } From 84872f4bea831986990a10aea6b45f39cd025b5f Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 11:43:10 -0700 Subject: [PATCH 2/8] add query params in update dashboard 1. is_favorite=true/false -- to set dashboard to favorite 2. rename_to= -- to update the title of the dashboard 3. tags= -- to update tags add validation - body and query params both cannot co-exist in PUT request --- src/handlers/http/users/dashboards.rs | 91 +++++++++++++++++++++------ src/users/dashboards.rs | 4 +- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index ceb1d0c45..2da64e6ff 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -16,6 +16,8 @@ * */ +use std::collections::HashMap; + use crate::{ handlers::http::rbac::RBACError, storage::ObjectStorageError, @@ -68,37 +70,83 @@ pub async fn create_dashboard( pub async fn update_dashboard( req: HttpRequest, dashboard_id: Path, - Json(mut dashboard): Json, + Json(dashboard): Json, ) -> Result { let user_id = get_hash(&get_user_from_request(&req)?); let dashboard_id = validate_dashboard_id(dashboard_id.into_inner())?; + let mut existing_dashboard = DASHBOARDS + .get_dashboard_by_user(dashboard_id, &user_id) + .await + .ok_or(DashboardError::Metadata( + "Dashboard does not exist or user is not authorized", + ))?; - // Validate all tiles have valid IDs - if let Some(tiles) = &dashboard.tiles { - if tiles.iter().any(|tile| tile.tile_id.is_nil()) { - return Err(DashboardError::Metadata("Tile ID must be provided")); - } + let query_map = web::Query::>::from_query(req.query_string()) + .map_err(|_| DashboardError::InvalidQueryParameter)?; + + // Validate: either query params OR body, not both + let has_query_params = !query_map.is_empty(); + let has_body_update = dashboard.title != existing_dashboard.title || dashboard.tiles.is_some(); + + if has_query_params && has_body_update { + return Err(DashboardError::Metadata( + "Cannot use both query parameters and request body for updates", + )); } - // Check if tile_id are unique - if let Some(tiles) = &dashboard.tiles { - let unique_tiles: Vec<_> = tiles - .iter() - .map(|tile| tile.tile_id) - .collect::>() - .into_iter() - .collect(); - - if unique_tiles.len() != tiles.len() { - return Err(DashboardError::Metadata("Tile IDs must be unique")); + let mut final_dashboard = if has_query_params { + // Apply partial updates from query parameters + if let Some(is_favorite) = query_map.get("is_favorite") { + existing_dashboard.is_favorite = Some(is_favorite == "true"); + } + if let Some(tags) = query_map.get("tags") { + let parsed_tags: Vec = tags + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + existing_dashboard.tags = if parsed_tags.is_empty() { + None + } else { + Some(parsed_tags) + }; + } + if let Some(rename_to) = query_map.get("rename_to") { + let trimmed = rename_to.trim(); + if trimmed.is_empty() { + return Err(DashboardError::Metadata("Rename to cannot be empty")); + } + existing_dashboard.title = trimmed.to_string(); + } + existing_dashboard + } else { + if let Some(tiles) = &dashboard.tiles { + if tiles.iter().any(|tile| tile.tile_id.is_nil()) { + return Err(DashboardError::Metadata("Tile ID must be provided")); + } + + // Check if tile_id are unique + let unique_tiles: Vec<_> = tiles + .iter() + .map(|tile| tile.tile_id) + .collect::>() + .into_iter() + .collect(); + + if unique_tiles.len() != tiles.len() { + return Err(DashboardError::Metadata("Tile IDs must be unique")); + } } - } + + dashboard + }; DASHBOARDS - .update(&user_id, dashboard_id, &mut dashboard) + .update(&user_id, dashboard_id, &mut final_dashboard) .await?; - Ok((web::Json(dashboard), StatusCode::OK)) + Ok((web::Json(final_dashboard), StatusCode::OK)) } pub async fn delete_dashboard( @@ -164,6 +212,8 @@ pub enum DashboardError { Custom(String), #[error("Dashboard does not exist or is not accessible")] Unauthorized, + #[error("Invalid query parameter")] + InvalidQueryParameter, } impl actix_web::ResponseError for DashboardError { @@ -175,6 +225,7 @@ impl actix_web::ResponseError for DashboardError { Self::UserDoesNotExist(_) => StatusCode::NOT_FOUND, Self::Custom(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::InvalidQueryParameter => StatusCode::BAD_REQUEST, } } diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 3c7fb3c5e..32d55cfa9 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -83,9 +83,7 @@ impl Dashboard { } // if is_favorite is None, set it to false, else set it to the current value - self.is_favorite = Some( - self.is_favorite.unwrap_or(false), // default to false if not set - ); + self.is_favorite = self.is_favorite.or(Some(false)); } /// create a summary of the dashboard From f6f145761e716ba44eaa39ca26b0872e546fde7d Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 22:19:30 -0700 Subject: [PATCH 3/8] body optional in update dashboard --- src/handlers/http/users/dashboards.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 2da64e6ff..378af57df 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -70,7 +70,7 @@ pub async fn create_dashboard( pub async fn update_dashboard( req: HttpRequest, dashboard_id: Path, - Json(dashboard): Json, + dashboard: Option>, ) -> Result { let user_id = get_hash(&get_user_from_request(&req)?); let dashboard_id = validate_dashboard_id(dashboard_id.into_inner())?; @@ -86,7 +86,10 @@ pub async fn update_dashboard( // Validate: either query params OR body, not both let has_query_params = !query_map.is_empty(); - let has_body_update = dashboard.title != existing_dashboard.title || dashboard.tiles.is_some(); + let has_body_update = dashboard + .as_ref() + .map(|d| d.title != existing_dashboard.title || d.tiles.is_some()) + .unwrap_or(false); if has_query_params && has_body_update { return Err(DashboardError::Metadata( @@ -121,6 +124,9 @@ pub async fn update_dashboard( } existing_dashboard } else { + let dashboard = dashboard + .ok_or(DashboardError::Metadata("Request body is required"))? + .into_inner(); if let Some(tiles) = &dashboard.tiles { if tiles.iter().any(|tile| tile.tile_id.is_nil()) { return Err(DashboardError::Metadata("Tile ID must be provided")); From bda7a4c623afc7c51b31fba8512d4b9a3ee2a129 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 22:22:13 -0700 Subject: [PATCH 4/8] camel case in listing dashboard --- src/users/dashboards.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 32d55cfa9..eb4b760cb 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -119,7 +119,7 @@ impl Dashboard { if let Some(dashboard_id) = &self.dashboard_id { map.insert( - "dashboard_id".to_string(), + "dashboardId".to_string(), serde_json::Value::String(dashboard_id.to_string()), ); } @@ -136,7 +136,7 @@ impl Dashboard { } map.insert( - "is_favorite".to_string(), + "isFavorite".to_string(), serde_json::Value::Bool(self.is_favorite.unwrap_or(false)), ); From e7b9969f1d46a3c2bc770b1545197c09e0ebd288 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 23:25:24 -0700 Subject: [PATCH 5/8] add endpoint for list by tag --- src/handlers/http/modal/server.rs | 7 +++++++ src/handlers/http/users/dashboards.rs | 15 +++++++++++++++ src/users/dashboards.rs | 15 +++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index af503069c..575f2925d 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -307,6 +307,13 @@ impl Server { .authorize(Action::ListDashboard), ), ) + .service( + web::resource("/list_by_tag/{tag}").route( + web::get() + .to(dashboards::list_dashboards_by_tag) + .authorize(Action::ListDashboard), + ), + ) .service( web::scope("/{dashboard_id}") .service( diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 378af57df..32c256407 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -204,6 +204,21 @@ pub async fn list_tags() -> Result { Ok((web::Json(tags), StatusCode::OK)) } +pub async fn list_dashboards_by_tag(tag: Path) -> Result { + let tag = tag.into_inner(); + if tag.is_empty() { + return Err(DashboardError::Metadata("Tag cannot be empty")); + } + + let dashboards = DASHBOARDS.list_dashboards_by_tag(&tag).await; + let dashboard_summaries = dashboards + .iter() + .map(|dashboard| dashboard.to_summary()) + .collect::>(); + + Ok((web::Json(dashboard_summaries), StatusCode::OK)) +} + #[derive(Debug, thiserror::Error)] pub enum DashboardError { #[error("Failed to connect to storage: {0}")] diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index eb4b760cb..29f8f5ef9 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -345,6 +345,21 @@ impl Dashboards { tags } + /// List dashboards by tag + /// This function returns a list of dashboards that have the specified tag + pub async fn list_dashboards_by_tag(&self, tag: &str) -> Vec { + let dashboards = self.0.read().await; + dashboards + .iter() + .filter(|d| { + d.tags + .as_ref() + .map_or(false, |tags| tags.contains(&tag.to_string())) + }) + .cloned() + .collect() + } + /// Ensure the user is the owner of the dashboard /// This function is called when updating or deleting a dashboard /// check if the user is the owner of the dashboard From 0984bab1cec3fdebfd2e3c8b0aab3ee3b9888369 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Tue, 1 Jul 2025 00:03:53 -0700 Subject: [PATCH 6/8] refactor --- src/handlers/http/users/dashboards.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 32c256407..a8ad6098f 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -86,10 +86,9 @@ pub async fn update_dashboard( // Validate: either query params OR body, not both let has_query_params = !query_map.is_empty(); - let has_body_update = dashboard - .as_ref() - .map(|d| d.title != existing_dashboard.title || d.tiles.is_some()) - .unwrap_or(false); + let has_body_update = dashboard.as_ref().map_or(false, |d| { + d.title != existing_dashboard.title || d.tiles.is_some() + }); if has_query_params && has_body_update { return Err(DashboardError::Metadata( From 2e72ace2ae4f6faf9ef1af6dd391151e42f05814 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Tue, 1 Jul 2025 01:34:54 -0700 Subject: [PATCH 7/8] camel case in query param --- src/handlers/http/users/dashboards.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index a8ad6098f..ce397b2ac 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -98,7 +98,7 @@ pub async fn update_dashboard( let mut final_dashboard = if has_query_params { // Apply partial updates from query parameters - if let Some(is_favorite) = query_map.get("is_favorite") { + if let Some(is_favorite) = query_map.get("isFavorite") { existing_dashboard.is_favorite = Some(is_favorite == "true"); } if let Some(tags) = query_map.get("tags") { @@ -114,7 +114,7 @@ pub async fn update_dashboard( Some(parsed_tags) }; } - if let Some(rename_to) = query_map.get("rename_to") { + if let Some(rename_to) = query_map.get("renameTo") { let trimmed = rename_to.trim(); if trimmed.is_empty() { return Err(DashboardError::Metadata("Rename to cannot be empty")); From 3fef31ee6526a71c19f48c3b21988ea6cd99ea44 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Tue, 1 Jul 2025 01:40:09 -0700 Subject: [PATCH 8/8] clippy suggestions --- src/handlers/http/users/dashboards.rs | 6 +++--- src/users/dashboards.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index ce397b2ac..f503c3e58 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -86,9 +86,9 @@ pub async fn update_dashboard( // Validate: either query params OR body, not both let has_query_params = !query_map.is_empty(); - let has_body_update = dashboard.as_ref().map_or(false, |d| { - d.title != existing_dashboard.title || d.tiles.is_some() - }); + let has_body_update = dashboard + .as_ref() + .is_some_and(|d| d.title != existing_dashboard.title || d.tiles.is_some()); if has_query_params && has_body_update { return Err(DashboardError::Metadata( diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 29f8f5ef9..cd53ea68f 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -354,7 +354,7 @@ impl Dashboards { .filter(|d| { d.tags .as_ref() - .map_or(false, |tags| tags.contains(&tag.to_string())) + .is_some_and(|tags| tags.contains(&tag.to_string())) }) .cloned() .collect()