diff --git a/src/api/management/src/request/organization/org.rs b/src/api/management/src/request/organization/org.rs index 563c1f6d7d..0d5be0c41c 100644 --- a/src/api/management/src/request/organization/org.rs +++ b/src/api/management/src/request/organization/org.rs @@ -401,7 +401,7 @@ pub async fn org_summary(Path(org_id): Path) -> impl IntoResponse { params( ("org_id" = String, Path, description = "Organization name"), ("top" = Option, Query, description = "Top streams per stream type by size. Default 5, max 20."), - ("refresh" = Option, Query, description = "Set true to skip the short-lived cache and rebuild now."), + ("refresh" = Option, Query, description = "Set true (or 1) to skip the short-lived cache and rebuild now."), ), responses( (status = 200, description = "Success", content_type = "application/json", body = Object), @@ -418,17 +418,19 @@ pub async fn org_overview( Headers(user_email): Headers, Query(query): Query>, ) -> Response { - let top = match query.get("top").map(|v| v.parse::()) { + let top = match query.get("top").map(|v| v.parse::()) { Some(Err(_)) => { - return MetaHttpResponse::bad_request("'top' must be an integer, clamped to 1..20"); + return MetaHttpResponse::bad_request( + "'top' must be a non-negative integer; it is clamped to 1..20", + ); } - Some(Ok(n)) => Some(n as usize), + Some(Ok(n)) => Some(n), None => None, }; let top_n = overview::normalize_top_n(top); let refresh = query .get("refresh") - .is_some_and(|v| v == "true" || v == "1"); + .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"); if !refresh && let Some(hit) = overview::cached_overview(&org_id, &user_email.user_id, top_n) { return Json(hit).into_response(); } diff --git a/src/core/src/overview.rs b/src/core/src/overview.rs index c8ed3cf2de..970276ccd5 100644 --- a/src/core/src/overview.rs +++ b/src/core/src/overview.rs @@ -39,8 +39,7 @@ use stream::try_get_streams; const DEFAULT_TOP_N: usize = 5; const MAX_TOP_N: usize = 20; -/// User-facing stream types only; profiles, service graph, filelist and internal streams are left -/// out. +/// User-facing stream types; derived and internal streams are left out. const OVERVIEW_STREAM_TYPES: [StreamType; 4] = [ StreamType::Logs, StreamType::Metrics, @@ -97,6 +96,9 @@ pub struct Section { pub data: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// Why a section is locked, so the UI can pick the right message. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, } impl Section { @@ -105,6 +107,7 @@ impl Section { status: SectionStatus::Ok, data: Some(data), error: None, + reason: None, } } @@ -113,6 +116,7 @@ impl Section { status: SectionStatus::Error, data: None, error: Some(message.to_string()), + reason: None, } } @@ -121,14 +125,16 @@ impl Section { status: SectionStatus::Forbidden, data: None, error: None, + reason: None, } } - fn locked() -> Self { + fn locked(reason: &str) -> Self { Self { status: SectionStatus::Locked, data: None, error: None, + reason: Some(reason.to_string()), } } @@ -196,6 +202,7 @@ pub struct AlertsData { pub by_state: BTreeMap, /// Enabled rules by the severity level of their last classification. pub by_level: BTreeMap, + /// Same count as `by_state.error`; do not add the two together. pub failed_evaluation: u64, } @@ -242,14 +249,6 @@ pub async fn resolve_scope(org_id: &str, user_id: &str) -> Result Result>, anyhow::Error> { - crate::authz::list_objects_for_user(org_id, user_id, "GET", &ofga_object_type(resource)).await -} - /// Overview for a caller whose grants could not be resolved; each scoped section reports an error. pub async fn scope_unavailable() -> Overview { Overview { @@ -297,6 +296,14 @@ pub async fn get_overview(org_id: &str, scope: &OverviewScope, top_n: usize) -> overview } +async fn list_grants( + org_id: &str, + user_id: &str, + resource: &str, +) -> Result>, anyhow::Error> { + crate::authz::list_objects_for_user(org_id, user_id, "GET", &ofga_object_type(resource)).await +} + async fn build_overview(org_id: &str, scope: &OverviewScope, top_n: usize) -> Overview { let (streams, alerts, pipelines) = tokio::join!( async { @@ -494,7 +501,7 @@ async fn limits_section() -> Section { }; if get_license().await.is_none() { - return Section::locked(); + return Section::locked("no license loaded"); } Section::ok(LimitsData { scope: "deployment", @@ -506,7 +513,7 @@ async fn limits_section() -> Section { #[cfg(not(feature = "enterprise"))] async fn limits_section() -> Section { - Section::locked() + Section::locked("not available in this edition") } #[cfg(test)] @@ -634,8 +641,11 @@ mod tests { #[test] fn test_section_serialization_carries_status_and_omits_empty_fields() { - let locked = serde_json::to_value(Section::::locked()).unwrap(); - assert_eq!(locked, serde_json::json!({"status": "locked"})); + let locked = serde_json::to_value(Section::::locked("no license")).unwrap(); + assert_eq!( + locked, + serde_json::json!({"status": "locked", "reason": "no license"}) + ); let err = serde_json::to_value(Section::::error("boom")).unwrap(); assert_eq!(err, serde_json::json!({"status": "error", "error": "boom"})); } @@ -643,7 +653,12 @@ mod tests { #[cfg(not(feature = "enterprise"))] #[tokio::test] async fn test_limits_locked_without_enterprise() { - assert_eq!(limits_section().await.status, SectionStatus::Locked); + let limits = limits_section().await; + assert_eq!(limits.status, SectionStatus::Locked); + assert_eq!( + limits.reason.as_deref(), + Some("not available in this edition") + ); } #[tokio::test] @@ -756,9 +771,9 @@ mod tests { vec![stream("only_for_admin", StreamType::Logs, 7.0, 1)], 5, )), - alerts: Section::locked(), - pipelines: Section::locked(), - limits: Section::locked(), + alerts: Section::locked("test"), + pipelines: Section::locked("test"), + limits: Section::locked("test"), generated_at: now_micros(), cached: false, };