diff --git a/src/common/infra/config.rs b/src/common/infra/config.rs index e0c49c7151..22ebfbf689 100644 --- a/src/common/infra/config.rs +++ b/src/common/infra/config.rs @@ -26,6 +26,7 @@ use config::{ promql::ClusterLeader, ratelimit::CachedUserRoles, stream::StreamParams, + system_settings::SystemSetting, user::User, }, }; @@ -101,6 +102,12 @@ pub static SHORT_URLS: Lazy> = Lazy::new(DashM pub static USER_ROLES_CACHE: Lazy> = Lazy::new(Default::default); +/// System settings cache +/// Key format: "{scope}:{org_id}:{user_id}:{setting_key}" where org_id/user_id can be "_" if not +/// applicable +pub static SYSTEM_SETTINGS: Lazy>> = + Lazy::new(|| Arc::new(tokio::sync::RwLock::new(HashMap::new()))); + #[cfg(test)] mod tests { diff --git a/src/common/utils/auth.rs b/src/common/utils/auth.rs index db82ee2b10..03a05ca07a 100644 --- a/src/common/utils/auth.rs +++ b/src/common/utils/auth.rs @@ -307,10 +307,19 @@ impl FromRequest for AuthExtractor { path_columns[0].to_string() } } else if url_len == 2 || (url_len > 2 && path_columns[1].eq("settings")) { - // for settings, the post/delete require PUT permissions, GET needs LIST permissions - // also the special settings exception is for 3-part urls for logo /text - // which are of path /org/settings/logo , which need permission of operating - // on permission in general + // for settings (including settings/v2), the post/delete require PUT permissions, + // GET needs LIST permissions. This handles: + // - /org/settings (v1 settings API) + // - /org/settings/logo, /org/settings/text (logo/text endpoints) + // - /org/settings/v2/... (v2 multi-level settings API) + // + // Settings v2 API paths: + // - GET /{org_id}/settings/v2/{key} - get setting + // - GET /{org_id}/settings/v2 - list settings + // - POST /{org_id}/settings/v2/org - set org setting + // - POST /{org_id}/settings/v2/user/{user_id} - set user setting + // - DELETE /{org_id}/settings/v2/org/{key} - delete org setting + // - DELETE /{org_id}/settings/v2/user/{user_id}/{key} - delete user setting if path_columns[1].eq("settings") { if method.eq("POST") || method.eq("DELETE") { method = "PUT".to_string(); diff --git a/src/config/src/config.rs b/src/config/src/config.rs index e837ea29a6..f2df6cbf05 100644 --- a/src/config/src/config.rs +++ b/src/config/src/config.rs @@ -53,7 +53,7 @@ pub type RwAHashSet = tokio::sync::RwLock>; pub type RwBTreeMap = tokio::sync::RwLock>; // for DDL commands and migrations -pub const DB_SCHEMA_VERSION: u64 = 15; +pub const DB_SCHEMA_VERSION: u64 = 16; pub const DB_SCHEMA_KEY: &str = "/db_schema_version/"; // global version variables diff --git a/src/config/src/meta/alerts/deduplication.rs b/src/config/src/meta/alerts/deduplication.rs index 3354945fb2..457ec0673b 100644 --- a/src/config/src/meta/alerts/deduplication.rs +++ b/src/config/src/meta/alerts/deduplication.rs @@ -174,159 +174,7 @@ impl SemanticFieldGroup { /// instead. #[allow(dead_code)] pub fn default_presets() -> Vec { - vec![ - Self::new( - "host", - "Host", - &["host", "hostname", "node", "node_name"], - true, - ), - // IP addresses removed - too high cardinality for service dimensions - // Users with stable IPs can use "host" dimension instead - Self::new( - "service", - "Service", - &[ - // Generic - "service", - "service_name", - "svc", - "app", - "application", - "app_name", - // OpenTelemetry (traces and metrics) - "service.name", // OTLP attribute format - "attributes_service.name", // Flattened OTLP attributes - "resource_service_name", - "resource_attributes_service_name", - "resource_attributes_service.name", - // Prometheus/Metrics - "job", // Prometheus job label (common service identifier) - "service_instance", - "__name__", // Metric name can indicate service - // Kubernetes - "kubernetes_labels_app", - "kubernetes_labels_app_kubernetes_io_name", - "k8s_labels_app", - // AWS ECS/Fargate - "ecs_task_family", - "ecs_service_name", - "ecs_container_name", - // AWS Lambda - "lambda_function_name", - "aws_lambda_function_name", - // GCP Cloud Run/Functions - "resource_labels_service_name", - "cloud_run_service_name", - "resource_labels_function_name", - // Azure - "ServiceName", - "ContainerName", - "azure_service_name", - // Systemd (VMs) - "_SYSTEMD_UNIT", - ], - true, - ), - // Kubernetes groups - Self::new( - "k8s-cluster", - "K8s Cluster", - &["k8s_cluster", "cluster", "cluster_name", "cluster_id"], - false, - ), - Self::new( - "k8s-namespace", - "K8s Namespace", - &["k8s_namespace", "namespace", "k8s_ns", "ns"], - false, - ), - Self::new( - "k8s-pod", - "K8s Pod", - &["k8s_pod", "pod", "pod_name", "pod_id"], - false, - ), - Self::new( - "k8s-node", - "K8s Node", - &["k8s_node", "node", "node_name", "kubernetes_node"], - false, - ), - Self::new( - "k8s-container", - "K8s Container", - &[ - "container", - "container_name", - "container_id", - "k8s_container", - ], - false, - ), - // Environment dimension (common across all platforms) - Self::new( - "environment", - "Environment", - &[ - "environment", - "env", - "stage", - "tier", - "deployment_environment", - "service_deployment_environment", /* OTLP traces: service_ + attribute key - * (dots replaced with underscores) */ - "attributes_deployment_environment", // Flattened OTLP attributes - "resource_deployment_environment", - "resource_attributes_deployment_environment", - "kubernetes_labels_environment", - "kubernetes_labels_env", - "k8s_labels_environment", - "ecs_task_definition_tags_environment", - "labels_environment", - ], - true, - ), - // Version dimension - Self::new( - "version", - "Version", - &[ - "version", - "app_version", - "service_version", - "release", - // OTLP traces - service_ prefix with dots replaced by underscores - "service_service_version", /* OTLP traces: service_ + service.version (dots - * → underscores) */ - "attributes_service_version", // Flattened OTLP attributes - "resource_service_version", - "resource_attributes_service_version", - // Kubernetes - "kubernetes_labels_version", - "k8s_labels_version", - "aws_lambda_version", - ], - true, - ), - // Region dimension (cloud + datacenter) - Self::new( - "region", - "Region", - &[ - "region", - "aws_region", - "gcp_region", - "azure_region", - "resource_labels_location", - "kubernetes_node_labels_topology_kubernetes_io_region", - "kubernetes_node_labels_region", - "datacenter", - "location", - ], - false, - ), - ] + vec![] } /// Load default semantic groups @@ -399,6 +247,22 @@ pub struct GlobalDeduplicationConfig { /// Can be overridden per-alert. #[serde(skip_serializing_if = "Option::is_none")] pub time_window_minutes: Option, + + /// FQN (Fully Qualified Name) priority dimensions for service correlation + /// + /// Defines which semantic dimensions are used to derive the service-fqn, + /// in priority order. The first dimension with a value wins. + /// + /// Default priority (if empty): + /// 1. k8s-deployment, k8s-statefulset, k8s-daemonset, k8s-job (K8s workloads) + /// 2. aws-ecs-task, faas-name, gcp-cloud-run, azure-cloud-role (Cloud workloads) + /// 3. process-name (Bare metal) + /// 4. service (Fallback) + /// + /// Example custom priority: ["k8s-deployment", "aws-ecs-task", "service"] + /// This would skip statefulset/daemonset and use deployment directly. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fqn_priority_dimensions: Vec, } /// Per-alert deduplication configuration (from main branch) @@ -600,9 +464,25 @@ impl GlobalDeduplicationConfig { alert_dedup_enabled: false, alert_fingerprint_groups: vec![], time_window_minutes: None, + fqn_priority_dimensions: Self::default_fqn_priority(), } } + /// Get default FQN priority dimensions + /// + /// For OSS builds, returns empty (must be configured). + /// For enterprise builds, ServiceStreamsConfig provides the full default list. + /// + /// Default priority order for deriving service-fqn (first match wins): + /// 1. K8s workload: deployment, statefulset, daemonset, job + /// 2. Cloud workload: ECS task family, Lambda function, Cloud Run service + /// 3. Bare metal: process name + /// 4. Fallback: service dimension + pub fn default_fqn_priority() -> Vec { + // OSS builds return empty - enterprise provides defaults via ServiceStreamsConfig + vec![] + } + /// Map a field name to its semantic group ID /// /// Returns the first matching semantic group ID, or None if no match. @@ -743,6 +623,7 @@ mod tests { ], alert_fingerprint_groups: vec![], time_window_minutes: Some(10), + fqn_priority_dimensions: vec![], }; assert!(config.validate().is_ok()); @@ -814,6 +695,7 @@ mod tests { ], alert_fingerprint_groups: vec![], time_window_minutes: Some(10), + fqn_priority_dimensions: vec![], }; // Should succeed - overlaps are allowed, just logged as warnings @@ -831,6 +713,7 @@ mod tests { ], alert_fingerprint_groups: vec![], time_window_minutes: Some(10), + fqn_priority_dimensions: vec![], }; let json = serde_json::to_string_pretty(&config).unwrap(); @@ -896,19 +779,9 @@ mod tests { #[test] fn test_default_presets() { + // OSS builds return empty defaults - semantic groups are loaded from + // enterprise JSON or configured via API let presets = SemanticFieldGroup::default_presets(); - assert!(!presets.is_empty()); - - // Check that we have common groups - assert!(presets.iter().any(|g| g.id == "host")); - assert!(presets.iter().any(|g| g.id == "service")); - assert!(presets.iter().any(|g| g.id == "k8s-cluster")); - - // Validate all preset IDs - for preset in &presets { - assert!(SemanticFieldGroup::validate_id(&preset.id)); - assert!(!preset.display.is_empty()); - assert!(!preset.fields.is_empty()); - } + assert!(presets.is_empty()); } } diff --git a/src/config/src/meta/mod.rs b/src/config/src/meta/mod.rs index a3bf9818c8..4241936ef6 100644 --- a/src/config/src/meta/mod.rs +++ b/src/config/src/meta/mod.rs @@ -39,6 +39,7 @@ pub mod service_streams; pub mod short_url; pub mod sql; pub mod stream; +pub mod system_settings; pub mod timed_annotations; pub mod triggers; pub mod user; diff --git a/src/config/src/meta/service_streams.rs b/src/config/src/meta/service_streams.rs index 3198a42d0d..24f01c5b97 100644 --- a/src/config/src/meta/service_streams.rs +++ b/src/config/src/meta/service_streams.rs @@ -297,6 +297,81 @@ pub enum CardinalityClass { VeryHigh, } +/// Response for grouped services API +/// Groups services by their FQN (Fully Qualified Name) for correlation visualization +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GroupedServicesResponse { + /// Services grouped by FQN + pub groups: Vec, + + /// Total number of unique FQNs + pub total_fqns: usize, + + /// Total number of services (across all FQNs) + pub total_services: usize, +} + +/// A group of services sharing the same FQN +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ServiceFqnGroup { + /// The Fully Qualified Name (e.g., "o2-openobserve-querier") + pub fqn: String, + + /// Services that share this FQN + pub services: Vec, + + /// Summary of streams in this group + pub stream_summary: StreamSummary, +} + +/// A service within an FQN group +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ServiceInGroup { + /// Service name (e.g., "openobserve", "querier") + pub service_name: String, + + /// How the FQN was derived (e.g., "k8s-statefulset", "k8s-deployment", "service") + pub derived_from: String, + + /// Streams by type + pub streams: ServiceStreams, + + /// Key dimensions for this service + pub dimensions: HashMap, +} + +/// Streams organized by type +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] +pub struct ServiceStreams { + /// Log stream names + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub logs: Vec, + + /// Trace stream names + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub traces: Vec, + + /// Metric stream names + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub metrics: Vec, +} + +/// Summary of streams in an FQN group +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] +pub struct StreamSummary { + /// Number of log streams + pub logs_count: usize, + + /// Number of trace streams + pub traces_count: usize, + + /// Number of metric streams + pub metrics_count: usize, + + /// Whether this group has all three telemetry types + pub has_full_correlation: bool, +} + impl CardinalityClass { /// Classify cardinality based on unique value count pub fn from_cardinality(cardinality: usize) -> Self { diff --git a/src/config/src/meta/system_settings.rs b/src/config/src/meta/system_settings.rs new file mode 100644 index 0000000000..f2853c720d --- /dev/null +++ b/src/config/src/meta/system_settings.rs @@ -0,0 +1,407 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! System Settings Types +//! +//! This module defines the types for the multi-level settings system. +//! Settings can be stored at three levels: +//! - System: Global defaults for all organizations +//! - Org: Organization-specific overrides +//! - User: User-specific preferences within an organization +//! +//! Resolution order (most specific wins): +//! 1. User level (if user_id provided) +//! 2. Org level (if org_id provided) +//! 3. System level (global defaults) + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Setting scope levels +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SettingScope { + /// Global system-level settings (defaults for all orgs) + System, + /// Organization-level settings (overrides system defaults) + Org, + /// User-level settings within an organization + User, +} + +impl SettingScope { + pub fn as_str(&self) -> &'static str { + match self { + SettingScope::System => "system", + SettingScope::Org => "org", + SettingScope::User => "user", + } + } +} + +impl std::fmt::Display for SettingScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for SettingScope { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "system" => Ok(SettingScope::System), + "org" => Ok(SettingScope::Org), + "user" => Ok(SettingScope::User), + _ => Err(format!("Invalid setting scope: {}", s)), + } + } +} + +/// Common setting categories for organizing settings +/// +/// NOTE: This enum provides a set of common categories but is not exhaustive. +/// The `setting_category` field in `SystemSetting` is an `Option` to allow +/// for custom categories beyond these predefined ones. Use these constants for +/// consistency when applicable, but feel free to use custom category strings +/// for domain-specific settings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SettingCategory { + /// Correlation and service identity settings + Correlation, + /// UI/UX preferences + Ui, + /// Alert and notification settings + Alerts, + /// Data retention settings + Retention, + /// Search settings + Search, + /// Ingestion settings + Ingestion, + /// General/miscellaneous settings + General, +} + +impl SettingCategory { + pub fn as_str(&self) -> &'static str { + match self { + SettingCategory::Correlation => "correlation", + SettingCategory::Ui => "ui", + SettingCategory::Alerts => "alerts", + SettingCategory::Retention => "retention", + SettingCategory::Search => "search", + SettingCategory::Ingestion => "ingestion", + SettingCategory::General => "general", + } + } +} + +impl std::fmt::Display for SettingCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for SettingCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "correlation" => Ok(SettingCategory::Correlation), + "ui" => Ok(SettingCategory::Ui), + "alerts" => Ok(SettingCategory::Alerts), + "retention" => Ok(SettingCategory::Retention), + "search" => Ok(SettingCategory::Search), + "ingestion" => Ok(SettingCategory::Ingestion), + "general" => Ok(SettingCategory::General), + _ => Err(format!("Invalid setting category: {}", s)), + } + } +} + +/// Well-known setting keys +pub mod keys { + /// FQN priority dimensions for service correlation + pub const FQN_PRIORITY_DIMENSIONS: &str = "fqn_priority_dimensions"; + /// Semantic field groups for dimension extraction + pub const SEMANTIC_FIELD_GROUPS: &str = "semantic_field_groups"; + /// UI theme preference + pub const THEME: &str = "theme"; + /// Scrape interval for metrics + pub const SCRAPE_INTERVAL: &str = "scrape_interval"; + /// Trace ID field name + pub const TRACE_ID_FIELD_NAME: &str = "trace_id_field_name"; + /// Span ID field name + pub const SPAN_ID_FIELD_NAME: &str = "span_id_field_name"; + /// Toggle ingestion logs + pub const TOGGLE_INGESTION_LOGS: &str = "toggle_ingestion_logs"; + /// Streaming aggregation enabled + pub const STREAMING_AGGREGATION_ENABLED: &str = "streaming_aggregation_enabled"; + /// Enable streaming search + pub const ENABLE_STREAMING_SEARCH: &str = "enable_streaming_search"; + /// Minimum auto refresh interval + pub const MIN_AUTO_REFRESH_INTERVAL: &str = "min_auto_refresh_interval"; + /// Light mode theme color + pub const LIGHT_MODE_THEME_COLOR: &str = "light_mode_theme_color"; + /// Dark mode theme color + pub const DARK_MODE_THEME_COLOR: &str = "dark_mode_theme_color"; +} + +/// A system setting record +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SystemSetting { + /// Unique identifier + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Setting scope (system, org, user) + pub scope: SettingScope, + /// Organization ID (required for org/user scope) + #[serde(skip_serializing_if = "Option::is_none")] + pub org_id: Option, + /// User ID (required for user scope) + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// Setting key identifier + pub setting_key: String, + /// Optional category for grouping + #[serde(skip_serializing_if = "Option::is_none")] + pub setting_category: Option, + /// The setting value (JSON) + pub setting_value: serde_json::Value, + /// Optional description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Created timestamp (microseconds) + #[serde(default)] + pub created_at: i64, + /// Updated timestamp (microseconds) + #[serde(default)] + pub updated_at: i64, + /// User who created this setting + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + /// User who last updated this setting + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, +} + +impl SystemSetting { + /// Create a new system-level setting + pub fn new_system(key: impl Into, value: serde_json::Value) -> Self { + let now = chrono::Utc::now().timestamp_micros(); + Self { + id: None, + scope: SettingScope::System, + org_id: None, + user_id: None, + setting_key: key.into(), + setting_category: None, + setting_value: value, + description: None, + created_at: now, + updated_at: now, + created_by: None, + updated_by: None, + } + } + + /// Create a new org-level setting + pub fn new_org( + org_id: impl Into, + key: impl Into, + value: serde_json::Value, + ) -> Self { + let now = chrono::Utc::now().timestamp_micros(); + Self { + id: None, + scope: SettingScope::Org, + org_id: Some(org_id.into()), + user_id: None, + setting_key: key.into(), + setting_category: None, + setting_value: value, + description: None, + created_at: now, + updated_at: now, + created_by: None, + updated_by: None, + } + } + + /// Create a new user-level setting + pub fn new_user( + org_id: impl Into, + user_id: impl Into, + key: impl Into, + value: serde_json::Value, + ) -> Self { + let now = chrono::Utc::now().timestamp_micros(); + Self { + id: None, + scope: SettingScope::User, + org_id: Some(org_id.into()), + user_id: Some(user_id.into()), + setting_key: key.into(), + setting_category: None, + setting_value: value, + description: None, + created_at: now, + updated_at: now, + created_by: None, + updated_by: None, + } + } + + /// Set the category for this setting + pub fn with_category(mut self, category: SettingCategory) -> Self { + self.setting_category = Some(category.as_str().to_string()); + self + } + + /// Set the description for this setting + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Validate the setting based on scope rules + pub fn validate(&self) -> Result<(), String> { + match self.scope { + SettingScope::System => { + if self.org_id.is_some() || self.user_id.is_some() { + return Err("System-level settings cannot have org_id or user_id".to_string()); + } + } + SettingScope::Org => { + if self.org_id.is_none() { + return Err("Org-level settings require org_id".to_string()); + } + if self.user_id.is_some() { + return Err("Org-level settings cannot have user_id".to_string()); + } + } + SettingScope::User => { + if self.org_id.is_none() { + return Err("User-level settings require org_id".to_string()); + } + if self.user_id.is_none() { + return Err("User-level settings require user_id".to_string()); + } + } + } + Ok(()) + } +} + +/// Request to create or update a setting +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SystemSettingPayload { + /// Setting key + pub setting_key: String, + /// Setting value (JSON) + pub setting_value: serde_json::Value, + /// Optional category + #[serde(skip_serializing_if = "Option::is_none")] + pub setting_category: Option, + /// Optional description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Query parameters for fetching settings +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SystemSettingQuery { + /// Filter by specific key + pub key: Option, + /// Filter by category + pub category: Option, + /// User ID for user-level resolution + pub user_id: Option, + /// Include system-level defaults in response + #[serde(default = "default_include_defaults")] + pub include_defaults: bool, +} + +fn default_include_defaults() -> bool { + true +} + +/// Response containing resolved settings +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ResolvedSettings { + /// The resolved settings (most specific value for each key) + pub settings: std::collections::HashMap, + /// Source information for each setting (which level it came from) + #[serde(skip_serializing_if = "Option::is_none")] + pub sources: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_setting_scope_from_str() { + assert_eq!( + "system".parse::().unwrap(), + SettingScope::System + ); + assert_eq!("org".parse::().unwrap(), SettingScope::Org); + assert_eq!("user".parse::().unwrap(), SettingScope::User); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_system_setting_validation() { + // Valid system setting + let system_setting = SystemSetting::new_system("test_key", serde_json::json!("value")); + assert!(system_setting.validate().is_ok()); + + // Valid org setting + let org_setting = SystemSetting::new_org("default", "test_key", serde_json::json!("value")); + assert!(org_setting.validate().is_ok()); + + // Valid user setting + let user_setting = SystemSetting::new_user( + "default", + "user@example.com", + "test_key", + serde_json::json!("value"), + ); + assert!(user_setting.validate().is_ok()); + + // Invalid: system with org_id + let mut invalid = SystemSetting::new_system("test_key", serde_json::json!("value")); + invalid.org_id = Some("default".to_string()); + assert!(invalid.validate().is_err()); + + // Invalid: org without org_id + let mut invalid = SystemSetting::new_org("default", "test_key", serde_json::json!("value")); + invalid.org_id = None; + assert!(invalid.validate().is_err()); + + // Invalid: user without user_id + let mut invalid = SystemSetting::new_user( + "default", + "user@example.com", + "test_key", + serde_json::json!("value"), + ); + invalid.user_id = None; + assert!(invalid.validate().is_err()); + } +} diff --git a/src/handler/http/request/alerts/deduplication.rs b/src/handler/http/request/alerts/deduplication.rs index f100fa8887..08cee0be0e 100644 --- a/src/handler/http/request/alerts/deduplication.rs +++ b/src/handler/http/request/alerts/deduplication.rs @@ -391,8 +391,10 @@ pub async fn preview_semantic_groups_diff( /// Save semantic field groups for an organization /// -/// Replaces all semantic groups with the provided list. -/// Usually called after user reviews the diff from preview-diff endpoint. +/// Merges provided groups with existing ones: +/// - Groups with matching IDs are updated (replaced) +/// - New groups (no matching ID) are added +/// - Existing groups not in the request are preserved #[cfg(feature = "enterprise")] #[utoipa::path( context_path = "/api", @@ -402,7 +404,7 @@ pub async fn preview_semantic_groups_diff( params( ("org_id" = String, Path, description = "Organization ID"), ), - request_body(content = Vec, description = "Semantic groups to save", content_type = "application/json"), + request_body(content = Vec, description = "Semantic groups to save (merged with existing)", content_type = "application/json"), responses( (status = 200, description = "Success"), (status = 400, description = "Bad request - Invalid semantic groups"), @@ -415,10 +417,10 @@ pub async fn save_semantic_groups( groups: web::Json>, ) -> Result { let org_id = org_id.into_inner(); - let new_groups = groups.into_inner(); + let incoming_groups = groups.into_inner(); // Validate all group IDs - for group in &new_groups { + for group in &incoming_groups { if !SemanticFieldGroup::validate_id(&group.id) { return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Invalid semantic group ID: '{}'. IDs must be lowercase, alphanumeric, dash-separated", group.id) @@ -430,7 +432,7 @@ pub async fn save_semantic_groups( let mut config = match crate::service::alerts::org_config::get_deduplication_config(&org_id).await { Ok(Some(cfg)) => cfg, - Ok(None) => GlobalDeduplicationConfig::default(), + Ok(None) => GlobalDeduplicationConfig::default_with_presets(), Err(e) => { log::error!( "Error getting deduplication config for org {}: {}", @@ -443,14 +445,43 @@ pub async fn save_semantic_groups( } }; - // Replace semantic groups - config.semantic_field_groups = new_groups; + // Merge incoming groups with existing ones + // Build a map of incoming groups by ID for quick lookup + let incoming_map: std::collections::HashMap = incoming_groups + .into_iter() + .map(|g| (g.id.clone(), g)) + .collect(); + + // Update existing groups if they're in incoming, keep others unchanged + let mut merged_groups: Vec = config + .semantic_field_groups + .into_iter() + .map(|existing| { + if let Some(updated) = incoming_map.get(&existing.id) { + updated.clone() + } else { + existing + } + }) + .collect(); + + // Add new groups that don't exist in current config + let existing_ids: std::collections::HashSet = + merged_groups.iter().map(|g| g.id.clone()).collect(); + for (id, group) in incoming_map { + if !existing_ids.contains(&id) { + merged_groups.push(group); + } + } + + let total_groups = merged_groups.len(); + config.semantic_field_groups = merged_groups; // Save config match crate::service::alerts::org_config::set_deduplication_config(&org_id, &config).await { Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({ "message": "Semantic groups saved successfully", - "total_groups": config.semantic_field_groups.len() + "total_groups": total_groups }))), Err(e) => { log::error!("Error saving semantic groups for org {}: {}", org_id, e); diff --git a/src/handler/http/request/organization/mod.rs b/src/handler/http/request/organization/mod.rs index 3fda3fdfad..d4204f40e5 100644 --- a/src/handler/http/request/organization/mod.rs +++ b/src/handler/http/request/organization/mod.rs @@ -15,3 +15,4 @@ pub mod es; pub mod org; pub mod settings; +pub mod system_settings; diff --git a/src/handler/http/request/organization/system_settings.rs b/src/handler/http/request/organization/system_settings.rs new file mode 100644 index 0000000000..33c9b3b42e --- /dev/null +++ b/src/handler/http/request/organization/system_settings.rs @@ -0,0 +1,276 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! System Settings HTTP Handler +//! +//! Provides REST API endpoints for multi-level system settings. +//! Settings resolution order (most specific wins): User -> Org -> System + +use std::io::Error as StdErr; + +use actix_web::{HttpResponse, delete, get, post, web}; +use config::meta::system_settings::{ + SettingScope, SystemSetting, SystemSettingPayload, SystemSettingQuery, +}; + +use crate::{common::meta::http::HttpResponse as MetaHttpResponse, service::db::system_settings}; + +/// Get a specific system setting with resolution (user -> org -> system) +#[utoipa::path( + context_path = "/api", + tag = "Organizations", + operation_id = "SystemSettingGetResolved", + summary = "Get resolved system setting", + description = "Retrieves a setting value with multi-level resolution. Checks user-level first, \ + then org-level, then system-level defaults. Returns the most specific setting found.", + security( + ("Authorization"= []) + ), + params( + ("org_id" = String, Path, description = "Organization name"), + ("key" = String, Path, description = "Setting key"), + ("user_id" = Option, Query, description = "User ID for user-level resolution"), + ), + responses( + (status = 200, description = "Success", content_type = "application/json", body = SystemSetting), + (status = 404, description = "Not Found", content_type = "application/json", body = ()), + (status = 400, description = "Failure", content_type = "application/json", body = ()), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "Settings", "operation": "get"})) + ) +)] +#[get("/{org_id}/settings/v2/{key}")] +async fn get_setting( + path: web::Path<(String, String)>, + query: web::Query, +) -> Result { + let (org_id, key) = path.into_inner(); + let user_id = query.user_id.as_deref(); + + match system_settings::get_resolved(Some(&org_id), user_id, &key).await { + Ok(Some(setting)) => Ok(HttpResponse::Ok().json(setting)), + Ok(None) => Ok(MetaHttpResponse::not_found("Setting not found")), + Err(e) => Ok(MetaHttpResponse::bad_request(e.to_string().as_str())), + } +} + +/// List all resolved settings for an organization/user +#[utoipa::path( + context_path = "/api", + tag = "Organizations", + operation_id = "SystemSettingListResolved", + summary = "List all resolved system settings", + description = "Lists all settings with multi-level resolution applied. Merges system, org, and user \ + levels, returning the most specific value for each key.", + security( + ("Authorization"= []) + ), + params( + ("org_id" = String, Path, description = "Organization name"), + ("user_id" = Option, Query, description = "User ID for user-level resolution"), + ("category" = Option, Query, description = "Filter by setting category"), + ), + responses( + (status = 200, description = "Success", content_type = "application/json", body = Object), + (status = 400, description = "Failure", content_type = "application/json", body = ()), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "Settings", "operation": "list"})) + ) +)] +#[get("/{org_id}/settings/v2")] +async fn list_settings( + path: web::Path, + query: web::Query, +) -> Result { + let org_id = path.into_inner(); + let user_id = query.user_id.as_deref(); + let category = query.category.as_deref(); + + match system_settings::list_resolved(Some(&org_id), user_id, category).await { + Ok(settings) => Ok(HttpResponse::Ok().json(settings)), + Err(e) => Ok(MetaHttpResponse::bad_request(e.to_string().as_str())), + } +} + +/// Create or update a setting at org level +#[utoipa::path( + context_path = "/api", + tag = "Organizations", + operation_id = "SystemSettingSetOrg", + summary = "Set organization-level setting", + description = "Creates or updates an organization-level setting. This setting applies to all users \ + in the organization unless overridden at the user level.", + security( + ("Authorization"= []) + ), + params( + ("org_id" = String, Path, description = "Organization name"), + ), + request_body(content = SystemSettingPayload, description = "Setting to create/update", content_type = "application/json"), + responses( + (status = 200, description = "Success", content_type = "application/json", body = SystemSetting), + (status = 400, description = "Failure", content_type = "application/json", body = ()), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "Settings", "operation": "create"})) + ) +)] +#[post("/{org_id}/settings/v2")] +async fn set_org_setting( + path: web::Path, + payload: web::Json, +) -> Result { + let org_id = path.into_inner(); + let payload = payload.into_inner(); + + let mut setting = SystemSetting::new_org(&org_id, &payload.setting_key, payload.setting_value); + if let Some(cat) = payload.setting_category.as_deref() { + setting.setting_category = Some(cat.to_string()); + } + if let Some(desc) = payload.description.as_deref() { + setting.description = Some(desc.to_string()); + } + + match system_settings::set(&setting).await { + Ok(result) => Ok(HttpResponse::Ok().json(result)), + Err(e) => Ok(MetaHttpResponse::bad_request(e.to_string().as_str())), + } +} + +/// Create or update a setting at user level +#[utoipa::path( + context_path = "/api", + tag = "Organizations", + operation_id = "SystemSettingSetUser", + summary = "Set user-level setting", + description = "Creates or updates a user-level setting. This setting applies only to the specific \ + user and overrides org-level and system-level settings.", + security( + ("Authorization"= []) + ), + params( + ("org_id" = String, Path, description = "Organization name"), + ("user_id" = String, Path, description = "User ID"), + ), + request_body(content = SystemSettingPayload, description = "Setting to create/update", content_type = "application/json"), + responses( + (status = 200, description = "Success", content_type = "application/json", body = SystemSetting), + (status = 400, description = "Failure", content_type = "application/json", body = ()), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "Settings", "operation": "create"})) + ) +)] +#[post("/{org_id}/settings/v2/user/{user_id}")] +async fn set_user_setting( + path: web::Path<(String, String)>, + payload: web::Json, +) -> Result { + let (org_id, user_id) = path.into_inner(); + let payload = payload.into_inner(); + + let mut setting = SystemSetting::new_user( + &org_id, + &user_id, + &payload.setting_key, + payload.setting_value, + ); + if let Some(cat) = payload.setting_category.as_deref() { + setting.setting_category = Some(cat.to_string()); + } + if let Some(desc) = payload.description.as_deref() { + setting.description = Some(desc.to_string()); + } + + match system_settings::set(&setting).await { + Ok(result) => Ok(HttpResponse::Ok().json(result)), + Err(e) => Ok(MetaHttpResponse::bad_request(e.to_string().as_str())), + } +} + +/// Delete an organization-level setting +#[utoipa::path( + context_path = "/api", + tag = "Organizations", + operation_id = "SystemSettingDeleteOrg", + summary = "Delete organization-level setting", + description = "Deletes an organization-level setting. After deletion, queries for this key will \ + fall back to system-level defaults.", + security( + ("Authorization"= []) + ), + params( + ("org_id" = String, Path, description = "Organization name"), + ("key" = String, Path, description = "Setting key"), + ), + responses( + (status = 200, description = "Success", content_type = "application/json", body = Object), + (status = 404, description = "Not Found", content_type = "application/json", body = ()), + (status = 400, description = "Failure", content_type = "application/json", body = ()), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "Settings", "operation": "delete"})) + ) +)] +#[delete("/{org_id}/settings/v2/{key}")] +async fn delete_org_setting(path: web::Path<(String, String)>) -> Result { + let (org_id, key) = path.into_inner(); + + match system_settings::delete(&SettingScope::Org, Some(&org_id), None, &key).await { + Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({"deleted": true}))), + Ok(false) => Ok(MetaHttpResponse::not_found("Setting not found")), + Err(e) => Ok(MetaHttpResponse::bad_request(e.to_string().as_str())), + } +} + +/// Delete a user-level setting +#[utoipa::path( + context_path = "/api", + tag = "Organizations", + operation_id = "SystemSettingDeleteUser", + summary = "Delete user-level setting", + description = "Deletes a user-level setting. After deletion, queries for this key will fall back \ + to org-level or system-level settings.", + security( + ("Authorization"= []) + ), + params( + ("org_id" = String, Path, description = "Organization name"), + ("user_id" = String, Path, description = "User ID"), + ("key" = String, Path, description = "Setting key"), + ), + responses( + (status = 200, description = "Success", content_type = "application/json", body = Object), + (status = 404, description = "Not Found", content_type = "application/json", body = ()), + (status = 400, description = "Failure", content_type = "application/json", body = ()), + ), + extensions( + ("x-o2-ratelimit" = json!({"module": "Settings", "operation": "delete"})) + ) +)] +#[delete("/{org_id}/settings/v2/user/{user_id}/{key}")] +async fn delete_user_setting( + path: web::Path<(String, String, String)>, +) -> Result { + let (org_id, user_id, key) = path.into_inner(); + + match system_settings::delete(&SettingScope::User, Some(&org_id), Some(&user_id), &key).await { + Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({"deleted": true}))), + Ok(false) => Ok(MetaHttpResponse::not_found("Setting not found")), + Err(e) => Ok(MetaHttpResponse::bad_request(e.to_string().as_str())), + } +} diff --git a/src/handler/http/request/service_streams/mod.rs b/src/handler/http/request/service_streams/mod.rs index 2e548cf277..e6f268d747 100644 --- a/src/handler/http/request/service_streams/mod.rs +++ b/src/handler/http/request/service_streams/mod.rs @@ -183,9 +183,70 @@ pub struct CorrelationRequest { pub available_dimensions: std::collections::HashMap, } +/// GET /api/{org_id}/service_streams/_grouped +/// +/// Get services grouped by their Fully Qualified Name (FQN) +/// +/// This endpoint is used by the Correlation Settings UI to display +/// which services are correlated together via their shared FQN. +/// +/// Response includes: +/// - Services grouped by FQN +/// - Each group shows which services share the FQN +/// - Stream counts per group (logs/traces/metrics) +/// - Whether each group has full telemetry coverage +#[utoipa::path( + get, + path = "/{org_id}/service_streams/_grouped", + tag = "Service Streams", + params( + ("org_id" = String, Path, description = "Organization ID") + ), + responses( + (status = 200, description = "Services grouped by FQN", body = GroupedServicesResponse), + (status = 401, description = "Unauthorized - Authentication required"), + (status = 403, description = "Forbidden - Enterprise feature"), + (status = 500, description = "Internal server error") + ), + security( + ("Authorization" = []) + ) +)] +#[get("/{org_id}/service_streams/_grouped")] +pub async fn get_services_grouped( + org_id: web::Path, + Headers(_user_email): Headers, // Require authentication +) -> Result { + let org_id = org_id.into_inner(); + + #[cfg(feature = "enterprise")] + { + match o2_enterprise::enterprise::service_streams::storage::ServiceStorage::list_grouped_by_fqn(&org_id) + .await + { + Ok(response) => Ok(MetaHttpResponse::json(response)), + Err(e) => Ok( + HttpResponse::InternalServerError().json(MetaHttpResponse::error( + 500u16, + format!("Failed to get grouped services: {}", e), + )), + ), + } + } + + #[cfg(not(feature = "enterprise"))] + { + Ok(HttpResponse::Forbidden().json(MetaHttpResponse::error( + 403u16, + "Service Discovery is an enterprise-only feature".to_string(), + ))) + } +} + // Re-export shared types from config for API documentation (utoipa) // These types are the same for both enterprise and non-enterprise builds pub use config::meta::service_streams::{ CardinalityClass, CorrelationResponse, DimensionAnalytics, DimensionAnalyticsSummary, - RelatedStreams, StreamInfo, + GroupedServicesResponse, RelatedStreams, ServiceFqnGroup, ServiceInGroup, ServiceStreams, + StreamInfo, StreamSummary, }; diff --git a/src/handler/http/request/status/mod.rs b/src/handler/http/request/status/mod.rs index 3cd74e6b9f..156688a110 100644 --- a/src/handler/http/request/status/mod.rs +++ b/src/handler/http/request/status/mod.rs @@ -163,6 +163,12 @@ struct ConfigResponse<'a> { service_streams_enabled: bool, #[cfg(not(feature = "enterprise"))] service_streams_enabled: bool, + /// Available FQN priority dimensions from O2_FQN_PRIORITY_DIMENSIONS env var + /// Used by UI to populate the FQN priority dimension selector + #[cfg(feature = "enterprise")] + fqn_priority_dimensions: Vec, + #[cfg(not(feature = "enterprise"))] + fqn_priority_dimensions: Vec, } #[derive(Serialize, serde::Deserialize)] @@ -430,6 +436,12 @@ pub async fn zo_config() -> Result { mysql_deprecated_warning: cfg.common.meta_store.starts_with("mysql"), service_graph_enabled, service_streams_enabled, + #[cfg(feature = "enterprise")] + fqn_priority_dimensions: o2_enterprise::enterprise::common::config::get_config() + .service_streams + .get_fqn_priority_dimensions(), + #[cfg(not(feature = "enterprise"))] + fqn_priority_dimensions: vec![], })) } diff --git a/src/handler/http/router/mod.rs b/src/handler/http/router/mod.rs index 31736b29a0..a5098cbba2 100644 --- a/src/handler/http/router/mod.rs +++ b/src/handler/http/router/mod.rs @@ -406,6 +406,13 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) { .service(organization::settings::delete_logo) .service(organization::settings::set_logo_text) .service(organization::settings::delete_logo_text) + // System settings v2 (multi-level) + .service(organization::system_settings::get_setting) + .service(organization::system_settings::list_settings) + .service(organization::system_settings::set_org_setting) + .service(organization::system_settings::set_user_setting) + .service(organization::system_settings::delete_org_setting) + .service(organization::system_settings::delete_user_setting) .service(organization::org::org_summary) .service(organization::org::get_user_passcode) .service(organization::org::update_user_passcode) @@ -646,7 +653,8 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) { .service(alerts::deduplication::preview_semantic_groups_diff) .service(alerts::deduplication::save_semantic_groups) .service(service_streams::get_dimension_analytics) - .service(service_streams::correlate_streams); + .service(service_streams::correlate_streams) + .service(service_streams::get_services_grouped); #[cfg(not(feature = "enterprise"))] let service = service diff --git a/src/handler/http/router/openapi.rs b/src/handler/http/router/openapi.rs index 4fbd016148..409a388518 100644 --- a/src/handler/http/router/openapi.rs +++ b/src/handler/http/router/openapi.rs @@ -40,6 +40,12 @@ use crate::{common::meta, handler::http::request}; request::organization::org::create_user_rumtoken, request::organization::settings::get, request::organization::settings::create, + request::organization::system_settings::get_setting, + request::organization::system_settings::list_settings, + request::organization::system_settings::set_org_setting, + request::organization::system_settings::set_user_setting, + request::organization::system_settings::delete_org_setting, + request::organization::system_settings::delete_user_setting, request::stream::list, request::stream::schema, request::stream::create, diff --git a/src/infra/src/table/entity/mod.rs b/src/infra/src/table/entity/mod.rs index adb531baf1..c2a8f7cae9 100644 --- a/src/infra/src/table/entity/mod.rs +++ b/src/infra/src/table/entity/mod.rs @@ -26,6 +26,7 @@ pub mod search_jobs; pub mod search_queue; pub mod sessions; pub mod system_prompts; +pub mod system_settings; pub mod templates; pub mod timed_annotation_panels; pub mod timed_annotations; diff --git a/src/infra/src/table/entity/system_settings.rs b/src/infra/src/table/entity/system_settings.rs new file mode 100644 index 0000000000..0d321fd418 --- /dev/null +++ b/src/infra/src/table/entity/system_settings.rs @@ -0,0 +1,41 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! SeaORM Entity for system_settings table + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "system_settings")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub scope: String, + pub org_id: Option, + pub user_id: Option, + pub setting_key: String, + pub setting_category: Option, + pub setting_value: String, // JSON stored as text + pub description: Option, + pub created_at: i64, + pub updated_at: i64, + pub created_by: Option, + pub updated_by: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/infra/src/table/migration/m20251207_000001_create_system_settings_table.rs b/src/infra/src/table/migration/m20251207_000001_create_system_settings_table.rs new file mode 100644 index 0000000000..f6f2e86b47 --- /dev/null +++ b/src/infra/src/table/migration/m20251207_000001_create_system_settings_table.rs @@ -0,0 +1,259 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use sea_orm::DbBackend; +use sea_orm_migration::prelude::*; + +use super::get_text_type; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table(create_system_settings_table(manager.get_database_backend())) + .await?; + + // Create indexes + manager.create_index(create_scope_index()).await?; + + manager.create_index(create_org_index()).await?; + + manager.create_index(create_user_index()).await?; + + manager.create_index(create_key_index()).await?; + + manager.create_index(create_category_index()).await?; + + // Create unique constraint index + manager.create_index(create_unique_setting_index()).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_system_settings_unique") + .table(SystemSettings::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_system_settings_category") + .table(SystemSettings::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_system_settings_key") + .table(SystemSettings::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_system_settings_user") + .table(SystemSettings::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_system_settings_org") + .table(SystemSettings::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_system_settings_scope") + .table(SystemSettings::Table) + .to_owned(), + ) + .await?; + + manager + .drop_table(Table::drop().table(SystemSettings::Table).to_owned()) + .await + } +} + +fn create_system_settings_table(backend: DbBackend) -> TableCreateStatement { + let text_type = get_text_type(); + + let mut table = Table::create() + .table(SystemSettings::Table) + .if_not_exists() + .col( + ColumnDef::new(SystemSettings::Id) + .big_integer() + .auto_increment() + .primary_key() + .not_null(), + ) + .col( + ColumnDef::new(SystemSettings::Scope) + .string_len(20) + .not_null(), + ) + .col(ColumnDef::new(SystemSettings::OrgId).string_len(100).null()) + .col( + ColumnDef::new(SystemSettings::UserId) + .string_len(256) + .null(), + ) + .col( + ColumnDef::new(SystemSettings::SettingKey) + .string_len(255) + .not_null(), + ) + .col( + ColumnDef::new(SystemSettings::SettingCategory) + .string_len(100) + .null(), + ) + .col( + ColumnDef::new(SystemSettings::Description) + .custom(Alias::new(&text_type)) + .null(), + ) + .col( + ColumnDef::new(SystemSettings::CreatedAt) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(SystemSettings::UpdatedAt) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(SystemSettings::CreatedBy) + .string_len(256) + .null(), + ) + .col( + ColumnDef::new(SystemSettings::UpdatedBy) + .string_len(256) + .null(), + ) + .to_owned(); + + // Use JSON type for PostgreSQL, TEXT for others + match backend { + DbBackend::Postgres => { + table.col( + ColumnDef::new(SystemSettings::SettingValue) + .json_binary() + .not_null(), + ); + } + _ => { + table.col( + ColumnDef::new(SystemSettings::SettingValue) + .custom(Alias::new(&text_type)) + .not_null(), + ); + } + } + + table +} + +fn create_scope_index() -> IndexCreateStatement { + Index::create() + .name("idx_system_settings_scope") + .table(SystemSettings::Table) + .col(SystemSettings::Scope) + .to_owned() +} + +fn create_org_index() -> IndexCreateStatement { + Index::create() + .name("idx_system_settings_org") + .table(SystemSettings::Table) + .col(SystemSettings::OrgId) + .to_owned() +} + +fn create_user_index() -> IndexCreateStatement { + Index::create() + .name("idx_system_settings_user") + .table(SystemSettings::Table) + .col(SystemSettings::OrgId) + .col(SystemSettings::UserId) + .to_owned() +} + +fn create_key_index() -> IndexCreateStatement { + Index::create() + .name("idx_system_settings_key") + .table(SystemSettings::Table) + .col(SystemSettings::SettingKey) + .to_owned() +} + +fn create_category_index() -> IndexCreateStatement { + Index::create() + .name("idx_system_settings_category") + .table(SystemSettings::Table) + .col(SystemSettings::SettingCategory) + .to_owned() +} + +fn create_unique_setting_index() -> IndexCreateStatement { + Index::create() + .name("idx_system_settings_unique") + .table(SystemSettings::Table) + .col(SystemSettings::Scope) + .col(SystemSettings::OrgId) + .col(SystemSettings::UserId) + .col(SystemSettings::SettingKey) + .unique() + .to_owned() +} + +#[derive(DeriveIden)] +enum SystemSettings { + Table, + Id, + Scope, + OrgId, + UserId, + SettingKey, + SettingCategory, + SettingValue, + Description, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, +} diff --git a/src/infra/src/table/migration/mod.rs b/src/infra/src/table/migration/mod.rs index 06006d1734..6c91876760 100644 --- a/src/infra/src/table/migration/mod.rs +++ b/src/infra/src/table/migration/mod.rs @@ -76,6 +76,7 @@ mod m20251118_000002_create_sessions_table; mod m20251118_000003_delete_meta_sessions; mod m20251126_100001_create_service_streams_table; mod m20251126_100002_create_service_streams_dimensions_table; +mod m20251207_000001_create_system_settings_table; pub struct Migrator; @@ -141,6 +142,7 @@ impl MigratorTrait for Migrator { Box::new(m20251118_000003_delete_meta_sessions::Migration), Box::new(m20251126_100001_create_service_streams_table::Migration), Box::new(m20251126_100002_create_service_streams_dimensions_table::Migration), + Box::new(m20251207_000001_create_system_settings_table::Migration), ] } } diff --git a/src/infra/src/table/mod.rs b/src/infra/src/table/mod.rs index 1081aa78c4..d8b94e19bf 100644 --- a/src/infra/src/table/mod.rs +++ b/src/infra/src/table/mod.rs @@ -46,6 +46,7 @@ pub mod service_streams_dimensions; pub mod sessions; pub mod short_urls; pub mod system_prompts; +pub mod system_settings; pub mod templates; pub mod timed_annotation_panels; pub mod timed_annotations; diff --git a/src/infra/src/table/service_streams.rs b/src/infra/src/table/service_streams.rs index 99f60e3113..78404a01de 100644 --- a/src/infra/src/table/service_streams.rs +++ b/src/infra/src/table/service_streams.rs @@ -48,7 +48,7 @@ pub struct Model { #[sea_orm(column_type = "String(StringLen::N(128))")] pub org_id: String, - #[sea_orm(column_type = "String(StringLen::N(512))")] + #[sea_orm(column_type = "String(StringLen::N(2048))")] pub service_key: String, /// Correlation key (hash of stable dimensions only) diff --git a/src/infra/src/table/system_settings.rs b/src/infra/src/table/system_settings.rs new file mode 100644 index 0000000000..2538e96ac4 --- /dev/null +++ b/src/infra/src/table/system_settings.rs @@ -0,0 +1,377 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! System Settings Table Operations +//! +//! Provides CRUD operations for the multi-level settings system. +//! Settings resolution order (most specific wins): +//! 1. User level (if user_id provided) +//! 2. Org level (if org_id provided) +//! 3. System level (global defaults) + +use anyhow::anyhow; +use config::meta::system_settings::{SettingScope, SystemSetting}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, +}; + +use crate::{ + db::{ORM_CLIENT, connect_to_orm}, + table::entity::system_settings::{ActiveModel, Column, Entity, Model}, +}; + +/// Get a single setting by scope, org_id, user_id, and key +pub async fn get( + scope: &SettingScope, + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> Result, anyhow::Error> { + let client = ORM_CLIENT.get_or_init(connect_to_orm).await; + + let mut query = Entity::find() + .filter(Column::Scope.eq(scope.as_str())) + .filter(Column::SettingKey.eq(key)); + + match scope { + SettingScope::System => { + query = query + .filter(Column::OrgId.is_null()) + .filter(Column::UserId.is_null()); + } + SettingScope::Org => { + if let Some(org) = org_id { + query = query.filter(Column::OrgId.eq(org)); + } else { + return Err(anyhow!("org_id required for org scope")); + } + query = query.filter(Column::UserId.is_null()); + } + SettingScope::User => { + if let Some(org) = org_id { + query = query.filter(Column::OrgId.eq(org)); + } else { + return Err(anyhow!("org_id required for user scope")); + } + if let Some(user) = user_id { + query = query.filter(Column::UserId.eq(user)); + } else { + return Err(anyhow!("user_id required for user scope")); + } + } + } + + let result = query + .one(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(result.map(model_to_setting)) +} + +/// Get a resolved setting value by checking all levels (user -> org -> system) +/// Returns the most specific setting found +pub async fn get_resolved( + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> Result, anyhow::Error> { + // Check user level first if user_id provided + if let (Some(org), Some(user)) = (org_id, user_id) + && let Some(setting) = get(&SettingScope::User, Some(org), Some(user), key).await? + { + return Ok(Some(setting)); + } + + // Check org level if org_id provided + if let Some(org) = org_id + && let Some(setting) = get(&SettingScope::Org, Some(org), None, key).await? + { + return Ok(Some(setting)); + } + + // Check system level + get(&SettingScope::System, None, None, key).await +} + +/// List all settings for a given scope +pub async fn list( + scope: Option<&SettingScope>, + org_id: Option<&str>, + user_id: Option<&str>, + category: Option<&str>, +) -> Result, anyhow::Error> { + let client = ORM_CLIENT.get_or_init(connect_to_orm).await; + + let mut query = Entity::find().order_by_asc(Column::SettingKey); + + if let Some(scope) = scope { + query = query.filter(Column::Scope.eq(scope.as_str())); + } + + if let Some(org) = org_id { + query = query.filter(Column::OrgId.eq(org)); + } + + if let Some(user) = user_id { + query = query.filter(Column::UserId.eq(user)); + } + + if let Some(cat) = category { + query = query.filter(Column::SettingCategory.eq(cat)); + } + + let results = query + .all(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(results.into_iter().map(model_to_setting).collect()) +} + +/// List all resolved settings for org/user (merging all levels) +pub async fn list_resolved( + org_id: Option<&str>, + user_id: Option<&str>, + category: Option<&str>, +) -> Result, anyhow::Error> { + let mut resolved = std::collections::HashMap::new(); + + // Get system-level settings first (lowest priority) + let system_settings = list(Some(&SettingScope::System), None, None, category).await?; + for setting in system_settings { + resolved.insert(setting.setting_key.clone(), setting); + } + + // Override with org-level settings if org_id provided + if let Some(org) = org_id { + let org_settings = list(Some(&SettingScope::Org), Some(org), None, category).await?; + for setting in org_settings { + resolved.insert(setting.setting_key.clone(), setting); + } + } + + // Override with user-level settings if both org_id and user_id provided + if let (Some(org), Some(user)) = (org_id, user_id) { + let user_settings = + list(Some(&SettingScope::User), Some(org), Some(user), category).await?; + for setting in user_settings { + resolved.insert(setting.setting_key.clone(), setting); + } + } + + Ok(resolved) +} + +/// Create or update a setting (upsert) +pub async fn set(setting: &SystemSetting) -> Result { + setting.validate().map_err(|e| anyhow!(e))?; + + let client = ORM_CLIENT.get_or_init(connect_to_orm).await; + let now = chrono::Utc::now().timestamp_micros(); + + // Check if setting already exists + let existing = get( + &setting.scope, + setting.org_id.as_deref(), + setting.user_id.as_deref(), + &setting.setting_key, + ) + .await?; + + if let Some(existing) = existing { + // Update existing + let active_model = ActiveModel { + id: Set(existing.id.unwrap()), + scope: Set(setting.scope.as_str().to_string()), + org_id: Set(setting.org_id.clone()), + user_id: Set(setting.user_id.clone()), + setting_key: Set(setting.setting_key.clone()), + setting_category: Set(setting.setting_category.clone()), + setting_value: Set(setting.setting_value.to_string()), + description: Set(setting.description.clone()), + created_at: Set(existing.created_at), + updated_at: Set(now), + created_by: Set(existing.created_by.clone()), + updated_by: Set(setting.updated_by.clone()), + }; + + let result = active_model + .update(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(model_to_setting(result)) + } else { + // Insert new + let active_model = ActiveModel { + id: Set(0), // Auto-increment + scope: Set(setting.scope.as_str().to_string()), + org_id: Set(setting.org_id.clone()), + user_id: Set(setting.user_id.clone()), + setting_key: Set(setting.setting_key.clone()), + setting_category: Set(setting.setting_category.clone()), + setting_value: Set(setting.setting_value.to_string()), + description: Set(setting.description.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(setting.created_by.clone()), + updated_by: Set(setting.updated_by.clone()), + }; + + let result = active_model + .insert(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(model_to_setting(result)) + } +} + +/// Delete a setting +pub async fn delete( + scope: &SettingScope, + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> Result { + let client = ORM_CLIENT.get_or_init(connect_to_orm).await; + + let mut query = Entity::delete_many() + .filter(Column::Scope.eq(scope.as_str())) + .filter(Column::SettingKey.eq(key)); + + match scope { + SettingScope::System => { + query = query + .filter(Column::OrgId.is_null()) + .filter(Column::UserId.is_null()); + } + SettingScope::Org => { + if let Some(org) = org_id { + query = query.filter(Column::OrgId.eq(org)); + } else { + return Err(anyhow!("org_id required for org scope")); + } + query = query.filter(Column::UserId.is_null()); + } + SettingScope::User => { + if let Some(org) = org_id { + query = query.filter(Column::OrgId.eq(org)); + } else { + return Err(anyhow!("org_id required for user scope")); + } + if let Some(user) = user_id { + query = query.filter(Column::UserId.eq(user)); + } else { + return Err(anyhow!("user_id required for user scope")); + } + } + } + + let result = query + .exec(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(result.rows_affected > 0) +} + +/// Delete all settings for an organization +pub async fn delete_org_settings(org_id: &str) -> Result { + let client = ORM_CLIENT.get_or_init(connect_to_orm).await; + + let result = Entity::delete_many() + .filter(Column::OrgId.eq(org_id)) + .exec(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(result.rows_affected) +} + +/// Delete all settings for a user in an organization +pub async fn delete_user_settings(org_id: &str, user_id: &str) -> Result { + let client = ORM_CLIENT.get_or_init(connect_to_orm).await; + + let result = Entity::delete_many() + .filter(Column::OrgId.eq(org_id)) + .filter(Column::UserId.eq(user_id)) + .exec(client) + .await + .map_err(|e| anyhow!("DbError# {e}"))?; + + Ok(result.rows_affected) +} + +/// Convert DB model to domain type +fn model_to_setting(model: Model) -> SystemSetting { + let setting_value = serde_json::from_str(&model.setting_value) + .unwrap_or(serde_json::Value::String(model.setting_value.clone())); + + SystemSetting { + id: Some(model.id), + scope: model.scope.parse().unwrap_or(SettingScope::System), + org_id: model.org_id, + user_id: model.user_id, + setting_key: model.setting_key, + setting_category: model.setting_category, + setting_value, + description: model.description, + created_at: model.created_at, + updated_at: model.updated_at, + created_by: model.created_by, + updated_by: model.updated_by, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_setting_scope_conversion() { + assert_eq!(SettingScope::System.as_str(), "system"); + assert_eq!(SettingScope::Org.as_str(), "org"); + assert_eq!(SettingScope::User.as_str(), "user"); + } + + #[test] + fn test_model_to_setting() { + let model = Model { + id: 1, + scope: "org".to_string(), + org_id: Some("default".to_string()), + user_id: None, + setting_key: "test_key".to_string(), + setting_category: Some("correlation".to_string()), + setting_value: r#"["a","b","c"]"#.to_string(), + description: Some("Test setting".to_string()), + created_at: 1000000, + updated_at: 2000000, + created_by: Some("admin".to_string()), + updated_by: Some("admin".to_string()), + }; + + let setting = model_to_setting(model); + + assert_eq!(setting.id, Some(1)); + assert_eq!(setting.scope, SettingScope::Org); + assert_eq!(setting.org_id, Some("default".to_string())); + assert_eq!(setting.user_id, None); + assert_eq!(setting.setting_key, "test_key"); + assert_eq!(setting.setting_value, serde_json::json!(["a", "b", "c"])); + } +} diff --git a/src/service/alerts/org_config.rs b/src/service/alerts/org_config.rs index 515b5e4f65..61d493de0a 100644 --- a/src/service/alerts/org_config.rs +++ b/src/service/alerts/org_config.rs @@ -95,6 +95,7 @@ pub async fn get_semantic_groups(org_id: &str) -> Result alert_dedup_enabled: false, alert_fingerprint_groups: vec![], time_window_minutes: None, + fqn_priority_dimensions: GlobalDeduplicationConfig::default_fqn_priority(), }; // Try to save, but don't fail if it doesn't work @@ -137,6 +138,7 @@ pub async fn initialize_semantic_groups(org_id: &str) -> Result<(), anyhow::Erro alert_dedup_enabled: false, alert_fingerprint_groups: vec![], time_window_minutes: None, + fqn_priority_dimensions: GlobalDeduplicationConfig::default_fqn_priority(), }; set_deduplication_config(org_id, &config).await?; diff --git a/src/service/db/mod.rs b/src/service/db/mod.rs index a7059a69a0..2f677517ec 100644 --- a/src/service/db/mod.rs +++ b/src/service/db/mod.rs @@ -50,6 +50,7 @@ pub mod schema; pub mod search_job; pub mod session; pub mod short_url; +pub mod system_settings; pub mod user; pub(crate) use infra_db::{Event, NEED_WATCH, NO_NEED_WATCH, get_coordinator}; diff --git a/src/service/db/system_settings.rs b/src/service/db/system_settings.rs new file mode 100644 index 0000000000..add7c1e0d7 --- /dev/null +++ b/src/service/db/system_settings.rs @@ -0,0 +1,300 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! System Settings Service Layer +//! +//! Provides cached access to system settings with multi-level resolution. +//! Cache is populated on startup and updated on write operations. + +use config::meta::system_settings::{SettingScope, SystemSetting}; +use infra::{errors::Result, table::system_settings as db}; + +use crate::common::infra::config::SYSTEM_SETTINGS; + +/// Generate a cache key for a system setting +fn cache_key( + scope: &SettingScope, + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> String { + let org = org_id.unwrap_or("_"); + let user = user_id.unwrap_or("_"); + format!("{}:{}:{}:{}", scope.as_str(), org, user, key) +} + +/// Get a single setting from cache or database +pub async fn get( + scope: &SettingScope, + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> Result> { + let cache_k = cache_key(scope, org_id, user_id, key); + + // Check cache first + if let Some(setting) = SYSTEM_SETTINGS.read().await.get(&cache_k) { + return Ok(Some(setting.clone())); + } + + // Get from database + let setting = db::get(scope, org_id, user_id, key) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string()))?; + + // Cache the result if found + if let Some(ref s) = setting { + SYSTEM_SETTINGS.write().await.insert(cache_k, s.clone()); + } + + Ok(setting) +} + +/// Get a resolved setting by checking all levels (user -> org -> system) +/// Returns the most specific setting found +pub async fn get_resolved( + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> Result> { + // Check user level first if user_id provided + if let (Some(org), Some(user)) = (org_id, user_id) + && let Some(setting) = get(&SettingScope::User, Some(org), Some(user), key).await? + { + return Ok(Some(setting)); + } + + // Check org level if org_id provided + if let Some(org) = org_id + && let Some(setting) = get(&SettingScope::Org, Some(org), None, key).await? + { + return Ok(Some(setting)); + } + + // Check system level + get(&SettingScope::System, None, None, key).await +} + +/// List all settings for a given scope +pub async fn list( + scope: Option<&SettingScope>, + org_id: Option<&str>, + user_id: Option<&str>, + category: Option<&str>, +) -> Result> { + db::list(scope, org_id, user_id, category) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string())) +} + +/// List all resolved settings for org/user (merging all levels) +pub async fn list_resolved( + org_id: Option<&str>, + user_id: Option<&str>, + category: Option<&str>, +) -> Result> { + db::list_resolved(org_id, user_id, category) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string())) +} + +/// Create or update a setting (upsert) +pub async fn set(setting: &SystemSetting) -> Result { + let result = db::set(setting) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string()))?; + + // Update cache + let cache_k = cache_key( + &result.scope, + result.org_id.as_deref(), + result.user_id.as_deref(), + &result.setting_key, + ); + SYSTEM_SETTINGS + .write() + .await + .insert(cache_k, result.clone()); + + Ok(result) +} + +/// Delete a setting +pub async fn delete( + scope: &SettingScope, + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) -> Result { + let result = db::delete(scope, org_id, user_id, key) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string()))?; + + // Remove from cache + let cache_k = cache_key(scope, org_id, user_id, key); + SYSTEM_SETTINGS.write().await.remove(&cache_k); + + Ok(result) +} + +/// Delete all settings for an organization +pub async fn delete_org_settings(org_id: &str) -> Result { + let result = db::delete_org_settings(org_id) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string()))?; + + // Clear org settings from cache + let mut cache = SYSTEM_SETTINGS.write().await; + let keys_to_remove: Vec = cache + .iter() + .filter(|(k, _)| k.split(':').nth(1) == Some(org_id)) + .map(|(k, _)| k.clone()) + .collect(); + for key in keys_to_remove { + cache.remove(&key); + } + + Ok(result) +} + +/// Delete all settings for a user in an organization +pub async fn delete_user_settings(org_id: &str, user_id: &str) -> Result { + let result = db::delete_user_settings(org_id, user_id) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string()))?; + + // Clear user settings from cache + let mut cache = SYSTEM_SETTINGS.write().await; + let keys_to_remove: Vec = cache + .iter() + .filter(|(k, _)| { + let parts: Vec<&str> = k.split(':').collect(); + parts.len() >= 3 && parts[1] == org_id && parts[2] == user_id + }) + .map(|(k, _)| k.clone()) + .collect(); + for key in keys_to_remove { + cache.remove(&key); + } + + Ok(result) +} + +/// Initialize cache with all system settings from database +pub async fn cache() -> Result<()> { + let settings = db::list(None, None, None, None) + .await + .map_err(|e| infra::errors::Error::Message(e.to_string()))?; + + let mut cache = SYSTEM_SETTINGS.write().await; + for setting in settings { + let cache_k = cache_key( + &setting.scope, + setting.org_id.as_deref(), + setting.user_id.as_deref(), + &setting.setting_key, + ); + cache.insert(cache_k, setting); + } + log::info!("System settings cached"); + Ok(()) +} + +/// Invalidate cache for a specific setting +pub async fn invalidate_cache( + scope: &SettingScope, + org_id: Option<&str>, + user_id: Option<&str>, + key: &str, +) { + let cache_k = cache_key(scope, org_id, user_id, key); + SYSTEM_SETTINGS.write().await.remove(&cache_k); +} + +/// Invalidate entire cache +pub async fn invalidate_all_cache() { + SYSTEM_SETTINGS.write().await.clear(); +} + +/// Get FQN priority dimensions for an organization +/// +/// Resolution order: +/// 1. Check settings v2 for org-level setting +/// 2. Fall back to system defaults from O2_FQN_PRIORITY_DIMENSIONS env var +/// +/// Returns empty vec for OSS builds if env var is not set. +pub async fn get_fqn_priority_dimensions(org_id: &str) -> Vec { + use config::meta::system_settings::keys::FQN_PRIORITY_DIMENSIONS; + + // Try to get from settings v2 (org level) + if let Ok(Some(setting)) = get_resolved(Some(org_id), None, FQN_PRIORITY_DIMENSIONS).await + && let Ok(dims) = serde_json::from_value::>(setting.setting_value) + && !dims.is_empty() + { + return dims; + } + + // Fall back to system defaults + get_default_fqn_priority_dimensions() +} + +/// Get the default FQN priority dimensions from config +/// +/// For enterprise builds, uses O2_FQN_PRIORITY_DIMENSIONS env var. +/// For OSS builds, returns empty vec. +pub fn get_default_fqn_priority_dimensions() -> Vec { + #[cfg(feature = "enterprise")] + { + use o2_enterprise::enterprise::common::config::get_config as get_o2_config; + get_o2_config() + .service_streams + .get_fqn_priority_dimensions() + } + #[cfg(not(feature = "enterprise"))] + { + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_key() { + // System scope + assert_eq!( + cache_key(&SettingScope::System, None, None, "test_key"), + "system:_:_:test_key" + ); + + // Org scope + assert_eq!( + cache_key(&SettingScope::Org, Some("default"), None, "test_key"), + "org:default:_:test_key" + ); + + // User scope + assert_eq!( + cache_key( + &SettingScope::User, + Some("default"), + Some("user@example.com"), + "test_key" + ), + "user:default:user@example.com:test_key" + ); + } +} diff --git a/web/src/components/alerts/AlertList.vue b/web/src/components/alerts/AlertList.vue index 8a0d691720..8dcb5de73c 100644 --- a/web/src/components/alerts/AlertList.vue +++ b/web/src/components/alerts/AlertList.vue @@ -84,15 +84,6 @@ along with this program. If not, see . data-test="alert-insights-btn" icon="insights" /> - . /> - - -
- -
-
- -
- {{ t('settings.alertCorrelation') }} -
-
- -
- - -
- -
-
-
- { - // Reload organization settings to get updated dedup config - await store.dispatch("getDefaultOrganizationSettings"); - showCorrelationDrawer.value = false; - }; - const exportAlert = async (row: any) => { // Find the alert based on uuid const alertToBeExported = await getAlertById(row.alert_id); @@ -2601,8 +2542,6 @@ export default defineComponent({ showHistoryDrawer, selectedHistoryAlertId, selectedHistoryAlertName, - showCorrelationDrawer, - onCorrelationSettingsSaved, refreshImportedAlerts, folderIdToBeCloned, updateFolderIdToBeCloned, diff --git a/web/src/components/alerts/OrganizationDeduplicationSettings.vue b/web/src/components/alerts/OrganizationDeduplicationSettings.vue index 6878af1f4b..c9933068a2 100644 --- a/web/src/components/alerts/OrganizationDeduplicationSettings.vue +++ b/web/src/components/alerts/OrganizationDeduplicationSettings.vue @@ -17,12 +17,13 @@ along with this program. If not, see .