From 9056a2c92d2b31539e12ffce3544001cb9979331 Mon Sep 17 00:00:00 2001 From: udev Date: Mon, 8 Dec 2025 11:36:56 +0530 Subject: [PATCH] feat: revamp Conditions in alerts and pipelines (#9267) Co-authored-by: Uddhav Dave Co-authored-by: sai nikhil kethe Co-authored-by: Shrinath Rao --- .github/workflows/playwright.yml | 1 + src/config/src/meta/alerts/mod.rs | 572 +++- src/config/src/meta/pipeline/components.rs | 225 +- src/config/src/meta/pipeline/mod.rs | 17 +- src/handler/http/models/alerts/mod.rs | 116 +- src/infra/src/table/alerts/mod.rs | 5 +- src/migration/pipeline_func.rs | 2 +- src/service/alerts/mod.rs | 1055 +++++++- src/service/pipeline/batch_execution.rs | 21 +- .../pages/pipelinesPages/pipelinesPage.js | 312 ++- .../Pipelines/pipeline-conditions.spec.js | 381 +++ web/src/components/alerts/AddAlert.spec.ts | 129 + web/src/components/alerts/AddAlert.vue | 95 +- web/src/components/alerts/AlertList.vue | 120 +- .../components/alerts/FilterCondition.spec.ts | 77 +- web/src/components/alerts/FilterCondition.vue | 71 +- web/src/components/alerts/FilterGroup.spec.ts | 874 ++---- .../alerts/FilterGroup.spec.ts.backup | 2361 +++++++++++++++++ web/src/components/alerts/FilterGroup.vue | 136 +- web/src/components/alerts/ImportAlert.vue | 115 +- .../components/pipeline/ImportPipeline.vue | 154 +- .../pipeline/NodeForm/Condition.spec.js | 422 +-- .../pipeline/NodeForm/Condition.vue | 158 +- .../NodeForm/ConditionOrOperator.spec.js | 668 +++-- web/src/plugins/pipelines/CustomNode.vue | 101 +- .../utils/alerts/alertDataTransforms.spec.ts | 1874 +++++++++++++ web/src/utils/alerts/alertDataTransforms.ts | 406 ++- 27 files changed, 9076 insertions(+), 1392 deletions(-) create mode 100644 tests/ui-testing/playwright-tests/Pipelines/pipeline-conditions.spec.js create mode 100644 web/src/components/alerts/FilterGroup.spec.ts.backup diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e316e15a65..0851ddee6b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -193,6 +193,7 @@ jobs: - testfolder: "Pipelines" browser: "chrome" run_files: [ + "pipeline-conditions.spec.js", "pipelines.spec.js", "pipeline-dynamic.spec.js", "pipeline-core.spec.js", diff --git a/src/config/src/meta/alerts/mod.rs b/src/config/src/meta/alerts/mod.rs index 0411e94a24..c64406759f 100644 --- a/src/config/src/meta/alerts/mod.rs +++ b/src/config/src/meta/alerts/mod.rs @@ -341,7 +341,7 @@ pub struct QueryCondition { #[serde(rename = "type")] pub query_type: QueryType, #[schema(value_type = Option)] - pub conditions: Option, + pub conditions: Option, pub sql: Option, pub promql: Option, // (cpu usage / cpu total) pub promql_condition: Option, // value >= 80 @@ -634,6 +634,155 @@ impl std::fmt::Display for Operator { } } +// Condition system v2 for pipeline conditions with linear evaluation +// This provides an alternative to the tree-based ConditionList (v1) for expressing +// mixed boolean operations with natural left-to-right ordering + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "UPPERCASE")] +pub enum LogicalOperator { + And, + Or, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(tag = "filterType", rename_all = "lowercase")] +pub enum ConditionItem { + Condition { + column: String, + operator: Operator, + #[schema(value_type = Object)] + value: Value, + #[serde(skip_serializing_if = "Option::is_none")] + ignore_case: Option, + #[serde(rename = "logicalOperator")] + logical_operator: LogicalOperator, + }, + Group { + #[serde(rename = "logicalOperator")] + logical_operator: LogicalOperator, + conditions: Vec, + }, +} + +impl ConditionItem { + /// Get the logical operator for this condition item + pub fn logical_operator(&self) -> &LogicalOperator { + match self { + ConditionItem::Condition { + logical_operator, .. + } => logical_operator, + ConditionItem::Group { + logical_operator, .. + } => logical_operator, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConditionGroup { + pub filter_type: String, // Always "group" + #[serde(rename = "logicalOperator")] + pub logical_operator: LogicalOperator, + pub conditions: Vec, +} + +impl ConditionGroup { + /// Checks if the condition group has conditions + pub fn has_conditions(&self) -> bool { + !self.conditions.is_empty() + } + + /// Validates the condition group structure + pub fn validate(&self) -> Result<(), String> { + if self.filter_type != "group" { + return Err(format!( + "Invalid filterType: expected 'group', got '{}'", + self.filter_type + )); + } + + if !self.has_conditions() { + return Err("Condition Group should have atleast 1 condition".to_owned()); + } + Ok(()) + } +} + +/// Versioned condition parameters for alerts +/// Supports both v1 (tree-based ConditionList) and v2 (linear ConditionGroup) +#[derive(Clone, Debug, PartialEq, ToSchema)] +pub enum AlertConditionParams { + /// v1 format: Tree-based ConditionList (default when no version field) + V1(ConditionList), + /// v2 format: Linear ConditionGroup (version: 2) + V2(ConditionGroup), +} + +impl<'de> Deserialize<'de> for AlertConditionParams { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + use serde_json::Value; + + let value = Value::deserialize(deserializer)?; + + // Get version field, default to 1 if not present + let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(1); + + match version { + 1 => { + let conditions: ConditionList = serde_json::from_value(value).map_err(|e| { + D::Error::custom(format!("Failed to parse V1 conditions: {}", e)) + })?; + Ok(AlertConditionParams::V1(conditions)) + } + 2 => { + // Get conditions field + let conditions_value = value + .get("conditions") + .ok_or_else(|| D::Error::custom("conditions field missing for v2"))?; + + let conditions: ConditionGroup = serde_json::from_value(conditions_value.clone()) + .map_err(|e| { + D::Error::custom(format!("Failed to parse V2 conditions: {}", e)) + })?; + Ok(AlertConditionParams::V2(conditions)) + } + _ => Err(D::Error::custom(format!( + "Unsupported version: {}", + version + ))), + } + } +} + +impl Serialize for AlertConditionParams { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + match self { + AlertConditionParams::V1(conditions) => { + // V1: Serialize directly as ConditionList (no version field) + conditions.serialize(serializer) + } + AlertConditionParams::V2(conditions) => { + // V2: Include version field + let mut state = serializer.serialize_struct("AlertConditionParams", 2)?; + state.serialize_field("version", &2)?; + state.serialize_field("conditions", conditions)?; + state.end() + } + } + } +} + #[cfg(test)] mod test { use chrono::{DateTime, FixedOffset, TimeZone}; @@ -1390,6 +1539,165 @@ mod test { } } + #[test] + fn test_condition_group_serialization() { + // Test simple ConditionGroup serialization + let condition_json = r#"{ + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "status", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "column": "level", + "operator": "=", + "value": "critical", + "logicalOperator": "OR" + } + ] + }"#; + + let condition_group: ConditionGroup = serde_json::from_str(condition_json).unwrap(); + assert_eq!(condition_group.filter_type, "group"); + assert_eq!(condition_group.logical_operator, LogicalOperator::And); + assert_eq!(condition_group.conditions.len(), 2); + + // Test serialization back to JSON + let serialized = serde_json::to_value(&condition_group).unwrap(); + assert_eq!(serialized["filterType"], "group"); + assert_eq!(serialized["logicalOperator"], "AND"); + } + + #[test] + fn test_condition_group_with_nested_groups() { + let condition_json = r#"{ + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "status", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + }, + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "web", + "logicalOperator": "AND" + } + ] + } + ] + }"#; + + let condition_group: ConditionGroup = serde_json::from_str(condition_json).unwrap(); + assert_eq!(condition_group.conditions.len(), 2); + + // Check nested group + if let ConditionItem::Group { conditions, .. } = &condition_group.conditions[1] { + assert_eq!(conditions.len(), 2); + } else { + panic!("Expected nested group"); + } + } + + #[test] + fn test_condition_item_logical_operator() { + let condition_item = ConditionItem::Condition { + column: "status".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }; + + assert_eq!(*condition_item.logical_operator(), LogicalOperator::And); + + let group_item = ConditionItem::Group { + logical_operator: LogicalOperator::Or, + conditions: vec![], + }; + + assert_eq!(*group_item.logical_operator(), LogicalOperator::Or); + } + + #[test] + fn test_condition_group_validation() { + // Valid condition group (needs at least 2 conditions) + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "status".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("critical".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ], + }; + assert!(condition_group.validate().is_ok()); + + // Invalid filter type + let invalid_condition_group = ConditionGroup { + filter_type: "invalid".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "status".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("critical".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ], + }; + assert!(invalid_condition_group.validate().is_err()); + + // Empty conditions (should fail - needs at least 2) + let empty_condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![], + }; + assert!(empty_condition_group.validate().is_err()); + } + #[test] fn test_backward_compatibility_with_fixed_offset() { // Test that alerts without timezone string still work with FixedOffset @@ -1476,4 +1784,266 @@ mod test { // London is close to UTC (0 or +60 during BST) assert!(london_offset >= 0 && london_offset <= 60); } + + #[test] + fn test_alert_condition_params_v2_deserialization() { + // Test V2 format deserialization + let v2_json = r#"{ + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "level", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + } + ] + } + }"#; + + let result: Result = serde_json::from_str(v2_json); + assert!( + result.is_ok(), + "V2 deserialization failed: {:?}", + result.err() + ); + + let params = result.unwrap(); + match params { + AlertConditionParams::V2(group) => { + assert_eq!(group.filter_type, "group"); + assert_eq!(group.logical_operator, LogicalOperator::And); + assert_eq!(group.conditions.len(), 2); + } + _ => panic!("Expected V2 variant"), + } + } + + #[test] + fn test_alert_condition_params_v1_deserialization() { + // Test V1 format deserialization (without version field) + let v1_json = r#"{ + "and": [ + { + "column": "level", + "operator": "=", + "value": "error", + "ignore_case": false + }, + { + "column": "service", + "operator": "=", + "value": "api", + "ignore_case": false + } + ] + }"#; + + let result: Result = serde_json::from_str(v1_json); + assert!( + result.is_ok(), + "V1 deserialization failed: {:?}", + result.err() + ); + + let params = result.unwrap(); + match params { + AlertConditionParams::V1(conditions) => { + assert!(matches!(conditions, ConditionList::AndNode { .. })); + } + _ => panic!("Expected V1 variant"), + } + } + + #[test] + fn test_query_condition_v2_deserialization() { + // Test full QueryCondition with V2 format + let query_condition_json = r#"{ + "type": "custom", + "conditions": { + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "level", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + } + ] + } + }, + "sql": null, + "promql": null, + "aggregation": null, + "promql_condition": null, + "vrl_function": null, + "multi_time_range": null + }"#; + + let result: Result = serde_json::from_str(query_condition_json); + assert!( + result.is_ok(), + "QueryCondition deserialization failed: {:?}", + result.err() + ); + + let query_cond = result.unwrap(); + assert_eq!(query_cond.query_type, QueryType::Custom); + assert!(query_cond.conditions.is_some()); + + match query_cond.conditions.unwrap() { + AlertConditionParams::V2(group) => { + assert_eq!(group.filter_type, "group"); + assert_eq!(group.logical_operator, LogicalOperator::And); + assert_eq!(group.conditions.len(), 2); + } + _ => panic!("Expected V2 variant"), + } + } + + #[test] + fn test_query_condition_v2_with_empty_strings() { + // Test with empty strings for optional fields (like the user's curl payload) + let query_condition_json = r#"{ + "type": "custom", + "conditions": { + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "level", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + } + ] + } + }, + "sql": "", + "promql": "", + "aggregation": null, + "promql_condition": null, + "vrl_function": null, + "multi_time_range": [] + }"#; + + let result: Result = serde_json::from_str(query_condition_json); + assert!( + result.is_ok(), + "QueryCondition deserialization with empty strings failed: {:?}", + result.err() + ); + + let query_cond = result.unwrap(); + assert_eq!(query_cond.query_type, QueryType::Custom); + assert!(query_cond.conditions.is_some()); + + match query_cond.conditions.unwrap() { + AlertConditionParams::V2(group) => { + assert_eq!(group.filter_type, "group"); + assert_eq!(group.logical_operator, LogicalOperator::And); + assert_eq!(group.conditions.len(), 2); + } + _ => panic!("Expected V2 variant"), + } + + // Verify empty strings are treated as Some("") not None + assert!(query_cond.sql.is_some()); + assert_eq!(query_cond.sql.unwrap(), ""); + } + + #[test] + fn test_exact_user_payload() { + // Test the exact structure from the user's curl command + let user_json = r#"{ + "type": "custom", + "conditions": { + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "level", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + } + ] + } + }, + "sql": null, + "promql": null, + "aggregation": null, + "promql_condition": null, + "vrl_function": null, + "search_event_type": null, + "multi_time_range": null +}"#; + + println!("Testing user JSON..."); + let result: Result = serde_json::from_str(user_json); + + if let Err(ref e) = result { + println!("Deserialization error: {}", e); + + // Try parsing just the conditions field + let value: serde_json::Value = serde_json::from_str(user_json).unwrap(); + if let Some(conditions) = value.get("conditions") { + println!("Conditions value: {:#?}", conditions); + let cond_result: Result = + serde_json::from_value(conditions.clone()); + println!( + "Direct AlertConditionParams parse result: {:?}", + cond_result + ); + } + } + + assert!( + result.is_ok(), + "User payload deserialization failed: {:?}", + result.err() + ); + } } diff --git a/src/config/src/meta/pipeline/components.rs b/src/config/src/meta/pipeline/components.rs index f3c6b8792c..8946971b34 100644 --- a/src/config/src/meta/pipeline/components.rs +++ b/src/config/src/meta/pipeline/components.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::meta::{ - alerts::{ConditionList, QueryCondition, TriggerCondition}, + alerts::{ConditionGroup, ConditionList, QueryCondition, TriggerCondition}, stream::{RemoteStreamParams, StreamParams, StreamType}, }; @@ -165,9 +165,79 @@ pub struct FunctionParams { pub num_args: u8, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] -pub struct ConditionParams { - pub conditions: ConditionList, +#[derive(Debug, Clone, PartialEq, ToSchema)] +pub enum ConditionParams { + /// v1 format: Tree-based ConditionList (default when no version field) + V1 { conditions: ConditionList }, + /// v2 format: Linear ConditionGroup (version: 2) + V2 { conditions: ConditionGroup }, +} + +// Custom deserializer to handle missing version field (defaults to V1 for backward compatibility) +impl<'de> Deserialize<'de> for ConditionParams { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + use serde_json::Value; + + let value = Value::deserialize(deserializer)?; + + // If version field is missing, default to 1 for backward compatibility + let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(1); + + let Some(conditions) = value.get("conditions") else { + return Err(D::Error::missing_field("conditions")); + }; + + match version { + 1 => { + let conditions: ConditionList = serde_json::from_value(conditions.clone()) + .map_err(|e| { + D::Error::custom(format!("Failed to parse v1 conditions: {}", e)) + })?; + Ok(ConditionParams::V1 { conditions }) + } + 2 => { + let conditions: ConditionGroup = serde_json::from_value(conditions.clone()) + .map_err(|e| { + D::Error::custom(format!("Failed to parse v2 conditions: {}", e)) + })?; + Ok(ConditionParams::V2 { conditions }) + } + _ => Err(D::Error::custom(format!( + "Unsupported version: {}", + version + ))), + } + } +} + +// Custom serializer to omit version field for V1 (backward compatibility) +impl Serialize for ConditionParams { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + match self { + ConditionParams::V1 { conditions } => { + // V1: Omit version field for backward compatibility + let mut state = serializer.serialize_struct("ConditionParams", 1)?; + state.serialize_field("conditions", conditions)?; + state.end() + } + ConditionParams::V2 { conditions } => { + // V2: Include version field + let mut state = serializer.serialize_struct("ConditionParams", 2)?; + state.serialize_field("version", &2)?; + state.serialize_field("conditions", conditions)?; + state.end() + } + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] @@ -325,4 +395,151 @@ mod tests { let or_node_data = json::from_value::(or_payload); assert!(or_node_data.is_ok()); } + + #[test] + fn test_condition_node_v2_serialization() { + // Test v2 condition format with version field + let payload = json::json!({ + "node_type": "condition", + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "status", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "column": "level", + "operator": "=", + "value": "critical", + "logicalOperator": "OR" + } + ] + } + }); + + let node_data = json::from_value::(payload); + assert!(node_data.is_ok()); + + // Verify it's V2 + if let Ok(NodeData::Condition(params)) = node_data { + assert!(matches!(params, ConditionParams::V2 { .. })); + } + } + + #[test] + fn test_condition_node_v2_nested_groups() { + // Test with nested groups + let nested_payload = json::json!({ + "node_type": "condition", + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "status", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "group", + "logicalOperator": "OR", + "conditions": [ + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + }, + { + "filterType": "condition", + "column": "service", + "operator": "=", + "value": "web", + "logicalOperator": "AND" + } + ], + "logicalOperator": "AND" + } + ] + } + }); + + let nested_node_data = json::from_value::(nested_payload); + assert!(nested_node_data.is_ok()); + } + + #[test] + fn test_deserialize_v2_nested_condition_nodes() { + let json_str = r#"[ + { + "id": "7035edaa-e96c-4114-8b3c-6c77db44418d", + "data": { + "node_type": "condition", + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "kubernetes_annotations_prometheus_io_path", + "operator": "=", + "value": "asdfa", + "logicalOperator": "AND" + }, + { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "column": "distinct_field_hc_0", + "operator": "=", + "value": "123", + "logicalOperator": "OR" + }, + { + "filterType": "condition", + "column": "kubernetes_annotations_prometheus_io_path", + "operator": "=", + "value": "123", + "logicalOperator": "AND" + } + ] + }, + { + "filterType": "condition", + "column": "kubernetes_container_hash", + "operator": "=", + "value": "dasfg", + "logicalOperator": "AND" + } + ] + } + }, + "position": { + "x": 271.25, + "y": 335 + }, + "io_type": "default" + } +]"#; + let result: Result, _> = json::from_str(json_str); + match &result { + Ok(nodes) => println!("✓ Successfully deserialized {} nodes", nodes.len()), + Err(e) => println!("✗ Deserialization error: {}", e), + } + assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err()); + } } diff --git a/src/config/src/meta/pipeline/mod.rs b/src/config/src/meta/pipeline/mod.rs index 92d9ae552d..c21c0c6f95 100644 --- a/src/config/src/meta/pipeline/mod.rs +++ b/src/config/src/meta/pipeline/mod.rs @@ -128,10 +128,19 @@ impl Pipeline { for node in self.nodes.iter_mut() { // ck 4 - if matches!(&node.data, NodeData::Condition(condition_params) if !condition_params.conditions.has_conditions()) - { - return Err(anyhow!("ConditionNode must have non-empty conditions")); - } else if let NodeData::Stream(stream_params) = &mut node.data { + if let NodeData::Condition(condition_params) = &node.data { + let has_empty_conditions = match condition_params { + components::ConditionParams::V1 { conditions } => !conditions.has_conditions(), + components::ConditionParams::V2 { conditions } => { + conditions.conditions.is_empty() + } + }; + if has_empty_conditions { + return Err(anyhow!("ConditionNode must have non-empty conditions")); + } + } + + if let NodeData::Stream(stream_params) = &mut node.data { // ck 8 if stream_params.stream_type == StreamType::EnrichmentTables && matches!(&self.source, PipelineSource::Realtime(_)) diff --git a/src/handler/http/models/alerts/mod.rs b/src/handler/http/models/alerts/mod.rs index d0f838ef29..910de2391a 100644 --- a/src/handler/http/models/alerts/mod.rs +++ b/src/handler/http/models/alerts/mod.rs @@ -163,7 +163,7 @@ pub struct QueryCondition { #[serde(rename = "type")] pub query_type: QueryType, #[schema(value_type = Option)] - pub conditions: Option, + pub conditions: Option, pub sql: Option, pub promql: Option, pub promql_condition: Option, @@ -353,6 +353,7 @@ impl From for QueryCondition { fn from(value: meta_alerts::QueryCondition) -> Self { Self { query_type: value.query_type.into(), + // Pass AlertConditionParams directly (supports both V1 and V2) conditions: value.conditions, sql: value.sql, promql: value.promql, @@ -531,6 +532,7 @@ impl From for meta_alerts::QueryCondition { fn from(value: QueryCondition) -> Self { Self { query_type: value.query_type.into(), + // Pass AlertConditionParams directly (supports both V1 and V2) conditions: value.conditions, sql: value.sql, promql: value.promql, @@ -639,3 +641,115 @@ impl From for meta_stream::StreamType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_query_condition_v2_deserialization() { + // Test that the API layer can deserialize V2 conditions + let json = r#"{ + "type": "custom", + "conditions": { + "version": 2, + "conditions": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [ + { + "filterType": "condition", + "type": "condition", + "column": "level", + "operator": "=", + "value": "error", + "logicalOperator": "AND" + }, + { + "filterType": "condition", + "type": "condition", + "column": "service", + "operator": "=", + "value": "api", + "logicalOperator": "OR" + } + ] + } + }, + "sql": null, + "promql": null, + "aggregation": null, + "promql_condition": null, + "vrl_function": null, + "search_event_type": null, + "multi_time_range": null + }"#; + + let result: Result = serde_json::from_str(json); + assert!( + result.is_ok(), + "V2 deserialization failed: {:?}", + result.err() + ); + + let query_cond = result.unwrap(); + assert_eq!(query_cond.query_type, QueryType::Custom); + assert!(query_cond.conditions.is_some()); + + // Verify it's V2 + match query_cond.conditions.unwrap() { + meta_alerts::AlertConditionParams::V2(group) => { + assert_eq!(group.filter_type, "group"); + assert_eq!(group.conditions.len(), 2); + } + _ => panic!("Expected V2 variant"), + } + } + + #[test] + fn test_query_condition_v1_deserialization() { + // Test that the API layer still supports V1 conditions + let json = r#"{ + "type": "custom", + "conditions": { + "and": [ + { + "column": "level", + "operator": "=", + "value": "error", + "ignore_case": false + }, + { + "column": "service", + "operator": "=", + "value": "api", + "ignore_case": false + } + ] + }, + "sql": null, + "promql": null, + "aggregation": null, + "promql_condition": null, + "vrl_function": null, + "search_event_type": null, + "multi_time_range": null + }"#; + + let result: Result = serde_json::from_str(json); + assert!( + result.is_ok(), + "V1 deserialization failed: {:?}", + result.err() + ); + + let query_cond = result.unwrap(); + assert!(query_cond.conditions.is_some()); + + // Verify it's V1 + match query_cond.conditions.unwrap() { + meta_alerts::AlertConditionParams::V1(_) => {} + _ => panic!("Expected V1 variant"), + } + } +} diff --git a/src/infra/src/table/alerts/mod.rs b/src/infra/src/table/alerts/mod.rs index b6fe36226a..a26a118401 100644 --- a/src/infra/src/table/alerts/mod.rs +++ b/src/infra/src/table/alerts/mod.rs @@ -18,8 +18,7 @@ use std::str::FromStr; use chrono::{DateTime, FixedOffset, TimeZone, Utc}; use config::meta::{ alerts::{ - ConditionList, QueryCondition as MetaQueryCondition, - TriggerCondition as MetaTriggerCondition, + QueryCondition as MetaQueryCondition, TriggerCondition as MetaTriggerCondition, alert::{Alert as MetaAlert, ListAlertsParams}, deduplication::DeduplicationConfig as MetaDeduplicationConfig, }, @@ -77,7 +76,7 @@ impl TryFrom for MetaAlert { .context_attributes .map(serde_json::from_value) .transpose()?; - let query_conditions: Option = value + let query_conditions: Option = value .query_conditions .map(serde_json::from_value) .transpose()?; diff --git a/src/migration/pipeline_func.rs b/src/migration/pipeline_func.rs index 16f9e366f3..376dfda5f7 100644 --- a/src/migration/pipeline_func.rs +++ b/src/migration/pipeline_func.rs @@ -241,7 +241,7 @@ async fn migrate_pipelines() -> Result<(), anyhow::Error> { pos_y += pos_offset; let condition_node = Node::new( ider::uuid(), - NodeData::Condition(ConditionParams { + NodeData::Condition(ConditionParams::V1 { conditions: ConditionList::LegacyConditions(routing_conditions), }), pos_x, diff --git a/src/service/alerts/mod.rs b/src/service/alerts/mod.rs index e2dbf60825..45207efbed 100644 --- a/src/service/alerts/mod.rs +++ b/src/service/alerts/mod.rs @@ -21,8 +21,8 @@ use config::{ TIMESTAMP_COL_NAME, ider, meta::{ alerts::{ - AggFunction, Condition, ConditionList, Operator, QueryCondition, QueryType, - TriggerCondition, TriggerEvalResults, + AggFunction, AlertConditionParams, Condition, ConditionList, Operator, QueryCondition, + QueryType, TriggerCondition, TriggerEvalResults, }, cluster::RoleGroup, search::{SearchEventContext, SearchEventType, SqlQuery}, @@ -722,12 +722,314 @@ impl ConditionExt for Condition { } } +// Trait implementations for AlertConditionParams to support both v1 and v2 +#[async_trait] +impl ConditionExt for AlertConditionParams { + async fn evaluate(&self, row: &Map) -> bool { + match self { + AlertConditionParams::V1(conditions) => conditions.evaluate(row).await, + AlertConditionParams::V2(conditions) => conditions.evaluate(row).await, + } + } +} + +#[async_trait] +impl ConditionListExt for AlertConditionParams { + async fn len(&self) -> u32 { + match self { + AlertConditionParams::V1(conditions) => conditions.len().await, + AlertConditionParams::V2(conditions) => conditions.conditions.len() as u32, + } + } + + async fn to_sql(&self, schema: &Schema) -> Result { + match self { + AlertConditionParams::V1(conditions) => conditions.to_sql(schema).await, + AlertConditionParams::V2(conditions) => conditions.to_sql(schema).await, + } + } + + async fn is_empty(&self) -> bool { + match self { + AlertConditionParams::V1(conditions) => conditions.is_empty().await, + AlertConditionParams::V2(conditions) => conditions.conditions.is_empty(), + } + } +} + +// Trait and implementation for ConditionGroup (V2 format) +#[async_trait] +pub trait ConditionGroupExt: Sync + Send + 'static { + async fn evaluate(&self, row: &Map) -> bool; + async fn to_sql(&self, schema: &Schema) -> Result; +} + +#[async_trait] +impl ConditionGroupExt for config::meta::alerts::ConditionGroup { + async fn evaluate(&self, row: &Map) -> bool { + evaluate_condition_items(&self.conditions, row).await + } + + async fn to_sql(&self, schema: &Schema) -> Result { + if self.conditions.is_empty() { + return Ok("".to_string()); + } + + // Convert items to SQL left-to-right with operators + let mut sql_parts = Vec::new(); + for item in &self.conditions { + sql_parts.push(condition_item_to_sql(item, schema).await?); + } + + // Apply logical operators left-to-right + // The logicalOperator on an item indicates the operator that comes BEFORE that item + if sql_parts.len() == 1 { + return Ok(format!("({})", sql_parts[0])); + } + + let mut result = sql_parts[0].clone(); + for (item, item_sql) in self.conditions.iter().skip(1).zip(sql_parts.iter().skip(1)) { + // Use the current item's logical operator (it indicates the operator before this item) + // Concatenate with the operator, relying on SQL operator precedence + match item.logical_operator() { + config::meta::alerts::LogicalOperator::And => { + result = format!("{} AND {}", result, item_sql); + } + config::meta::alerts::LogicalOperator::Or => { + result = format!("{} OR {}", result, item_sql); + } + } + } + + // Wrap the entire result in parentheses at the end + Ok(format!("({})", result)) + } +} + +// Trait implementation for ConditionItem +#[async_trait] +impl ConditionGroupExt for config::meta::alerts::ConditionItem { + async fn evaluate(&self, row: &Map) -> bool { + match self { + config::meta::alerts::ConditionItem::Condition { + column, + operator, + value, + ignore_case, + .. + } => { + evaluate_condition(row, column, operator, value, ignore_case.unwrap_or(false)).await + } + config::meta::alerts::ConditionItem::Group { conditions, .. } => { + evaluate_condition_items(conditions, row).await + } + } + } + + async fn to_sql(&self, schema: &Schema) -> Result { + condition_item_to_sql(self, schema).await + } +} + +/// Evaluates a list of condition items with left-to-right logical operator application +/// +/// Algorithm: +/// 1. Start with the first item's evaluation result +/// 2. For each subsequent item: +/// - Evaluate the item +/// - Apply the previous item's logical_operator to combine results +/// 3. Continue left-to-right until all items processed +/// +/// Example: [A AND, B OR, C AND] +/// - result = eval(A) +/// - result = result AND eval(B) // Apply A's operator +/// - result = result OR eval(C) // Apply B's operator +async fn evaluate_condition_items( + items: &[config::meta::alerts::ConditionItem], + row: &Map, +) -> bool { + if items.is_empty() { + return true; + } + + // Evaluate with operator precedence: AND before OR (matching SQL semantics) + // The logicalOperator on an item indicates the operator that comes BEFORE that item + + // First, evaluate all items + let mut results = Vec::new(); + let mut operators = Vec::new(); + + for (i, item) in items.iter().enumerate() { + results.push(item.evaluate(row).await); + if i > 0 { + operators.push(item.logical_operator()); + } + } + + // Phase 1: Process all AND operations first (higher precedence) + let mut i = 0; + while i < operators.len() { + if matches!(operators[i], config::meta::alerts::LogicalOperator::And) { + // Combine results[i] AND results[i+1] + results[i] = results[i] && results[i + 1]; + results.remove(i + 1); + operators.remove(i); + // Don't increment i, check same position again + } else { + i += 1; + } + } + + // Phase 2: Process all OR operations (lower precedence) + // After phase 1, only OR operators should remain + let mut result = results[0]; + for res in results.iter().skip(1) { + result = result || *res; + } + + result +} + +/// Evaluates a single condition against a record +async fn evaluate_condition( + row: &Map, + column: &str, + operator: &Operator, + condition_value: &Value, + ignore_case: bool, +) -> bool { + let val: &Value = match row.get(column) { + Some(val) => val, + None => { + return false; + } + }; + + match val { + Value::String(v) => { + let val = v.as_str(); + let con_val = condition_value.as_str().unwrap_or_default(); + + // Handle case-insensitive comparison + if ignore_case { + let val_lower = val.to_lowercase(); + let con_val_lower = con_val.to_lowercase(); + match operator { + Operator::EqualTo => val_lower == con_val_lower, + Operator::NotEqualTo => val_lower != con_val_lower, + Operator::GreaterThan => val_lower > con_val_lower, + Operator::GreaterThanEquals => val_lower >= con_val_lower, + Operator::LessThan => val_lower < con_val_lower, + Operator::LessThanEquals => val_lower <= con_val_lower, + Operator::Contains => val_lower.contains(&con_val_lower), + Operator::NotContains => !val_lower.contains(&con_val_lower), + } + } else { + match operator { + Operator::EqualTo => val == con_val, + Operator::NotEqualTo => val != con_val, + Operator::GreaterThan => val > con_val, + Operator::GreaterThanEquals => val >= con_val, + Operator::LessThan => val < con_val, + Operator::LessThanEquals => val <= con_val, + Operator::Contains => val.contains(con_val), + Operator::NotContains => !val.contains(con_val), + } + } + } + Value::Number(_) => { + let val = val.as_f64().unwrap_or_default(); + let con_val = if condition_value.is_number() { + condition_value.as_f64().unwrap_or_default() + } else { + condition_value + .as_str() + .unwrap_or_default() + .parse() + .unwrap_or_default() + }; + match operator { + Operator::EqualTo => val == con_val, + Operator::NotEqualTo => val != con_val, + Operator::GreaterThan => val > con_val, + Operator::GreaterThanEquals => val >= con_val, + Operator::LessThan => val < con_val, + Operator::LessThanEquals => val <= con_val, + _ => false, + } + } + Value::Bool(v) => { + let val = *v; + let con_val = if condition_value.is_boolean() { + condition_value.as_bool().unwrap_or_default() + } else { + condition_value + .as_str() + .unwrap_or_default() + .parse() + .unwrap_or_default() + }; + match operator { + Operator::EqualTo => val == con_val, + Operator::NotEqualTo => val != con_val, + _ => false, + } + } + _ => false, + } +} + +// Helper function to convert a ConditionItem to SQL +async fn condition_item_to_sql( + item: &config::meta::alerts::ConditionItem, + schema: &Schema, +) -> Result { + match item { + config::meta::alerts::ConditionItem::Condition { + column, + operator, + value, + ignore_case, + .. + } => { + // Create a Condition struct to use with build_expr + let condition = config::meta::alerts::Condition { + column: column.clone(), + operator: *operator, + value: value.clone(), + ignore_case: ignore_case.unwrap_or(false), + }; + + let data_type = match schema.field_with_name(&condition.column) { + Ok(field) => field.data_type(), + Err(_) => { + return Err(anyhow::anyhow!("Column {} not found", &condition.column)); + } + }; + + build_expr(&condition, "", data_type) + } + config::meta::alerts::ConditionItem::Group { + conditions, + logical_operator, + } => { + // Recursively handle nested group + let nested_group = config::meta::alerts::ConditionGroup { + filter_type: "group".to_string(), + logical_operator: *logical_operator, + conditions: conditions.clone(), + }; + nested_group.to_sql(schema).await + } + } +} + async fn build_sql( org_id: &str, stream_name: &str, stream_type: StreamType, query_condition: &QueryCondition, - conditions: &ConditionList, + conditions: &AlertConditionParams, ) -> Result { let schema = infra::schema::get(org_id, stream_name, stream_type).await?; let where_sql = if conditions.len().await == 0 { @@ -950,3 +1252,750 @@ fn build_expr( }; Ok(expr) } + +#[cfg(test)] +mod tests { + use arrow_schema::{DataType, Field, Schema}; + use config::{ + meta::alerts::{ConditionGroup, ConditionItem, LogicalOperator, Operator}, + utils::json::Value, + }; + + use super::*; + + #[tokio::test] + async fn test_condition_group_to_sql_simple() { + // Create a simple schema + let schema = Schema::new(vec![ + Field::new("level", DataType::Utf8, false), + Field::new("service", DataType::Utf8, false), + ]); + + // Create a simple condition group: level = 'error' AND service = 'api' + // Remember: the logicalOperator on an item comes BEFORE that item + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first item) + }, + ConditionItem::Condition { + column: "service".to_string(), + operator: Operator::EqualTo, + value: Value::String("api".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND before this item + }, + ], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + // Should produce: ("level" = 'error' AND "service" = 'api') + assert_eq!(sql, "(\"level\" = 'error' AND \"service\" = 'api')"); + } + + #[tokio::test] + async fn test_condition_group_to_sql_with_or() { + let schema = Schema::new(vec![ + Field::new("level", DataType::Utf8, false), + Field::new("status", DataType::Utf8, false), + ]); + + // Create condition group: level = 'error' OR status = 'critical' + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first item) + }, + ConditionItem::Condition { + column: "status".to_string(), + operator: Operator::EqualTo, + value: Value::String("critical".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::Or, // OR before this item + }, + ], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + // Should produce: ("level" = 'error' OR "status" = 'critical') + assert_eq!(sql, "(\"level\" = 'error' OR \"status\" = 'critical')"); + } + + #[tokio::test] + async fn test_condition_group_to_sql_nested_groups() { + let schema = Schema::new(vec![ + Field::new("level", DataType::Utf8, false), + Field::new("service", DataType::Utf8, false), + Field::new("status", DataType::Utf8, false), + ]); + + // Create nested condition group: + // level = 'error' AND (service = 'api' OR service = 'web') + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first item) + }, + ConditionItem::Group { + logical_operator: LogicalOperator::And, // AND before this group + conditions: vec![ + ConditionItem::Condition { + column: "service".to_string(), + operator: Operator::EqualTo, + value: Value::String("api".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, /* Not used (first item in + * group) */ + }, + ConditionItem::Condition { + column: "service".to_string(), + operator: Operator::EqualTo, + value: Value::String("web".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::Or, // OR before this item + }, + ], + }, + ], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + // Exact SQL match for nested group: + // level = 'error' AND (service = 'api' OR service = 'web') + assert_eq!( + sql, + "(\"level\" = 'error' AND (\"service\" = 'api' OR \"service\" = 'web'))" + ); + } + + #[tokio::test] + async fn test_condition_group_to_sql_numeric_conditions() { + let schema = Schema::new(vec![ + Field::new("count", DataType::Int64, false), + Field::new("temperature", DataType::Float64, false), + ]); + + // Create condition group: count > 100 AND temperature >= 50.5 + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "count".to_string(), + operator: Operator::GreaterThan, + value: Value::Number(serde_json::Number::from(100)), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first item) + }, + ConditionItem::Condition { + column: "temperature".to_string(), + operator: Operator::GreaterThanEquals, + value: Value::Number(serde_json::Number::from_f64(50.5).unwrap()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND before this item + }, + ], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + // Should produce: ("count" > 100 AND "temperature" >= 50.5) + assert_eq!(sql, "(\"count\" > 100 AND \"temperature\" >= 50.5)"); + } + + #[tokio::test] + async fn test_condition_group_to_sql_contains_operator() { + let schema = Schema::new(vec![Field::new("message", DataType::Utf8, false)]); + + // Create condition group with Contains operator + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ConditionItem::Condition { + column: "message".to_string(), + operator: Operator::Contains, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + // Contains should generate str_match function + assert_eq!(sql, "(str_match(\"message\", 'error'))"); + } + + #[tokio::test] + async fn test_condition_group_to_sql_empty_conditions() { + let schema = Schema::new(Vec::::new()); + + // Empty condition group should return empty string + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + assert_eq!(sql, ""); + } + + #[tokio::test] + async fn test_condition_group_to_sql_single_condition() { + let schema = Schema::new(vec![Field::new("level", DataType::Utf8, false)]); + + // Single condition should be wrapped in parentheses + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + assert_eq!(sql, "(\"level\" = 'error')"); + } + + #[tokio::test] + async fn test_condition_group_to_sql_mixed_and_or_same_level() { + let schema = Schema::new(vec![ + Field::new("level", DataType::Utf8, false), + Field::new("status", DataType::Utf8, false), + Field::new("service", DataType::Utf8, false), + ]); + + // Create condition group with mixed operators at same level: + // level = 'error' AND status = 'active' OR service = 'api' + // This tests left-to-right evaluation: (level = 'error' AND status = 'active') OR service = + // 'api' + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "level".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first item) + }, + ConditionItem::Condition { + column: "status".to_string(), + operator: Operator::EqualTo, + value: Value::String("active".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND before this item + }, + ConditionItem::Condition { + column: "service".to_string(), + operator: Operator::EqualTo, + value: Value::String("api".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::Or, // OR before this item + }, + ], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + + // Verify evaluation with operator precedence (AND before OR) + // level = 'error' AND status = 'active' OR service = 'api' + // SQL operator precedence will parse this as: (level = 'error' AND status = 'active') OR + // service = 'api' + assert_eq!( + sql, + "(\"level\" = 'error' AND \"status\" = 'active' OR \"service\" = 'api')" + ); + } + + #[tokio::test] + async fn test_condition_group_to_sql_missing_column() { + let schema = Schema::new(vec![Field::new("level", DataType::Utf8, false)]); + + // Reference non-existent column + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ConditionItem::Condition { + column: "nonexistent".to_string(), + operator: Operator::EqualTo, + value: Value::String("error".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }], + }; + + let result = condition_group.to_sql(&schema).await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Column nonexistent not found") + ); + } + + #[tokio::test] + async fn test_condition_group_evaluate_complex() { + use config::utils::json::json; + + use crate::service::alerts::ConditionGroupExt; + + // Test the condition: kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test' + // AND kubernetes_host = 'test2') With proper Group logic structure + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "kubernetes_docker_id".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Ignored because next is Group + }, + ConditionItem::Group { + logical_operator: LogicalOperator::Or, // OR before this group + conditions: vec![ + ConditionItem::Condition { + column: "kubernetes_container_image".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ConditionItem::Condition { + column: "kubernetes_host".to_string(), + operator: Operator::EqualTo, + value: Value::String("test2".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ], + }, + ], + }; + + // Test case 1: Data that should NOT pass (none of the conditions match) + let test_data_fail = json!({ + "kubernetes_docker_id": "tes123t", + "kubernetes_container_image": "test1234", + "kubernetes_host": "test2", + "log": "request id : camelcase" + }); + let result = condition_group + .evaluate(test_data_fail.as_object().unwrap()) + .await; + assert!( + !result, + "Should NOT pass: kubernetes_docker_id doesn't match, and kubernetes_container_image doesn't match" + ); + + // Test case 2: Data that should pass (first condition matches) + let test_data_pass1 = json!({ + "kubernetes_docker_id": "test", + "kubernetes_container_image": "anything", + "kubernetes_host": "anything" + }); + let result = condition_group + .evaluate(test_data_pass1.as_object().unwrap()) + .await; + assert!( + result, + "Should pass: first condition matches (kubernetes_docker_id = 'test')" + ); + + // Test case 3: Data that should pass (nested group matches) + let test_data_pass2 = json!({ + "kubernetes_docker_id": "something_else", + "kubernetes_container_image": "test", + "kubernetes_host": "test2" + }); + let result = condition_group + .evaluate(test_data_pass2.as_object().unwrap()) + .await; + assert!( + result, + "Should pass: nested group matches (kubernetes_container_image = 'test' AND kubernetes_host = 'test2')" + ); + + // Test case 4: Data that should NOT pass (only one condition in nested group matches) + let test_data_fail2 = json!({ + "kubernetes_docker_id": "something_else", + "kubernetes_container_image": "test", + "kubernetes_host": "wrong_host" + }); + let result = condition_group + .evaluate(test_data_fail2.as_object().unwrap()) + .await; + assert!( + !result, + "Should NOT pass: only kubernetes_container_image matches, but kubernetes_host doesn't" + ); + } + + #[tokio::test] + async fn test_condition_group_evaluate_with_nested_group() { + use config::utils::json::json; + + use crate::service::alerts::ConditionGroupExt; + + // Test evaluation with nested group: kubernetes_docker_id = 'test' OR + // (kubernetes_container_image = 'test' AND kubernetes_host = 'test2') Structure: + // - kubernetes_docker_id = 'test' [OR with next] + // - A group containing: (kubernetes_container_image = 'test' AND kubernetes_host = 'test2') + + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "kubernetes_docker_id".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::Or, // OR with next item + }, + ConditionItem::Group { + logical_operator: LogicalOperator::And, // AND inside the group + conditions: vec![ + ConditionItem::Condition { + column: "kubernetes_container_image".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND with next in group + }, + ConditionItem::Condition { + column: "kubernetes_host".to_string(), + operator: Operator::EqualTo, + value: Value::String("test2".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ], + }, + ], + }; + + // Test with the data from the conversation + let test_data = json!({ + "kubernetes_docker_id": "tes123t", + "kubernetes_container_image": "test1234", + "kubernetes_host": "test2" + }); + + let result = condition_group + .evaluate(test_data.as_object().unwrap()) + .await; + + // Evaluation: kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test' AND + // kubernetes_host = 'test2') = FALSE OR (FALSE AND TRUE) + // = FALSE OR FALSE + // = FALSE + assert!( + !result, + "Should NOT PASS: kubernetes_docker_id doesn't match, and in the group only kubernetes_host matches (need both)" + ); + + println!("Test data: {:?}", test_data); + println!("Evaluation result: {} (should be false)", result); + } + + #[tokio::test] + async fn test_condition_group_to_sql_complex_with_nested_group() { + use arrow_schema::{DataType, Field, Schema}; + + use crate::service::alerts::ConditionGroupExt; + + let schema = Schema::new(vec![ + Field::new("kubernetes_docker_id", DataType::Utf8, false), + Field::new("kubernetes_container_image", DataType::Utf8, false), + Field::new("kubernetes_host", DataType::Utf8, false), + ]); + + // Test SQL generation with nested group and mixed operators + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "kubernetes_docker_id".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ConditionItem::Group { + logical_operator: LogicalOperator::Or, + conditions: vec![ConditionItem::Condition { + column: "kubernetes_container_image".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }], + }, + ConditionItem::Condition { + column: "kubernetes_host".to_string(), + operator: Operator::EqualTo, + value: Value::String("test2".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ], + }; + + let sql = condition_group.to_sql(&schema).await.unwrap(); + println!("Generated SQL: {}", sql); + + // Verify the generated SQL has correct operator placement + assert!(sql.contains("OR") && sql.contains("AND")); + } + + #[tokio::test] + async fn test_condition_group_evaluate_operator_precedence() { + use config::utils::json::json; + + use crate::service::alerts::ConditionGroupExt; + + // Test operator precedence: A OR (B) AND C should evaluate as A OR ((B) AND C) + // Structure: kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test') AND + // kubernetes_host = 'test2' + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "kubernetes_docker_id".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ConditionItem::Group { + logical_operator: LogicalOperator::Or, + conditions: vec![ConditionItem::Condition { + column: "kubernetes_container_image".to_string(), + operator: Operator::EqualTo, + value: Value::String("test".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }], + }, + ConditionItem::Condition { + column: "kubernetes_host".to_string(), + operator: Operator::EqualTo, + value: Value::String("test2".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ], + }; + + // Test with the data from the conversation - should NOT pass + let test_data_fail = json!({ + "kubernetes_docker_id": "tes123t", + "kubernetes_container_image": "test1234", + "kubernetes_host": "test2" + }); + + let result = condition_group + .evaluate(test_data_fail.as_object().unwrap()) + .await; + + // With the fix: + // - Between Item0 and Group: use Group's operator = OR + // - kubernetes_docker_id = 'test' -> false + // - Group (kubernetes_container_image = 'test') -> false + // - result = false OR false = false + // + // Wait, we still need to process Item2 (kubernetes_host)... + // Let me trace through: + // - i=0: result = kubernetes_docker_id = 'test' = false next = Group = + // kubernetes_container_image = 'test' = false operator = Group's OR result = false OR + // false = false + // - i=1: result = false (from above) next = kubernetes_host = 'test2' = true operator = + // Group.conditions[0].logicalOperator = AND result = false AND true = false + // + // Hmm, this gives false, but we need to think about what the Group's internal condition's + // operator means... + + println!("Test data: {:?}", test_data_fail); + println!("Evaluation result: {} (should be false)", result); + + assert!(!result, "Should NOT PASS: neither condition matches"); + + // Test case where kubernetes_docker_id matches (should PASS with OR precedence) + let test_data_docker_id_matches = json!({ + "kubernetes_docker_id": "test", + "kubernetes_container_image": "wrong", + "kubernetes_host": "wrong" + }); + + let result2 = condition_group + .evaluate(test_data_docker_id_matches.as_object().unwrap()) + .await; + + println!("\nTest with docker_id matching:"); + println!("Test data: {:?}", test_data_docker_id_matches); + println!("Evaluation result: {} (should be true)", result2); + + // SQL: (kubernetes_docker_id = 'test' OR (kubernetes_container_image = 'test' AND + // kubernetes_host = 'test2')) = (TRUE OR (FALSE AND FALSE)) = TRUE + assert!(result2, "Should PASS when kubernetes_docker_id matches"); + } + + #[tokio::test] + async fn test_deeply_nested_groups_with_precedence() { + use config::utils::json::json; + + use crate::service::alerts::ConditionGroupExt; + + // Complex nested structure: A OR (B AND C OR (D AND E)) AND F + // This tests: nested groups + operator precedence at multiple levels + let condition_group = ConditionGroup { + filter_type: "group".to_string(), + logical_operator: LogicalOperator::And, + conditions: vec![ + ConditionItem::Condition { + column: "A".to_string(), + operator: Operator::EqualTo, + value: Value::String("match".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first) + }, + ConditionItem::Group { + logical_operator: LogicalOperator::Or, // OR before this group + conditions: vec![ + ConditionItem::Condition { + column: "B".to_string(), + operator: Operator::EqualTo, + value: Value::String("match".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // Not used (first in group) + }, + ConditionItem::Condition { + column: "C".to_string(), + operator: Operator::EqualTo, + value: Value::String("match".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND before this + }, + ConditionItem::Group { + logical_operator: LogicalOperator::Or, // OR before this nested group + conditions: vec![ + ConditionItem::Condition { + column: "D".to_string(), + operator: Operator::EqualTo, + value: Value::String("match".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, + }, + ConditionItem::Condition { + column: "E".to_string(), + operator: Operator::EqualTo, + value: Value::String("match".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND before this + }, + ], + }, + ], + }, + ConditionItem::Condition { + column: "F".to_string(), + operator: Operator::EqualTo, + value: Value::String("match".to_string()), + ignore_case: None, + logical_operator: LogicalOperator::And, // AND before this + }, + ], + }; + + // Test case 1: Only A matches + // A OR (B AND C OR (D AND E)) AND F + // = TRUE OR (FALSE AND FALSE OR (FALSE AND FALSE)) AND FALSE + // = TRUE OR (FALSE OR FALSE) AND FALSE + // = TRUE OR FALSE AND FALSE + // With precedence: TRUE OR (FALSE AND FALSE) + // = TRUE OR FALSE = TRUE ✓ + let test1 = json!({"A": "match", "B": "no", "C": "no", "D": "no", "E": "no", "F": "no"}); + assert!( + condition_group.evaluate(test1.as_object().unwrap()).await, + "Should PASS: A matches, and there's OR before the group" + ); + + // Test case 2: D and E match (inner nested group), and F matches + // A OR (B AND C OR (D AND E)) AND F + // = FALSE OR (FALSE AND FALSE OR (TRUE AND TRUE)) AND TRUE + // = FALSE OR (FALSE OR TRUE) AND TRUE + // = FALSE OR TRUE AND TRUE + // With precedence: FALSE OR (TRUE AND TRUE) + // = FALSE OR TRUE = TRUE ✓ + let test2 = + json!({"A": "no", "B": "no", "C": "no", "D": "match", "E": "match", "F": "match"}); + assert!( + condition_group.evaluate(test2.as_object().unwrap()).await, + "Should PASS: Inner nested group (D AND E) matches, plus F matches" + ); + + // Test case 3: B and C match, and F matches + // A OR (B AND C OR (D AND E)) AND F + // = FALSE OR (TRUE AND TRUE OR (FALSE AND FALSE)) AND TRUE + // = FALSE OR (TRUE OR FALSE) AND TRUE + // = FALSE OR TRUE AND TRUE + // With precedence: FALSE OR (TRUE AND TRUE) + // = FALSE OR TRUE = TRUE ✓ + let test3 = + json!({"A": "no", "B": "match", "C": "match", "D": "no", "E": "no", "F": "match"}); + assert!( + condition_group.evaluate(test3.as_object().unwrap()).await, + "Should PASS: B AND C match, plus F matches" + ); + + // Test case 4: Only F matches (should fail) + // A OR (B AND C OR (D AND E)) AND F + // = FALSE OR (FALSE AND FALSE OR (FALSE AND FALSE)) AND TRUE + // = FALSE OR (FALSE OR FALSE) AND TRUE + // = FALSE OR FALSE AND TRUE + // With precedence: FALSE OR (FALSE AND TRUE) + // = FALSE OR FALSE = FALSE ✓ + let test4 = json!({"A": "no", "B": "no", "C": "no", "D": "no", "E": "no", "F": "match"}); + assert!( + !condition_group.evaluate(test4.as_object().unwrap()).await, + "Should FAIL: Only F matches, but the OR part fails" + ); + + println!("✓ All deeply nested group tests with operator precedence passed!"); + } +} diff --git a/src/service/pipeline/batch_execution.rs b/src/service/pipeline/batch_execution.rs index 9e44e103b0..a5554edda8 100644 --- a/src/service/pipeline/batch_execution.rs +++ b/src/service/pipeline/batch_execution.rs @@ -45,7 +45,7 @@ use tokio::{ use crate::{ common::infra::config::QUERY_FUNCTIONS, service::{ - alerts::ConditionExt, + alerts::{ConditionExt, ConditionGroupExt}, ingestion::{apply_vrl_fn, compile_vrl_function}, self_reporting::publish_error, }, @@ -651,12 +651,21 @@ async fn process_node( }; flattened = true; } + + // Evaluate based on condition version + let passes = match condition_params { + config::meta::pipeline::components::ConditionParams::V1 { conditions } => { + // v1: Use tree-based ConditionList evaluation + conditions.evaluate(record.as_object().unwrap()).await + } + config::meta::pipeline::components::ConditionParams::V2 { conditions } => { + // v2: Use linear ConditionGroup evaluation + conditions.evaluate(record.as_object().unwrap()).await + } + }; + // only send to children when passing all condition evaluations - if condition_params - .conditions - .evaluate(record.as_object().unwrap()) - .await - { + if passes { send_to_children( &mut child_senders, PipelineItem { diff --git a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js index 6e1235d0d8..2e8c1b3dbe 100644 --- a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js +++ b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js @@ -1,5 +1,6 @@ // pipelinesPage.js const { expect } = require('@playwright/test') +const testLogger = require('../../playwright-tests/utils/test-logger.js'); const randomNodeName = `remote-node-${Math.floor(Math.random() * 1000)}`; @@ -110,6 +111,36 @@ export class PipelinesPage { this.deletedSuccessfullyText = page.getByText('deleted successfully'); this.conditionDropdown = page.locator("div:nth-child(2) > div:nth-child(2) > .q-field > .q-field__inner > .q-field__control > .q-field__control-container > .q-field__native"); this.deleteButtonNth1 = page.locator("button").filter({ hasText: "delete" }).nth(1); + + // Condition-specific locators for comprehensive testing + this.addConditionSection = page.locator('[data-test="add-condition-section"]'); + this.addConditionGroupBtn = page.locator('[data-test="alert-conditions-add-condition-group-btn"]'); + this.toggleOperatorBtn = page.locator('[data-test="alert-conditions-toggle-operator-btn"]'); + this.deleteConditionBtn = page.locator('[data-test="alert-conditions-delete-condition-btn"]'); + this.reorderBtn = page.locator('[data-test="alert-conditions-reorder-btn"]'); + this.addConditionCancelBtn = page.locator('[data-test="add-condition-cancel-btn"]'); + this.addConditionDeleteBtn = page.locator('[data-test="add-condition-delete-btn"]'); + this.scheduledAlertTabs = page.locator('[data-test="scheduled-alert-tabs"]'); + this.nestedGroups = page.locator('.el-border'); + this.operatorLabels = page.locator('span.tw-lowercase'); + this.firstConditionLabel = page.locator('.tw-flex.tw-items-start.tw-gap-1').first().locator('span').first(); + this.noteContainer = page.locator('.note-container'); + this.noteHeading = page.locator('.note-heading'); + this.noteInfo = page.locator('.note-info'); + this.qDialog = page.locator('.q-dialog'); + this.qDialogBackdrop = page.locator('.q-dialog__backdrop'); + this.qNotificationMessage = page.locator('.q-notification__message'); + this.qMenu = page.locator('.q-menu'); + + // Pipeline node locators + this.pipelineNodeOutputStreamNode = page.locator('[data-test="pipeline-node-output-stream-node"]'); + this.pipelineNodeOutputDeleteBtn = page.locator('[data-test="pipeline-node-output-delete-btn"]'); + this.pipelineNodeDefaultConditionNode = page.locator('[data-test="pipeline-node-default-condition-node"]'); + this.pipelineNodeInputOutputHandle = page.locator('[data-test="pipeline-node-input-output-handle"]'); + this.pipelineNodeDefaultInputHandle = page.locator('[data-test="pipeline-node-default-input-handle"]'); + this.pipelineNodeDefaultOutputHandle = page.locator('[data-test="pipeline-node-default-output-handle"]'); + this.pipelineNodeOutputInputHandle = page.locator('[data-test="pipeline-node-output-input-handle"]'); + this.addPipelineBackBtn = page.locator('[data-test="add-pipeline-back-btn"]'); } // Methods from original PipelinesPage @@ -390,7 +421,7 @@ export class PipelinesPage { if (functionName?.trim() === fileName) { fileFound = true; - console.log("Uploaded file found:", functionName); + testLogger.debug("Uploaded file found", { functionName }); // Click the 'Delete Function' button await row.locator('[title="Delete Function"]').click(); @@ -624,4 +655,283 @@ export class PipelinesPage { await this.confirmDeletePipeline(); await this.verifyPipelineDeleted(); } + + // ========== Condition-specific methods ========== + + async fillCondition(columnName, operator, value, index = 0) { + const columnSelects = this.columnSelect; + + // Try clicking the input first + await columnSelects.nth(index).locator('input').click(); + await this.page.waitForTimeout(200); + + // Check if dropdown opened, if not click the dropdown icon + let menuVisible = await this.qMenu.isVisible().catch(() => false); + if (!menuVisible) { + await columnSelects.nth(index).locator('i').click(); + await this.page.waitForTimeout(200); + } + + await columnSelects.nth(index).locator('input').fill(columnName); + await this.page.waitForTimeout(500); + + // Select from dropdown + const options = this.qMenu.locator('.q-item'); + await options.first().click(); + await this.page.waitForTimeout(300); + + // Select operator + await this.operatorSelect.nth(index).click(); + await this.page.waitForTimeout(500); + + // Scope to the visible dropdown menu and select the operator + const visibleMenu = this.qMenu.last(); + await visibleMenu.locator('.q-item').getByText(operator, { exact: true }).click(); + await this.page.waitForTimeout(300); + + // Fill value + await this.valueInput.nth(index).locator('input').click(); + await this.valueInput.nth(index).locator('input').fill(value); + await this.page.waitForTimeout(300); + } + + async addNewCondition() { + await this.addConditionButton.first().click(); + await this.page.waitForTimeout(500); + } + + async addConditionGroup() { + await this.addConditionGroupBtn.first().click(); + await this.page.waitForTimeout(500); + } + + async toggleConditionOperator(index = 0) { + await this.toggleOperatorBtn.nth(index).click(); + await this.page.waitForTimeout(300); + } + + async deleteConditionByIndex(index) { + await this.deleteConditionBtn.nth(index).click(); + await this.page.waitForTimeout(300); + } + + async createPipelineWithCondition(streamName) { + await this.openPipelineMenu(); + await this.page.waitForTimeout(1000); + await this.addPipeline(); + await this.selectStream(); + await this.dragStreamToTarget(this.streamButton); + await this.selectLogs(); + await this.enterStreamName(streamName); + await this.page.waitForTimeout(2000); + await this.page.getByRole("option", { name: streamName, exact: true }).click(); + await this.saveInputNodeStream(); + await this.page.waitForTimeout(2000); + + // Remove default output stream node + await this.pipelineNodeOutputStreamNode.first().hover(); + await this.page.waitForTimeout(500); + await this.pipelineNodeOutputDeleteBtn.first().click(); + await this.confirmButton.click(); + + // Add condition node + await this.selectAndDragCondition(); + await this.page.waitForTimeout(1000); + } + + async saveConditionAndCompletePipeline(pipelineName) { + await this.saveCondition(); + await this.page.waitForTimeout(2000); + + // Wait for the condition dialog to close and the condition node to appear on canvas + await this.page.waitForSelector('[data-test="add-condition-section"]', { state: 'hidden', timeout: 5000 }).catch(() => {}); + await this.page.waitForTimeout(1000); + + // Verify the condition node was created successfully + await expect(this.pipelineNodeDefaultConditionNode).toBeVisible({ timeout: 5000 }); + + // Add destination stream + await this.selectAndDragSecondStream(); + await this.streamNameInput.click(); + await this.streamNameInput.fill("destination-node"); + await this.page.waitForTimeout(1000); + await this.clickInputNodeStreamSave(); + + await this.page.waitForTimeout(2000); + await this.page.waitForSelector('[data-test="pipeline-node-input-output-handle"]', { state: 'visible', timeout: 10000 }); + await this.page.waitForSelector('[data-test="pipeline-node-default-input-handle"]', { state: 'visible', timeout: 10000 }); + await this.page.waitForSelector('[data-test="pipeline-node-output-input-handle"]', { state: 'visible', timeout: 10000 }); + + await this.page.waitForSelector('.q-dialog__backdrop', { state: 'hidden', timeout: 3000 }).catch(() => {}); + + // Connect nodes + await this.pipelineNodeInputOutputHandle.hover({ force: true }); + await this.page.mouse.down(); + await this.pipelineNodeDefaultInputHandle.hover({ force: true }); + await this.page.mouse.up(); + await this.page.waitForTimeout(500); + + await this.pipelineNodeDefaultOutputHandle.hover({ force: true }); + await this.page.mouse.down(); + await this.pipelineNodeOutputInputHandle.hover({ force: true }); + await this.page.mouse.up(); + await this.page.waitForTimeout(1000); + + await this.enterPipelineName(pipelineName); + await this.savePipeline(); + + // Wait for save to complete + await this.page.waitForTimeout(3000); + } + + async deletePipelineByName(pipelineName) { + await this.searchPipeline(pipelineName); + await this.page.waitForTimeout(1000); + const deletePipelineButton = this.page.locator( + `[data-test="pipeline-list-${pipelineName}-delete-pipeline"]` + ); + await deletePipelineButton.waitFor({ state: "visible" }); + await deletePipelineButton.click(); + await this.confirmDeletePipeline(); + await this.verifyPipelineDeleted(); + } + + async openPipelineForEdit(pipelineName) { + await this.page.locator(`[data-test="pipeline-list-${pipelineName}-update-pipeline"]`).click(); + await this.page.waitForTimeout(2000); + } + + async clickConditionNode() { + await this.pipelineNodeDefaultConditionNode.click(); + await this.page.waitForTimeout(1000); + } + + async reconnectSourceToDestination() { + await this.pipelineNodeInputOutputHandle.hover({ force: true }); + await this.page.mouse.down(); + await this.pipelineNodeOutputInputHandle.hover({ force: true }); + await this.page.mouse.up(); + await this.page.waitForTimeout(1000); + } + + async verifyConditionDialogOpen() { + await expect(this.addConditionSection).toBeVisible(); + } + + async verifyConditionDialogClosed() { + await expect(this.addConditionSection).not.toBeVisible(); + } + + async verifyFirstConditionLabel(text) { + await expect(this.firstConditionLabel).toHaveText(text); + } + + async verifyConditionCount(count) { + await expect(this.valueInput).toHaveCount(count); + } + + async verifyNestedGroupsExist() { + const count = await this.nestedGroups.count(); + expect(count).toBeGreaterThan(1); + } + + async verifyScheduledAlertTabsVisible() { + await expect(this.scheduledAlertTabs.first()).toBeVisible(); + } + + async verifyOperatorLabel(text, index = 0) { + await expect(this.operatorLabels.nth(index)).toContainText(text); + } + + async verifyNoteContainer() { + await expect(this.noteContainer).toBeVisible(); + await expect(this.noteHeading).toContainText("Condition value Guidelines"); + } + + async captureConditionValues() { + const valueInputs = this.valueInput.locator('input'); + const values = []; + const count = await valueInputs.count(); + for (let i = 0; i < count; i++) { + const value = await valueInputs.nth(i).inputValue(); + values.push(value); + } + return values; + } + + async clickReorderButton(index = 1) { + await this.reorderBtn.nth(index).click(); + await this.page.waitForTimeout(500); + } + + async captureConditionScreenshot() { + return await this.addConditionSection.screenshot(); + } + + async cancelConditionDialog() { + await this.addConditionCancelBtn.click(); + await this.page.waitForTimeout(500); + } + + async clickDeleteConditionNode() { + const deleteButton = this.addConditionDeleteBtn; + if (await deleteButton.count() > 0) { + await deleteButton.click(); + await this.page.waitForTimeout(500); + await this.confirmButton.click(); + await this.page.waitForTimeout(1000); + } + } + + async verifyConditionValueInputValue(value, index = 0) { + await expect(this.valueInput.nth(index).locator('input')).toHaveValue(value); + } + + async clearAndFillConditionValue(value, index = 0) { + await this.valueInput.nth(index).locator('input').clear(); + await this.valueInput.nth(index).locator('input').fill(value); + } + + async verifyDeleteButtonCount(expectedCount) { + const count = await this.deleteConditionBtn.count(); + expect(count).toBeLessThan(expectedCount); + } + + async tryToSaveWithoutValidConditions() { + await this.addConditionSaveButton.click(); + await this.page.waitForTimeout(1000); + } + + async verifyNotificationVisible() { + const notification = this.qNotificationMessage; + if (await notification.count() > 0) { + await expect(notification.first()).toBeVisible(); + } + await this.page.waitForTimeout(2000); + } + + async fillPartialCondition(columnName) { + await this.columnSelect.first().locator('input').click(); + await this.columnSelect.first().locator('input').fill(columnName); + await this.page.waitForTimeout(300); + const options = this.qMenu.locator('.q-item'); + if (await options.count() > 0) { + await options.first().click(); + } + } + + async selectOperatorFromMenu(operator) { + await this.operatorSelect.first().click(); + await this.page.waitForTimeout(500); + const visibleMenu = this.qMenu.last(); + await visibleMenu.locator('.q-item').getByText(operator, { exact: true }).click(); + } + + async verifyConfirmationDialog() { + const dialog = this.qDialog; + if (await dialog.count() > 0) { + await expect(dialog.first()).toBeVisible(); + await this.confirmButton.click(); + } + } } \ No newline at end of file diff --git a/tests/ui-testing/playwright-tests/Pipelines/pipeline-conditions.spec.js b/tests/ui-testing/playwright-tests/Pipelines/pipeline-conditions.spec.js new file mode 100644 index 0000000000..a4c84f454b --- /dev/null +++ b/tests/ui-testing/playwright-tests/Pipelines/pipeline-conditions.spec.js @@ -0,0 +1,381 @@ +import { test, expect } from "../baseFixtures.js"; +import logData from "../../fixtures/log.json"; +import logsdata from "../../../test-data/logs_data.json"; +import PageManager from "../../pages/page-manager.js"; +const testLogger = require('../utils/test-logger.js'); + +test.describe.configure({ mode: "parallel" }); + +test.use({ + contextOptions: { + slowMo: 1000 + } +}); + +async function login(page) { + await page.goto(process.env["ZO_BASE_URL"]); + if (await page.getByText('Login as internal user').isVisible()) { + await page.getByText('Login as internal user').click(); + } + await page.waitForTimeout(1000); + await page + .locator('[data-cy="login-user-id"]') + .fill(process.env["ZO_ROOT_USER_EMAIL"]); + await page.locator("label").filter({ hasText: "Password *" }).click(); + await page + .locator('[data-cy="login-password"]') + .fill(process.env["ZO_ROOT_USER_PASSWORD"]); + await page.locator('[data-cy="login-sign-in"]').click(); +} + +async function ingestion(page, streamName = "e2e_automate_conditions") { + const orgId = process.env["ORGNAME"]; + const basicAuthCredentials = Buffer.from( + `${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}` + ).toString('base64'); + + const headers = { + "Authorization": `Basic ${basicAuthCredentials}`, + "Content-Type": "application/json", + }; + + const response = await page.evaluate(async ({ url, headers, orgId, streamName, logsdata }) => { + const fetchResponse = await fetch(`${url}/api/${orgId}/${streamName}/_json`, { + method: 'POST', + headers: headers, + body: JSON.stringify(logsdata) + }); + return await fetchResponse.json(); + }, { + url: process.env.INGESTION_URL, + headers: headers, + orgId: orgId, + streamName: streamName, + logsdata: logsdata + }); + testLogger.debug('API response received', { response, streamName }); +} + +test.describe("Pipeline Conditions - Comprehensive Tests", () => { + let pageManager; + + test.beforeEach(async ({ page }) => { + await login(page); + pageManager = new PageManager(page); + await page.waitForTimeout(5000); + + // Ingest data to unique streams for each test to avoid conflicts + await ingestion(page, "e2e_conditions_basic"); + await ingestion(page, "e2e_conditions_groups"); + await ingestion(page, "e2e_conditions_validation"); + await ingestion(page, "e2e_conditions_precedence"); + await ingestion(page, "e2e_conditions_multiple"); + await ingestion(page, "e2e_conditions_delete"); + await ingestion(page, "e2e_conditions_operators"); + + await page.goto( + `${logData.logsUrl}?org_identifier=${process.env["ORGNAME"]}` + ); + await page.waitForTimeout(2000); + }); + + test("should create, edit, and test basic condition operations with multiple operators", async ({ page }) => { + const pipelineName = `pipeline-basic-ops-${Math.random().toString(36).substring(7)}`; + + // Create pipeline with condition + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_basic"); + + // Verify condition dialog is open + await pageManager.pipelinesPage.verifyConditionDialogOpen(); + + // Test 1: Verify first condition shows "if" label + await pageManager.pipelinesPage.verifyFirstConditionLabel("if"); + + // Test 2: Fill first condition with "Contains" operator + await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "Contains", "prometheus", 0); + await pageManager.pipelinesPage.verifyConditionValueInputValue("prometheus", 0); + + // Test 3: Add second condition with "=" operator + await pageManager.pipelinesPage.addNewCondition(); + await pageManager.pipelinesPage.fillCondition("kubernetes_host", "=", "test-host", 1); + + // Test 4: Verify both conditions exist + await pageManager.pipelinesPage.verifyConditionCount(2); + + // Test 5: Toggle second condition to OR operator + await pageManager.pipelinesPage.toggleConditionOperator(0); + await page.waitForTimeout(500); + + // Test 6: Add third condition and test other operators + await pageManager.pipelinesPage.addNewCondition(); + await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "!=", "excluded-pod", 2); + await pageManager.pipelinesPage.verifyConditionCount(3); + + // Test 7: Delete second condition (not first, not last) + await pageManager.pipelinesPage.deleteConditionByIndex(1); + await page.waitForTimeout(500); + await pageManager.pipelinesPage.verifyConditionCount(2); + + // Test 8: Verify first condition cannot be deleted (Bug #9075) + await pageManager.pipelinesPage.verifyDeleteButtonCount(2); + + // Save and complete pipeline + await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName); + await ingestion(page); + + // Test 9: Edit existing condition + await pageManager.pipelinesPage.openPipelineMenu(); + await page.waitForTimeout(1000); + await pageManager.pipelinesPage.openPipelineForEdit(pipelineName); + await pageManager.pipelinesPage.clickConditionNode(); + await pageManager.pipelinesPage.verifyConditionDialogOpen(); + + // Verify existing value and modify + await pageManager.pipelinesPage.verifyConditionValueInputValue("prometheus", 0); + await pageManager.pipelinesPage.clearAndFillConditionValue("modified-value", 0); + + await pageManager.pipelinesPage.saveCondition(); + await page.waitForTimeout(1000); + await pageManager.pipelinesPage.savePipeline(); + + // Cleanup + await pageManager.pipelinesPage.openPipelineMenu(); + await pageManager.pipelinesPage.deletePipelineByName(pipelineName); + }); + + test("should handle condition groups, nesting, and reordering", async ({ page }) => { + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_groups"); + + // Test 1: Add first condition + await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "test-A", 0); + + // Test 2: Add nested condition group + await pageManager.pipelinesPage.addConditionGroup(); + await page.waitForTimeout(500); + + // Verify nested group exists + await pageManager.pipelinesPage.verifyNestedGroupsExist(); + + // Test 3: Add conditions in nested group + const addConditionButtons = pageManager.pipelinesPage.addConditionButton; + const buttonCount = await addConditionButtons.count(); + expect(buttonCount).toBeGreaterThan(1); + + // Fill the first condition in nested group + await pageManager.pipelinesPage.fillCondition("kubernetes_namespace_name", "=", "test-B", 1); + + // Add another condition in nested group + await addConditionButtons.nth(1).click(); + await page.waitForTimeout(500); + + // Fill the second condition in nested group + await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "=", "test-C", 2); + + // Test 4: Verify group-level tabs exist for nested group + await pageManager.pipelinesPage.verifyScheduledAlertTabsVisible(); + + // Test 5: Test reorder button with actual reordering validation + const reorderButtons = pageManager.pipelinesPage.reorderBtn; + const reorderButtonCount = await reorderButtons.count(); + expect(reorderButtonCount).toBeGreaterThan(1); + + // Capture order of condition values before reorder + const beforeValues = await pageManager.pipelinesPage.captureConditionValues(); + testLogger.info('Condition values before reorder:', { beforeValues }); + + // Take screenshot before reorder + const beforeScreenshot = await pageManager.pipelinesPage.captureConditionScreenshot(); + + // Click reorder button at nested level + await pageManager.pipelinesPage.clickReorderButton(1); + + // Capture order of condition values after reorder + const afterValues = await pageManager.pipelinesPage.captureConditionValues(); + testLogger.info('Condition values after reorder:', { afterValues }); + + // Take screenshot after reorder + const afterScreenshot = await pageManager.pipelinesPage.captureConditionScreenshot(); + + // Verify screenshots were captured + expect(beforeScreenshot.length).toBeGreaterThan(0); + expect(afterScreenshot.length).toBeGreaterThan(0); + + // Verify that the order has changed (arrays should not be equal) + expect(beforeValues.length).toBe(afterValues.length); + expect(beforeValues).not.toEqual(afterValues); + + // Verify reorder button is still visible after click + await expect(reorderButtons.nth(1)).toBeVisible(); + + // Cancel without saving + await pageManager.pipelinesPage.cancelConditionDialog(); + await pageManager.pipelinesPage.verifyConfirmationDialog(); + }); + + test("should validate fields and show proper error messages", async ({ page }) => { + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_validation"); + + // Test 1: Verify guidelines are displayed + await pageManager.pipelinesPage.verifyNoteContainer(); + const noteInfo = pageManager.pipelinesPage.noteInfo; + await expect(noteInfo).toContainText('""'); + await expect(noteInfo).toContainText('null'); + + // Test 2: Try to save without any valid conditions + await pageManager.pipelinesPage.tryToSaveWithoutValidConditions(); + + // Should show error notification + await pageManager.pipelinesPage.verifyNotificationVisible(); + + // Test 3: Fill partial condition (missing value) + await pageManager.pipelinesPage.fillPartialCondition("kubernetes"); + await pageManager.pipelinesPage.selectOperatorFromMenu("="); + + // Try to save without value + await pageManager.pipelinesPage.tryToSaveWithoutValidConditions(); + + // Test 4: Test cancel with unsaved changes + await pageManager.pipelinesPage.valueInput.first().locator('input').fill("test-value"); + await pageManager.pipelinesPage.addNewCondition(); + await page.waitForTimeout(500); + + await pageManager.pipelinesPage.cancelConditionDialog(); + await page.waitForTimeout(500); + + // Should show confirmation dialog + await pageManager.pipelinesPage.verifyConfirmationDialog(); + }); + + test("should test complex conditions and operator precedence (A OR B AND C)", async ({ page }) => { + const pipelineName = `pipeline-complex-${Math.random().toString(36).substring(7)}`; + + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_precedence"); + + // Create: A OR B AND C (tests operator precedence) + // Condition A + await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "value-A", 0); + + // Condition B with OR + await pageManager.pipelinesPage.addNewCondition(); + await pageManager.pipelinesPage.toggleConditionOperator(0); + await page.waitForTimeout(300); + await pageManager.pipelinesPage.fillCondition("kubernetes_host", "=", "value-B", 1); + + // Condition C with AND (default) + await pageManager.pipelinesPage.addNewCondition(); + await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "=", "value-C", 2); + + // Verify 3 conditions exist + await pageManager.pipelinesPage.verifyConditionCount(3); + + // Verify operators are set correctly + const operatorLabels = pageManager.pipelinesPage.operatorLabels; + if (await operatorLabels.count() >= 2) { + await pageManager.pipelinesPage.verifyOperatorLabel("OR", 0); + } + + await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName); + await ingestion(page); + + await pageManager.pipelinesPage.openPipelineMenu(); + await pageManager.pipelinesPage.deletePipelineByName(pipelineName); + }); + + test("should test multiple conditions: A, B, C, and D", async ({ page }) => { + const pipelineName = `pipeline-multiple-${Math.random().toString(36).substring(7)}`; + + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_multiple"); + + // Wait for condition dialog to be fully loaded + await pageManager.pipelinesPage.verifyConditionDialogOpen(); + await page.waitForTimeout(1000); + + // Condition A at root level + await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "value-A", 0); + + // Add condition B + await pageManager.pipelinesPage.addNewCondition(); + await page.waitForTimeout(500); + await pageManager.pipelinesPage.fillCondition("kubernetes_host", "=", "value-B", 1); + + // Add condition C + await pageManager.pipelinesPage.addNewCondition(); + await page.waitForTimeout(500); + await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "=", "value-C", 2); + + // Add condition D + await pageManager.pipelinesPage.addNewCondition(); + await page.waitForTimeout(500); + await pageManager.pipelinesPage.fillCondition("kubernetes_namespace_name", "=", "value-D", 3); + + // Verify all 4 conditions exist + await pageManager.pipelinesPage.verifyConditionCount(4); + + await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName); + await ingestion(page); + + await pageManager.pipelinesPage.openPipelineMenu(); + await pageManager.pipelinesPage.deletePipelineByName(pipelineName); + }); + + test("should delete condition node from pipeline", async ({ page }) => { + const pipelineName = `pipeline-delete-${Math.random().toString(36).substring(7)}`; + + // Create pipeline with condition + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_delete"); + await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", "=", "test", 0); + await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName); + + // Open pipeline in edit mode + await pageManager.pipelinesPage.openPipelineMenu(); + await page.waitForTimeout(1000); + await pageManager.pipelinesPage.openPipelineForEdit(pipelineName); + + // Click on condition node to open + await pageManager.pipelinesPage.clickConditionNode(); + + // Verify delete button exists and click it + await pageManager.pipelinesPage.clickDeleteConditionNode(); + + // Verify dialog closed + await pageManager.pipelinesPage.verifyConditionDialogClosed(); + + // After deleting condition node, reconnect source to destination + await page.waitForTimeout(1000); + await pageManager.pipelinesPage.reconnectSourceToDestination(); + + // Save the updated pipeline + await pageManager.pipelinesPage.savePipeline(); + await page.waitForTimeout(3000); + + // Cleanup + await pageManager.pipelinesPage.openPipelineMenu(); + await pageManager.pipelinesPage.deletePipelineByName(pipelineName); + }); + + test("should test different comparison operators (>=, !=, Contains, NotContains)", async ({ page }) => { + const pipelineName = `pipeline-operators-${Math.random().toString(36).substring(7)}`; + + await pageManager.pipelinesPage.createPipelineWithCondition("e2e_conditions_operators"); + + // Test >= operator + await pageManager.pipelinesPage.fillCondition("kubernetes_container_name", ">=", "100", 0); + + // Add != operator condition + await pageManager.pipelinesPage.addNewCondition(); + await pageManager.pipelinesPage.fillCondition("kubernetes_host", "!=", "excluded", 1); + + // Add NotContains operator condition + await pageManager.pipelinesPage.addNewCondition(); + await pageManager.pipelinesPage.fillCondition("kubernetes_pod_name", "NotContains", "ignore", 2); + + // Verify all 3 conditions exist + await pageManager.pipelinesPage.verifyConditionCount(3); + + await pageManager.pipelinesPage.saveConditionAndCompletePipeline(pipelineName); + await ingestion(page); + + await pageManager.pipelinesPage.openPipelineMenu(); + await pageManager.pipelinesPage.deletePipelineByName(pipelineName); + }); +}); diff --git a/web/src/components/alerts/AddAlert.spec.ts b/web/src/components/alerts/AddAlert.spec.ts index 3f711eed12..13eb6a0f6f 100644 --- a/web/src/components/alerts/AddAlert.spec.ts +++ b/web/src/components/alerts/AddAlert.spec.ts @@ -21,6 +21,7 @@ import store from "@/test/unit/helpers/store"; import { installQuasar } from "@/test/unit/helpers"; import router from "@/test/unit/helpers/router"; import { generateWhereClause } from "@/utils/alerts/alertQueryBuilder"; +import { detectConditionsVersion } from "@/utils/alerts/alertDataTransforms"; import PreviewAlert from "@/components/alerts/PreviewAlert.vue"; @@ -1159,6 +1160,134 @@ describe("AddAlert Component", () => { }); }); + describe('V2 Conditions Version Field Placement', () => { + let w: any; + beforeEach(() => { + w = mount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); + w.vm.formData.name = "test_v2_alert"; + w.vm.formData.stream_name = "_rundata"; + w.vm.formData.stream_type = "logs"; + w.vm.formData.is_real_time = false; + w.vm.scheduledAlertRef = { tab: 'custom' }; + w.vm.isAggregationEnabled = false; + }); + + it('should wrap V2 conditions with version field INSIDE conditions object when saving', () => { + // Set up V2 format conditions + const v2Conditions = { + filterType: 'group', + logicalOperator: 'AND', + groupId: 'test-group', + conditions: [ + { + id: 'test-id', + filterType: 'condition', + column: 'status', + operator: '=', + value: '200', + logicalOperator: 'AND', + } + ] + }; + w.vm.formData.query_condition.conditions = v2Conditions; + w.vm.formData.query_condition.aggregation = null; + w.vm.formData.query_condition.type = 'custom'; + + const payload = w.vm.getAlertPayload(); + + // Simulate the version wrapping logic from onSubmit() + const version = detectConditionsVersion(w.vm.formData.query_condition.conditions); + if (version === 2) { + payload.query_condition.conditions = { + version: 2, + conditions: w.vm.formData.query_condition.conditions, + }; + } + + // Backend expects: conditions: { version: 2, conditions: {...} } + expect(payload.query_condition.conditions).toHaveProperty('version'); + expect(payload.query_condition.conditions.version).toBe(2); + expect(payload.query_condition.conditions).toHaveProperty('conditions'); + expect(payload.query_condition.conditions.conditions.filterType).toBe('group'); + expect(payload.query_condition.conditions.conditions.logicalOperator).toBe('AND'); + }); + + it('should NOT have version field at query_condition level for V2', () => { + // Set up V2 format conditions + w.vm.formData.query_condition.conditions = { + filterType: 'group', + logicalOperator: 'OR', + groupId: 'test-group', + conditions: [] + }; + + const payload = w.vm.getAlertPayload(); + + // Simulate the version wrapping logic from onSubmit() + const version = detectConditionsVersion(w.vm.formData.query_condition.conditions); + if (version === 2) { + payload.query_condition.conditions = { + version: 2, + conditions: w.vm.formData.query_condition.conditions, + }; + } + + // Version should NOT be at this level + expect(payload.query_condition).not.toHaveProperty('version'); + // But should be inside conditions + expect(payload.query_condition.conditions).toHaveProperty('version'); + }); + + it('should handle V1 conditions without version field', () => { + // Set up V1 format conditions (tree-based) + w.vm.formData.query_condition.conditions = { + or: [ + { column: 'status', operator: '=', value: '200', ignore_case: true } + ] + }; + + const payload = w.vm.getAlertPayload(); + + // V1 format should not have version field at any level + expect(payload.query_condition).not.toHaveProperty('version'); + expect(payload.query_condition.conditions).not.toHaveProperty('version'); + // Should be transformed to V1 backend format + expect(payload.query_condition.conditions).toHaveProperty('or'); + }); + + it('should correctly extract V2 conditions when loading from backend', async () => { + // Simulate backend response with V2 format + w.vm.formData.query_condition.conditions = { + version: 2, + conditions: { + filterType: 'group', + logicalOperator: 'AND', + groupId: 'backend-group', + conditions: [ + { + id: 'backend-id', + filterType: 'condition', + column: 'level', + operator: '=', + value: 'error', + logicalOperator: 'AND', + } + ] + } + }; + + // Call the data loading logic (simulating mounted hook behavior) + await w.vm.$nextTick(); + + // After loading, conditions should be extracted from nested structure + // The actual extraction happens in the component's data loading logic + // This test verifies the structure matches backend expectations + expect(w.vm.formData.query_condition.conditions.version).toBe(2); + expect(w.vm.formData.query_condition.conditions.conditions).toBeDefined(); + expect(w.vm.formData.query_condition.conditions.conditions.filterType).toBe('group'); + }); + }); + describe('validateFormAndNavigateToErrorField and navigateToErrorField', () => { it('navigates on invalid and returns false', async () => { const w = mount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); diff --git a/web/src/components/alerts/AddAlert.vue b/web/src/components/alerts/AddAlert.vue index 9ab91436e5..0d4ba5b852 100644 --- a/web/src/components/alerts/AddAlert.vue +++ b/web/src/components/alerts/AddAlert.vue @@ -525,6 +525,11 @@ import { removeConditionGroup as removeConditionGroupUtil, transformFEToBE as transformFEToBEUtil, retransformBEToFE as retransformBEToFEUtil, + detectConditionsVersion, + convertV0ToV2, + convertV1ToV2, + convertV1BEToV2, + ensureIds, type TransformContext, } from "@/utils/alerts/alertDataTransforms"; @@ -537,12 +542,18 @@ const defaultValue: any = () => { query_condition: { conditions: { - "or": [ + filterType: "group", + logicalOperator: "AND", + groupId: "", + conditions: [ { - "column": "", - "operator": ">", - "value": "", - "ignore_case": false + filterType: "condition", + column: "", + operator: ">", + value: "", + values: [], + logicalOperator: "AND", + id: "" } ] }, @@ -1205,20 +1216,26 @@ export default defineComponent({ const conditions: any[] = []; query.fields.filter.forEach((filter: any) => { if (filter.type === 'list' && filter.values && filter.values.length > 0) { + // V2: Create condition with filterType and logicalOperator conditions.push({ + filterType: 'condition', column: filter.column, operator: "=", value: filter.values[0], - ignore_case: false + values: [], + logicalOperator: 'AND', + id: getUUID() }); } }); if (conditions.length > 0) { + // V2: Create group with filterType and conditions array formData.value.query_condition.conditions = { + filterType: 'group', + logicalOperator: 'AND', groupId: getUUID(), - label: 'and', - items: conditions + conditions: conditions }; } } @@ -1503,18 +1520,54 @@ export default defineComponent({ id: getUUID(), }; }); - //this is done because - //if we are getting the conditions as null or undefined then we need to create a new group - //if we are getting the conditions as an object then we need to transform it to the frontend format - if(this.formData.query_condition.conditions && ( !Array.isArray(this.formData.query_condition.conditions) && Object.keys(this.formData.query_condition.conditions).length != 0)){ - this.formData.query_condition.conditions = this.retransformBEToFE(this.formData.query_condition.conditions); + // VERSION DETECTION AND CONVERSION + // Supports three versions: + // - V0: Flat array of conditions with implicit AND between all (no groups) + // - V1: Tree-based structure with {and: [...]} or {or: [...]} or {label, items, groupId} + // - V2: Linear structure with filterType, logicalOperator per condition + + // Check for V2 format from backend + // Backend sends: query_condition: { conditions: { version: 2, conditions: {...} } } + if (this.formData.query_condition.conditions?.version === "2" || this.formData.query_condition.conditions?.version === 2) { + // V2: Extract the nested conditions object + // Backend sends { version: 2, conditions: {...} }, we need just the inner conditions + this.formData.query_condition.conditions = ensureIds(this.formData.query_condition.conditions.conditions); + } else if(this.formData.query_condition.conditions && ( !Array.isArray(this.formData.query_condition.conditions) && Object.keys(this.formData.query_condition.conditions).length != 0)){ + // No version field - could be V1 or old V2 + // Detect version by structure + const version = detectConditionsVersion(this.formData.query_condition.conditions); + + if (version === 0) { + // V0: Flat array format - convert to V2 + // V0 had implicit AND between all conditions (no groups) + this.formData.query_condition.conditions = ensureIds(convertV0ToV2(this.formData.query_condition.conditions)); + } else if (version === 1) { + // V1 format - need to convert to V2 + // First check if it's BE format ({and: [...]}) or FE format ({label, items, groupId}) + if (this.formData.query_condition.conditions.and || this.formData.query_condition.conditions.or) { + // V1 Backend format - convert to V2 + this.formData.query_condition.conditions = ensureIds(convertV1BEToV2(this.formData.query_condition.conditions)); + } else if (this.formData.query_condition.conditions.label && this.formData.query_condition.conditions.items) { + // V1 Frontend format - convert to V2 + // this wont be executed atleast once but we are keeping it incase any FE logics got saved already + this.formData.query_condition.conditions = ensureIds(convertV1ToV2(this.formData.query_condition.conditions)); + } + } else { + // V2 format - ensure all IDs exist + this.formData.query_condition.conditions = ensureIds(this.formData.query_condition.conditions); + } + } else if (Array.isArray(this.formData.query_condition.conditions) && this.formData.query_condition.conditions.length > 0) { + // V0: Flat array of conditions - convert to V2 + // This handles the case where conditions is directly an array at the top level + this.formData.query_condition.conditions = ensureIds(convertV0ToV2(this.formData.query_condition.conditions)); } else if (this.formData.query_condition.conditions == null || this.formData.query_condition.conditions == undefined || this.formData.query_condition.conditions.length == 0 || Object.keys(this.formData.query_condition.conditions).length == 0){ - + // No conditions - create empty V2 group this.formData.query_condition.conditions = { + filterType: 'group', + logicalOperator: 'AND', + conditions: [], groupId: getUUID(), - label: 'and', - items: [] } } }, @@ -1654,8 +1707,14 @@ export default defineComponent({ } } - // Transform the form data to the backend format - payload.query_condition.conditions = this.transformFEToBE(this.formData.query_condition.conditions); + // VERSION HANDLING + // All conditions are converted to V2 format during data loading, + // so we always wrap with version field for backend + // Backend expects: query_condition: { conditions: { version: 2, conditions: {...} } } + payload.query_condition.conditions = { + version: 2, + conditions: this.formData.query_condition.conditions, + }; if (this.beingUpdated) { payload.folder_id = this.router.currentRoute.value.query.folder || "default"; diff --git a/web/src/components/alerts/AlertList.vue b/web/src/components/alerts/AlertList.vue index c1c0eea683..8a0d691720 100644 --- a/web/src/components/alerts/AlertList.vue +++ b/web/src/components/alerts/AlertList.vue @@ -1325,66 +1325,27 @@ export default defineComponent({ //localAllAlerts is the alerts that we use to store localAllAlerts = localAllAlerts.map((data: any) => { let conditions = "--"; - //this is deprecated because we are using the new condition format - //the new format looks like this - // { - // "or": [ - // { - // "column": "_timestamp", - // "operator": "<=", - // "value": "100", - // "ignore_case": false - // }, - // { - // "column": "job", - // "operator": "not_contains", - // "value": "12", - // "ignore_case": true - // }, - // { - // "or": [ - // { - // "column": "job", - // "operator": "contains", - // "value": "1222", - // "ignore_case": true - // }, - // { - // "column": "level", - // "operator": "not_contains", - // "value": "dsff", - // "ignore_case": true - // }, - // { - // "or": [ - // { - // "column": "job", - // "operator": "=", - // "value": "111", - // "ignore_case": true - // }, - // { - // "column": "level", - // "operator": "contains", - // "value": "1222", - // "ignore_case": true - // } - // ] - // }, - // { - // "column": "log", - // "operator": "!=", - // "value": "33", - // "ignore_case": true - // } - // ] - // } - // ] - // } - //converted into - // (_timestamp <= '100' OR job not_contains '12' OR (job contains '1222' OR level not_contains 'dsff' OR (job = '111' OR level contains '1222') OR log != '33')) + + // Handle different condition formats based on structure if (Object.keys(data.condition).length && data.condition.type == 'custom') { - conditions = transformToExpression(data.condition.conditions) + const conditionData = data.condition.conditions; + + // Detect format by structure, not by version field (more reliable) + if (conditionData?.filterType === 'group') { + // V2 format: {filterType: "group", logicalOperator: "AND", conditions: [...]} + // This works whether or not there's a version field + conditions = transformV2ToExpression(conditionData); + } else if (conditionData?.version === 2 && conditionData?.conditions) { + // V2 format with version wrapper: {version: 2, conditions: {filterType: "group", ...}} + conditions = transformV2ToExpression(conditionData.conditions); + } else if (conditionData?.or || conditionData?.and) { + // V1 format: {or: [...]} or {and: [...]} + conditions = transformToExpression(conditionData); + } else { + // V0 format (legacy query_condition) or other formats + // Try to display as-is if it's a string, otherwise show placeholder + conditions = typeof conditionData === 'string' ? conditionData : '--'; + } } else if (data.condition.sql) { conditions = data.condition.sql; } else if (data.condition.promql) { @@ -2383,6 +2344,7 @@ export default defineComponent({ folderIdToBeCloned.value = folderId.value; }; + // V1 format: {or: [...]} or {and: [...]} function transformToExpression(data: any, wrap = true): any { if (!data) return null; @@ -2406,6 +2368,46 @@ export default defineComponent({ const joined = parts.join(` ${label} `); return wrap ? `(${joined})` : joined; } + + // V2 format: {filterType: "group", logicalOperator: "AND", conditions: [...]} + function transformV2ToExpression(group: any, isRoot = true): string { + if (!group || !group.conditions || group.conditions.length === 0) { + return ''; + } + + const parts: string[] = []; + + group.conditions.forEach((item: any, index: number) => { + if (item.filterType === 'group') { + // Nested group - recursively build expression + const nestedExpr = transformV2ToExpression(item, false); + if (nestedExpr) { + // Use parent group's logicalOperator for the operator before nested groups + if (index > 0) { + parts.push(`${group.logicalOperator} (${nestedExpr})`); + } else { + parts.push(`(${nestedExpr})`); + } + } + } else { + // Condition + const column = item.column || 'field'; + const operator = item.operator || '='; + const value = typeof item.value === 'string' ? `'${item.value}'` : item.value; + const conditionStr = `${column} ${operator} ${value}`; + + // Add operator before condition (except for first item) + if (index > 0 && item.logicalOperator) { + parts.push(`${item.logicalOperator} ${conditionStr}`); + } else { + parts.push(conditionStr); + } + } + }); + + const joined = parts.join(' '); + return isRoot ? `(${joined})` : joined; + } //this function is used to filter the alerts by the local search not the global search //this will be used when the user is searching for the alerts in the same folder const filterAlertsByQuery = (query: string) => { diff --git a/web/src/components/alerts/FilterCondition.spec.ts b/web/src/components/alerts/FilterCondition.spec.ts index 0153112046..721888e7da 100644 --- a/web/src/components/alerts/FilterCondition.spec.ts +++ b/web/src/components/alerts/FilterCondition.spec.ts @@ -32,6 +32,7 @@ describe('FilterCondition.vue Branch Coverage', () => { column: '', operator: '', value: '', + logicalOperator: 'AND', }, streamFields: [ { label: 'Field 1', value: 'field1' }, @@ -41,6 +42,7 @@ describe('FilterCondition.vue Branch Coverage', () => { index: 0, label: 'AND', depth: 0, + isFirstInGroup: true, }; beforeEach(() => { @@ -68,12 +70,17 @@ describe('FilterCondition.vue Branch Coverage', () => { expect(labelElement.text().trim()).toBe('if'); }); - it('should show computed label when index is not 0', async () => { + it('should show lowercase operator when not first in group', async () => { const wrapper = mount(FilterCondition, { props: { ...defaultProps, + condition: { + ...defaultProps.condition, + logicalOperator: 'OR', + }, index: 1, // Branch condition: index != 0 depth: 0, + isFirstInGroup: false, label: 'OR', }, global: { @@ -84,17 +91,20 @@ describe('FilterCondition.vue Branch Coverage', () => { }, }); - // Branch: computedLabel (when index != 0) - const labelElement = wrapper.find('.tw-text-sm'); - expect(labelElement.text().trim()).toBe('OR'); + // Branch: computedLabel (when not first in group) - CSS lowercase applied + const labelElement = wrapper.find('.tw-lowercase'); + // Text content is still 'OR' but CSS applies text-transform: lowercase + expect(labelElement.exists()).toBe(true); + expect(labelElement.text().trim()).toBe('OR'); // Content is uppercase, CSS transforms to lowercase visually }); - it('should show "if" when index is 0 regardless of depth', async () => { + it('should show "if" only for first condition in root group (index 0, depth 0)', async () => { const wrapper = mount(FilterCondition, { props: { ...defaultProps, index: 0, - depth: 1, // Even with depth != 0, index 0 should show "if" + depth: 0, // V2: "if" only shown for root group first condition + isFirstInGroup: true, label: 'AND', }, global: { @@ -105,10 +115,34 @@ describe('FilterCondition.vue Branch Coverage', () => { }, }); - // Branch: index == 0 shows "if" regardless of depth (updated logic) + // Branch: index == 0 && depth == 0 shows "if" const labelElement = wrapper.find('.tw-text-sm'); expect(labelElement.text().trim()).toBe('if'); }); + + it('should show empty space for first condition in nested groups', async () => { + const wrapper = mount(FilterCondition, { + props: { + ...defaultProps, + index: 0, + depth: 1, // Nested group + isFirstInGroup: true, + label: 'AND', + }, + global: { + plugins: [Quasar, mockI18n], + provide: { + store: mockStore, + }, + }, + }); + + // Branch: isFirstInGroup && depth > 0 shows empty space (no label) + const labelContainer = wrapper.find('.tw-min-w-\\[60px\\]'); + expect(labelContainer.exists()).toBe(true); + // Should not contain operator label or "if" + expect(labelContainer.text().trim()).toBe(''); + }); }); @@ -371,11 +405,16 @@ describe('FilterCondition.vue Branch Coverage', () => { }); describe('Computed Label Branch Coverage', () => { - it('should return the correct computed label', async () => { + it('should return the correct computed label when not first in group', async () => { const wrapper = mount(FilterCondition, { props: { ...defaultProps, label: 'CUSTOM_LABEL', + isFirstInGroup: false, // V2: Only show label when not first + condition: { + ...defaultProps.condition, + logicalOperator: 'AND', + }, }, global: { plugins: [Quasar, mockI18n], @@ -385,8 +424,26 @@ describe('FilterCondition.vue Branch Coverage', () => { }, }); - // Test computedLabel computed property - expect((wrapper.vm as any).computedLabel).toBe('CUSTOM_LABEL'); + // Test computedLabel computed property - should return condition's logicalOperator + expect((wrapper.vm as any).computedLabel).toBe('AND'); + }); + + it('should return empty string for first condition in group', async () => { + const wrapper = mount(FilterCondition, { + props: { + ...defaultProps, + isFirstInGroup: true, // First condition in group + }, + global: { + plugins: [Quasar, mockI18n], + provide: { + store: mockStore, + }, + }, + }); + + // Test computedLabel computed property - should return empty string + expect((wrapper.vm as any).computedLabel).toBe(''); }); }); }); \ No newline at end of file diff --git a/web/src/components/alerts/FilterCondition.vue b/web/src/components/alerts/FilterCondition.vue index e441491131..9c09d39d64 100644 --- a/web/src/components/alerts/FilterCondition.vue +++ b/web/src/components/alerts/FilterCondition.vue @@ -1,9 +1,39 @@