diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index e92ae836d..575f2925d 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -300,6 +300,20 @@ impl Server { .authorize(Action::ListDashboard), ), ) + .service( + web::resource("/list_tags").route( + web::get() + .to(dashboards::list_tags) + .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 93dc4fd7d..f503c3e58 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,88 @@ pub async fn create_dashboard( pub async fn update_dashboard( req: HttpRequest, dashboard_id: Path, - Json(mut 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())?; + 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 + .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( + "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("isFavorite") { + 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("renameTo") { + 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 { + 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")); + } + + // 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( @@ -145,6 +198,26 @@ 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)) +} + +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}")] @@ -159,6 +232,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 { @@ -170,6 +245,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 b838a1938..cd53ea68f 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,9 @@ 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 = self.is_favorite.or(Some(false)); } /// create a summary of the dashboard @@ -96,6 +103,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(), @@ -105,11 +119,27 @@ 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()), ); } + 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( + "isFavorite".to_string(), + serde_json::Value::Bool(self.is_favorite.unwrap_or(false)), + ); + map } } @@ -175,6 +205,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 +234,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 +252,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 +331,35 @@ 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 + } + + /// 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() + .is_some_and(|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 @@ -296,10 +368,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", + ) + }) } }