From 4e70bcfe33bc7a084b2f49d19f6f0f99e76c373b Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Thu, 29 Oct 2026 11:47:46 +0000 Subject: [PATCH 01/10] fix(traces): span header wrap, empty stacktrace layout, Eval Jobs Name width (#16035) --- .../onlineEvals/EvalJobList.spec.ts | 57 +++++++++++++++++++ .../components/onlineEvals/EvalJobList.vue | 1 + .../traces/TraceDetailsSidebar.spec.ts | 11 ++++ .../plugins/traces/TraceDetailsSidebar.vue | 13 ++++- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 web/src/enterprise/components/onlineEvals/EvalJobList.spec.ts diff --git a/web/src/enterprise/components/onlineEvals/EvalJobList.spec.ts b/web/src/enterprise/components/onlineEvals/EvalJobList.spec.ts new file mode 100644 index 0000000000..04816124da --- /dev/null +++ b/web/src/enterprise/components/onlineEvals/EvalJobList.spec.ts @@ -0,0 +1,57 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { defineComponent } from "vue"; + +vi.mock("@/types/i18n", async (importOriginal) => { + const actual: any = await importOriginal(); + return { ...actual, useI18nTyped: () => ({ t: (key: string) => key }) }; +}); + +vi.mock("@/lib/vue-shortcut-manager", () => ({ useShortcuts: vi.fn() })); + +import EvalJobList from "./EvalJobList.vue"; + +const OTableStub = defineComponent({ + name: "OTable", + props: { columns: { type: Array, default: () => [] } }, + template: "
", +}); + +function mountList() { + return mount(EvalJobList, { + props: { rows: [], search: "" }, + global: { + stubs: { + OTable: OTableStub, + EvalListShell: { template: "
" }, + }, + }, + }); +} + +describe("EvalJobList columns", () => { + it("gives the flex Name column a 160px floor so the fixed columns cannot squeeze it", () => { + const columns = mountList().findComponent(OTableStub).props("columns") as any[]; + const name = columns.find((c) => c.id === "name"); + expect(name).toBeDefined(); + expect(name.meta.flex).toBe(true); + expect(name.minSize).toBe(160); + }); +}); diff --git a/web/src/enterprise/components/onlineEvals/EvalJobList.vue b/web/src/enterprise/components/onlineEvals/EvalJobList.vue index ac198b1779..7c1f149551 100644 --- a/web/src/enterprise/components/onlineEvals/EvalJobList.vue +++ b/web/src/enterprise/components/onlineEvals/EvalJobList.vue @@ -337,6 +337,7 @@ const columns = computed(() => accessorKey: "name", sortable: true, size: COL.name, + minSize: 160, // `flex` (not `autoWidth`): fills leftover width on load AND stays // resizable — matches Dashboards/AlertList; `autoWidth` has no resize grip. meta: { align: "left", flex: true }, diff --git a/web/src/plugins/traces/TraceDetailsSidebar.spec.ts b/web/src/plugins/traces/TraceDetailsSidebar.spec.ts index 32c74ce1aa..a9a197c992 100644 --- a/web/src/plugins/traces/TraceDetailsSidebar.spec.ts +++ b/web/src/plugins/traces/TraceDetailsSidebar.spec.ts @@ -396,6 +396,17 @@ describe("TraceDetailsSidebar", async () => { expect(spanId.text()).toContain(mockSpan.span_id); }); + it("wraps the header chips and keeps the action group from shrinking", () => { + const chips = wrapper.find('[data-test="trace-details-sidebar-header-toolbar-chips"]'); + const actions = wrapper.find('[data-test="trace-details-sidebar-header-toolbar-actions"]'); + expect(chips.classes()).toEqual(expect.arrayContaining(["flex-wrap", "min-w-0"])); + expect(chips.classes()).not.toContain("flex-nowrap"); + expect(actions.classes()).toContain("shrink-0"); + expect( + actions.find('[data-test="trace-details-sidebar-header-toolbar-span-id"]').exists(), + ).toBe(true); + }); + it("should emit close when close button is clicked", async () => { const closeBtn = wrapper.find('[data-test="trace-details-sidebar-header-close-btn"]'); expect(closeBtn.exists()).toBe(true); diff --git a/web/src/plugins/traces/TraceDetailsSidebar.vue b/web/src/plugins/traces/TraceDetailsSidebar.vue index a5fdd01f9b..5c031b0ce4 100644 --- a/web/src/plugins/traces/TraceDetailsSidebar.vue +++ b/web/src/plugins/traces/TraceDetailsSidebar.vue @@ -64,7 +64,10 @@ along with this program. If not, see . >
-
+
.
-
+
Date: Thu, 29 Oct 2026 12:34:05 +0000 Subject: [PATCH 02/10] chore(alerts): remove alert notification grouping (#16027) --- .../http/src/handler/http/router/openapi.rs | 2 - .../src/request/alerts/dedup_stats.rs | 13 - src/config/src/meta/alerts/deduplication.rs | 216 +-- src/config/src/meta/alerts/grouping.rs | 9 - src/config/src/metrics/base.rs | 89 -- src/core/src/alerts/alert.rs | 80 +- src/core/src/alerts/grouping.rs | 1184 ----------------- src/core/src/alerts/mod.rs | 2 - src/core/src/alerts/scheduler/handlers.rs | 334 +---- src/core/src/http.rs | 1 - src/jobs/src/job/alert_grouping.rs | 49 - src/jobs/src/job/mod.rs | 4 - web/src/ts/interfaces/alert.ts | 6 - 13 files changed, 30 insertions(+), 1959 deletions(-) delete mode 100644 src/core/src/alerts/grouping.rs delete mode 100644 src/jobs/src/job/alert_grouping.rs diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs index dfe40aca8b..e680da4372 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -748,8 +748,6 @@ use crate::{ config::meta::alerts::deduplication::GlobalDeduplicationConfig, config::meta::correlation::FieldAlias, config::meta::alerts::deduplication::DeduplicationConfig, - config::meta::alerts::deduplication::GroupingConfig, - config::meta::alerts::deduplication::SendStrategy, openobserve_api_management::request::alerts::dedup_stats::DedupSummaryResponse, // Backfill pipelines::backfill::BackfillRequest, diff --git a/src/api/management/src/request/alerts/dedup_stats.rs b/src/api/management/src/request/alerts/dedup_stats.rs index abd7ccbd5b..a8765f8c0f 100644 --- a/src/api/management/src/request/alerts/dedup_stats.rs +++ b/src/api/management/src/request/alerts/dedup_stats.rs @@ -32,8 +32,6 @@ pub struct DedupSummaryResponse { pub passed_total: i64, /// Suppression rate (0.0 to 1.0) pub suppression_rate: f64, - /// Current pending batches (grouping) - pub pending_batches: i64, /// Timestamp when data was collected pub timestamp: i64, } @@ -71,13 +69,6 @@ pub async fn get_dedup_summary(Path(org_id): Path) -> Response { .filter(|alert| alert.deduplication.as_ref().is_some_and(|d| d.enabled)) .count(); - // Get current pending batches count from in-memory state - #[cfg(feature = "enterprise")] - let pending_batches = openobserve_core::alerts::grouping::get_pending_batch_count(&org_id); - - #[cfg(not(feature = "enterprise"))] - let pending_batches = 0; - // For suppressions/passed counts, we'll use database for now // In future, could query Prometheus metrics for time-series data #[cfg(feature = "enterprise")] @@ -100,7 +91,6 @@ pub async fn get_dedup_summary(Path(org_id): Path) -> Response { suppressions_total, passed_total, suppression_rate, - pending_batches, timestamp: config::utils::time::now_micros(), }; @@ -126,14 +116,12 @@ mod tests { suppressions_total: 100, passed_total: 200, suppression_rate: 0.333, - pending_batches: 3, timestamp: 1000, }; assert_eq!(r.total_alerts, 10); assert_eq!(r.alerts_with_dedup, 5); assert_eq!(r.suppressions_total, 100); assert_eq!(r.passed_total, 200); - assert_eq!(r.pending_batches, 3); } #[test] @@ -144,7 +132,6 @@ mod tests { suppressions_total: 0, passed_total: 0, suppression_rate: 0.0, - pending_batches: 0, timestamp: 0, }; assert_eq!(r.total_alerts, 0); diff --git a/src/config/src/meta/alerts/deduplication.rs b/src/config/src/meta/alerts/deduplication.rs index bc7cc7de25..1633f7f613 100644 --- a/src/config/src/meta/alerts/deduplication.rs +++ b/src/config/src/meta/alerts/deduplication.rs @@ -133,10 +133,6 @@ pub struct DeduplicationConfig { deserialize_with = "deserialize_optional_i32" )] pub time_window_minutes: Option, - - /// Optional alert grouping configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub grouping: Option, } impl MemorySize for DeduplicationConfig { @@ -145,80 +141,6 @@ impl MemorySize for DeduplicationConfig { } } -/// Configuration for alert grouping -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq)] -pub struct GroupingConfig { - /// Enable alert grouping - #[serde(default)] - pub enabled: bool, - - /// Maximum number of alerts in a single group - #[serde(default = "default_max_group_size")] - pub max_group_size: usize, - - /// How to send grouped notifications - #[serde(default)] - pub send_strategy: SendStrategy, - - /// Initial wait time before sending first notification (seconds) - #[serde(default = "default_group_wait")] - pub group_wait_seconds: i64, -} - -impl MemorySize for GroupingConfig { - fn mem_size(&self) -> usize { - std::mem::size_of::() - } -} - -impl Default for GroupingConfig { - fn default() -> Self { - Self { - enabled: false, - max_group_size: default_max_group_size(), - send_strategy: SendStrategy::default(), - group_wait_seconds: default_group_wait(), - } - } -} - -/// Strategy for sending grouped alert notifications -#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum SendStrategy { - /// Send only the first alert with a count of others - #[default] - #[serde(rename = "first_with_count")] - FirstWithCount, - - /// Send an aggregated summary of all alerts - Summary, - - /// Send all alerts in one notification - All, -} - -impl std::str::FromStr for SendStrategy { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "first_with_count" => Ok(Self::FirstWithCount), - "summary" => Ok(Self::Summary), - "all" => Ok(Self::All), - _ => Ok(Self::default()), - } - } -} - -fn default_max_group_size() -> usize { - 100 -} - -fn default_group_wait() -> i64 { - 30 -} - impl GlobalDeduplicationConfig { /// Validate the organization-level deduplication configuration pub fn validate(&self) -> Result<(), String> { @@ -267,18 +189,6 @@ impl DeduplicationConfig { return Err("Time window must be positive".to_string()); } - // Validate grouping config if present - if let Some(grouping) = &self.grouping - && grouping.enabled - { - if grouping.max_group_size == 0 { - return Err("Max group size must be at least 1".to_string()); - } - if grouping.group_wait_seconds < 0 { - return Err("Group wait seconds must be non-negative".to_string()); - } - } - Ok(()) } } @@ -309,7 +219,6 @@ mod tests { assert!(!config.enabled); assert!(config.fingerprint_fields.is_empty()); assert_eq!(config.time_window_minutes, None); - assert_eq!(config.grouping, None); } #[test] @@ -339,7 +248,6 @@ mod tests { enabled: true, fingerprint_fields: vec!["hostname".to_string(), "service".to_string()], time_window_minutes: Some(10), - grouping: None, }; assert!(config.validate().is_ok()); @@ -375,12 +283,6 @@ mod tests { enabled: true, fingerprint_fields: vec!["service".to_string(), "hostname".to_string()], time_window_minutes: Some(10), - grouping: Some(GroupingConfig { - enabled: true, - max_group_size: 50, - send_strategy: SendStrategy::Summary, - group_wait_seconds: 60, - }), }; let json = serde_json::to_string_pretty(&config).unwrap(); @@ -401,8 +303,6 @@ mod tests { assert!(json.get("fingerprint_fields").is_none()); // time_window_minutes should be omitted when None assert!(json.get("time_window_minutes").is_none()); - // grouping should be omitted when None - assert!(json.get("grouping").is_none()); } #[test] @@ -425,26 +325,6 @@ mod tests { ); } - #[test] - fn test_send_strategy_from_str() { - use std::str::FromStr; - assert_eq!( - SendStrategy::from_str("first_with_count").unwrap(), - SendStrategy::FirstWithCount - ); - assert_eq!( - SendStrategy::from_str("summary").unwrap(), - SendStrategy::Summary - ); - assert_eq!(SendStrategy::from_str("all").unwrap(), SendStrategy::All); - // unknown → default (FirstWithCount) - assert_eq!( - SendStrategy::from_str("UNKNOWN").unwrap(), - SendStrategy::default() - ); - assert_eq!(SendStrategy::from_str("").unwrap(), SendStrategy::default()); - } - #[test] fn test_global_dedup_config_validate_disabled_ok() { let config = GlobalDeduplicationConfig { @@ -502,78 +382,17 @@ mod tests { assert!(config.validate().is_ok()); } - #[test] - fn test_per_alert_dedup_config_validate_grouping_zero_size_err() { - let config = DeduplicationConfig { - enabled: true, - fingerprint_fields: vec![], - time_window_minutes: None, - grouping: Some(GroupingConfig { - enabled: true, - max_group_size: 0, - send_strategy: SendStrategy::All, - group_wait_seconds: 10, - }), - }; - let result = config.validate(); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Max group size")); - } - - #[test] - fn test_per_alert_dedup_config_validate_grouping_negative_wait_err() { - let config = DeduplicationConfig { - enabled: true, - fingerprint_fields: vec![], - time_window_minutes: None, - grouping: Some(GroupingConfig { - enabled: true, - max_group_size: 5, - send_strategy: SendStrategy::Summary, - group_wait_seconds: -1, - }), - }; - let result = config.validate(); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Group wait seconds")); - } - - #[test] - fn test_per_alert_dedup_config_validate_grouping_disabled_skip() { - // grouping present but enabled=false → skip grouping validation - let config = DeduplicationConfig { - enabled: true, - fingerprint_fields: vec![], - time_window_minutes: None, - grouping: Some(GroupingConfig { - enabled: false, - max_group_size: 0, // would fail if checked - send_strategy: SendStrategy::All, - group_wait_seconds: -1, // would fail if checked - }), - }; - assert!(config.validate().is_ok()); - } - #[test] fn test_deduplication_config_mem_size() { let config = DeduplicationConfig { enabled: true, fingerprint_fields: vec!["host".to_string(), "service".to_string()], time_window_minutes: Some(5), - grouping: None, }; let size = config.mem_size(); assert!(size >= std::mem::size_of::()); } - #[test] - fn test_grouping_config_mem_size() { - let config = GroupingConfig::default(); - let size = config.mem_size(); - assert_eq!(size, std::mem::size_of::()); - } - #[test] fn test_deserialize_optional_i32_string_number() { // time_window_minutes as a JSON string containing a number @@ -623,7 +442,6 @@ mod tests { assert!(!obj.contains_key("fingerprint_fields")); // Option::is_none → absent assert!(!obj.contains_key("time_window_minutes")); - assert!(!obj.contains_key("grouping")); } #[test] @@ -632,18 +450,28 @@ mod tests { enabled: true, fingerprint_fields: vec!["host".to_string()], time_window_minutes: Some(30), - grouping: Some(GroupingConfig { - enabled: true, - max_group_size: 10, - send_strategy: SendStrategy::Summary, - group_wait_seconds: 30, - }), }; let json = serde_json::to_value(&config).unwrap(); let obj = json.as_object().unwrap(); assert!(obj.contains_key("fingerprint_fields")); assert!(obj.contains_key("time_window_minutes")); - assert!(obj.contains_key("grouping")); + } + + #[test] + fn test_stored_grouping_object_still_deserializes_and_is_dropped_on_save() { + let stored = serde_json::json!({ + "fingerprint_fields": ["host"], + "grouping": { + "enabled": true, + "max_group_size": 50, + "send_strategy": "summary", + "group_wait_seconds": 60 + } + }); + let config: DeduplicationConfig = serde_json::from_value(stored).unwrap(); + assert_eq!(config.fingerprint_fields, vec!["host".to_string()]); + let saved = serde_json::to_value(&config).unwrap(); + assert!(saved.get("grouping").is_none()); } #[test] @@ -677,16 +505,6 @@ mod tests { assert_eq!(default_upgrade_window(), 30); } - #[test] - fn test_default_max_group_size() { - assert_eq!(default_max_group_size(), 100); - } - - #[test] - fn test_default_group_wait() { - assert_eq!(default_group_wait(), 30); - } - // ── §5.5 MN-6: reserve, then confirm on successful delivery ───────────── #[test] diff --git a/src/config/src/meta/alerts/grouping.rs b/src/config/src/meta/alerts/grouping.rs index ce206be45a..d613b17555 100644 --- a/src/config/src/meta/alerts/grouping.rs +++ b/src/config/src/meta/alerts/grouping.rs @@ -91,11 +91,6 @@ pub enum MultiAlertError { /// A PromQL alert opted in without a `promql_condition`: there is no /// threshold to classify a series against, so no group can have a level. PromqlConditionMissing, - /// Alert-level notification grouping batches every group under one - /// fingerprint with enqueue-time delivery accounting, collapsing the - /// per-group pages this feature exists to split apart. Rejected until - /// per-group batching (§5.5 MN-4) exists. - NotificationGroupingUnsupported, } impl std::fmt::Display for MultiAlertError { @@ -129,10 +124,6 @@ impl std::fmt::Display for MultiAlertError { Self::PromqlConditionMissing => f.write_str( "per-series alerting needs a PromQL condition to classify each series against", ), - Self::NotificationGroupingUnsupported => f.write_str( - "per-group alerting cannot yet be combined with notification grouping; turn off \ - notification grouping or per-group alerting", - ), } } } diff --git a/src/config/src/metrics/base.rs b/src/config/src/metrics/base.rs index c7904251cd..dcf1e25a97 100644 --- a/src/config/src/metrics/base.rs +++ b/src/config/src/metrics/base.rs @@ -1420,73 +1420,6 @@ pub static ALERT_RUNS_SUPPRESSED_TOTAL: Lazy = Lazy::new(|| { .expect("Metric created") }); -// Alert grouping/batching metrics -pub static ALERT_GROUPING_BATCHES_PENDING: Lazy = Lazy::new(|| { - IntGaugeVec::new( - Opts::new( - "alert_grouping_batches_pending", - "Current number of pending alert batches", - ) - .namespace(NAMESPACE) - .const_labels(create_const_labels()), - &["organization"], - ) - .expect("Metric created") -}); - -pub static ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL: Lazy = Lazy::new(|| { - IntCounterVec::new( - Opts::new( - "alert_grouping_notifications_sent_total", - "Total number of grouped notifications sent", - ) - .namespace(NAMESPACE) - .const_labels(create_const_labels()), - &["organization", "send_strategy", "reason"], - ) - .expect("Metric created") -}); - -pub static ALERT_GROUPING_BATCH_SIZE: Lazy = Lazy::new(|| { - HistogramVec::new( - HistogramOpts::new( - "alert_grouping_batch_size", - "Number of alerts per grouped notification", - ) - .namespace(NAMESPACE) - .const_labels(create_const_labels()) - .buckets(vec![1.0, 2.0, 3.0, 5.0, 10.0, 25.0, 50.0, 100.0]), - &["organization", "send_strategy"], - ) - .expect("Metric created") -}); - -pub static ALERT_GROUPING_WAIT_TIME: Lazy = Lazy::new(|| { - HistogramVec::new( - HistogramOpts::new( - "alert_grouping_wait_time_seconds", - "Time batches waited before sending (seconds)", - ) - .namespace(NAMESPACE) - .const_labels(create_const_labels()) - .buckets(vec![5.0, 10.0, 15.0, 20.0, 30.0, 45.0, 60.0, 90.0, 120.0]), - &["organization"], - ) - .expect("Metric created") -}); - -pub static ALERT_GROUPING_SEND_ERRORS_TOTAL: Lazy = Lazy::new(|| { - IntCounterVec::new( - Opts::new( - "alert_grouping_send_errors_total", - "Total grouped notification send failures", - ) - .namespace(NAMESPACE) - .const_labels(create_const_labels()), - &["organization", "error_type"], - ) - .expect("Metric created") -}); pub static META_NUM_DASHBOARDS: Lazy = Lazy::new(|| { IntGaugeVec::new( Opts::new("meta_num_dashboards", "Metadata dashboard nums") @@ -3555,23 +3488,6 @@ pub(crate) fn register(registry: &Registry) { .register(Box::new(ALERT_RUNS_SUPPRESSED_TOTAL.clone())) .expect("Metric registered"); - // alert grouping metrics - registry - .register(Box::new(ALERT_GROUPING_BATCHES_PENDING.clone())) - .expect("Metric registered"); - registry - .register(Box::new(ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL.clone())) - .expect("Metric registered"); - registry - .register(Box::new(ALERT_GROUPING_BATCH_SIZE.clone())) - .expect("Metric registered"); - registry - .register(Box::new(ALERT_GROUPING_WAIT_TIME.clone())) - .expect("Metric registered"); - registry - .register(Box::new(ALERT_GROUPING_SEND_ERRORS_TOTAL.clone())) - .expect("Metric registered"); - // db stats registry .register(Box::new(DB_QUERY_NUMS.clone())) @@ -4038,11 +3954,6 @@ mod tests { let _ = ALERT_DEDUP_PASSED_TOTAL.clone(); let _ = ALERT_DEDUP_ERRORS_TOTAL.clone(); let _ = ALERT_RUNS_SUPPRESSED_TOTAL.clone(); - let _ = ALERT_GROUPING_BATCHES_PENDING.clone(); - let _ = ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL.clone(); - let _ = ALERT_GROUPING_BATCH_SIZE.clone(); - let _ = ALERT_GROUPING_WAIT_TIME.clone(); - let _ = ALERT_GROUPING_SEND_ERRORS_TOTAL.clone(); } #[test] diff --git a/src/core/src/alerts/alert.rs b/src/core/src/alerts/alert.rs index 6ff7282175..ac197dd8df 100644 --- a/src/core/src/alerts/alert.rs +++ b/src/core/src/alerts/alert.rs @@ -336,10 +336,6 @@ pub enum AlertError { destination: String, platform: String, }, - #[error( - "notification grouping batches several alerts into one message, which cannot carry this alert's correlation key — turn off one of grouping or notify_on_recovery" - )] - RecoveryWithNotificationGrouping, #[error( "destination '{destination}' is {platform}, which is resolved by updating the alert the firing opened. That needs the firing payload to be a JSON object, and template '{template}' does not render one" )] @@ -457,22 +453,7 @@ fn validate_multi_alert_config(alert: &Alert) -> Result<(), AlertError> { &alert.trigger_condition, alert.creates_incident, ) - .map_err(AlertError::InvalidMultiAlert)?; - - // Checked here, not in `validate_multi_alert`, because the grouping - // config is a sibling of the query condition rather than part of it. - // Same shape as MN-11's incidents rule. - let notification_grouping = alert - .deduplication - .as_ref() - .and_then(|d| d.grouping.as_ref()) - .is_some_and(|g| g.enabled); - if alert.query_condition.multi_alert_enabled() && notification_grouping { - return Err(AlertError::InvalidMultiAlert( - config::meta::alerts::grouping::MultiAlertError::NotificationGroupingUnsupported, - )); - } - Ok(()) + .map_err(AlertError::InvalidMultiAlert) } /// Refuses, at save time, a custom-aggregation `group_by` entry that is not a column of the stream. @@ -534,16 +515,6 @@ async fn validate_recovery_templates(org_id: &str, alert: &Alert) -> Result<(), } } - // A batch carries no single alert's correlation key, so its recovery could close nothing. - if alert - .deduplication - .as_ref() - .and_then(|d| d.grouping.as_ref()) - .is_some_and(|g| g.enabled) - { - return Err(AlertError::RecoveryWithNotificationGrouping); - } - let alert_template = match alert.template.as_ref().filter(|n| !n.is_empty()) { Some(name) => db::alerts::templates::get(org_id, name).await.ok(), None => None, @@ -4952,37 +4923,14 @@ mod threshold_validation_tests { assert!(validate_rules(&alert).is_ok()); } - /// §5.5: alert-level notification grouping would collapse per-group pages - /// back into one batch — rejected at save time, never silently rerouted. #[test] - fn test_multi_alert_rejects_notification_grouping() { - let mut alert = multi_alert_fixture(); - alert.deduplication = Some(config::meta::alerts::deduplication::DeduplicationConfig { - enabled: true, - grouping: Some(config::meta::alerts::deduplication::GroupingConfig { - enabled: true, - ..serde_json::from_str("{}").expect("GroupingConfig defaults") - }), - ..Default::default() + fn test_multi_alert_with_a_stored_grouping_object_loads_and_validates() { + let mut stored = serde_json::to_value(multi_alert_fixture()).unwrap(); + stored["deduplication"] = json!({ + "enabled": true, + "grouping": {"enabled": true, "max_group_size": 10, "group_wait_seconds": 30} }); - - let err = validate_rules(&alert).unwrap_err(); - assert!(matches!( - err, - AlertError::InvalidMultiAlert( - config::meta::alerts::grouping::MultiAlertError::NotificationGroupingUnsupported - ) - )); - - // Grouping configured but DISABLED must stay valid. - alert - .deduplication - .as_mut() - .unwrap() - .grouping - .as_mut() - .unwrap() - .enabled = false; + let alert: Alert = serde_json::from_value(stored).expect("legacy grouping must still load"); assert!(validate_rules(&alert).is_ok()); } @@ -5475,24 +5423,12 @@ mod tests { use super::*; #[tokio::test] - async fn a_multi_alert_with_recovery_saves_but_not_with_notification_grouping() { + async fn a_multi_alert_with_recovery_saves() { let mut alert = Alert::default(); alert.notify_on_recovery = true; alert.query_condition.query_type = QueryType::PromQL; alert.query_condition.promql_multi_alert = true; assert!(validate_recovery_templates("default", &alert).await.is_ok()); - - alert.deduplication = Some(config::meta::alerts::deduplication::DeduplicationConfig { - grouping: Some(config::meta::alerts::deduplication::GroupingConfig { - enabled: true, - ..Default::default() - }), - ..Default::default() - }); - assert!(matches!( - validate_recovery_templates("default", &alert).await, - Err(AlertError::RecoveryWithNotificationGrouping) - )); } #[test] diff --git a/src/core/src/alerts/grouping.rs b/src/core/src/alerts/grouping.rs deleted file mode 100644 index e98eaea861..0000000000 --- a/src/core/src/alerts/grouping.rs +++ /dev/null @@ -1,1184 +0,0 @@ -// Copyright 2026 OpenObserve Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Alert grouping and batching for deduplication -//! -//! Implements wait-and-collect logic to batch multiple alerts with the same -//! fingerprint before sending a single grouped notification. - -use std::{ - collections::HashMap, - sync::{Arc, LazyLock as Lazy}, -}; - -use chrono::Utc; -use config::{ - meta::{ - alerts::alert::Alert, - self_reporting::usage::{RunOutcome, TriggerData}, - }, - utils::json, -}; -use dashmap::{DashMap, mapref::entry::Entry}; - -/// Keyed by [`batch_key`]: a fingerprint carries no org, so it alone would merge two orgs' batches. -static PENDING_BATCHES: Lazy>> = - Lazy::new(|| Arc::new(DashMap::new())); - -/// A batch of alerts waiting to be sent together -#[derive(Clone, Debug)] -pub struct PendingBatch { - pub fingerprint: String, - /// Group identity for a per-group batch (§5.5). `None` for an ordinary - /// alert-level batch. Every entry shares the batch's fingerprint, and the - /// group is part of that fingerprint, so one batch is always one group. - pub group_labels: Option>, - pub org_id: String, - pub alerts: Vec, - pub timer_started_at: i64, - pub group_wait_seconds: i64, - pub max_group_size: usize, - /// Evaluated level shared by every entry in this batch. Well-defined - /// because the fingerprint carries the level as an implicit component for - /// multi-level alerts — a Warning batch and a Critical batch are distinct. - pub level: Option, -} - -/// An alert waiting in a batch -#[derive(Clone, Debug)] -pub struct BatchedAlert { - pub alert: Alert, - pub rows: Vec>, - pub timestamp: i64, - /// This evaluation's history row, held back until the flush knows whether the send landed. - pub trigger_data: TriggerData, -} - -impl PendingBatch { - /// Create a new pending batch - #[allow(clippy::too_many_arguments)] - pub fn new( - fingerprint: String, - org_id: String, - alert: Alert, - rows: Vec>, - trigger_data: TriggerData, - group_wait_seconds: i64, - max_group_size: usize, - level: Option, - group_labels: Option>, - ) -> Self { - let now = Utc::now().timestamp_micros(); - Self { - fingerprint, - group_labels, - org_id, - alerts: vec![BatchedAlert { - alert, - rows, - timestamp: now, - trigger_data, - }], - timer_started_at: now, - group_wait_seconds, - max_group_size, - level, - } - } - - /// Add an alert to this batch - pub fn add_alert( - &mut self, - alert: Alert, - rows: Vec>, - trigger_data: TriggerData, - ) -> bool { - if self.alerts.len() >= self.max_group_size { - return false; // Batch full - } - - let now = Utc::now().timestamp_micros(); - self.alerts.push(BatchedAlert { - alert, - rows, - timestamp: now, - trigger_data, - }); - true - } - - /// Check if batch wait time has expired - pub fn is_expired(&self) -> bool { - let now = Utc::now().timestamp_micros(); - let elapsed_seconds = (now - self.timer_started_at) / 1_000_000; - elapsed_seconds >= self.group_wait_seconds - } - - /// Check if batch is at max capacity - pub fn is_full(&self) -> bool { - self.alerts.len() >= self.max_group_size - } -} - -/// What the batcher did with an evaluation handed to [`add_to_batch`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BatchAdmission { - /// Collected into a batch that is still waiting; its flush publishes the row. - Queued, - /// Collected into a batch that is now full; the caller flushes it immediately. - Ready, - /// The batch was full and not yet taken, so this evaluation joined no batch at all. - Refused, -} - -fn batch_key(org_id: &str, fingerprint: &str) -> String { - format!("{org_id}/{fingerprint}") -} - -/// One history row per evaluation in the batch, built before the send consumes it. -fn flush_rows(batch: &PendingBatch) -> Vec { - let group_size = batch.alerts.len() as i32; - batch - .alerts - .iter() - .map(|batched| TriggerData { - dedup_enabled: Some(true), - grouped: Some(true), - group_size: Some(group_size), - ..batched.trigger_data.clone() - }) - .collect() -} - -/// A failed send is `NotifyFailed`, not `Error`: the evaluation itself succeeded. -fn stamp_flush_error(rows: &mut [TriggerData], error: &anyhow::Error) { - let text = format!("error sending notification for alert: {error}"); - for row in rows { - row.status = RunOutcome::NotifyFailed; - row.error = Some(text.clone()); - row.delivery_attempted = Some(true); - } -} - -/// Add alert to pending batch or create new batch, reporting whether it was collected at all. -#[allow(clippy::too_many_arguments)] -pub fn add_to_batch( - fingerprint: String, - org_id: String, - alert: Alert, - rows: Vec>, - trigger_data: TriggerData, - group_wait_seconds: i64, - max_group_size: usize, - level: Option, - // Group identity when this is a per-group batch (§5.5). Every entry - // sharing a fingerprint shares a group, because the group is part of the - // fingerprint — so this is set once, when the batch is created. - group_labels: Option>, -) -> BatchAdmission { - match PENDING_BATCHES.entry(batch_key(&org_id, &fingerprint)) { - Entry::Occupied(mut entry) => { - let batch = entry.get_mut(); - let alert_name = alert.name.clone(); - if !batch.add_alert(alert, rows, trigger_data) { - log::warn!( - "[grouping] Failed to add alert '{}' to batch {} (already full), org_id: {}", - alert_name, - fingerprint, - org_id - ); - return BatchAdmission::Refused; - } - log::debug!( - "[grouping] Added alert '{}' to existing batch {} (count: {}/{})", - alert_name, - fingerprint, - batch.alerts.len(), - batch.max_group_size - ); - if batch.is_full() { - log::info!( - "[grouping] Batch {} reached max size ({}), ready to send", - fingerprint, - batch.max_group_size - ); - BatchAdmission::Ready - } else { - BatchAdmission::Queued - } - } - Entry::Vacant(entry) => { - log::info!( - "[grouping] Created new batch for fingerprint {}, alert: '{}', org: {}, wait_seconds: {}, max_size: {}", - fingerprint, - alert.name, - org_id, - group_wait_seconds, - max_group_size - ); - let batch = entry.insert(PendingBatch::new( - fingerprint, - org_id, - alert, - rows, - trigger_data, - group_wait_seconds, - max_group_size, - level, - group_labels, - )); - if batch.is_full() { - BatchAdmission::Ready - } else { - BatchAdmission::Queued - } - } - } -} - -/// Get and remove a batch if it's ready (expired or full) -pub fn get_ready_batch(org_id: &str, fingerprint: &str) -> Option { - // Checked inside `remove_if`: a `get` guard held across `remove` deadlocks the shard. - PENDING_BATCHES - .remove_if(&batch_key(org_id, fingerprint), |_, batch| { - batch.is_expired() || batch.is_full() - }) - .map(|(_, batch)| batch) - .inspect(|batch| { - log::debug!( - "[grouping] Retrieved ready batch for fingerprint {} ({} alerts)", - fingerprint, - batch.alerts.len() - ); - }) -} - -/// Take every expired batch out of the map, and report each org's remaining pending batches. -pub fn get_expired_batches() -> Vec { - let mut expired = Vec::new(); - let mut pending_per_org: HashMap = HashMap::new(); - let now = Utc::now().timestamp_micros(); - - PENDING_BATCHES.retain(|key, batch| { - if batch.is_expired() { - let elapsed_seconds = (now - batch.timer_started_at) / 1_000_000; - log::info!( - "[grouping] Batch {} expired after {}s with {} alerts", - key, - elapsed_seconds, - batch.alerts.len() - ); - expired.push(batch.clone()); - false // Remove from map - } else { - if let Some(count) = pending_per_org.get_mut(&batch.org_id) { - *count += 1; - } else { - pending_per_org.insert(batch.org_id.clone(), 1); - } - true // Keep in map - } - }); - // Recounted here, off the scheduler path; reset drops orgs whose batches drained. - config::metrics::ALERT_GROUPING_BATCHES_PENDING.reset(); - for (org_id, count) in &pending_per_org { - config::metrics::ALERT_GROUPING_BATCHES_PENDING - .with_label_values(&[org_id.as_str()]) - .set(*count); - } - - if !expired.is_empty() { - log::debug!( - "[grouping] Found {} expired batches, {} still pending", - expired.len(), - PENDING_BATCHES.len() - ); - } - - expired -} - -/// Get count of pending batches for an organization -pub fn get_pending_batch_count(org_id: &str) -> i64 { - PENDING_BATCHES - .iter() - .filter(|entry| entry.org_id == org_id) - .count() as i64 -} - -/// The row of an evaluation no batch took: it fired, and no notification was sent for it. -pub fn refused_row(trigger_data: &TriggerData) -> TriggerData { - TriggerData { - status: RunOutcome::NotifyFailed, - error: Some( - "alert grouping batch was already full; this evaluation joined no batch and no \ - notification was sent" - .to_string(), - ), - delivery_attempted: Some(false), - ..trigger_data.clone() - } -} - -/// Sends a batch and publishes its members' rows; false when nothing was delivered. -#[cfg(feature = "enterprise")] -pub async fn flush_batch(trace_id: &str, batch: PendingBatch) -> bool { - let decisions = entry_decisions(&batch).await; - flush_decided(trace_id, batch, decisions).await -} - -/// Sends one batch's notification; `Ok` says whether any destination or workflow was tried. -#[cfg(feature = "enterprise")] -pub async fn send_grouped_notification( - trace_id: &str, - batch: crate::alerts::grouping::PendingBatch, -) -> Result { - use config::meta::alerts::deduplication::SendStrategy; - - use crate::alerts::alert::AlertExt; - - let elapsed_seconds = - (chrono::Utc::now().timestamp_micros() - batch.timer_started_at) / 1_000_000; - - log::info!( - "[alert_grouping_worker] Sending grouped notification for {} alerts with fingerprint {} (waited {}s)", - batch.alerts.len(), - batch.fingerprint, - elapsed_seconds - ); - - // Record wait time metric - config::metrics::ALERT_GROUPING_WAIT_TIME - .with_label_values(&[batch.org_id.as_str()]) - .observe(elapsed_seconds as f64); - - // Get the first alert (primary) and grouping config - let primary_alert = &batch.alerts[0].alert; - let grouping_config = primary_alert - .deduplication - .as_ref() - .and_then(|d| d.grouping.as_ref()) - .ok_or_else(|| anyhow::anyhow!("Grouping config not found"))?; - - let send_strategy = &grouping_config.send_strategy; - - // Collect alert details - let alert_names: Vec = batch.alerts.iter().map(|a| a.alert.name.clone()).collect(); - - let alert_count = batch.alerts.len(); - let suppressed_count = alert_count.saturating_sub(1); - - // Build notification context based on strategy - let notification_context = match send_strategy { - SendStrategy::FirstWithCount => { - // "Alert a60 fired (2 others suppressed: a80, a90)" - if suppressed_count > 0 { - let suppressed_names = alert_names[1..].join(", "); - format!( - "Alert '{}' fired ({} other{} suppressed: {})", - alert_names[0], - suppressed_count, - if suppressed_count == 1 { "" } else { "s" }, - suppressed_names - ) - } else { - format!("Alert '{}' fired", alert_names[0]) - } - } - SendStrategy::Summary => { - // "3 alerts fired: a60, a80, a90" - format!( - "{} alert{} fired: {}", - alert_count, - if alert_count == 1 { "" } else { "s" }, - alert_names.join(", ") - ) - } - SendStrategy::All => { - // Full details of each alert - let mut details = vec![format!("Grouped Alerts ({} total)", alert_count)]; - for (i, batched) in batch.alerts.iter().enumerate() { - details.push(format!( - "\n{}. Alert: '{}'\n Triggered at: {}\n Rows matched: {}", - i + 1, - batched.alert.name, - chrono::DateTime::::from_timestamp( - batched.timestamp / 1_000_000, - 0 - ) - .map(|dt| dt.to_rfc3339()) - .unwrap_or_else(|| "unknown".to_string()), - batched.rows.len() - )); - } - details.join("\n") - } - }; - - log::info!( - "[alert_grouping_worker] Strategy: {:?}, Sending notification with context: {}", - send_strategy, - notification_context.chars().take(200).collect::() // Log first 200 chars - ); - - // Prepare combined trigger data based on strategy - let combined_rows = match send_strategy { - SendStrategy::FirstWithCount | SendStrategy::Summary => { - // For FirstWithCount and Summary, just use the first alert's data - batch.alerts[0].rows.clone() - } - SendStrategy::All => { - // For All, combine all rows from all alerts - let mut all_rows = Vec::new(); - for batched in &batch.alerts { - all_rows.extend(batched.rows.clone()); - } - all_rows - } - }; - - // Use the primary alert for notification and inject grouped context - let mut notification_alert = primary_alert.clone(); - - // Inject grouped context into context_attributes for template access - let mut context_attrs = notification_alert.context_attributes.unwrap_or_default(); - context_attrs.insert("grouped_alerts".to_string(), alert_names.join(", ")); - context_attrs.insert("alert_count".to_string(), alert_count.to_string()); - context_attrs.insert("grouped_summary".to_string(), notification_context.clone()); - context_attrs.insert("is_grouped".to_string(), "true".to_string()); - notification_alert.context_attributes = Some(context_attrs); - - // Get the timestamp for notification - let rows_end_time = batch - .alerts - .iter() - .map(|a| a.timestamp) - .max() - .unwrap_or_else(|| chrono::Utc::now().timestamp_micros()); - - let start_time = batch.alerts.iter().map(|a| a.timestamp).min(); - - let evaluation_timestamp = chrono::Utc::now().timestamp_micros(); - - // Send notification using alert's send_notification method - match notification_alert - .send_notification( - trace_id, - &combined_rows, - rows_end_time, - start_time, - evaluation_timestamp, - // Well-defined for the whole batch: the fingerprint carries the - // level as an implicit component for multi-level alerts, so every - // entry classified the same. `{alert_level}` renders it. - batch.level, - // A batch aggregates several evaluations; no single exact count - // describes it — `{alert_count}` falls back to the row total. - None, - // Group identity for a batched per-group send (M-4). Every entry - // in a batch shares one fingerprint, and the group component is - // part of that fingerprint, so the whole batch is one group. - batch.group_labels.as_ref(), - &[], - None, - ) - .await - { - Ok(outcome) => { - let (success_msg, err_msg) = (outcome.success_message, outcome.error_message); - if !err_msg.is_empty() { - log::error!( - "[alert_grouping_worker] Some destinations failed for grouped notification (org_id: {}, fingerprint: {}): {}", - batch.org_id, - batch.fingerprint, - err_msg - ); - // Record error metric - config::metrics::ALERT_GROUPING_SEND_ERRORS_TOTAL - .with_label_values(&[batch.org_id.as_str(), "partial_failure"]) - .inc(); - return Err(anyhow::anyhow!("Partial failure: {}", err_msg)); - } - - // Record successful send metrics - let strategy_str = format!("{:?}", send_strategy).to_lowercase(); - let reason = if elapsed_seconds >= grouping_config.group_wait_seconds { - "expired" - } else { - "max_size" - }; - - config::metrics::ALERT_GROUPING_NOTIFICATIONS_SENT_TOTAL - .with_label_values(&[batch.org_id.as_str(), strategy_str.as_str(), reason]) - .inc(); - - config::metrics::ALERT_GROUPING_BATCH_SIZE - .with_label_values(&[batch.org_id.as_str(), strategy_str.as_str()]) - .observe(alert_count as f64); - - log::info!( - "[alert_grouping_worker] Successfully sent grouped notification (fingerprint: {}): {}", - batch.fingerprint, - success_msg - ); - Ok(outcome.attempted) - } - Err(e) => { - log::error!( - "[alert_grouping_worker] Failed to send grouped notification (org_id: {}, fingerprint: {}): {}", - batch.org_id, - batch.fingerprint, - e - ); - // Record error metric - config::metrics::ALERT_GROUPING_SEND_ERRORS_TOTAL - .with_label_values(&[batch.org_id.as_str(), "send_failed"]) - .inc(); - Err(anyhow::anyhow!("Send failed: {}", e)) - } - } -} - -/// A member muted at its own evaluation leaves with a `Suppressed` row and does not count. -#[cfg(feature = "enterprise")] -async fn flush_decided( - trace_id: &str, - mut batch: PendingBatch, - decisions: Vec>, -) -> bool { - drop_muted_entries(&mut batch, decisions); - if batch.alerts.is_empty() { - return false; - } - let mut rows = flush_rows(&batch); - let delivered = match send_grouped_notification(trace_id, batch).await { - Ok(attempted) => { - for row in &mut rows { - row.delivery_attempted = Some(attempted); - } - true - } - Err(e) => { - stamp_flush_error(&mut rows, &e); - false - } - }; - for row in rows { - usage_reporting::publish_triggers_usage(row); - } - delivered -} - -/// Judged at each entry's own timestamp, so a firing admitted before a window is still sent. -#[cfg(feature = "enterprise")] -async fn entry_decisions( - batch: &PendingBatch, -) -> Vec> { - if !crate::alerts::downtimes::any_since( - &batch.org_id, - config::meta::downtimes::TargetModule::Alerts, - oldest_entry(batch), - ) { - return vec![None; batch.alerts.len()]; - } - let mut decisions = Vec::with_capacity(batch.alerts.len()); - for (entry, at) in batch.alerts.iter().zip(judged_at(batch)) { - decisions.push(entry_downtime(entry, at).await); - } - decisions -} - -/// The instant each entry is judged at: its own evaluation, never the flush. -#[cfg(feature = "enterprise")] -fn judged_at(batch: &PendingBatch) -> Vec { - batch.alerts.iter().map(|entry| entry.timestamp).collect() -} - -/// The downtime gate looks back to here, so a window that ended before the flush still mutes. -#[cfg(feature = "enterprise")] -fn oldest_entry(batch: &PendingBatch) -> i64 { - judged_at(batch).into_iter().min().unwrap_or_default() -} - -#[cfg(feature = "enterprise")] -fn drop_muted_entries( - batch: &mut PendingBatch, - decisions: Vec>, -) { - for (entry, downtime) in take_muted(batch, decisions) { - log::info!( - "[alert_grouping_worker] alert {}/{} dropped from its batch by downtime {}", - entry.alert.org_id, - entry.alert.name, - downtime.id - ); - crate::alerts::alert::count_suppressed_run(&entry.alert.org_id, "alerts"); - usage_reporting::publish_triggers_usage(suppressed_entry_record(&entry, downtime)); - } -} - -/// Removes the muted entries from the batch and returns them with their downtime. -#[cfg(feature = "enterprise")] -fn take_muted( - batch: &mut PendingBatch, - decisions: Vec>, -) -> Vec<(BatchedAlert, config::meta::downtimes::ActiveDowntime)> { - let (kept, muted) = split_muted(std::mem::take(&mut batch.alerts), decisions); - batch.alerts = kept; - muted -} - -/// The entries no downtime covers, in their order, and the others with their downtime. -#[cfg(feature = "enterprise")] -fn split_muted( - entries: Vec, - decisions: Vec>, -) -> (Vec, Vec<(T, config::meta::downtimes::ActiveDowntime)>) { - let mut kept = Vec::with_capacity(entries.len()); - let mut muted = Vec::new(); - for (entry, decision) in entries.into_iter().zip(decisions) { - match decision { - Some(downtime) => muted.push((entry, downtime)), - None => kept.push(entry), - } - } - (kept, muted) -} - -#[cfg(feature = "enterprise")] -async fn entry_downtime( - entry: &BatchedAlert, - at: i64, -) -> Option { - let alert_id = entry.alert.id.as_ref()?.to_string(); - let (folder, _) = - crate::db::alerts::alert::get_alert_from_cache(&entry.alert.org_id, &alert_id).await?; - let multi = entry.alert.query_condition.multi_alert_enabled(); - let identity = - crate::alerts::scheduler::handlers::downtime_identity_by_key(&entry.alert, &entry.rows) - .await; - let mut decision: Option = None; - for (key, dims) in &identity { - let downtime = crate::alerts::downtimes::active_for_alert_key( - &entry.alert.org_id, - &alert_id, - &folder.folder_id, - dims, - multi.then_some(key.as_str()), - at, - )?; - decision = Some(match decision { - Some(merged) => crate::alerts::downtimes::enterprise::merged(merged, downtime), - None => downtime, - }); - } - decision -} - -/// The held-back evaluation row of a dropped entry, recorded as suppressed by its downtime. -#[cfg(feature = "enterprise")] -fn suppressed_entry_record( - entry: &BatchedAlert, - downtime: config::meta::downtimes::ActiveDowntime, -) -> TriggerData { - TriggerData { - status: RunOutcome::Suppressed, - downtime_id: Some(downtime.id), - delivery_attempted: Some(false), - error: None, - grouped: Some(true), - ..entry.trigger_data.clone() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_alert() -> Alert { - serde_json::from_value(serde_json::json!({})).unwrap() - } - - fn admit(fp: &str, org: &str, max_group_size: usize, row: TriggerData) -> BatchAdmission { - add_to_batch( - fp.to_string(), - org.to_string(), - make_alert(), - vec![], - row, - 3600, - max_group_size, - None, - None, - ) - } - - fn add_one(fp: &str, org: &str, max_group_size: usize) -> bool { - admit(fp, org, max_group_size, TriggerData::default()) == BatchAdmission::Ready - } - - fn evaluation_row(key: &str) -> TriggerData { - TriggerData { - key: key.to_string(), - status: RunOutcome::Firing, - ..Default::default() - } - } - - #[test] - fn test_pending_batch_new_has_one_alert() { - let batch = PendingBatch::new( - "fp1".to_string(), - "myorg".to_string(), - make_alert(), - vec![], - TriggerData::default(), - 30, - 10, - None, - None, - ); - assert_eq!(batch.alerts.len(), 1); - assert!(!batch.is_full()); - } - - #[test] - fn test_pending_batch_is_full_at_capacity() { - let mut batch = PendingBatch::new( - "fp2".to_string(), - "myorg".to_string(), - make_alert(), - vec![], - TriggerData::default(), - 30, - 2, - None, - None, - ); - assert!(!batch.is_full()); - let added = batch.add_alert(make_alert(), vec![], TriggerData::default()); - assert!(added); - assert!(batch.is_full()); - } - - #[test] - fn test_pending_batch_add_alert_returns_false_when_full() { - let mut batch = PendingBatch::new( - "fp3".to_string(), - "myorg".to_string(), - make_alert(), - vec![], - TriggerData::default(), - 30, - 1, - None, - None, - ); - assert!(batch.is_full()); - let added = batch.add_alert(make_alert(), vec![], TriggerData::default()); - assert!(!added); - assert_eq!(batch.alerts.len(), 1); - } - - #[test] - fn test_pending_batch_not_expired_immediately() { - let batch = PendingBatch::new( - "fp4".to_string(), - "myorg".to_string(), - make_alert(), - vec![], - TriggerData::default(), - 3600, // 1 hour wait - 10, - None, - None, - ); - assert!(!batch.is_expired()); - } - - #[test] - fn test_add_to_batch_creates_new_batch() { - let (fp, org) = ("grouping_test_add_creates_new_batch_unique", "org-test-add"); - - assert!(!add_one(fp, org, 10)); - assert!(PENDING_BATCHES.contains_key(&batch_key(org, fp))); - - PENDING_BATCHES.remove(&batch_key(org, fp)); - } - - #[test] - fn test_add_to_batch_returns_true_when_full() { - let (fp, org) = ("grouping_test_add_batch_full_unique", "org-test-full"); - - assert!(!add_one(fp, org, 2)); // new batch, 1 alert, not full - assert!(add_one(fp, org, 2)); // 2nd alert fills batch, ready=true - - PENDING_BATCHES.remove(&batch_key(org, fp)); - } - - #[test] - fn test_get_ready_batch_returns_none_when_not_expired() { - let (fp, org) = ("grouping_test_get_ready_not_expired_unique", "org-test-get"); - add_one(fp, org, 10); - - assert!(get_ready_batch(org, fp).is_none()); // 3600s wait, not expired - - PENDING_BATCHES.remove(&batch_key(org, fp)); - } - - #[test] - fn test_get_pending_batch_count_counts_org_batches() { - let (fp1, fp2) = ( - "grouping_count_fp1_unique_org", - "grouping_count_fp2_unique_org", - ); - let org = "org-count-unique-test"; - add_one(fp1, org, 10); - add_one(fp2, org, 10); - - assert!(get_pending_batch_count(org) >= 2); - - PENDING_BATCHES.remove(&batch_key(org, fp1)); - PENDING_BATCHES.remove(&batch_key(org, fp2)); - } - - #[test] - fn test_add_to_batch_keeps_orgs_with_same_fingerprint_apart() { - let fp = "grouping_test_same_fp_two_orgs"; - let (org_a, org_b) = ("org-isolation-a", "org-isolation-b"); - - assert!(!add_one(fp, org_a, 2)); - assert!(!add_one(fp, org_b, 2)); - for org in [org_a, org_b] { - let batch = PENDING_BATCHES.get(&batch_key(org, fp)).unwrap(); - assert_eq!(batch.org_id, org); - assert_eq!(batch.alerts.len(), 1); - } - - PENDING_BATCHES.remove(&batch_key(org_a, fp)); - PENDING_BATCHES.remove(&batch_key(org_b, fp)); - } - - #[test] - fn test_get_ready_batch_takes_full_batch() { - let fp = "grouping_test_get_ready_full"; - let org = "org-ready-full"; - assert!(!add_one(fp, org, 2)); - assert!(add_one(fp, org, 2)); - - // Run on another thread so a deadlock fails the test instead of hanging it. - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || tx.send(get_ready_batch(org, fp))); - let batch = rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("get_ready_batch deadlocked") - .expect("a full batch is ready"); - - assert_eq!(batch.alerts.len(), 2); - assert!(!PENDING_BATCHES.contains_key(&batch_key(org, fp))); - } - - #[test] - fn test_get_ready_batch_ignores_other_org() { - let fp = "grouping_test_get_ready_other_org"; - let (owner, other) = ("org-ready-owner", "org-ready-other"); - assert!(!add_one(fp, owner, 2)); - assert!(add_one(fp, owner, 2)); - - assert!(get_ready_batch(other, fp).is_none()); - assert!(PENDING_BATCHES.contains_key(&batch_key(owner, fp))); - - PENDING_BATCHES.remove(&batch_key(owner, fp)); - } - - #[test] - fn test_add_to_batch_ready_when_max_size_is_one() { - let fp = "grouping_test_max_size_one"; - let org = "org-max-size-one"; - - assert!(add_one(fp, org, 1)); - assert_eq!(get_ready_batch(org, fp).unwrap().alerts.len(), 1); - } - - #[test] - fn test_flush_rows_carry_each_held_back_row_with_the_real_group_size() { - let (fp, org) = ("grouping_test_flush_rows", "org-flush-rows"); - assert_eq!( - admit(fp, org, 10, evaluation_row("a/1")), - BatchAdmission::Queued - ); - assert_eq!( - admit(fp, org, 10, evaluation_row("b/2")), - BatchAdmission::Queued - ); - let batch = PENDING_BATCHES.remove(&batch_key(org, fp)).unwrap().1; - - let rows = flush_rows(&batch); - let keys: Vec<&str> = rows.iter().map(|row| row.key.as_str()).collect(); - assert_eq!(keys, ["a/1", "b/2"]); - for row in &rows { - assert_eq!(row.status, RunOutcome::Firing); - assert_eq!( - (row.dedup_enabled, row.grouped, row.group_size), - (Some(true), Some(true), Some(2)) - ); - } - } - - #[test] - fn test_stamp_flush_error_marks_every_member_notify_failed() { - let mut rows = vec![evaluation_row("a/1"), evaluation_row("b/2")]; - stamp_flush_error(&mut rows, &anyhow::anyhow!("Send failed: http 500")); - for row in &rows { - assert_eq!(row.status, RunOutcome::NotifyFailed); - assert_eq!( - row.error.as_deref(), - Some("error sending notification for alert: Send failed: http 500") - ); - // A failure must count as an attempt, or failures could outnumber attempts. - assert_eq!(row.delivery_attempted, Some(true)); - } - } - - #[test] - fn test_add_to_batch_refuses_an_evaluation_when_the_full_batch_was_not_taken() { - let (fp, org) = ("grouping_test_refused", "org-refused"); - assert_eq!( - admit(fp, org, 1, evaluation_row("a/1")), - BatchAdmission::Ready - ); - assert_eq!( - admit(fp, org, 1, evaluation_row("a/2")), - BatchAdmission::Refused - ); - let batch = PENDING_BATCHES.remove(&batch_key(org, fp)).unwrap().1; - assert_eq!(batch.alerts.len(), 1); - assert_eq!(batch.alerts[0].trigger_data.key, "a/1"); - } - - #[test] - fn test_refused_row_is_notify_failed_and_not_shown_as_sent() { - let row = refused_row(&evaluation_row("a/2")); - assert_eq!(row.key, "a/2"); - assert_eq!(row.status, RunOutcome::NotifyFailed); - assert!( - row.error - .is_some_and(|error| error.contains("no notification was sent")) - ); - // The history page shows a dedup-enabled, ungrouped row as "notification sent". - assert_eq!((row.dedup_enabled, row.grouped), (None, None)); - assert_eq!(row.delivery_attempted, Some(false)); - } - - #[test] - fn test_expiry_sweep_reports_pending_batches_per_org() { - let (busy, drained) = ("org-gauge-busy", "org-gauge-drained"); - let (fp1, fp2) = ("grouping_gauge_fp1", "grouping_gauge_fp2"); - let gauge = |org: &str| { - config::metrics::ALERT_GROUPING_BATCHES_PENDING - .with_label_values(&[org]) - .get() - }; - add_one(fp1, busy, 10); - add_one(fp2, busy, 10); - add_one(fp1, drained, 2); - - get_expired_batches(); - assert_eq!((gauge(busy), gauge(drained)), (2, 1)); - - assert!(add_one(fp1, drained, 2)); - assert!(get_ready_batch(drained, fp1).is_some()); - get_expired_batches(); - assert_eq!((gauge(busy), gauge(drained)), (2, 0)); - - PENDING_BATCHES.remove(&batch_key(busy, fp1)); - PENDING_BATCHES.remove(&batch_key(busy, fp2)); - } - - #[cfg(feature = "enterprise")] - fn downtime(id: &str) -> config::meta::downtimes::ActiveDowntime { - config::meta::downtimes::ActiveDowntime { - id: id.to_string(), - name: id.to_string(), - ends_at: 0, - incident_mode: Default::default(), - ..Default::default() - } - } - - #[cfg(feature = "enterprise")] - #[test] - fn a_flush_drops_the_muted_entries_and_keeps_the_rest_in_order() { - let (kept, muted) = split_muted( - vec!["a", "b", "c"], - vec![None, Some(downtime("dt-1")), None], - ); - assert_eq!(kept, ["a", "c"]); - assert_eq!(muted.len(), 1); - assert_eq!((muted[0].0, muted[0].1.id.as_str()), ("b", "dt-1")); - - let (kept, muted) = split_muted(vec!["a"], vec![Some(downtime("dt-1"))]); - assert!(kept.is_empty(), "an emptied batch sends nothing"); - assert_eq!(muted.len(), 1); - } - - #[cfg(feature = "enterprise")] - fn queued_batch(fp: &str, org: &str, timestamps: &[i64]) -> PendingBatch { - for (i, _) in timestamps.iter().enumerate() { - admit(fp, org, 10, evaluation_row(&format!("a/{i}"))); - } - let mut batch = PENDING_BATCHES.remove(&batch_key(org, fp)).unwrap().1; - for (entry, at) in batch.alerts.iter_mut().zip(timestamps) { - entry.timestamp = *at; - } - batch - } - - // Dropping an entry publishes its row, and the usage queue starts on a Tokio runtime. - #[cfg(feature = "enterprise")] - #[tokio::test] - async fn an_entry_queued_before_the_window_stays_in_the_batch_at_flush() { - let window_opens = 100; - let mut batch = queued_batch("grouping_test_before_window", "org-before", &[50, 150]); - let decisions = judged_at(&batch) - .into_iter() - .map(|at| (at >= window_opens).then(|| downtime("dt-1"))) - .collect(); - drop_muted_entries(&mut batch, decisions); - let kept: Vec<_> = batch.alerts.iter().map(|e| e.timestamp).collect(); - assert_eq!(kept, [50]); - } - - #[cfg(feature = "enterprise")] - #[test] - fn an_entry_inside_a_window_that_ended_before_the_flush_is_still_judged() { - use config::meta::downtimes::{ - Downtime, DowntimeSchedule, DowntimeTarget, Repeat, TargetFolders, TargetModule, - }; - - use crate::alerts::downtimes::enterprise::{any_live_target, any_target_since}; - - const MINUTE: i64 = 60_000_000; - let (opens, closes) = (540 * MINUTE, 600 * MINUTE); - let only_window = Downtime { - alert_groups: None, - end_on_clear: None, - source: None, - id: "dt-1".to_string(), - org: "org-ended".to_string(), - folder_id: "default".to_string(), - name: "dt-1".to_string(), - reason: None, - condition: None, - targets: vec![DowntimeTarget { - module: TargetModule::Alerts, - folders: TargetFolders::All, - tags: vec![], - ids: vec![], - slo_mode: None, - incident_mode: Default::default(), - }], - schedule: DowntimeSchedule { - repeat: Repeat::None, - starts_at: opens, - ends_at: Some(closes), - timezone: "UTC".to_string(), - start_time_local: None, - duration_secs: 3_600, - weekdays: vec![], - }, - cancelled_at: None, - cancelled_by: None, - show_banner: false, - notifications: None, - origin_region: None, - version: 1, - created_by: "lin".to_string(), - created_at: 0, - updated_by: "lin".to_string(), - updated_at: 0, - }; - let rows = std::slice::from_ref(&only_window); - let batch = queued_batch("grouping_test_ended_window", "org-ended", &[598 * MINUTE]); - let flush = 602 * MINUTE; - assert!(!any_live_target(rows, TargetModule::Alerts, flush)); - assert!(any_target_since( - rows, - TargetModule::Alerts, - oldest_entry(&batch), - flush - )); - } - - #[cfg(feature = "enterprise")] - #[tokio::test] - async fn an_emptied_batch_records_no_delivery() { - let batch = queued_batch("grouping_test_emptied", "org-emptied", &[150, 160]); - let decisions = vec![Some(downtime("dt-1")), Some(downtime("dt-1"))]; - assert!(!flush_decided("t", batch, decisions).await); - } - - #[cfg(feature = "enterprise")] - #[test] - fn a_dropped_entry_is_recorded_as_suppressed_with_its_downtime() { - let entry = BatchedAlert { - alert: make_alert(), - rows: vec![], - timestamp: 5, - trigger_data: TriggerData { - start_time: 5, - end_time: 9, - ..evaluation_row("a/1") - }, - }; - let record = suppressed_entry_record(&entry, downtime("dt-1")); - assert_eq!(record.key, "a/1"); - assert_eq!(record.status, RunOutcome::Suppressed); - assert_eq!(record.downtime_id.as_deref(), Some("dt-1")); - assert_eq!((record.start_time, record.end_time), (5, 9)); - assert_eq!(record.grouped, Some(true)); - assert_eq!(record.delivery_attempted, Some(false)); - } - - #[cfg(feature = "enterprise")] - #[test] - fn a_muted_entry_leaves_the_batch_before_its_rows_are_built() { - let (fp, org) = ("grouping_test_muted_flush", "org-muted-flush"); - assert_eq!( - admit(fp, org, 10, evaluation_row("a/1")), - BatchAdmission::Queued - ); - assert_eq!( - admit(fp, org, 10, evaluation_row("b/2")), - BatchAdmission::Queued - ); - let mut batch = PENDING_BATCHES.remove(&batch_key(org, fp)).unwrap().1; - - let muted = take_muted(&mut batch, vec![None, Some(downtime("dt-1"))]); - let suppressed: Vec<_> = muted - .into_iter() - .map(|(entry, downtime)| suppressed_entry_record(&entry, downtime)) - .collect(); - let sent = flush_rows(&batch); - - // One row per evaluation: the muted one only as Suppressed, the rest as the batch. - assert_eq!(suppressed.len(), 1); - assert_eq!(suppressed[0].key, "b/2"); - assert_eq!(suppressed[0].status, RunOutcome::Suppressed); - let keys: Vec<&str> = sent.iter().map(|row| row.key.as_str()).collect(); - assert_eq!(keys, ["a/1"]); - assert_eq!(sent[0].group_size, Some(1)); - } -} diff --git a/src/core/src/alerts/mod.rs b/src/core/src/alerts/mod.rs index b9fcf7b132..71d99f1db0 100644 --- a/src/core/src/alerts/mod.rs +++ b/src/core/src/alerts/mod.rs @@ -57,8 +57,6 @@ pub mod destinations; pub mod downtimes; pub mod external_alerts; #[cfg(feature = "enterprise")] -pub mod grouping; -#[cfg(feature = "enterprise")] pub mod incidents; pub mod lifecycle; #[cfg(feature = "enterprise")] diff --git a/src/core/src/alerts/scheduler/handlers.rs b/src/core/src/alerts/scheduler/handlers.rs index df876df811..495760c3d6 100644 --- a/src/core/src/alerts/scheduler/handlers.rs +++ b/src/core/src/alerts/scheduler/handlers.rs @@ -2782,9 +2782,9 @@ pub(crate) async fn downtime_decision_except( }) } -/// The identity a downtime is matched on, shared by evaluation and the grouped flush. +/// The identity a downtime is matched on. #[cfg(feature = "enterprise")] -pub(crate) async fn downtime_identity( +async fn downtime_identity( alert: &config::meta::alerts::alert::Alert, rows: &[config::utils::json::Map], ) -> Vec> { @@ -2794,18 +2794,6 @@ pub(crate) async fn downtime_identity( } } -/// [downtime_identity] with each map's group key, for the group mutes of a multi-alert. -#[cfg(feature = "enterprise")] -pub(crate) async fn downtime_identity_by_key( - alert: &config::meta::alerts::alert::Alert, - rows: &[config::utils::json::Map], -) -> Vec<(String, HashMap)> { - match identity_slo(alert) { - Some(slo_id) => vec![(String::new(), slo_dimensions(&alert.org_id, slo_id).await)], - None => downtime_identities_by_key(alert, rows).await, - } -} - /// Every group's downtimes merged when each group has one; else `dispatch_per_group` decides. #[cfg(feature = "enterprise")] pub(crate) fn muted_in_every_group( @@ -3182,88 +3170,6 @@ fn correlates_muted_firing( creates_incident && incidents_enabled && downtime.incident_mode.is_muted() } -/// The names of the alert's Slack app destinations. -#[cfg(feature = "enterprise")] -async fn slack_app_names(alert: &config::meta::alerts::alert::Alert) -> Vec { - crate::alerts::slack_publish::slack_app_destinations(&alert.org_id, &alert.destinations) - .await - .into_iter() - .map(|(_, name)| name) - .collect() -} - -/// The alert as its batch carries it, without its Slack app destinations; `None` when that -/// leaves the batch nothing to notify. [pure] -#[cfg(feature = "enterprise")] -fn batch_member( - alert: &config::meta::alerts::alert::Alert, - slack_app: &[String], -) -> Option { - let mut batched = alert.clone(); - batched - .destinations - .retain(|name| !slack_app.contains(name)); - has_delivery_target(&batched).then_some(batched) -} - -/// Sends the alert to its Slack app destinations now, outside its batch; returns why one failed. -#[allow(clippy::too_many_arguments)] -#[cfg(feature = "enterprise")] -async fn send_slack_app_unbatched( - trace_id: &str, - alert: &config::meta::alerts::alert::Alert, - slack: Vec, - rows: &[serde_json::Map], - rows_end_time: i64, - start_time: Option, - triggered_at: i64, - level: Option, - actual_value: Option, -) -> Option { - use crate::alerts::alert::AlertExt; - - if slack.is_empty() { - return None; - } - let mut direct = alert.clone(); - direct.destinations = slack; - direct.workflows.clear(); - let sent = direct - .send_notification( - trace_id, - rows, - rows_end_time, - start_time, - triggered_at, - level, - actual_value, - None, - &[], - None, - ) - .await; - let error = composite_delivery_error(&sent, None); - if let Some(e) = &error { - log::error!( - "[SCHEDULER trace_id {trace_id}] alert {}: Slack app send outside its batch failed: {e}", - alert.name - ); - } - error -} - -/// Puts a failed out-of-batch Slack send on the run's row; the run itself stays delivered. [pure] -#[cfg(feature = "enterprise")] -fn note_slack_failure(row: &mut TriggerData, error: Option) { - let Some(error) = error else { - return; - }; - row.error = Some(match row.error.take() { - Some(earlier) => format!("{earlier}; {error}"), - None => error, - }); -} - /// Who acked a single-row alert's current firing, when that ack withholds this run's send. async fn single_row_ack( alert: &config::meta::alerts::alert::Alert, @@ -4108,10 +4014,9 @@ async fn handle_alert_triggers( // delivery. Writing only one would either re-page forever or never again. // // Called ONLY on paths where the notification was actually delivered (or - // handed to a delivery mechanism that owns it: grouping batch, incident - // correlation). `last_notified_level`'s contract is the last DELIVERED - // level — stamping it before a send that then FAILS would make the retry - // look like a repeat inside an active silence window and suppress it. + // handed to a delivery mechanism that owns it: incident correlation). `last_notified_level`'s + // contract is the last DELIVERED level — stamping it before a send that then FAILS would make + // the retry look like a repeat inside an active silence window and suppress it. // MN-2: multi-alerts keep their delivery state on the per-group rows, so // they never write these alert-level fields. Leaving them stale would be // harmless now that nothing reads them for this path, but writing them @@ -4186,187 +4091,6 @@ async fn handle_alert_triggers( // notification is skipped. && delivery.should_deliver() { - // Per-group dispatch IS this alert's batching unit (§5.5 MN-1), so a - // multi-alert never takes the alert-level grouping path. Letting it - // through would batch every group under one alert-scoped fingerprint - // and record delivery at ENQUEUE time — no per-group fingerprint - // (M-5), no per-group delivery state (MN-6), and no per-group failure - // accounting (MN-7). The groups would be silently collapsed back into - // the single notification multi-alerts exist to split apart. - let is_multi_alert = alert.query_condition.multi_alert_enabled(); - - // Check if grouping is enabled BEFORE deduplication (enterprise-only feature) - #[cfg(feature = "enterprise")] - let grouping_wanted = !is_multi_alert - && has_target - && alert - .deduplication - .as_ref() - .and_then(|d| d.grouping.as_ref()) - .map(|g| g.enabled) - .unwrap_or(false); - // A batch notifies its first alert's destinations, so one with none must not open it. - #[cfg(feature = "enterprise")] - let batched = if grouping_wanted { - let slack_app = slack_app_names(&alert).await; - batch_member(&alert, &slack_app).map(|member| (member, slack_app)) - } else { - None - }; - - #[cfg(not(feature = "enterprise"))] - let _ = is_multi_alert; - - #[cfg(feature = "enterprise")] - if let Some((batch_alert, slack_app)) = batched { - { - let grouping_config = alert - .deduplication - .as_ref() - .and_then(|d| d.grouping.as_ref()) - .unwrap(); - - // Calculate fingerprint for grouping - let fingerprint = if let Some(dedup_config) = alert.deduplication.as_ref() { - let org_config = - match crate::alerts::org_config::get_deduplication_config(&new_trigger.org) - .await - { - Ok(Some(config)) => Some(config), - _ => None, - }; - - let semantic_groups = - crate::db::system_settings::get_semantic_field_groups(&new_trigger.org) - .await; - - if let Some(first_row) = data.first() { - // Level is an implicit fingerprint component: a - // Warning batch must never absorb a Critical - // escalation (§7.1). - crate::alerts::deduplication::calculate_fingerprint( - &alert, - first_row, - dedup_config, - org_config.as_ref(), - &semantic_groups, - eval_level, - ) - } else { - alert.get_unique_key() - } - } else { - alert.get_unique_key() - }; - - log::debug!( - "[SCHEDULER trace_id {scheduler_trace_id}] Adding alert to batch, org: {}, alert: {}, fingerprint: {fingerprint}, rows: {}", - new_trigger.org, - alert.name, - data.len() - ); - - // R-137: each member firing gets its own Slack app message, never a batch. - let slack_error = send_slack_app_unbatched( - &scheduler_trace_id, - &alert, - slack_app, - &data, - trigger_results.end_time, - Some(start_time), - triggered_at, - eval_level, - trigger_results.actual_value, - ) - .await; - note_slack_failure(&mut trigger_data_stream, slack_error); - - // Add to batch - let admission = crate::alerts::grouping::add_to_batch( - fingerprint.clone(), - new_trigger.org.clone(), - batch_alert, - data.clone(), - trigger_data_stream.clone(), - grouping_config.group_wait_seconds, - grouping_config.max_group_size, - eval_level, - // Alert-level batch: multi-alerts never reach here (they - // dispatch per group instead), so there is no group - // identity to carry. Per-group batching is the post-v1 - // enhancement specified in §5.5. - None, - ); - - // Whether the batch handoff counts as a DELIVERY for the - // silence/escalation baseline. Queued = yes (the flush worker - // owns it from here). Immediately flushed = only if the send - // actually succeeded — stamping a failed send would start a - // silence window with zero destinations reached and suppress - // the retry. - let grouped_delivery_ok = match admission { - crate::alerts::grouping::BatchAdmission::Ready => { - log::info!( - "[SCHEDULER trace_id {scheduler_trace_id}] Batch {fingerprint} reached max size, sending immediately", - ); - // Absent means the expiry worker took the batch and publishes its rows. - match crate::alerts::grouping::get_ready_batch( - &new_trigger.org, - &fingerprint, - ) { - Some(batch) => { - crate::alerts::grouping::flush_batch(&scheduler_trace_id, batch) - .await - } - None => true, - } - } - crate::alerts::grouping::BatchAdmission::Queued => { - log::debug!( - "[SCHEDULER trace_id {scheduler_trace_id}] Alert added to batch, waiting for more alerts or timeout, fingerprint: {}", - fingerprint - ); - true - } - crate::alerts::grouping::BatchAdmission::Refused => { - publish_triggers_usage(crate::alerts::grouping::refused_row( - &trigger_data_stream, - )); - // No batch owns this evaluation, so no silence window may open for it. - false - } - }; - - // Alert added to batch, don't send individual notification. - if grouped_delivery_ok { - record_delivery(&mut trigger_data); - } - trigger_data.period_end_time = if should_store_last_end_time { - Some(trigger_results.end_time) - } else { - None - }; - new_trigger.data = json::to_string(&trigger_data).unwrap(); - db::scheduler::update_trigger(new_trigger, true, &query_trace_id).await?; - // No episode: a batch holds several alerts, so no per-alert key can ride it. - if let Some(alert_id) = alert.id.as_ref() { - let _ = persist_alert_run_state( - &alert, - &alert_id.to_string(), - &trigger_data_stream.status, - eval_level, - trigger_results.group_classification.as_ref(), - trigger_results.rule_value.as_deref(), - &config::meta::alerts::recovery::EpisodeInput::undelivered( - alert.keep_firing_for, - ), - ) - .await; - } - return Ok(()); - } - } - // Apply deduplication if enabled (enterprise-only feature) #[cfg(feature = "enterprise")] let data = { @@ -9745,7 +9469,6 @@ mod tests { } } - /// The grouped flush calls [downtime_identity] too, so both points judge an SLO alert alike. #[cfg(feature = "enterprise")] #[test] fn an_slo_alert_is_matched_on_its_slo_dimensions_not_its_rows() { @@ -9983,51 +9706,4 @@ mod tests { "{written:?}" ); } - - #[cfg(feature = "enterprise")] - #[test] - fn a_slack_app_only_alert_opens_no_batch_and_a_mixed_one_batches_the_rest() { - let slack = vec!["slack-app".to_string()]; - let mut alert = config::meta::alerts::alert::Alert::default(); - alert.destinations = slack.clone(); - assert!( - batch_member(&alert, &slack).is_none(), - "a batch with no destinations would drop the email of an alert that joins it" - ); - alert.destinations.push("email".to_string()); - let member = batch_member(&alert, &slack).expect("the email still needs the batch"); - assert_eq!(member.destinations, vec!["email".to_string()]); - } - - #[cfg(feature = "enterprise")] - #[test] - fn a_failed_slack_send_outside_the_batch_shows_on_the_run_row() { - let failed = Err(AlertError::SendNotificationError { - error_message: "TopicNotFound: slack_outbound".to_string(), - }); - let mut row = TriggerData { - status: RunOutcome::Firing, - ..Default::default() - }; - note_slack_failure(&mut row, composite_delivery_error(&failed, None)); - assert!( - row.error - .as_deref() - .is_some_and(|e| e.contains("TopicNotFound")), - "{:?}", - row.error - ); - assert_eq!(row.status, RunOutcome::Firing, "the run stays delivered"); - - let mut partial = TriggerData { - error: Some("partial query".to_string()), - ..Default::default() - }; - note_slack_failure(&mut partial, Some("slack down".to_string())); - assert_eq!(partial.error.as_deref(), Some("partial query; slack down")); - - let mut clean = TriggerData::default(); - note_slack_failure(&mut clean, None); - assert_eq!(clean.error, None); - } } diff --git a/src/core/src/http.rs b/src/core/src/http.rs index a665ed1991..0db066b3c1 100644 --- a/src/core/src/http.rs +++ b/src/core/src/http.rs @@ -101,7 +101,6 @@ impl From for Response { | AlertError::RecoveryOnRealtimeAlert | AlertError::RecoveryDestinationsWithoutRecovery | AlertError::RecoveryDestinationIsPlatform { .. } - | AlertError::RecoveryWithNotificationGrouping | AlertError::RecoveryNeedsJsonPayload { .. } | AlertError::RecoveryNeedsRoutingKey { .. } | AlertError::MultiAlertGroupingError(_) diff --git a/src/jobs/src/job/alert_grouping.rs b/src/jobs/src/job/alert_grouping.rs deleted file mode 100644 index 5e4de20f7f..0000000000 --- a/src/jobs/src/job/alert_grouping.rs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 OpenObserve Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Background job to process expired alert batches - -use std::time::Duration; - -use tokio::time; - -/// Background worker that processes expired alert batches -pub async fn process_expired_batches() { - log::info!("[alert_grouping_worker] Starting alert grouping background worker"); - - let mut interval = time::interval(Duration::from_secs(1)); - - loop { - interval.tick().await; - - #[cfg(feature = "enterprise")] - { - let batches = openobserve_core::alerts::grouping::get_expired_batches(); - let trace_id = config::ider::generate_trace_id(); - let trace_id = format!("expired_batched_{trace_id}"); - - if !batches.is_empty() { - log::debug!( - "[alert_grouping_worker] Processing {} expired batches with trace_id {trace_id}", - batches.len() - ); - - for batch in batches { - openobserve_core::alerts::grouping::flush_batch(&trace_id, batch).await; - } - } - } - } -} diff --git a/src/jobs/src/job/mod.rs b/src/jobs/src/job/mod.rs index cece6024d4..6b4d189aa4 100644 --- a/src/jobs/src/job/mod.rs +++ b/src/jobs/src/job/mod.rs @@ -35,8 +35,6 @@ mod agent_signals; mod ai_chat_retention; mod alert_eval_ledger_reaper; mod alert_group_reaper; -#[cfg(feature = "enterprise")] -pub mod alert_grouping; #[cfg(feature = "cloud")] mod cloud; mod compactor; @@ -1405,8 +1403,6 @@ pub async fn init() -> Result<(), anyhow::Error> { tokio::task::spawn(metrics::run()); let _ = promql::run(); tokio::task::spawn(scheduler::run()); - #[cfg(feature = "enterprise")] - tokio::task::spawn(alert_grouping::process_expired_batches()); tokio::task::spawn(infra::cache::file_downloader::run()); // Note: Service discovery extraction runs automatically during parquet file processing // See src/jobs/src/job/files/parquet.rs:queue_services_from_data_file for diff --git a/web/src/ts/interfaces/alert.ts b/web/src/ts/interfaces/alert.ts index bbbd72427a..b8cca26cad 100644 --- a/web/src/ts/interfaces/alert.ts +++ b/web/src/ts/interfaces/alert.ts @@ -54,12 +54,6 @@ export interface Alert { enabled: boolean; fingerprint_fields: string[]; time_window_minutes?: number; - grouping?: { - enabled: boolean; - max_group_size: number; - send_strategy: "first_with_count" | "summary" | "all"; - group_wait_seconds: number; - }; }; creates_incident?: boolean; /** Send one notification to the firing's destinations when the alert recovers. */ From bb4411573e683f4da76de2744411d330045ca082 Mon Sep 17 00:00:00 2001 From: Huaijin Date: Thu, 29 Oct 2026 13:10:20 +0000 Subject: [PATCH 03/10] fix: keep stream stats scanning past a failing stream and add stream key length checks (#16028) --- src/compaction/src/stats.rs | 85 ++++++++++++++++++++++++++++------ src/infra/src/errors/mod.rs | 69 +++++++++++++++++++++++++++ src/infra/src/file_list/mod.rs | 76 ++++++++++++++++++++++++++++++ src/schema/src/lib.rs | 36 ++++++++++++++ 4 files changed, 253 insertions(+), 13 deletions(-) diff --git a/src/compaction/src/stats.rs b/src/compaction/src/stats.rs index 69bdd923ca..c41bb28912 100644 --- a/src/compaction/src/stats.rs +++ b/src/compaction/src/stats.rs @@ -82,6 +82,7 @@ pub async fn update_stats_from_file_list() -> Result<(), anyhow::Error> { let grouped = db::schema::list_all_streams_grouped().await; let mut total_streams = 0; + let mut failed_streams = 0; for (org_id, stream_types) in grouped { for (stream_type, streams) in stream_types { if stream_type == StreamType::Index || stream_type == StreamType::Filelist { @@ -95,18 +96,19 @@ pub async fn update_stats_from_file_list() -> Result<(), anyhow::Error> { } let start = std::time::Instant::now(); - for (date_range, is_recent) in iter.iter() { - if !is_recent && no_need_update_old_stats { - continue; - } - update_stream_stats_with_retry( - &org_id, - stream_type, - &stream_name, - date_range.clone(), - *is_recent, - ) - .await?; + // a stream that fails must not stop the stats of every stream after it + if update_stream_stats_ranges( + &org_id, + stream_type, + &stream_name, + &iter, + no_need_update_old_stats, + ) + .await + .is_err() + { + failed_streams += 1; + continue; } log::info!( @@ -116,6 +118,9 @@ pub async fn update_stats_from_file_list() -> Result<(), anyhow::Error> { } } } + if failed_streams > 0 { + log::warn!("[STATS] {failed_streams} streams failed to update stats in this scan"); + } // Update global metrics metrics::STREAM_STATS_STREAMS_TOTAL @@ -184,6 +189,29 @@ async fn update_stats_lock_node() -> Result, anyhow::Error> { } } +async fn update_stream_stats_ranges( + org_id: &str, + stream_type: StreamType, + stream_name: &str, + ranges: &[((String, String), bool)], + recent_only: bool, +) -> Result<(), anyhow::Error> { + for (date_range, is_recent) in ranges { + if !is_recent && recent_only { + continue; + } + update_stream_stats_with_retry( + org_id, + stream_type, + stream_name, + date_range.clone(), + *is_recent, + ) + .await?; + } + Ok(()) +} + // Transient metastore errors must not abort the whole run, which would restart it from scratch async fn update_stream_stats_with_retry( org_id: &str, @@ -218,7 +246,10 @@ async fn update_stream_stats_with_retry( metrics::STREAM_STATS_SCAN_ERRORS_TOTAL .with_label_values(&labels) .inc(); - if attempt >= STATS_SCAN_MAX_ATTEMPTS { + let permanent = e + .downcast_ref::() + .is_some_and(infra::errors::Error::is_data_exception); + if attempt >= STATS_SCAN_MAX_ATTEMPTS || permanent { log::error!( "[STATS] update stats for {org_id}/{stream_type}/{stream_name} failed after {attempt} attempts: {e}" ); @@ -342,6 +373,34 @@ mod tests { assert_eq!(old_range, (String::new(), boundary)); } + /// CI runs this under both meta stores; only PostgreSQL bounds the stream_stats key column. + #[tokio::test] + async fn test_too_long_stream_fails_without_retry() { + infra_file_list::create_table().await.unwrap(); + infra_file_list::create_table_index().await.unwrap(); + let stream_name = "s".repeat(300); + let start = std::time::Instant::now(); + let result = update_stream_stats_ranges( + "org", + StreamType::Logs, + &stream_name, + &stats_date_ranges(), + true, + ) + .await; + let meta_store = + config::meta::meta_store::MetaStore::from(get_config().common.meta_store.as_str()); + if meta_store == config::meta::meta_store::MetaStore::PostgreSQL { + assert!(result.is_err()); + assert!( + start.elapsed() < STATS_SCAN_RETRY_DELAY, + "a data error was retried" + ); + } else { + assert!(result.is_ok(), "{result:?}"); + } + } + #[test] fn test_yesterday_boundary_hour_is_zero() { let boundary = get_yesterday_boundary(); diff --git a/src/infra/src/errors/mod.rs b/src/infra/src/errors/mod.rs index 291c54071f..87f7e9c6fe 100644 --- a/src/infra/src/errors/mod.rs +++ b/src/infra/src/errors/mod.rs @@ -310,6 +310,16 @@ impl Error { } } + /// True for a PostgreSQL data exception (SQLSTATE class 22), which a retry cannot fix. + pub fn is_data_exception(&self) -> bool { + match self { + Error::SqlxError(sqlx::Error::Database(e)) => e + .code() + .is_some_and(|code| code.len() == 5 && code.starts_with("22")), + _ => false, + } + } + /// The HTTP status code this error maps to. This is the single source of /// truth shared by the HTTP response mapping and audit logging. pub fn http_status(&self) -> u16 { @@ -746,4 +756,63 @@ mod tests { assert_eq!(e.http_status(), 403); assert_eq!(e.to_string(), "Organization [t1] is deleted"); } + + #[derive(Debug)] + struct CodedDbError(&'static str); + + impl std::fmt::Display for CodedDbError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } + } + + impl std::error::Error for CodedDbError {} + + impl sqlx::error::DatabaseError for CodedDbError { + fn message(&self) -> &str { + self.0 + } + + fn code(&self) -> Option> { + Some(self.0.into()) + } + + fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn into_error(self: Box) -> Box { + self + } + + fn kind(&self) -> sqlx::error::ErrorKind { + sqlx::error::ErrorKind::Other + } + } + + fn db_error(code: &'static str) -> Error { + Error::SqlxError(sqlx::Error::Database(Box::new(CodedDbError(code)))) + } + + #[test] + fn test_is_data_exception() { + // 22001: string_data_right_truncation, e.g. a stream key longer than VARCHAR(256) + assert!(db_error("22001").is_data_exception()); + assert!(db_error("22P02").is_data_exception()); + // 40001 serialization_failure is transient; "22" alone is SQLite's SQLITE_NOLFS + assert!(!db_error("40001").is_data_exception()); + assert!(!db_error("22").is_data_exception()); + assert!(!Error::Message("22001".to_string()).is_data_exception()); + // callers that return anyhow, such as the stats job, downcast to reach it + let wrapped = anyhow::Error::from(db_error("22001")); + assert!( + wrapped + .downcast_ref::() + .is_some_and(Error::is_data_exception) + ); + } } diff --git a/src/infra/src/file_list/mod.rs b/src/infra/src/file_list/mod.rs index 6c18e41186..cf71286a40 100644 --- a/src/infra/src/file_list/mod.rs +++ b/src/infra/src/file_list/mod.rs @@ -37,6 +37,10 @@ pub mod sqlite; const STREAM_SCAN_WINDOW_MICROS: i64 = 3600 * 1_000_000; const STREAMS_PER_QUERY: usize = 1000; +/// Longest org id the PostgreSQL file_list and schema_history `org` columns hold. +pub const ORG_ID_MAX_LEN: usize = 100; +/// Longest `{org}/{stream_type}/{stream}` key the PostgreSQL file_list `stream` columns hold. +pub const STREAM_KEY_MAX_LEN: usize = 256; static CLIENT: Lazy> = Lazy::new(connect_default); pub static LOCAL_CACHE: Lazy> = Lazy::new(connect_local_cache); @@ -66,6 +70,19 @@ pub fn connect_local_cache() -> Box { Box::::default() } +/// Why a stream's file_list key or its org cannot be stored, if it cannot. +pub fn stream_key_violation( + org_id: &str, + stream_type: StreamType, + stream_name: &str, +) -> Option { + // only the PostgreSQL backend bounds these columns; Nats keeps file_list in SQLite + if MetaStore::from(get_config().common.meta_store.as_str()) != MetaStore::PostgreSQL { + return None; + } + stream_key_length_violation(org_id, stream_type, stream_name) +} + /// The two file tables an org deletion empties stream by stream. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StreamRowsTable { @@ -848,6 +865,26 @@ fn validate_time_range(time_range: (i64, i64)) -> Result<()> { Ok(()) } +fn stream_key_length_violation( + org_id: &str, + stream_type: StreamType, + stream_name: &str, +) -> Option { + // VARCHAR(n) counts characters, not bytes + let org_len = org_id.chars().count(); + if org_len > ORG_ID_MAX_LEN { + return Some(format!( + "Organization id too long: {org_id} is {org_len} characters, only {ORG_ID_MAX_LEN} accepted" + )); + } + let len = org_len + stream_type.as_str().len() + stream_name.chars().count() + 2; + (len > STREAM_KEY_MAX_LEN).then(|| { + format!( + "Stream name too long: {org_id}/{stream_type}/{stream_name} is {len} characters, only {STREAM_KEY_MAX_LEN} accepted" + ) + }) +} + /// Daily when the stream is dynamically merged (its files can span a day), else the type's level. pub async fn max_ts_bound_level( org_id: &str, @@ -1057,6 +1094,45 @@ mod tests { use super::*; + #[test] + fn test_stream_key_length_boundary() { + // "org/metrics/" is 12 characters + let fits = "m".repeat(STREAM_KEY_MAX_LEN - 12); + assert!(stream_key_length_violation("org", StreamType::Metrics, &fits).is_none()); + let over = format!("{fits}m"); + let violation = stream_key_length_violation("org", StreamType::Metrics, &over).unwrap(); + assert!(violation.starts_with("Stream name too long"), "{violation}"); + assert!(violation.contains("is 257 characters"), "{violation}"); + // a multi-byte name is measured in characters, as the VARCHAR column is + let wide = "é".repeat(STREAM_KEY_MAX_LEN - 12); + assert!(stream_key_length_violation("org", StreamType::Metrics, &wide).is_none()); + } + + #[test] + fn test_stream_key_length_org_boundary() { + let org = "o".repeat(ORG_ID_MAX_LEN); + assert!(stream_key_length_violation(&org, StreamType::Logs, "s").is_none()); + let org = format!("{org}o"); + let violation = stream_key_length_violation(&org, StreamType::Logs, "s").unwrap(); + assert!( + violation.starts_with("Organization id too long"), + "{violation}" + ); + } + + /// CI runs this under both meta stores; only PostgreSQL bounds the file_list key. + #[test] + fn test_stream_key_violation_follows_meta_store() { + let over = "s".repeat(STREAM_KEY_MAX_LEN); + let bounded = + MetaStore::from(get_config().common.meta_store.as_str()) == MetaStore::PostgreSQL; + assert_eq!( + stream_key_violation("org", StreamType::Logs, &over).is_some(), + bounded + ); + assert!(stream_key_violation("org", StreamType::Logs, "s").is_none()); + } + // ── FileListJobStatus::from(i32) ────────────────────────────────────────── #[test] diff --git a/src/schema/src/lib.rs b/src/schema/src/lib.rs index 13bb4a0d75..a46aebcd66 100644 --- a/src/schema/src/lib.rs +++ b/src/schema/src/lib.rs @@ -60,6 +60,7 @@ pub use watcher::{ }; const SCHEMA_CONFORMANCE_FAILED: &str = "schema_conformance_failed"; +const STREAM_NAME_TOO_LONG: &str = "stream_name_too_long"; /// Shared by both ingestion-window discard messages and the test for them. const WINDOW_DISCARD_MARKER: &str = " data can be ingested. Data discarded."; @@ -142,6 +143,27 @@ pub struct SavedStreamSettings { pub previous_settings: Option, } +/// Rejects a stream whose file_list key or org cannot be stored, counting it as an ingest error. +pub fn check_stream_key_len( + org_id: &str, + stream_type: StreamType, + stream_name: &str, +) -> Result<()> { + let Some(violation) = infra::file_list::stream_key_violation(org_id, stream_type, stream_name) + else { + return Ok(()); + }; + metrics::INGEST_ERRORS + .with_label_values(&[ + org_id, + stream_type.as_str(), + stream_name, + STREAM_NAME_TOO_LONG, + ]) + .inc(); + Err(anyhow::anyhow!("{violation}. Data discarded.")) +} + /// Validate, normalize, and persist stream settings stored in schema metadata. /// /// This is the canonical settings write path outside schema auto-evolution, which persists through @@ -1059,6 +1081,20 @@ mod tests { ))); } + #[test] + fn test_check_stream_key_len_counts_and_discards() { + let over = "s".repeat(infra::file_list::STREAM_KEY_MAX_LEN); + let result = check_stream_key_len("org", StreamType::Logs, &over); + assert_eq!( + result.is_err(), + infra::file_list::stream_key_violation("org", StreamType::Logs, &over).is_some() + ); + if let Err(err) = result { + assert!(err.to_string().ends_with("Data discarded."), "{err}"); + } + assert!(check_stream_key_len("org", StreamType::Logs, "s").is_ok()); + } + #[test] fn test_normalize_stream_settings_index_fields_updated_at() { let mut settings = StreamSettings { From c6181bb26392bc3ed238293893bd1b3ddad39a3e Mon Sep 17 00:00:00 2001 From: Ashish Kolhe <35160958+oasisk@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:20:02 +0000 Subject: [PATCH 04/10] ci: one ENT build per approved head, remove dead query-agent and claude-* workflows (#16039) --- .github/workflows/claude-auto-implement.yml | 132 ---------------- .github/workflows/claude-pr-fix.yml | 146 ------------------ .../workflows/dispatch-event-enterprise.yml | 28 +++- .github/workflows/query-agent.yml | 97 ------------ 4 files changed, 27 insertions(+), 376 deletions(-) delete mode 100644 .github/workflows/claude-auto-implement.yml delete mode 100644 .github/workflows/claude-pr-fix.yml delete mode 100644 .github/workflows/query-agent.yml diff --git a/.github/workflows/claude-auto-implement.yml b/.github/workflows/claude-auto-implement.yml deleted file mode 100644 index 3cf0e1e6eb..0000000000 --- a/.github/workflows/claude-auto-implement.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Claude Auto-Implement - -# Trigger on label or @claude_impl comment on issues/PRs -on: - issues: - types: [labeled] - issue_comment: - types: [created] - -concurrency: - group: claude-implement-${{ github.event.issue.number }} - cancel-in-progress: false - -jobs: - # Security: Check if user is a member of argocd-dev team - check-permission: - if: | - github.event.sender.type != 'Bot' && ( - (github.event_name == 'issues' && github.event.label.name == 'auto-implement') || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude_impl')) - ) - runs-on: ubuntu-latest - outputs: - authorized: ${{ steps.check.outputs.authorized }} - steps: - - name: Check team membership - id: check - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.E2E_DEV_CI_TOKEN }} - script: | - const team = 'argocd-dev'; - const org = 'openobserve'; - const username = context.actor; - - try { - // Check if user is a member of the team - await github.rest.teams.getMembershipForUserInOrg({ - org: org, - team_slug: team, - username: username - }); - console.log(`✅ ${username} is a member of ${org}/${team}`); - core.setOutput('authorized', 'true'); - } catch (error) { - if (error.status === 404) { - console.log(`❌ ${username} is NOT a member of ${org}/${team}`); - core.setOutput('authorized', 'false'); - - // Post a comment explaining why the workflow didn't run - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `⚠️ @${username} You don't have permission to trigger \`@claude_impl\`. Only members of the [argocd-dev](https://github.com/orgs/openobserve/teams/argocd-dev) team can use this command.` - }); - } else { - throw error; - } - } - - # Prepare job for label/comment triggers - detects if PR or Issue - prepare-auto: - needs: check-permission - if: | - needs.check-permission.outputs.authorized == 'true' && - github.event.sender.type != 'Bot' && ( - (github.event_name == 'issues' && github.event.label.name == 'auto-implement') || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude_impl')) - ) - runs-on: ubuntu-latest - outputs: - mode: ${{ steps.detect.outputs.mode }} - branch: ${{ steps.detect.outputs.branch }} - pr_number: ${{ steps.detect.outputs.pr_number }} - steps: - - name: Detect if PR or Issue - id: detect - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.E2E_DEV_CI_TOKEN }} - script: | - const isPR = !!context.payload.issue.pull_request; - const num = context.issue.number; - - if (isPR) { - // Get PR details to get branch name - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: num - }); - - core.setOutput('mode', 'pr_fix'); - core.setOutput('branch', pr.head.ref); - core.setOutput('pr_number', String(num)); - console.log(`Detected PR #${num} on branch '${pr.head.ref}'`); - } else { - core.setOutput('mode', 'implement'); - core.setOutput('branch', ''); - core.setOutput('pr_number', ''); - console.log(`Detected Issue #${num}`); - } - - # Dispatch to o2-enterprise workflow - dispatch: - needs: [check-permission, prepare-auto] - if: | - needs.check-permission.outputs.authorized == 'true' && - github.event.sender.type != 'Bot' && ( - (github.event_name == 'issues' && github.event.label.name == 'auto-implement') || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude_impl')) - ) - runs-on: ubuntu-latest - - steps: - - name: Dispatch Claude Implement Event to o2-enterprise - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.E2E_DEV_CI_TOKEN }} - repository: openobserve/o2-enterprise - event-type: claude-impl-requested - client-payload: |- - { - "source_repo": "openobserve", - "issue_number": "${{ github.event.issue.number }}", - "issue_title": ${{ toJSON(github.event.issue.title) }}, - "issue_body": ${{ toJSON(github.event.issue.body) }}, - "mode": "${{ needs.prepare-auto.outputs.mode }}", - "branch": "${{ needs.prepare-auto.outputs.branch }}", - "pr_number": "${{ needs.prepare-auto.outputs.pr_number }}" - } diff --git a/.github/workflows/claude-pr-fix.yml b/.github/workflows/claude-pr-fix.yml deleted file mode 100644 index c0268d7624..0000000000 --- a/.github/workflows/claude-pr-fix.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Claude Fix - -# Trigger on comments containing @claude_fix (works on both PRs and Issues) -on: - issue_comment: - types: [created] - -jobs: - # Security: Check if user is a member of argocd-dev team - check-permission: - if: | - github.event.sender.type != 'Bot' && - contains(github.event.comment.body, '@claude_fix') - runs-on: ubuntu-latest - outputs: - authorized: ${{ steps.check.outputs.authorized }} - steps: - - name: Check team membership - id: check - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.E2E_DEV_CI_TOKEN }} - script: | - const team = 'argocd-dev'; - const org = 'openobserve'; - const username = context.actor; - - try { - // Check if user is a member of the team - await github.rest.teams.getMembershipForUserInOrg({ - org: org, - team_slug: team, - username: username - }); - console.log(`✅ ${username} is a member of ${org}/${team}`); - core.setOutput('authorized', 'true'); - } catch (error) { - if (error.status === 404) { - console.log(`❌ ${username} is NOT a member of ${org}/${team}`); - core.setOutput('authorized', 'false'); - - // Post a comment explaining why the workflow didn't run - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `⚠️ @${username} You don't have permission to trigger \`@claude_fix\`. Only members of the [argocd-dev](https://github.com/orgs/openobserve/teams/argocd-dev) team can use this command.` - }); - } else { - throw error; - } - } - - # Determine context and prepare inputs for reusable workflow - prepare: - needs: check-permission - if: | - needs.check-permission.outputs.authorized == 'true' && - github.event.sender.type != 'Bot' && - contains(github.event.comment.body, '@claude_fix') - - runs-on: ubuntu-latest - outputs: - mode: ${{ steps.context.outputs.mode }} - branch: ${{ steps.context.outputs.branch }} - number: ${{ steps.context.outputs.number }} - title: ${{ steps.context.outputs.title }} - issue_body: ${{ steps.context.outputs.issue_body }} - pr_number: ${{ steps.context.outputs.pr_number }} - trigger_comment: ${{ steps.context.outputs.trigger_comment }} - trigger_comment_id: ${{ steps.context.outputs.trigger_comment_id }} - - steps: - - name: Determine Context (PR or Issue) - id: context - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.E2E_DEV_CI_TOKEN }} - script: | - const isPR = !!context.payload.issue.pull_request; - - core.setOutput('trigger_comment', context.payload.comment.body); - core.setOutput('trigger_comment_id', context.payload.comment.id); - - if (isPR) { - // Get PR details - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number - }); - - core.setOutput('mode', 'pr_fix'); - core.setOutput('branch', pr.head.ref); - core.setOutput('number', context.issue.number); - core.setOutput('title', pr.title); - core.setOutput('pr_number', context.issue.number); - core.setOutput('issue_body', pr.body || ''); - - console.log(`PR #${pr.number}: ${pr.title}`); - console.log(`Branch: ${pr.head.ref}`); - } else { - // Get Issue details - const { data: issue } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number - }); - - const branchName = `fix/issue-${issue.number}`; - - core.setOutput('mode', 'issue_fix'); - core.setOutput('branch', branchName); - core.setOutput('number', context.issue.number); - core.setOutput('title', issue.title); - core.setOutput('pr_number', ''); - core.setOutput('issue_body', issue.body || ''); - - console.log(`Issue #${issue.number}: ${issue.title}`); - console.log(`Will create branch: ${branchName}`); - } - - # Dispatch to o2-enterprise workflow (similar to dispatch-event-enterprise.yml pattern) - dispatch: - needs: [check-permission, prepare] - runs-on: ubuntu-latest - - steps: - - name: Dispatch Claude Fix Event to o2-enterprise - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.E2E_DEV_CI_TOKEN }} - repository: openobserve/o2-enterprise - event-type: claude-fix-requested - client-payload: |- - { - "source_repo": "openobserve", - "issue_number": "${{ needs.prepare.outputs.number }}", - "issue_title": ${{ toJSON(needs.prepare.outputs.title) }}, - "issue_body": ${{ toJSON(needs.prepare.outputs.issue_body) }}, - "mode": "${{ needs.prepare.outputs.mode }}", - "branch": "${{ needs.prepare.outputs.branch }}", - "pr_number": "${{ needs.prepare.outputs.pr_number }}", - "trigger_comment": ${{ toJSON(needs.prepare.outputs.trigger_comment) }}, - "trigger_comment_id": "${{ needs.prepare.outputs.trigger_comment_id }}" - } diff --git a/.github/workflows/dispatch-event-enterprise.yml b/.github/workflows/dispatch-event-enterprise.yml index 8cb67ae371..99cc984889 100644 --- a/.github/workflows/dispatch-event-enterprise.yml +++ b/.github/workflows/dispatch-event-enterprise.yml @@ -41,11 +41,17 @@ jobs: APPROVED=false if [ "$DECISION" = "APPROVED" ]; then APPROVED=true; fi # A second approval or an unrelated label must not rebuild a commit the enterprise build already passed. - STATE=$(gh api "repos/$GH_REPO/commits/$SHA/status" --jq '.statuses[] | select(.context == "ent-build-status-check") | .state') + STATUS=$(gh api "repos/$GH_REPO/commits/$SHA/status" --jq '.statuses[] | select(.context == "ent-build-status-check") | "\(.state) \(now - (.updated_at | fromdateiso8601) | floor)"') + read -r STATE AGE <<< "$STATUS" if [ "$APPROVED" = "true" ] && [ "$STATE" = "success" ]; then echo "ent-build-status-check already passed on $SHA." APPROVED=false fi + # o2-enterprise cancels the older run for a branch, so re-dispatching a SHA still building would restart it; past an hour it is presumed lost. + if [ "$APPROVED" = "true" ] && [ "$STATE" = "pending" ] && [ "${AGE:-3600}" -lt 3600 ]; then + echo "ent-build-status-check has been pending on $SHA for ${AGE}s; not re-dispatching." + APPROVED=false + fi echo "approved=$APPROVED" >> "$GITHUB_OUTPUT" initiate_enterprise_build: @@ -98,7 +104,19 @@ jobs: if: ${{ steps.classify.outputs.run_enterprise == 'true' }} run: echo ${{ env.SHORT_COMMIT_ID }} + # Posted before the dispatch so it can never overwrite the enterprise result; the approval guard reads it. + - name: Report dispatched + id: pending + if: ${{ steps.classify.outputs.run_enterprise == 'true' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_HEAD_SHA" \ + -f state=pending -f context=ent-build-status-check -f description="dispatched to o2-enterprise; waiting for its build" + - name: Repository Dispatch + id: dispatch if: ${{ steps.classify.outputs.run_enterprise == 'true' }} uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 env: @@ -109,3 +127,11 @@ jobs: repository: openobserve/o2-enterprise event-type: initiate-enterprise-build client-payload: '{"o2_enterprise_repo": "${{ env.BRANCH_NAME }}", "o2_opensource_repo": "${{ env.BRANCH_NAME }}" ,"o2_commit_id": "${{ env.SHORT_COMMIT_ID }}", "o2_full_commit":"${{env.COMMIT_ID}}", "o2_target_branch": "${{ github.event.pull_request.base.ref }}"}' + # A pending left by a dispatch that never went out would make the approval guard skip the next one for an hour. + - name: Report dispatch failed + if: ${{ (failure() || cancelled()) && steps.pending.outcome == 'success' && steps.dispatch.outcome != 'success' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_HEAD_SHA" \ + -f state=error -f context=ent-build-status-check -f description="dispatch to o2-enterprise failed; push or re-approve the PR to retry" diff --git a/.github/workflows/query-agent.yml b/.github/workflows/query-agent.yml deleted file mode 100644 index 723a3bd41d..0000000000 --- a/.github/workflows/query-agent.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Query Agent Caller (openobserve) - -# Thin caller: watches openobserve (OSS) PRs and forwards to the reusable -# Query Agent engine in o2-enterprise. All real logic (triage, build, test, -# generation) lives in the engine; this file only gates (allowlist + command -# detection) and forwards. -# -# WHY A CALLER (not self-contained): a reusable workflow runs in the CALLER's -# context, so triggering from here makes the run use openobserve's own -# GITHUB_TOKEN / github-actions[bot] — which can comment on OSS PRs, push -# to OSS branches (for Phase B autoqa commits), and create OSS PRs. The -# engine file in o2-enterprise would run in ENT context and could NOT do any -# of these for OSS. -# -# TWO ENTRY POINTS: -# - issue_comment → an allowlisted engineer comments /query-agent or -# /qa-generate on an OSS PR. -# - workflow_dispatch → manual "Run workflow" button against a PR number. -# Dispatch is inherently restricted to repo write access. -# -# ACCESS CONTROL (comment path): gate on the commenter being in the hardcoded -# engineering-team allowlist. This is checked here AND re-validated in the -# engine's triage job (harmless double-check). -# -# SECRETS: DEEPSEEK_API_KEY_E2E and ORG_ADMIN_TOKEN must exist in -# openobserve secrets. E2E_DEV_CI_TOKEN is forwarded as CROSS_REPO_TOKEN -# for any ENT-side cross-repo operations. - -on: - issue_comment: - types: [created] - workflow_dispatch: - inputs: - pr_number: - description: "OSS PR number to run Query Agent on" - required: true - dry_run: - description: "Triage only — skip tests and generation" - type: boolean - default: true - generation_mode: - description: "Phase B: generate new SQL queries for this PR" - type: choice - required: false - default: "none" - options: - - none - - full - -permissions: - contents: read - pull-requests: write - issues: write - -concurrency: - group: query-agent-openobserve-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }} - cancel-in-progress: true - -jobs: - # Validate workflow_dispatch inputs before forwarding to the engine. - # Skipped for issue_comment events (no inputs to validate). - validate_inputs: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: Validate pr_number is a positive integer - env: - PR_NUMBER: ${{ inputs.pr_number }} - run: | - if ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then - echo "::error::pr_number must be a positive integer, got: $PR_NUMBER" - exit 1 - fi - - call-query-agent: - needs: [validate_inputs] - # Manual dispatch: always run (restricted to repo write access by GitHub). - # Comment: only if the commenter is on the allowlist AND the command matches. - if: > - github.event_name == 'workflow_dispatch' || - (github.event.issue.pull_request && - contains(fromJSON('["/query-agent","/qa-generate"]'), github.event.comment.body) && - contains(fromJSON('["bjp232004","hengfeiyang","prabhatsharma","uddhavdave","oasisk","haohuaijin","Subhra264","Loaki07","007harshmahajan","chaitanya-sistla","ktx-kirtan","omkarK06","ktx-vaidehi","ktx-abhay","neha00290","ktx-akshay","ktx-sadhna","nikhilsaikethe","YashodhanJoshi1","ktx-riya","mmosarafO2","Shrinath-O2","ktx-mihir","vamsikarthikr","pr1sm8"]'), github.actor)) - uses: openobserve/o2-enterprise/.github/workflows/query-agent.yml@cb74bab8b9da410f525f7931a9396ec8cd885665 - with: - source_repo: openobserve - # dispatch supplies pr_number directly; comment path uses the commented PR number. - pr_number: ${{ inputs.pr_number || github.event.issue.number }} - # generation_mode: dispatch uses the typed choice; comment path: /qa-generate → full. - generation_mode: ${{ github.event_name == 'workflow_dispatch' && (inputs.generation_mode || 'none') || (github.event.comment.body == '/qa-generate' && 'full' || 'none') }} - # dry_run: dispatch honors the typed boolean; comment path always runs (not dry). - dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} - actor: ${{ github.actor }} - secrets: - DEEPSEEK_API_KEY_E2E: ${{ secrets.DEEPSEEK_API_KEY_E2E }} - ORG_ADMIN_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }} - CROSS_REPO_TOKEN: ${{ secrets.E2E_DEV_CI_TOKEN }} From f88272514512336c2f24d5e8d7b00735094ae439 Mon Sep 17 00:00:00 2001 From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:38:24 +0000 Subject: [PATCH 05/10] feat(web): keep unsaved editor work as browser drafts across the app (#15913) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 114 +- .../pages/alertsPages/alertCreationWizard.js | 3 + .../alertsPages/alertDestinationsPage.js | 12 + .../pages/alertsPages/alertTemplatesPage.js | 12 + .../pages/alertsPages/alertsPage.js | 3 + .../pages/alertsPages/downtimesPage.js | 12 + .../pages/alertsPages/editorDraftHelpers.js | 20 + .../anomalyPages/anomalyDetectionPage.js | 2 + .../dashboardPages/dashboard-settings.js | 16 + .../pages/dashboardPages/dashboardPage.js | 3 + .../pages/functionsPages/functionsPage.js | 13 + .../aiToolsetsFormValidationPage.js | 3 + .../pages/generalPages/cipherKeys.js | 8 +- .../pages/generalPages/draftSignOut.js | 20 + .../ui-testing/pages/generalPages/homePage.js | 2 + .../pages/generalPages/modelPricingPage.js | 5 + .../regexPatternsFormValidationPage.js | 4 + tests/ui-testing/pages/iamPages/rolesPage.js | 7 +- tests/ui-testing/pages/logsPages/logsPage.js | 2 + .../pages/pipelinesPages/pipelinesPage.js | 27 +- .../pages/reportsPages/reportsPage.js | 3 + .../pages/rumPages/rumProductAnalyticsPage.js | 4 +- .../pages/sdrPages/sdrPatternsPage.js | 8 + .../ui-testing/pages/sloPages/sloFormPage.js | 7 + .../syntheticsPages/syntheticsCreatePage.js | 6 +- .../pages/tracesPages/tracesPage.js | 2 + .../IAM/iam-roles-staging.spec.js | 10 +- .../Pipelines/pipeline-traces.spec.js | 2 +- .../Pipelines/pipelines.spec.js | 14 +- .../rum-product-analytics.spec.js | 2 +- .../alerts-13243-prebuilt-form-reuse.spec.js | 4 +- .../SDR/regexPatternManagement.spec.js | 4 +- web/src/components/ConfirmDialogProvider.vue | 7 +- .../ai_toolsets/AddAiToolset.drafts.spec.ts | 509 +++++ .../components/ai_toolsets/AddAiToolset.vue | 275 ++- .../components/alerts/AddAlert.drafts.spec.ts | 776 +++++++ web/src/components/alerts/AddAlert.vue | 38 +- .../alerts/AddDestination.drafts.spec.ts | 477 +++++ .../AddDestination.slackAppReview.spec.ts | 2 + .../components/alerts/AddDestination.spec.ts | 2 + web/src/components/alerts/AddDestination.vue | 196 +- .../alerts/AddTemplate.drafts.spec.ts | 322 +++ web/src/components/alerts/AddTemplate.spec.ts | 2 + web/src/components/alerts/AddTemplate.vue | 94 +- .../alerts/AlertList.editorDrafts.spec.ts | 254 +++ web/src/components/alerts/AlertList.spec.ts | 246 +++ web/src/components/alerts/AlertList.vue | 89 +- .../AlertsDestinationList.deeplink.spec.ts | 169 ++ .../alerts/AlertsDestinationList.spec.ts | 105 +- .../alerts/AlertsDestinationList.vue | 45 +- .../components/alerts/TemplateList.spec.ts | 272 ++- web/src/components/alerts/TemplateList.vue | 117 +- .../downtimes/AddDowntime.drafts.spec.ts | 401 ++++ .../alerts/downtimes/AddDowntime.spec.ts | 2 + .../alerts/downtimes/AddDowntime.vue | 85 +- .../downtimes/DowntimeList.actions.spec.ts | 122 +- .../alerts/downtimes/DowntimeList.vue | 83 +- .../cipherkeys/AddCipherKey.drafts.spec.ts | 424 ++++ .../cipherkeys/AddCipherKey.spec.ts | 11 +- .../components/cipherkeys/AddCipherKey.vue | 239 ++- .../common/UpdatePasswordDialog.spec.ts | 160 +- .../common/UpdatePasswordDialog.vue | 39 +- .../common/drafts/EditorDraftDialogs.spec.ts | 83 + .../common/drafts/EditorDraftDialogs.vue | 55 + .../common/drafts/EditorDraftDot.spec.ts | 172 ++ .../common/drafts/EditorDraftDot.vue | 128 ++ .../common/drafts/EditorDraftInfoHint.vue | 40 + .../drafts/EditorDraftLeaveDialog.spec.ts | 181 ++ .../common/drafts/EditorDraftLeaveDialog.vue | 141 ++ .../drafts/EditorDraftRestoreDialog.spec.ts | 132 ++ .../drafts/EditorDraftRestoreDialog.vue | 122 ++ .../drafts/EditorDraftSignOutDialog.spec.ts | 66 + .../drafts/EditorDraftSignOutDialog.vue | 57 + .../components/common/drafts/useDraftLabel.ts | 31 + .../panelDraft/PanelLeaveDialog.spec.ts | 87 + .../panelDraft/PanelLeaveDialog.vue | 82 +- .../settings/AddSettingVariable.vue | 78 + .../DashboardSettingsDraftScope.spec.ts | 57 + .../settings/DashboardSettingsDraftScope.vue | 44 + .../settings/GeneralSettings.spec.ts | 93 + .../dashboards/settings/GeneralSettings.vue | 114 +- .../dashboards/settings/TabsSettings.vue | 62 +- .../dashboards/settings/VariableSettings.vue | 27 +- .../settings/dashboardSettingsDraft.ts | 235 +++ .../fleet/FleetConfigurations.spec.ts | 22 + .../components/fleet/FleetConfigurations.vue | 12 +- .../functions/AddFunction.drafts.spec.ts | 382 ++++ .../components/functions/AddFunction.spec.js | 48 +- web/src/components/functions/AddFunction.vue | 150 +- .../components/functions/FunctionList.spec.ts | 109 + web/src/components/functions/FunctionList.vue | 44 +- .../iam/groups/EditGroup.leave.spec.ts | 97 + web/src/components/iam/groups/EditGroup.vue | 5 + .../iam/groups/GroupServiceAccounts.vue | 14 +- web/src/components/iam/groups/GroupUsers.vue | 16 +- web/src/components/iam/roles/AppRoles.spec.ts | 90 +- web/src/components/iam/roles/AppRoles.vue | 9 + .../iam/roles/EditRole.drafts.spec.ts | 369 ++++ web/src/components/iam/roles/EditRole.spec.js | 20 +- web/src/components/iam/roles/EditRole.vue | 192 +- web/src/components/iam/roles/RoleTable.vue | 15 + web/src/components/iam/users/AddUser.spec.ts | 36 +- web/src/components/iam/users/AddUser.vue | 20 +- web/src/components/oncall/OnCallL0Editor.vue | 11 +- .../oncall/OnCallPolicyEditor.drafts.spec.ts | 406 ++++ .../oncall/OnCallPolicyEditor.spec.ts | 8 +- .../components/oncall/OnCallPolicyEditor.vue | 104 +- .../OnCallScheduleEditor.drafts.spec.ts | 369 ++++ .../oncall/OnCallScheduleEditor.spec.ts | 55 +- .../oncall/OnCallScheduleEditor.vue | 101 +- web/src/components/oncall/scheduleDrafts.ts | 95 + .../pipeline/PipelineEditor.drafts.spec.ts | 615 ++++++ .../pipeline/PipelineEditor.spec.js | 50 +- .../components/pipeline/PipelineEditor.vue | 400 ++-- .../components/pipeline/PipelinesList.spec.ts | 95 + web/src/components/pipeline/PipelinesList.vue | 25 + web/src/components/pipeline/pipelineDraft.ts | 123 ++ .../reports/CreateReport.drafts.spec.ts | 491 +++++ web/src/components/reports/CreateReport.vue | 123 +- web/src/components/reports/ReportHistory.vue | 11 + web/src/components/reports/ReportList.spec.ts | 159 ++ web/src/components/reports/ReportList.vue | 51 +- .../reports/ReportRunHistory.spec.ts | 29 +- .../components/reports/ReportRunHistory.vue | 8 +- .../reports/ReportRunNowDialog.spec.ts | 19 + .../components/reports/ReportRunNowDialog.vue | 14 +- web/src/components/reports/reportDraft.ts | 18 + .../NamedEventsListPage.spec.ts | 78 +- .../productAnalytics/NamedEventsListPage.vue | 89 +- .../settings/AddRegexPattern.drafts.spec.ts | 348 ++++ .../settings/AddRegexPattern.spec.ts | 3 +- .../components/settings/AddRegexPattern.vue | 100 +- web/src/components/settings/AiToolsets.vue | 47 +- .../AnnouncementBannerEditor.drafts.spec.ts | 395 ++++ .../settings/AnnouncementBannerEditor.spec.ts | 14 +- .../settings/AnnouncementBannerEditorForm.vue | 174 +- .../settings/AnnouncementBannerList.vue | 77 +- web/src/components/settings/CipherKeys.vue | 49 +- .../settings/CorrelationSettings.spec.ts | 25 + .../settings/CorrelationSettings.vue | 2 + .../ModelPricingEditor.drafts.spec.ts | 369 ++++ .../settings/ModelPricingEditor.vue | 52 + .../components/settings/ModelPricingList.vue | 90 +- .../components/settings/RegexPatternList.vue | 40 +- .../components/settings/announcementDrafts.ts | 5 + .../synthetic-monitoring/MonitorTable.spec.ts | 32 +- .../synthetic-monitoring/MonitorTable.vue | 31 +- .../synthetics/CheckTypePicker.spec.ts | 78 +- .../components/synthetics/CheckTypePicker.vue | 37 +- .../synthetics/configure/CheckAuthNetwork.vue | 6 +- .../synthetics/configure/CheckConfigure.vue | 1 + .../configure/types/CheckHttpConfig.vue | 4 +- .../composables/alerts/useAlertEditorDraft.ts | 185 ++ .../dashboard/usePanelDraft.spec.ts | 13 - .../composables/dashboard/usePanelDraft.ts | 33 +- web/src/composables/iam/useRoleSave.ts | 9 +- .../composables/rum/useFunnelDraft.spec.ts | 20 + web/src/composables/rum/useFunnelDraft.ts | 12 +- web/src/composables/rum/useNamedEvents.ts | 34 + web/src/composables/shared/router.spec.ts | 51 +- web/src/composables/shared/router.ts | 18 +- web/src/composables/useAlertForm.ts | 49 +- web/src/composables/useConfirmDialog.ts | 2 + .../composables/useEditorDraft.router.spec.ts | 108 + web/src/composables/useEditorDraft.spec.ts | 1812 +++++++++++++++++ web/src/composables/useEditorDraft.ts | 857 ++++++++ web/src/composables/useSearchWebSocket.ts | 5 + web/src/composables/useStreamingSearch.ts | 9 +- .../AIObservability/ExperimentBrowser.spec.ts | 97 +- .../AIObservability/ExperimentBrowser.vue | 43 +- .../ExperimentForm.drafts.spec.ts | 373 ++++ .../AIObservability/ExperimentForm.spec.ts | 1 + .../AIObservability/ExperimentForm.vue | 100 +- .../RemoteTaskFormPage.drafts.spec.ts | 571 ++++++ .../RemoteTaskFormPage.spec.ts | 2 + .../AIObservability/RemoteTaskFormPage.vue | 152 +- .../components/OnlineEvals.drafts.spec.ts | 736 +++++++ web/src/enterprise/components/OnlineEvals.vue | 83 + .../components/onlineEvals/EvalJobList.vue | 16 + .../components/onlineEvals/ScorerList.vue | 16 + .../onlineEvals/forms/JobFormPage.vue | 103 +- .../onlineEvals/forms/ScorerFormPage.vue | 107 +- .../onlineEvals/utils/routeSync.spec.ts | 18 + .../components/onlineEvals/utils/routeSync.ts | 11 + .../mixins/mainLayout.mixin.spec.ts | 66 + web/src/enterprise/mixins/mainLayout.mixin.ts | 21 +- .../AIObservability/ExperimentsPage.spec.ts | 37 + .../views/AIObservability/ExperimentsPage.vue | 14 + .../AIObservability/RemoteTasksPage.spec.ts | 55 +- .../views/AIObservability/RemoteTasksPage.vue | 34 +- web/src/layouts/MainLayout.rum.spec.ts | 1 + .../layouts/MainLayout.signOutDrafts.spec.ts | 183 ++ .../layouts/MainLayout.signOutEditor.spec.ts | 254 +++ web/src/layouts/MainLayout.vue | 55 +- .../core/EmptyState/EmptyStateActionCard.vue | 2 + web/src/locales/languages/ar-SA.json | 147 +- web/src/locales/languages/de-DE.json | 147 +- web/src/locales/languages/en-US.json | 115 +- web/src/locales/languages/es-ES.json | 149 +- web/src/locales/languages/fr-FR.json | 149 +- web/src/locales/languages/it-IT.json | 149 +- web/src/locales/languages/ja-JP.json | 149 +- web/src/locales/languages/ko-KR.json | 147 +- web/src/locales/languages/nl-NL.json | 149 +- web/src/locales/languages/pl-PL.json | 149 +- web/src/locales/languages/pt-PT.json | 149 +- web/src/locales/languages/ru-RU.json | 149 +- web/src/locales/languages/tr-TR.json | 149 +- web/src/locales/languages/vi-VN.json | 149 +- web/src/locales/languages/zh-CN.json | 149 +- web/src/locales/languages/zh-TW.json | 149 +- web/src/services/http.ts | 36 +- web/src/services/httpsearch.spec.ts | 15 +- web/src/services/httpsearch.ts | 6 +- web/src/stores/index.spec.ts | 113 +- web/src/stores/index.ts | 13 +- web/src/test/unit/helpers/draftDom.ts | 53 + web/src/test/unit/helpers/fillField.ts | 32 + web/src/utils/alerts/alertEditorDraft.spec.ts | 116 ++ web/src/utils/alerts/alertEditorDraft.ts | 115 ++ web/src/utils/commons.spec.ts | 36 + web/src/utils/commons.ts | 11 + web/src/utils/drafts/draftSession.ts | 117 ++ web/src/utils/drafts/editorDraftStore.spec.ts | 594 ++++++ web/src/utils/drafts/editorDraftStore.ts | 637 ++++++ .../utils/drafts/redactDraftSecrets.spec.ts | 415 ++++ web/src/utils/drafts/redactDraftSecrets.ts | 303 +++ web/src/utils/drafts/signOutFlow.spec.ts | 278 +++ web/src/utils/drafts/signOutFlow.ts | 114 ++ web/src/utils/drafts/urlCredentials.spec.ts | 49 + web/src/utils/drafts/urlCredentials.ts | 45 + web/src/utils/sessionServices.spec.ts | 103 + web/src/utils/sessionServices.ts | 92 + .../synthetics/checkDraftSecrets.spec.ts | 236 +++ web/src/utils/synthetics/checkDraftSecrets.ts | 166 ++ .../PromptEditorPage.drafts.spec.ts | 365 ++++ .../AIObservability/PromptEditorPage.vue | 108 +- .../PromptsPage.drafts.spec.ts | 159 ++ web/src/views/AIObservability/PromptsPage.vue | 37 +- .../SaveAsPromptDialog.spec.ts | 7 + web/src/views/AddAlertView.spec.ts | 63 + web/src/views/AddAlertView.vue | 37 +- .../DashboardSettings.drafts.spec.ts | 579 ++++++ .../views/Dashboards/DashboardSettings.vue | 220 +- web/src/views/Dashboards/ViewDashboard.vue | 120 +- .../addPanel/AddPanel.drafts.spec.ts | 206 +- .../views/Dashboards/addPanel/AddPanel.vue | 36 +- .../viewDashboard.defaultRefresh.spec.ts | 1 + .../views/Dashboards/viewDashboard.spec.ts | 103 +- .../viewDashboard.timeRange.spec.ts | 1 + .../views/FleetConfigEditor.drafts.spec.ts | 552 +++++ web/src/views/FleetConfigEditor.spec.ts | 109 +- web/src/views/FleetConfigEditor.vue | 173 +- web/src/views/FleetManager.spec.ts | 22 + web/src/views/FleetManager.vue | 15 + web/src/views/Functions.spec.ts | 38 +- web/src/views/Functions.vue | 19 +- web/src/views/Login.spec.ts | 13 + web/src/views/Login.vue | 14 +- web/src/views/OnCall/OnCallPolicies.spec.ts | 45 + web/src/views/OnCall/OnCallPolicies.vue | 15 + web/src/views/OnCall/OnCallTeamDetail.spec.ts | 92 + web/src/views/OnCall/OnCallTeamDetail.vue | 57 +- web/src/views/OnCall/OnCallTeams.spec.ts | 113 + web/src/views/OnCall/OnCallTeams.vue | 24 + web/src/views/RUM/AppAnalytics.spec.ts | 38 + web/src/views/RUM/AppAnalytics.vue | 26 +- .../views/RUM/NamedEventEditor.drafts.spec.ts | 304 +++ web/src/views/RUM/NamedEventEditor.spec.ts | 18 +- web/src/views/RUM/NamedEventEditor.vue | 102 +- web/src/views/SyntheticMonitoring.spec.ts | 100 +- web/src/views/SyntheticMonitoring.vue | 62 + .../views/reports/ReportDetail.drafts.spec.ts | 178 ++ web/src/views/reports/ReportDetail.vue | 11 +- web/src/views/slos/AddSlo.drafts.spec.ts | 356 ++++ web/src/views/slos/AddSlo.vue | 62 +- web/src/views/slos/SloList.drafts.spec.ts | 144 ++ web/src/views/slos/SloList.vue | 27 +- .../CreateBrowserTest.drafts.spec.ts | 493 +++++ .../synthetics/CreateBrowserTest.spec.ts | 269 ++- .../views/synthetics/CreateBrowserTest.vue | 251 ++- .../CreateProtocolCheck.drafts.spec.ts | 482 +++++ .../synthetics/CreateProtocolCheck.spec.ts | 1 + .../views/synthetics/CreateProtocolCheck.vue | 180 +- .../StatusPageEditor.drafts.spec.ts | 354 ++++ .../status-pages/StatusPageEditor.spec.ts | 3 + .../status-pages/StatusPageEditor.vue | 167 +- .../status-pages/StatusPagesList.spec.ts | 81 + .../status-pages/StatusPagesList.vue | 25 +- 289 files changed, 32510 insertions(+), 2154 deletions(-) create mode 100644 tests/ui-testing/pages/alertsPages/editorDraftHelpers.js create mode 100644 tests/ui-testing/pages/generalPages/draftSignOut.js create mode 100644 web/src/components/ai_toolsets/AddAiToolset.drafts.spec.ts create mode 100644 web/src/components/alerts/AddAlert.drafts.spec.ts create mode 100644 web/src/components/alerts/AddDestination.drafts.spec.ts create mode 100644 web/src/components/alerts/AddTemplate.drafts.spec.ts create mode 100644 web/src/components/alerts/AlertList.editorDrafts.spec.ts create mode 100644 web/src/components/alerts/AlertsDestinationList.deeplink.spec.ts create mode 100644 web/src/components/alerts/downtimes/AddDowntime.drafts.spec.ts create mode 100644 web/src/components/cipherkeys/AddCipherKey.drafts.spec.ts create mode 100644 web/src/components/common/drafts/EditorDraftDialogs.spec.ts create mode 100644 web/src/components/common/drafts/EditorDraftDialogs.vue create mode 100644 web/src/components/common/drafts/EditorDraftDot.spec.ts create mode 100644 web/src/components/common/drafts/EditorDraftDot.vue create mode 100644 web/src/components/common/drafts/EditorDraftInfoHint.vue create mode 100644 web/src/components/common/drafts/EditorDraftLeaveDialog.spec.ts create mode 100644 web/src/components/common/drafts/EditorDraftLeaveDialog.vue create mode 100644 web/src/components/common/drafts/EditorDraftRestoreDialog.spec.ts create mode 100644 web/src/components/common/drafts/EditorDraftRestoreDialog.vue create mode 100644 web/src/components/common/drafts/EditorDraftSignOutDialog.spec.ts create mode 100644 web/src/components/common/drafts/EditorDraftSignOutDialog.vue create mode 100644 web/src/components/common/drafts/useDraftLabel.ts create mode 100644 web/src/components/dashboards/panelDraft/PanelLeaveDialog.spec.ts create mode 100644 web/src/components/dashboards/settings/DashboardSettingsDraftScope.spec.ts create mode 100644 web/src/components/dashboards/settings/DashboardSettingsDraftScope.vue create mode 100644 web/src/components/dashboards/settings/dashboardSettingsDraft.ts create mode 100644 web/src/components/functions/AddFunction.drafts.spec.ts create mode 100644 web/src/components/iam/groups/EditGroup.leave.spec.ts create mode 100644 web/src/components/iam/roles/EditRole.drafts.spec.ts create mode 100644 web/src/components/oncall/OnCallPolicyEditor.drafts.spec.ts create mode 100644 web/src/components/oncall/OnCallScheduleEditor.drafts.spec.ts create mode 100644 web/src/components/oncall/scheduleDrafts.ts create mode 100644 web/src/components/pipeline/PipelineEditor.drafts.spec.ts create mode 100644 web/src/components/pipeline/pipelineDraft.ts create mode 100644 web/src/components/reports/CreateReport.drafts.spec.ts create mode 100644 web/src/components/reports/reportDraft.ts create mode 100644 web/src/components/settings/AddRegexPattern.drafts.spec.ts create mode 100644 web/src/components/settings/AnnouncementBannerEditor.drafts.spec.ts create mode 100644 web/src/components/settings/ModelPricingEditor.drafts.spec.ts create mode 100644 web/src/composables/alerts/useAlertEditorDraft.ts create mode 100644 web/src/composables/useEditorDraft.router.spec.ts create mode 100644 web/src/composables/useEditorDraft.spec.ts create mode 100644 web/src/composables/useEditorDraft.ts create mode 100644 web/src/enterprise/components/AIObservability/ExperimentForm.drafts.spec.ts create mode 100644 web/src/enterprise/components/AIObservability/RemoteTaskFormPage.drafts.spec.ts create mode 100644 web/src/enterprise/components/OnlineEvals.drafts.spec.ts create mode 100644 web/src/enterprise/mixins/mainLayout.mixin.spec.ts create mode 100644 web/src/layouts/MainLayout.signOutDrafts.spec.ts create mode 100644 web/src/layouts/MainLayout.signOutEditor.spec.ts create mode 100644 web/src/test/unit/helpers/draftDom.ts create mode 100644 web/src/test/unit/helpers/fillField.ts create mode 100644 web/src/utils/alerts/alertEditorDraft.spec.ts create mode 100644 web/src/utils/alerts/alertEditorDraft.ts create mode 100644 web/src/utils/drafts/draftSession.ts create mode 100644 web/src/utils/drafts/editorDraftStore.spec.ts create mode 100644 web/src/utils/drafts/editorDraftStore.ts create mode 100644 web/src/utils/drafts/redactDraftSecrets.spec.ts create mode 100644 web/src/utils/drafts/redactDraftSecrets.ts create mode 100644 web/src/utils/drafts/signOutFlow.spec.ts create mode 100644 web/src/utils/drafts/signOutFlow.ts create mode 100644 web/src/utils/drafts/urlCredentials.spec.ts create mode 100644 web/src/utils/drafts/urlCredentials.ts create mode 100644 web/src/utils/sessionServices.spec.ts create mode 100644 web/src/utils/sessionServices.ts create mode 100644 web/src/utils/synthetics/checkDraftSecrets.spec.ts create mode 100644 web/src/utils/synthetics/checkDraftSecrets.ts create mode 100644 web/src/views/AIObservability/PromptEditorPage.drafts.spec.ts create mode 100644 web/src/views/AIObservability/PromptsPage.drafts.spec.ts create mode 100644 web/src/views/Dashboards/DashboardSettings.drafts.spec.ts create mode 100644 web/src/views/FleetConfigEditor.drafts.spec.ts create mode 100644 web/src/views/RUM/NamedEventEditor.drafts.spec.ts create mode 100644 web/src/views/reports/ReportDetail.drafts.spec.ts create mode 100644 web/src/views/slos/AddSlo.drafts.spec.ts create mode 100644 web/src/views/slos/SloList.drafts.spec.ts create mode 100644 web/src/views/synthetics/CreateBrowserTest.drafts.spec.ts create mode 100644 web/src/views/synthetics/CreateProtocolCheck.drafts.spec.ts create mode 100644 web/src/views/synthetics/status-pages/StatusPageEditor.drafts.spec.ts create mode 100644 web/src/views/synthetics/status-pages/StatusPagesList.spec.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 50767a0af9..fe1e8604da 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -1145,8 +1145,6 @@ "descriptionPlaceholder": "dc1e7db58b2d8e76", "expectedOutputScorersOnly": "a758ba9ab3e54c96", "identitySection": "999f23fcd7bec707", - "leaveMessage": "80d5a857b711267a", - "leaveTitle": "726aa0947f6e5a3c", "managedPromptLabel": "39f780053bf3874c", "managedPromptPlaceholder": "1e841b12d6e50a5c", "modelHelp": "0017404e73bb5f67", @@ -2059,7 +2057,9 @@ "saveDraft": "6c462142d3681836", "saveDraftBlocked": "ada35dbc29caf104", "signingKeyLabel": "1e35ad097b3e5161", + "signingKeyNotKept": "83b1905702beccb4", "signingKeyOnce": "1aa204d0f83c1125", + "signingKeyRegenerated": "4373f6263a2ff96e", "signingSection": "5dab0cdd17696359", "signingToggle": "46290db957af7e4a", "submit": "859390eb495b2ead", @@ -4935,6 +4935,8 @@ "refreshCorrelatedData": "be48b202ce487e75", "relatedAlerts": "0685977cc348e3ab", "reopen": "a886d1dc4f128e27", + "reopenConfirmMessage": "86fafd629d710a4f", + "reopenConfirmTitle": "ab7b675b9e7d8901", "reopenIncidentTooltip": "7ceb345025e4d912", "rerunAnalysisCancelLabel": "49780701dc2d7cc6", "rerunAnalysisConfirmLabel": "91f507f3f665bfba", @@ -4942,6 +4944,8 @@ "rerunAnalysisMessageWithCost": "ff93e89be136dccb", "rerunAnalysisTitle": "1dbbf8659f4c7e93", "resolve": "c8f193b315c86f3a", + "resolveConfirmMessage": "4944253a343a8f91", + "resolveConfirmTitle": "936a89903b8c7a02", "scrollToBottom": "6a36c1b26f1c1ab7", "scrollToTop": "0d26ae7f847f3b0b", "search": "5e6e84d15f2209b0", @@ -5745,10 +5749,6 @@ "tools": "ea93d6a262ecb87a", "warning": "e981ddae45d8f4ca" }, - "keepEditing": "e76fd2add01005ae", - "leaveConfirm": "eb1a70e39274bb76", - "leaveMessage": "49a8469d4708244a", - "leaveTitle": "85bcf41666a53691", "messageHelp": "24291ae18455f299", "orgOption": "a37fb6c849811fda", "orgsPick": "2730183d6b776b40", @@ -7492,6 +7492,7 @@ "secretTypeRequired": "78222784e82d56e6", "staticSecret": "48207b624239110a", "staticSecretName": "dcd1d5223f73b3a9", + "staticSecretNameNotKept": "6af1e9c37ee70a6b", "staticSecretNameRequired": "5cb676adf8d94110", "step1WithType": "4811a01103cdae90", "step2": "14d01bcc63894238", @@ -7613,6 +7614,87 @@ "durationGte": "6dfb5405b57b3e78", "durationLte": "2c9e5b46d0083bea", "edit": "464c4ffd019e1e96", + "editorDraft": { + "changedOnServer": "31c5e2462dff57b5", + "changedOnServerInBrowser": "fa25c5664ea9d527", + "changesNotKept": "bdf2661fc4c03cc2", + "continueDraft": "84ab20d6d5098bb2", + "continueEditing": "422f24539e152077", + "cookieField": "d6ceef50c02cc8c1", + "credentialFileField": "18f53ff77a7b7561", + "credentialsNotSaved": "2a2f354952b1d8d1", + "credentialsNotSavedInBrowser": "3f0d7aab593d1829", + "discard": "eb1a70e39274bb76", + "dotUndated": "f8edaa9e0057bb34", + "endpointUrlField": "1ff7cefbedd3ec3e", + "entities": { + "ai_toolset": "5d4c1b546a390b3c", + "alert": "df905058dd67a409", + "alert_destination": "8f6bdfe63c656d78", + "alert_template": "e53e49efb542ef88", + "announcement_banner": "044a21366f710c63", + "cipher_key": "60dfb555634f1dde", + "dashboard_settings": "66cd9688a2ae0682", + "downtime": "f14ff57ca5d5bcf7", + "eval_job": "146cb507bd28bb93", + "eval_scorer": "c528cab03ad47ed5", + "experiment": "a530c507fd893f52", + "fleet_config": "dc439d1404bfdd76", + "function": "78f9ac018e554365", + "iam_role": "4b168d88dc872a77", + "model_pricing": "a7f057a5cd0dbaa3", + "oncall_policy": "e9a5466ecd86d187", + "oncall_schedule": "f4deccd0b1247598", + "pipeline": "23bf0d244a4cb62c", + "prompt": "cf07194ee232eb53", + "regex_pattern": "b720f8540f13a430", + "remote_task": "f99cbf3b0d4cae6b", + "report": "845e91831319e89c", + "rum_named_event": "162d29356c77bebf", + "slo": "17a0c4286c4f7d56", + "status_page": "6a374b1ae7376e00", + "synthetic_browser": "d3f1197cfb15fb2c", + "synthetic_check": "4ed4872ff67cfd6f" + }, + "envVarField": "037619c446acee51", + "headerField": "d27ee64d01cb39e5", + "headersField": "378f37b1fb33fa4d", + "itemDeleted": "43faf9172f4e875f", + "keepDraft": "bbcc810f654fd360", + "keepFailed": "1ee27b6e6b74161c", + "keepFailedSignedOut": "74fc31e3496b7931", + "keepInBrowser": "4ea64db253418471", + "leaveMessage": "40ac82c478964852", + "leaveMessageDiscardOnly": "1d475018243c1f52", + "leaveMessageForced": "2eb1b95e99d92cc3", + "leaveMessageForcedUnnamed": "ee18694e5119d37f", + "leaveMessageInBrowser": "254141f9a24f4d36", + "leaveMessageNoDraft": "13612cc1736c66e3", + "leaveTitle": "44e4660b5596bd91", + "newDot": "71c7942a26e9f022", + "newDotDated": "e186d1313f55bd18", + "passwordField": "5e884898da280471", + "reenterBeforeSave": "be01256782b88803", + "reenterField": "054cfd5a50d55bc5", + "restoreMessage": "4ea177195934a143", + "restoreTitle": "f8edaa9e0057bb34", + "restoreTitleInBrowser": "6b8c8ef67018994d", + "retention": "d5a9e776737f341c", + "retentionInBrowser": "23fbefc046415a38", + "rowDot": "b9bdf652b9717601", + "secretField": "f0794054f5c2ef3d", + "signOutConfirm": "48f0d3d397d49f13", + "signOutDiscardsOthers": "59c04992e719d48a", + "signOutMessage": "aebeeea15b9a9ade", + "signOutTitle": "a10549eb0cd583fa", + "startFresh": "652aceea790a47a6", + "startingUrlField": "39723c1bea479cfc", + "stay": "08fd1de4b04df4a2", + "stepValueField": "79e4ee1042a39163", + "tokenField": "3c469e9d6c5875d3", + "urlField": "e7a241debad56609", + "variableField": "12b704ef49f6f67b" + }, "enable": "5342e09f2729fbc6", "endTime": "2e46006a5eebb262", "enterValueOrVariable": "ac0e5aaf865f7ecd", @@ -9473,6 +9555,7 @@ "changes": "624d7003ad951738", "discard": "eb1a70e39274bb76", "discardAndLeave": "07e5b9156de3452c", + "discardAndSignOut": "72caebf3876e4db0", "discardMessage": "0b6bd9517e6ab9f0", "discardTitle": "9897bf7fa36cfa5a", "discardTooltip": "f9bfa3dc7c8fcdea", @@ -9481,6 +9564,7 @@ "enter": "e73b0adc4bae0acd", "keepEditing": "e76fd2add01005ae", "leaveMessage": "7bc654b25ddf9ce7", + "leaveMessageForced": "d1b708c2d2e14fb2", "redo": "74273989b0966e23", "save": "1509f561f2416598", "saveTooltip": "26ac39b198ea73b7", @@ -9630,6 +9714,8 @@ "keepDraft": "bbcc810f654fd360", "keepFailed": "9d2829036f00d895", "leaveMessage": "b31a14e8f8d5922f", + "leaveMessageDiscardOnly": "15fc0972b15950bc", + "leaveMessageNoDraft": "f52b2524789ebab9", "leaveTitle": "44e4660b5596bd91", "panelActionsDot": "88466df69951dc76", "panelDeleted": "a91c6bb862b8ecfd", @@ -12748,7 +12834,10 @@ "fitView": "a36cb149f9dab825", "fromBase": "856100181d79dea9", "fromBaseHint": "7c8b03f2cff91080", + "importNotApplied": "dda41b70ebe46cac", "issues": "1b2ac8d46c6ee466", + "leaveNotCreated": "81e3af12f5010d32", + "leaveNotCreatedNoKeep": "a71e6e3d4b0f9ace", "modeDraft": "70dc58575d8804af", "modePublished": "5ae693346f17c78e", "modeRollingOut": "ac44395ce3dbb586", @@ -13508,10 +13597,6 @@ "jsonConfigHelp": "0c185848ea2f93db", "k8sPresetNoMatch": "1eee67a46add41db", "k8sPresetSeeded": "65c1ed300191a282", - "leaveConfirm": { - "message": "64b8d0c4540ac1c7", - "title": "470e7ee2a5795561" - }, "loadFailed": "ace3a6e578ff57eb", "moduleColumn": "f148224ae44fb843", "moduleGrantedCount": "5973332663ba05f6", @@ -17411,6 +17496,7 @@ "l0AllowPromotion": "3b05f473702cbfc5", "l0AllowPromotionNote": "85fb5e2d9cbe2d92", "l0AllowSuppress": "10b861e19cf7c60d", + "l0BudgetInvalidNotSaved": "c91fd8f660cd2a76", "l0BudgetLabel": "70059e80720899e4", "l0BudgetRange": "9142e36abd0e465e", "l0BudgetSeconds": "08cc4d4ec73f6979", @@ -20543,7 +20629,6 @@ "cached": "a251c18205f10e5c", "cachedReport": "cbe329c91b15af56", "cachedReportHint": "120c19baaa220811", - "cancelReportChangesConfirm": "00c621ab574df419", "continue": "31fbef162594de01", "createdAt": "3d44337010bfbdf7", "cron": "5b7bb1fa97159bc0", @@ -20915,8 +21000,6 @@ "deletedElsewhere": "019ac67ad3c6aaae", "deletedNotice": "1c4d0de5f6f38fad", "details": "45989de49fb7f66d", - "discardMessage": "92c6572f0180a456", - "discardTitle": "85bcf41666a53691", "duplicate": "02cdaabfca805d6a", "duplicateName": "9dff944bff1ce8c6", "edit": "464c4ffd019e1e96", @@ -25335,7 +25418,6 @@ "folder": "034a0062488256f1", "http": "58579fe1c24f6cc3", "httpDesc": "a9a750071beda385", - "leave": "fc6e4a408d56be96", "loadEditFailed": "7b1928809f0de0a4", "modalSubtitle": "fc15d0f77021245b", "modalTitle": "583f1d0575249e75", @@ -25349,13 +25431,10 @@ "saving": "4eca334abcd79a68", "ssh": "01c4d3c21642b217", "sshDesc": "a918e773651e8e1d", - "stay": "08fd1de4b04df4a2", "tcp": "a9a0fa445f12a963", "tcpDesc": "a8bcab1ed196f00f", "tls": "a4cd41f68fadaef3", "tlsDesc": "4596da651029630a", - "unsavedBody": "322380c1273e3abd", - "unsavedTitle": "a710c2b90913b537", "updateCheck": "eb3ab750e977cb82", "updated": "f76cab03d11c9483", "willBeCreatedIn": "5cf821e86d65d9e0" @@ -26185,7 +26264,6 @@ "failedToPreviewChanges": "ca4cbe4fcaa69730", "failedToSaveChanges": "650940a9a53530c0", "incidentSeverityUpdatedTo": "dd41b9f4404eca40", - "incidentStatusUpdatedTo": "3eeaf134bb5545af", "incidentsRefreshed": "d154f9499bcd0ad9", "invalidJson": "0d2b45241c24aa5f", "noAlertsSelectedForDeletion": "b6cfe1b86ee14dc7", diff --git a/tests/ui-testing/pages/alertsPages/alertCreationWizard.js b/tests/ui-testing/pages/alertsPages/alertCreationWizard.js index 29ea695cb6..b94b425e49 100644 --- a/tests/ui-testing/pages/alertsPages/alertCreationWizard.js +++ b/tests/ui-testing/pages/alertsPages/alertCreationWizard.js @@ -12,6 +12,7 @@ import { expect } from '@playwright/test'; import testLogger from '../../playwright-tests/utils/test-logger.js'; import MonacoEditorHelper from '../../playwright-tests/utils/MonacoEditorHelper.js'; import { openOSelectDropdown } from './oselectHelpers.js'; +import { discardEditorDraftIfAsked } from './editorDraftHelpers.js'; export class AlertCreationWizard { constructor(page, commonActions, locators) { @@ -74,6 +75,7 @@ export class AlertCreationWizard { const maxRetries = 2; for (let attempt = 1; attempt <= maxRetries; attempt++) { await this.page.locator(this.locators.addAlertBackButton || '[data-test="add-alert-back-btn"]').first().click(); + await discardEditorDraftIfAsked(this.page); await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); await this.page.waitForTimeout(1500); @@ -1117,6 +1119,7 @@ export class AlertCreationWizard { // Cancel alert creation await this.page.locator(this.locators.alertBackButton).click(); + await discardEditorDraftIfAsked(this.page); await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); return { toggleSuccessful, message }; diff --git a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js index aa5da6be9d..7ef4226642 100644 --- a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js @@ -110,6 +110,9 @@ export class AlertDestinationsPage { this.testResultIdle = '[data-test="test-result-idle"]'; this.saveButton = '[data-test="add-destination-submit-btn"]'; this.cancelButton = '[data-test="add-destination-cancel-btn"]'; + // Editor drafts: Cancel with unsaved changes opens the shared leave dialog + this.draftLeaveDialog = '[data-test="editor-draft-leave-dialog"]'; + this.draftLeaveDiscardBtn = '[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]'; this.backButton = '[data-test="add-destination-back-btn"]'; this.successNotification = '[data-test-variant="success"]'; this.toastSuccess = '[data-test-variant="success"]'; @@ -1561,6 +1564,15 @@ export class AlertDestinationsPage { await this.page.keyboard.press('Escape'); } } + // Unsaved changes are discarded through the leave dialog so no draft is left behind. + const asked = await this.page.locator(this.draftLeaveDialog) + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (asked) { + await this.page.locator(this.draftLeaveDiscardBtn).click(); + await this.page.locator(this.draftLeaveDialog).waitFor({ state: 'hidden', timeout: 10000 }); + } // Wait for the cancel button (and form) to detach so subsequent navigation sees a clean state. await this.page.locator(this.cancelButton).first().waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); testLogger.debug('Clicked Cancel button'); diff --git a/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js b/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js index 5d327c0193..dcc8d2b327 100644 --- a/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js +++ b/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js @@ -59,6 +59,9 @@ export class AlertTemplatesPage { this.headerCheckbox = '[data-test="o2-table-select-all"]'; this.addTemplateTitle = '[data-test="add-template-title"]'; this.templateCancelBtn = '[data-test="add-template-cancel-btn"]'; + // Editor drafts: Cancel with unsaved changes opens the shared leave dialog + this.draftLeaveDialog = '[data-test="editor-draft-leave-dialog"]'; + this.draftLeaveDiscardBtn = '[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]'; this.templateCloneBtnPattern = '[data-test="alert-template-list-{templateName}-clone-template"]'; // Inline locators moved from methods @@ -1218,6 +1221,15 @@ export class AlertTemplatesPage { async clickTemplateCancelBtn() { await this.page.locator(this.templateCancelBtn).click(); + // Unsaved changes are discarded through the leave dialog so no draft is left behind. + const asked = await this.page.locator(this.draftLeaveDialog) + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (asked) { + await this.page.locator(this.draftLeaveDiscardBtn).click(); + await this.page.locator(this.draftLeaveDialog).waitFor({ state: 'hidden', timeout: 10000 }); + } await this.page.waitForTimeout(1000); } diff --git a/tests/ui-testing/pages/alertsPages/alertsPage.js b/tests/ui-testing/pages/alertsPages/alertsPage.js index 4489cc1a76..070645fcc5 100644 --- a/tests/ui-testing/pages/alertsPages/alertsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertsPage.js @@ -19,6 +19,7 @@ import fs from 'fs'; import { CommonActions } from '../commonActions'; import { AlertCreationWizard } from './alertCreationWizard.js'; import { openOSelectDropdown } from './oselectHelpers.js'; +import { discardEditorDraftIfAsked } from './editorDraftHelpers.js'; import { AlertManagement } from './alertManagement.js'; import { AlertBulkOperations } from './alertBulkOperations.js'; const testLogger = require('../../playwright-tests/utils/test-logger.js'); @@ -1963,6 +1964,7 @@ export class AlertsPage { // Try clicking with force to bypass potential overlays await backButton.click({ force: true, timeout: 10000 }); + await discardEditorDraftIfAsked(this.page); testLogger.info('Closed alert wizard via back button'); } catch (error) { testLogger.warn('Back button click failed, trying close button', { error: error.message }); @@ -3722,6 +3724,7 @@ export class AlertsPage { */ async clickBackButton() { await this.page.locator(this.locators.alertBackButton).first().click({ timeout: 15000 }); + await discardEditorDraftIfAsked(this.page); await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); } diff --git a/tests/ui-testing/pages/alertsPages/downtimesPage.js b/tests/ui-testing/pages/alertsPages/downtimesPage.js index 916679f699..2306f8745c 100644 --- a/tests/ui-testing/pages/alertsPages/downtimesPage.js +++ b/tests/ui-testing/pages/alertsPages/downtimesPage.js @@ -32,6 +32,9 @@ export class DowntimesPage { this.nameValue = '[data-test="add-downtime-name-value"]'; this.saveBtn = '[data-test="add-downtime-save"]'; this.cancelBtn = '[data-test="add-downtime-cancel"]'; + // Editor drafts: Cancel with unsaved changes opens the shared leave dialog + this.draftLeaveDialog = '[data-test="editor-draft-leave-dialog"]'; + this.draftLeaveDiscardBtn = '[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]'; this.checkScheduleBanner = '[data-test="add-downtime-check-schedule"]'; this.staleBanner = '[data-test="add-downtime-stale-banner"]'; this.reloadBtn = '[data-test="add-downtime-reload"]'; @@ -598,6 +601,15 @@ export class DowntimesPage { async cancelCreate() { await this.page.locator(this.cancelBtn).click(); + // Unsaved changes are discarded through the leave dialog so no draft is left behind. + const asked = await this.page.locator(this.draftLeaveDialog) + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (asked) { + await this.page.locator(this.draftLeaveDiscardBtn).click(); + await this.page.locator(this.draftLeaveDialog).waitFor({ state: 'hidden', timeout: 10000 }); + } } async expectCheckScheduleNudge() { diff --git a/tests/ui-testing/pages/alertsPages/editorDraftHelpers.js b/tests/ui-testing/pages/alertsPages/editorDraftHelpers.js new file mode 100644 index 0000000000..8ca2ecb240 --- /dev/null +++ b/tests/ui-testing/pages/alertsPages/editorDraftHelpers.js @@ -0,0 +1,20 @@ +/** Discards through the editor-draft leave dialog when Back/Cancel opens it, so no draft pops the restore dialog later. */ +export async function discardEditorDraftIfAsked(page) { + const dialog = page.locator('[data-test="editor-draft-leave-dialog"]'); + const asked = await dialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (!asked) return; + await dialog.locator('[data-test="o-dialog-neutral-btn"]').click(); + await dialog.waitFor({ state: 'hidden', timeout: 10000 }); +} + +/** A draft an earlier test left (e.g. by page.goto) would open the restore dialog over the next editor; call before opening it. */ +export async function clearEditorDrafts(page) { + await page.evaluate(() => { + Object.keys(localStorage) + .filter((key) => key.startsWith('o2_draft:') || key === 'o2_draft_index') + .forEach((key) => localStorage.removeItem(key)); + }); +} diff --git a/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js b/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js index 226dc0f8ff..8385a4d916 100644 --- a/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js +++ b/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js @@ -6,6 +6,7 @@ const { expect } = require('@playwright/test'); const testLogger = require('../../playwright-tests/utils/test-logger.js'); const MonacoEditorHelper = require('../../playwright-tests/utils/MonacoEditorHelper.js'); const { cleanupTestAnomalies: apiCleanupTestAnomalies } = require('../../playwright-tests/utils/api-helper.js'); +const { discardEditorDraftIfAsked } = require('../alertsPages/editorDraftHelpers.js'); class AnomalyDetectionPage { /** @@ -722,6 +723,7 @@ class AnomalyDetectionPage { const btn = this.page.locator(this.selectors.cancelBtn); await btn.waitFor({ state: 'visible', timeout: 15000 }); await btn.click({ force: true }); + await discardEditorDraftIfAsked(this.page); } getStreamTypeSelectLocator() { return this.page.locator(this.selectors.streamTypeSelect); } diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-settings.js b/tests/ui-testing/pages/dashboardPages/dashboard-settings.js index 82ec309a36..4a7afb6a0c 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-settings.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-settings.js @@ -149,11 +149,26 @@ export default class DashboardSetting { async cancelSettingDashboard() { await this.cancelBtn.waitFor({ state: "visible" }); await this.cancelBtn.click(); + await this.discardSettingsDraftIfAsked(); + } + + // Closing with unsaved settings opens the leave dialog; Discard keeps the old close-and-forget behaviour. + async discardSettingsDraftIfAsked() { + const dialog = this.page.locator('[data-test="editor-draft-leave-dialog"]'); + const drawer = this.page.locator('[data-test="dashboard-settings-drawer"]'); + const asked = await Promise.race([ + dialog.waitFor({ state: "visible", timeout: 5000 }).then(() => true), + drawer.waitFor({ state: "hidden", timeout: 5000 }).then(() => false), + ]).catch(() => false); + if (!asked) return; + await dialog.locator('[data-test="o-dialog-neutral-btn"]').click(); + await dialog.waitFor({ state: "hidden", timeout: 10000 }); } //close setting dashboard// async closeSettingDashboard() { await this.closeSetting.waitFor({ state: "visible" }); await this.closeSetting.click(); + await this.discardSettingsDraftIfAsked(); // The ODrawer animates out over a backdrop that still swallows pointer // events mid-transition, so a following click (e.g. the back button) can be // eaten. Wait for it to actually be gone. @@ -546,6 +561,7 @@ export default class DashboardSetting { // Wait for close button with a short timeout await closeBtn.waitFor({ state: "visible", timeout: 3000 }); await closeBtn.click({ timeout: 2000 }); + await this.discardSettingsDraftIfAsked(); // Wait for dialog to actually disappear await settingsDialog.waitFor({ state: "hidden", timeout: 5000 }); diff --git a/tests/ui-testing/pages/dashboardPages/dashboardPage.js b/tests/ui-testing/pages/dashboardPages/dashboardPage.js index a26da27125..2c1450e0e8 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboardPage.js +++ b/tests/ui-testing/pages/dashboardPages/dashboardPage.js @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { confirmDraftSignOut } from '../generalPages/draftSignOut.js'; import { dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator, Past30SecondsValue @@ -502,6 +503,7 @@ export class DashboardPage { async signOut() { await this.profileButton.click(); await this.signOutButton.click(); + await confirmDraftSignOut(this.page); } @@ -517,6 +519,7 @@ export class DashboardPage { // Now click the logout item await this.logoutMenuItem.click({ force: true }); + await confirmDraftSignOut(this.page); } async notAvailableDashboard() { diff --git a/tests/ui-testing/pages/functionsPages/functionsPage.js b/tests/ui-testing/pages/functionsPages/functionsPage.js index 8c5154f374..b9015dc474 100644 --- a/tests/ui-testing/pages/functionsPages/functionsPage.js +++ b/tests/ui-testing/pages/functionsPages/functionsPage.js @@ -55,6 +55,10 @@ class FunctionsPage { this.confirmDialog = this.page.locator('[data-test="confirm-dialog"]'); this.confirmDialogPrimaryBtn = this.page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]'); this.confirmDialogSecondaryBtn = this.page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]'); + + // Editor drafts: Cancel/Back with unsaved changes opens the shared leave dialog + this.draftLeaveDialog = this.page.locator('[data-test="editor-draft-leave-dialog"]'); + this.draftLeaveDiscardBtn = this.draftLeaveDialog.locator('[data-test="o-dialog-neutral-btn"]'); } // ==================== Locator factories (runtime-dynamic) ==================== @@ -186,8 +190,17 @@ class FunctionsPage { await this.page.waitForTimeout(2000); } + /** Cancel the editor; unsaved changes are discarded through the leave dialog so no draft is left behind. */ async clickCancelButton() { await this.cancelButton.click(); + const asked = await this.draftLeaveDialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (asked) { + await this.draftLeaveDiscardBtn.click(); + await this.draftLeaveDialog.waitFor({ state: 'hidden', timeout: 10000 }); + } await this.page.waitForTimeout(1500); // Wait for dialog to fully close } diff --git a/tests/ui-testing/pages/generalPages/aiToolsetsFormValidationPage.js b/tests/ui-testing/pages/generalPages/aiToolsetsFormValidationPage.js index bcdf3f0aff..1f61b41ae8 100644 --- a/tests/ui-testing/pages/generalPages/aiToolsetsFormValidationPage.js +++ b/tests/ui-testing/pages/generalPages/aiToolsetsFormValidationPage.js @@ -1,6 +1,7 @@ // Copyright 2026 OpenObserve Inc. const testLogger = require('../../playwright-tests/utils/test-logger.js'); +import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js'; export class AiToolsetsFormValidationPage { /** @@ -82,6 +83,7 @@ export class AiToolsetsFormValidationPage { async clickAddToolset() { testLogger.debug('Clicking Add AI Toolset button'); + await clearEditorDrafts(this.page); await this.page.locator(this.addToolsetBtn).click(); // Wait for the form to mount — name input is the first interactive field await this.page.locator(this.nameInput).waitFor({ state: 'visible', timeout: 10000 }); @@ -130,6 +132,7 @@ export class AiToolsetsFormValidationPage { async clickCancel() { testLogger.debug('Clicking Cancel button'); await this.page.locator(this.cancelBtn).click(); + await discardEditorDraftIfAsked(this.page); } // ── Locator getters (for expect() assertions in spec) ───────────────────── diff --git a/tests/ui-testing/pages/generalPages/cipherKeys.js b/tests/ui-testing/pages/generalPages/cipherKeys.js index ccf9470335..cd11d28882 100644 --- a/tests/ui-testing/pages/generalPages/cipherKeys.js +++ b/tests/ui-testing/pages/generalPages/cipherKeys.js @@ -1,5 +1,7 @@ // cipher keys.js import { expect } from '@playwright/test'; +import { confirmDraftSignOut } from './draftSignOut.js'; +import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js'; export class CipherKeys { @@ -28,7 +30,7 @@ export class CipherKeys { this.cancelButton = '[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]'; this.updateButton = cipherName => `[data-test="cipherkey-list-${cipherName}-update"]`; this.profileButton = page.locator('[data-test="header-my-account-profile-icon"]'); - this.signOutButton = page.locator('[data-test="header-sign-out-btn"]'); + this.signOutButton = page.locator('[data-test="menu-link-logout-item"]'); } @@ -61,8 +63,8 @@ export class CipherKeys { async addCipherKey() { await this.addCipherKeyButton.waitFor({ state: 'visible', timeout: 10000 }); + await clearEditorDrafts(this.page); await this.addCipherKeyButton.click(); - } async addCipherKeyName(name) { @@ -180,6 +182,7 @@ export class CipherKeys { async updateCipherKeysCancel() { await this.page.waitForSelector('[data-test="add-cipher-key-cancel-btn"]'); await this.page.locator('[data-test="add-cipher-key-cancel-btn"]').click(); + await discardEditorDraftIfAsked(this.page); } @@ -244,6 +247,7 @@ export class CipherKeys { async signOut() { await this.profileButton.click(); await this.signOutButton.click(); + await confirmDraftSignOut(this.page); } diff --git a/tests/ui-testing/pages/generalPages/draftSignOut.js b/tests/ui-testing/pages/generalPages/draftSignOut.js new file mode 100644 index 0000000000..f580444b41 --- /dev/null +++ b/tests/ui-testing/pages/generalPages/draftSignOut.js @@ -0,0 +1,20 @@ +// Sign Out stops on the drafts count dialog, or, when an editor with unsaved changes is open, on that editor's leave dialog instead. +export async function confirmDraftSignOut(page, timeout = 5000) { + const confirm = page.locator('[data-test="editor-draft-signout-confirm"]'); + const discard = page + .locator('[data-test="editor-draft-leave-dialog"], [data-test="dashboard-panel-leave-dialog"]') + .locator('[data-test="o-dialog-neutral-btn"]') + .first(); + // With no drafts and no dirty editor the app goes straight to /login, which ends the wait at once. + await Promise.race([ + confirm.waitFor({ state: 'visible', timeout }), + discard.waitFor({ state: 'visible', timeout }), + page.waitForURL(/\/login/, { timeout }), + ]).catch(() => {}); + if (await confirm.isVisible().catch(() => false)) { + await confirm.click(); + } else if (await discard.isVisible().catch(() => false)) { + await discard.click(); + } + await page.waitForURL(/\/login/, { timeout }).catch(() => {}); +} diff --git a/tests/ui-testing/pages/generalPages/homePage.js b/tests/ui-testing/pages/generalPages/homePage.js index 9099737642..fb03cdde16 100644 --- a/tests/ui-testing/pages/generalPages/homePage.js +++ b/tests/ui-testing/pages/generalPages/homePage.js @@ -1,5 +1,6 @@ // homePage.js - Landing Page Object for OpenObserve import { expect } from '@playwright/test'; +import { confirmDraftSignOut } from './draftSignOut.js'; import { openNavFlyoutChild, gotoMetricsEditor, clickNavUntilRoute } from '../commonActions.js'; export class HomePage { @@ -378,6 +379,7 @@ export class HomePage { async logout() { await this.openProfileMenu(); await this.logoutButton.click(); + await confirmDraftSignOut(this.page); } async openThemeManager() { diff --git a/tests/ui-testing/pages/generalPages/modelPricingPage.js b/tests/ui-testing/pages/generalPages/modelPricingPage.js index b81ef1368b..58584bb0bd 100644 --- a/tests/ui-testing/pages/generalPages/modelPricingPage.js +++ b/tests/ui-testing/pages/generalPages/modelPricingPage.js @@ -5,6 +5,7 @@ // Editor: /web/settings/model_pricing/edit?org_identifier={org}&id={id} import { expect } from '@playwright/test'; +import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js'; const modelPricingStrings = require('../../../../web/src/locales/languages/en-US.json').modelPricing; // en-US trigger labels for each rule operator. The rule-op select renders a @@ -194,11 +195,13 @@ export class ModelPricingPage { // ---------------------------------------------------------------- async clickAdd() { + await clearEditorDrafts(this.page); await this.addBtn.click(); await this.editorTitle.waitFor({ state: 'visible', timeout: 10000 }); } async clickEmptyAdd() { + await clearEditorDrafts(this.page); await this.emptyAddBtn.click(); await this.editorTitle.waitFor({ state: 'visible', timeout: 10000 }); } @@ -403,11 +406,13 @@ export class ModelPricingPage { async clickCancel() { await this.cancelBtn.click(); + await discardEditorDraftIfAsked(this.page); await this.listTitle.waitFor({ state: 'visible', timeout: 10000 }); } async clickEditorBack() { await this.editorBackBtn.click(); + await discardEditorDraftIfAsked(this.page); await this.listTitle.waitFor({ state: 'visible', timeout: 10000 }); } diff --git a/tests/ui-testing/pages/generalPages/regexPatternsFormValidationPage.js b/tests/ui-testing/pages/generalPages/regexPatternsFormValidationPage.js index 2be33c0dbf..c181881e0b 100644 --- a/tests/ui-testing/pages/generalPages/regexPatternsFormValidationPage.js +++ b/tests/ui-testing/pages/generalPages/regexPatternsFormValidationPage.js @@ -1,5 +1,7 @@ // Copyright 2026 OpenObserve Inc. +import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js'; + export class RegexPatternsFormValidationPage { /** * @param {import('@playwright/test').Page} page @@ -66,6 +68,7 @@ export class RegexPatternsFormValidationPage { // ── Drawer helpers ──────────────────────────────────────────────────────── async openAddPatternDrawer() { + await clearEditorDrafts(this.page); await this.page.locator(this.addPatternButton).click(); await this.page.locator(this.drawer).waitFor({ state: 'visible', timeout: 8000 }); } @@ -84,6 +87,7 @@ export class RegexPatternsFormValidationPage { async clickCancel() { await this.page.locator(this.cancelButton).click(); + await discardEditorDraftIfAsked(this.page); } async touchNameField() { diff --git a/tests/ui-testing/pages/iamPages/rolesPage.js b/tests/ui-testing/pages/iamPages/rolesPage.js index 285f07ae48..782459ff9f 100644 --- a/tests/ui-testing/pages/iamPages/rolesPage.js +++ b/tests/ui-testing/pages/iamPages/rolesPage.js @@ -113,9 +113,10 @@ export class RolesPage { // ---------- leaving with unsaved changes ---------- // ConfirmDialog renders inside ODialog, so the buttons carry the dialog's slugs. - this.leaveConfirm = page.locator('[data-test="confirm-dialog"]'); - this.leaveConfirmOk = page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]'); - this.leaveConfirmCancel = page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]'); + // The shared unsaved-changes dialog: Discard drops the changes, closing it stays on the editor. + this.leaveConfirm = page.locator('[data-test="editor-draft-leave-dialog"]'); + this.leaveConfirmOk = page.locator('[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-neutral-btn"]'); + this.leaveConfirmCancel = page.locator('[data-test="editor-draft-leave-dialog"] [data-test="o-dialog-close-btn"]'); // ---------- unsaved drawer ---------- this.drawer = page.locator('[data-test="edit-role-unsaved-drawer"]'); diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index 5e70ecce4b..cb89269fa1 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { confirmDraftSignOut } from '../generalPages/draftSignOut.js'; import { LogsQueryPage } from './logsQueryPage.js'; import { LoginPage } from '../generalPages/loginPage.js'; import { IngestionPage } from '../generalPages/ingestionPage.js'; @@ -2732,6 +2733,7 @@ export class LogsPage { await this.page.locator('[data-test="header-my-account-profile-icon"]').click(); // Header.vue data-test="menu-link-logout-item" on the Sign Out menu item await this.page.locator('[data-test="menu-link-logout-item"]').click(); + await confirmDraftSignOut(this.page); } // LogsQueryPage methods - delegate to LogsQueryPage diff --git a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js index 6c39e9dfe3..764f855d2f 100644 --- a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js +++ b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js @@ -335,6 +335,10 @@ export class PipelinesPage { this.queryNode = page.locator('[data-test="pipeline-node-input-query-node"]'); this.queryNodeDeleteBtn = page.locator('[data-test="pipeline-node-input-delete-btn"]'); this.cancelPipelineBtn = page.locator('[data-test="add-pipeline-cancel-btn"]'); + // Editor drafts: Cancel/leave with unsaved changes opens the shared leave dialog + this.draftLeaveDialog = page.locator('[data-test="editor-draft-leave-dialog"]'); + this.draftLeaveDiscardBtn = this.draftLeaveDialog.locator('[data-test="o-dialog-neutral-btn"]'); + this.draftLeaveCloseBtn = this.draftLeaveDialog.locator('[data-test="o-dialog-close-btn"]'); this.dashboardsMenuLink = page.locator('[data-test="menu-link-\\/dashboards-item"]'); // PipelineEditor emits a toast "Please connect all nodes before saving" // when the user clicks Save without joining nodes. OToast renders it via @@ -2233,7 +2237,7 @@ export class PipelinesPage { async cancelAndDeletePipeline(pipelineName, searchPrefix = 'automatepi') { // Click the cancel/back button await this.page.locator('[data-test="add-pipeline-cancel-btn"]').click(); - await this.confirmButton.click(); + await this.discardDraftIfAsked(); await this.page.waitForTimeout(2000); // Search for the pipeline @@ -2846,6 +2850,19 @@ export class PipelinesPage { */ async clickCancelPipelineBtn() { await this.cancelPipelineBtn.click(); + await this.discardDraftIfAsked(); + } + + /** Discards unsaved changes through the leave dialog when it opens, so no draft is left behind. */ + async discardDraftIfAsked() { + const asked = await this.draftLeaveDialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (asked) { + await this.draftLeaveDiscardBtn.click(); + await this.draftLeaveDialog.waitFor({ state: 'hidden', timeout: 10000 }); + } } /** @@ -2856,8 +2873,8 @@ export class PipelinesPage { } async acceptUnsavedChangesPromptAndExpectDashboards() { - await expect(this.discardChangesOkBtn, 'Leaving an unsaved pipeline must show the confirm dialog').toBeVisible({ timeout: 10000 }); - await this.discardChangesOkBtn.click(); + await expect(this.draftLeaveDialog, 'Leaving an unsaved pipeline must show the leave dialog').toBeVisible({ timeout: 10000 }); + await this.draftLeaveDiscardBtn.click(); await expect(this.page).toHaveURL(/dashboards/, { timeout: 15000 }); } @@ -3220,8 +3237,7 @@ export class PipelinesPage { */ async cleanupPipelineCreation() { await this.cancelPipelineBtn.click().catch(() => {}); - await this.page.waitForTimeout(500); - await this.confirmButton.click().catch(() => {}); + await this.discardDraftIfAsked(); testLogger.info('Pipeline creation cleanup completed'); } @@ -3524,6 +3540,7 @@ export class PipelinesPage { */ async clickCancelPipelineBtnForce() { await this.cancelPipelineBtn.click({ force: true }); + await this.discardDraftIfAsked(); testLogger.info('Clicked cancel pipeline button (force)'); } diff --git a/tests/ui-testing/pages/reportsPages/reportsPage.js b/tests/ui-testing/pages/reportsPages/reportsPage.js index 4659a3d0a0..8962663994 100644 --- a/tests/ui-testing/pages/reportsPages/reportsPage.js +++ b/tests/ui-testing/pages/reportsPages/reportsPage.js @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { confirmDraftSignOut } from '../generalPages/draftSignOut.js'; import { dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator, Past30SecondsValue, openNavFlyoutChild @@ -484,6 +485,7 @@ export class ReportsPage { async signOut() { await this.profileButton.click({ force: true }); await this.signOutButton.click({ force: true }); + await confirmDraftSignOut(this.page); } async pauseReport(reportName) { @@ -573,6 +575,7 @@ export class ReportsPage { await this.signOutButton.waitFor({ state: 'visible', timeout: 5000 }); // Now click the logout item await this.signOutButton.click({ force: true }); + await confirmDraftSignOut(this.page); } async notAvailableReport(reportName) { diff --git a/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js b/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js index dcca5991d8..4d314a130d 100644 --- a/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js +++ b/tests/ui-testing/pages/rumPages/rumProductAnalyticsPage.js @@ -169,8 +169,8 @@ export class RumProductAnalyticsPage { this.addRuleBtn = page.locator('[data-test="rum-analytics-named-events-add-rule-btn"]'); this.eventPreview = page.locator('[data-test="rum-analytics-named-events-preview"]'); this.eventCancelBtn = page.locator('[data-test="rum-analytics-event-editor-cancel-btn"]'); - this.eventDiscardDialog = page.locator('[data-test="rum-analytics-event-editor-discard-dialog"]'); - this.eventDiscardConfirmBtn = this.eventDiscardDialog.locator('[data-test="o-dialog-primary-btn"]'); + this.eventDiscardDialog = page.locator('[data-test="editor-draft-leave-dialog"]'); + this.eventDiscardConfirmBtn = this.eventDiscardDialog.locator('[data-test="o-dialog-neutral-btn"]'); this.eventRules = page.locator('[data-test^="rum-analytics-named-events-rule-"][data-test$="-kind"]'); this.sessionsTable = page.locator('[data-test="rum-sessions-table"]'); diff --git a/tests/ui-testing/pages/sdrPages/sdrPatternsPage.js b/tests/ui-testing/pages/sdrPages/sdrPatternsPage.js index 104955ef86..6056187254 100644 --- a/tests/ui-testing/pages/sdrPages/sdrPatternsPage.js +++ b/tests/ui-testing/pages/sdrPages/sdrPatternsPage.js @@ -2,6 +2,7 @@ import { expect } from '@playwright/test'; import testLogger from '../../playwright-tests/utils/test-logger.js'; import { getAuthHeaders, getOrgIdentifier } from '../../playwright-tests/utils/cloud-auth.js'; +import { discardEditorDraftIfAsked, clearEditorDrafts } from '../alertsPages/editorDraftHelpers.js'; export class SDRPatternsPage { constructor(page) { @@ -74,10 +75,17 @@ export class SDRPatternsPage { async clickAddPattern() { testLogger.info('Clicking Add Pattern button'); + await clearEditorDrafts(this.page); await this.addPatternButton.click(); await this.addPatternTitle.waitFor({ state: 'visible' }); } + async clickCancel() { + testLogger.info('Cancelling the pattern drawer'); + await this.cancelButton.click(); + await discardEditorDraftIfAsked(this.page); + } + async fillPatternDetails(name, description, pattern) { testLogger.info(`Filling pattern details: ${name}`); await this.patternNameInput.click(); diff --git a/tests/ui-testing/pages/sloPages/sloFormPage.js b/tests/ui-testing/pages/sloPages/sloFormPage.js index 8f15ef6add..1da9342be9 100644 --- a/tests/ui-testing/pages/sloPages/sloFormPage.js +++ b/tests/ui-testing/pages/sloPages/sloFormPage.js @@ -355,6 +355,13 @@ export class SloFormPage { // Cancel sits beside Save in the page header actions; it is the only // control there that is not the save button. await this.page.getByRole('button', { name: /cancel/i }).first().click(); + // Unsaved changes open the shared leave dialog; discard so no draft is left behind. + const leaveDialog = this.page.locator('[data-test="editor-draft-leave-dialog"]'); + const asked = await leaveDialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + if (asked) await leaveDialog.locator('[data-test="o-dialog-neutral-btn"]').click(); await this.page.waitForURL((url) => !/\/slos\/(add|edit)/.test(url.pathname), { timeout: 20000, }); diff --git a/tests/ui-testing/pages/syntheticsPages/syntheticsCreatePage.js b/tests/ui-testing/pages/syntheticsPages/syntheticsCreatePage.js index 6976cea343..935bbf8a76 100644 --- a/tests/ui-testing/pages/syntheticsPages/syntheticsCreatePage.js +++ b/tests/ui-testing/pages/syntheticsPages/syntheticsCreatePage.js @@ -66,7 +66,8 @@ export class SyntheticsCreatePage { privateLocationsEmpty: '[data-test="synthetics-check-locations-private-empty"]', saveButton: '[data-test="synthetics-create-save-btn"]', cancelButton: '[data-test="synthetics-create-cancel-btn"]', - unsavedDialog: '[data-test="synthetics-create-unsaved-dialog"]', + unsavedDialog: '[data-test="editor-draft-leave-dialog"]', + leaveDiscardButton: '[data-test="o-dialog-neutral-btn"]', dialogPrimary: '[data-test="o-dialog-primary-btn"]', gateUrlField: '[data-test="synthetics-create-url-input-field"]', gateUrlError: '[data-test="synthetics-create-url-input-error"]', @@ -253,10 +254,11 @@ export class SyntheticsCreatePage { async cancel() { await this.page.locator(this.locators.cancelButton).click(); } + // Leaves through the shared leave dialog's Discard, so no draft is left behind. async confirmLeave() { const dialog = this.page.locator(this.locators.unsavedDialog); await expect(dialog).toBeVisible({ timeout: 15000 }); - await dialog.locator(this.locators.dialogPrimary).click(); + await dialog.locator(this.locators.leaveDiscardButton).click(); } async fillGate(url, name) { diff --git a/tests/ui-testing/pages/tracesPages/tracesPage.js b/tests/ui-testing/pages/tracesPages/tracesPage.js index acdb8eac67..510e440976 100644 --- a/tests/ui-testing/pages/tracesPages/tracesPage.js +++ b/tests/ui-testing/pages/tracesPages/tracesPage.js @@ -1,5 +1,6 @@ // tracesPage.js import { expect } from '@playwright/test'; +import { confirmDraftSignOut } from '../generalPages/draftSignOut.js'; import { dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator, Past30SecondsValue } from '../commonActions.js'; @@ -652,6 +653,7 @@ export class TracesPage { async signOut() { await this.profileButton.click(); await this.signOutButton.click(); + await confirmDraftSignOut(this.page); } // Query Editor Methods diff --git a/tests/ui-testing/playwright-tests/IAM/iam-roles-staging.spec.js b/tests/ui-testing/playwright-tests/IAM/iam-roles-staging.spec.js index fc181596eb..affdf24f36 100644 --- a/tests/ui-testing/playwright-tests/IAM/iam-roles-staging.spec.js +++ b/tests/ui-testing/playwright-tests/IAM/iam-roles-staging.spec.js @@ -159,11 +159,11 @@ test.describe('IAM · Edit Role · staged changes', { tag: '@enterprise' }, () = await pm.rolesPage.grantScope('function', 'AllowList'); await pm.rolesPage.cancelButton.click(); - // A leave guard may stand in the way; discarding is the point of the test. - const confirm = page.locator('[data-test="confirm-dialog"]'); - if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) { - await confirm.locator('[data-test="o-dialog-primary-btn"]').click(); - } + // A staged change always asks before leaving; discarding is the point of the test. + const confirm = page.locator('[data-test="editor-draft-leave-dialog"]'); + await expect(confirm).toBeVisible({ timeout: 10000 }); + await confirm.locator('[data-test="o-dialog-neutral-btn"]').click(); + await expect(confirm).toBeHidden(); await page.waitForTimeout(2000); expect(await getPerms(page, name)).toEqual([]); }); diff --git a/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js b/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js index 5c87339b08..ad6b6bb593 100644 --- a/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js +++ b/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js @@ -502,7 +502,7 @@ test.describe("Traces Pipeline Tests", { tag: ['@all', '@pipelines', '@traces', await pageManager.pipelinesPage.selectStreamOptionByName(TRACES_STREAM); await pageManager.pipelinesPage.saveInputNodeStream(); - // onBeforeRouteLeave must hold the navigation behind the confirm dialog; accepting it completes the navigation. + // The route guard holds the navigation behind the leave dialog; Discard completes it. await pageManager.pipelinesPage.clickDashboardsMenu(); await pageManager.pipelinesPage.acceptUnsavedChangesPromptAndExpectDashboards(); diff --git a/tests/ui-testing/playwright-tests/Pipelines/pipelines.spec.js b/tests/ui-testing/playwright-tests/Pipelines/pipelines.spec.js index f83055d035..846cb220ae 100644 --- a/tests/ui-testing/playwright-tests/Pipelines/pipelines.spec.js +++ b/tests/ui-testing/playwright-tests/Pipelines/pipelines.spec.js @@ -429,11 +429,10 @@ test.describe("Pipeline testcases", { tag: ['@all', '@pipelines'] }, () => { await pipelinePage.selectStreamOption(); await pipelinePage.saveInputNodeStream(); - // Click the dashboards menu — onBeforeRouteLeave fires and shows a Vue - // ConfirmDialog instead of window.confirm (native dialog). Accept it. + // Leaving with unsaved changes opens the shared leave dialog; Discard completes the navigation. await pipelinePage.clickDashboardsMenu(); - await pipelinePage.discardChangesOkBtn.waitFor({ state: 'visible', timeout: 10000 }); - await pipelinePage.discardChangesOkBtn.click(); + await pipelinePage.draftLeaveDiscardBtn.waitFor({ state: 'visible', timeout: 10000 }); + await pipelinePage.draftLeaveDiscardBtn.click(); // Assert the page navigates to the desired URL containing "dashboards" await page.waitForURL(/dashboards/); @@ -458,11 +457,10 @@ test.describe("Pipeline testcases", { tag: ['@all', '@pipelines'] }, () => { await pipelinePage.selectStreamOption(); await pipelinePage.saveInputNodeStream(); - // Click the dashboards menu — onBeforeRouteLeave fires and shows a Vue - // ConfirmDialog instead of window.confirm (native dialog). Dismiss it. + // Closing the leave dialog keeps the user on the editor. await pipelinePage.clickDashboardsMenu(); - await pipelinePage.discardChangesCancelBtn.waitFor({ state: 'visible', timeout: 10000 }); - await pipelinePage.discardChangesCancelBtn.click(); + await pipelinePage.draftLeaveCloseBtn.waitFor({ state: 'visible', timeout: 10000 }); + await pipelinePage.draftLeaveCloseBtn.click(); // Assert page url to have pipeline await expect(page).toHaveURL(/pipeline/); diff --git a/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js b/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js index 00412581c1..ef03837db1 100644 --- a/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js +++ b/tests/ui-testing/playwright-tests/RUM-Analytics/rum-product-analytics.spec.js @@ -1112,7 +1112,7 @@ test.describe('RUM Product Analytics', () => { await pa.eventCancelBtn.click(); await expect(pa.eventDiscardDialog).toBeVisible({ timeout: 20000 }); - await expect(pa.eventDiscardDialog).toContainText('Discard changes?'); + await expect(pa.eventDiscardDialog).toContainText('Leave with unsaved changes?'); await pa.eventDiscardConfirmBtn.click(); await expect(pa.namedEventsList).toBeVisible({ timeout: 20000 }); expect(w.writes, 'leaving the editor writes nothing').toEqual([]); diff --git a/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-13243-prebuilt-form-reuse.spec.js b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-13243-prebuilt-form-reuse.spec.js index add66e27d0..fb826ef3cf 100644 --- a/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-13243-prebuilt-form-reuse.spec.js +++ b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-13243-prebuilt-form-reuse.spec.js @@ -53,7 +53,7 @@ test.describe('Prebuilt destination form reuse (#13243)', () => { // a form instance that has already been used, not a fresh page. await pm.alertDestinationsPage.clickNewDestination(); await pm.alertDestinationsPage.selectDestinationType('discord'); - await page.locator('[data-test="add-destination-cancel-btn"]').click(); + await pm.alertDestinationsPage.clickCancel(); await page.locator('[data-test="prebuilt-destination-selector"]').waitFor({ state: 'hidden', timeout: 15000, @@ -102,7 +102,7 @@ test.describe('Prebuilt destination form reuse (#13243)', () => { `reuse_${type}`, ); - await page.locator('[data-test="add-destination-cancel-btn"]').click(); + await pm.alertDestinationsPage.clickCancel(); await page.locator('[data-test="prebuilt-destination-selector"]').waitFor({ state: 'hidden', timeout: 15000, diff --git a/tests/ui-testing/playwright-tests/SDR/regexPatternManagement.spec.js b/tests/ui-testing/playwright-tests/SDR/regexPatternManagement.spec.js index ea90e727f8..7214c6d537 100644 --- a/tests/ui-testing/playwright-tests/SDR/regexPatternManagement.spec.js +++ b/tests/ui-testing/playwright-tests/SDR/regexPatternManagement.spec.js @@ -40,7 +40,7 @@ test.describe("Regex Pattern Management Tests", { tag: '@enterprise' }, () => { testLogger.info('Verifying drawer stays open after save with empty name'); await expect(pm.sdrPatternsPage.addPatternTitle).toBeVisible({ timeout: 5000 }); - await pm.sdrPatternsPage.cancelButton.click(); + await pm.sdrPatternsPage.clickCancel(); await page.waitForTimeout(500); // Test 2: Empty regex pattern — form should not close @@ -126,7 +126,7 @@ test.describe("Regex Pattern Management Tests", { tag: '@enterprise' }, () => { 'This will be cancelled', '^test$' ); - await pm.sdrPatternsPage.cancelButton.click(); + await pm.sdrPatternsPage.clickCancel(); await page.waitForTimeout(500); const patternVisible = await pm.sdrPatternsPage.checkPatternExists('cancelled_pattern'); diff --git a/web/src/components/ConfirmDialogProvider.vue b/web/src/components/ConfirmDialogProvider.vue index 0bed5c3f0e..17acf607b4 100644 --- a/web/src/components/ConfirmDialogProvider.vue +++ b/web/src/components/ConfirmDialogProvider.vue @@ -8,8 +8,11 @@ size="sm" :title="currentDialog.title" :primary-button-label="currentDialog.confirmLabel || t('common.ok')" - :secondary-button-label="currentDialog.cancelLabel || t('common.cancel')" - :persistent="currentDialog.persistent ?? true" + :secondary-button-label=" + currentDialog.confirmOnly ? undefined : currentDialog.cancelLabel || t('common.cancel') + " + :persistent="currentDialog.confirmOnly || (currentDialog.persistent ?? true)" + :show-close="!currentDialog.confirmOnly" :primary-button-variant="currentDialog.confirmVariant ?? 'primary'" :initial-focus="currentDialog.focusCancel ? 'secondary' : 'primary'" @click:primary="handleConfirm" diff --git a/web/src/components/ai_toolsets/AddAiToolset.drafts.spec.ts b/web/src/components/ai_toolsets/AddAiToolset.drafts.spec.ts new file mode 100644 index 0000000000..c421ff184f --- /dev/null +++ b/web/src/components/ai_toolsets/AddAiToolset.drafts.spec.ts @@ -0,0 +1,509 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { h, nextTick } from "vue"; +import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router"; +import i18n from "@/locales"; +import store from "@/test/unit/helpers/store"; +import aiToolsetsService from "@/services/ai_toolsets"; +import { queryClient } from "@/composables/query/queryClient"; +import { + editorDraftKey, + readEditorDraft, + writeEditorDraft, + type EditorDraftScope, +} from "@/utils/drafts/editorDraftStore"; +import { fillField } from "@/test/unit/helpers/fillField"; +import AiToolsets from "@/components/settings/AiToolsets.vue"; +import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom"; +import AddAiToolset from "./AddAiToolset.vue"; + +vi.mock("@/services/ai_toolsets", () => ({ + default: { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(() => Promise.resolve({ data: {} })), + update: vi.fn(() => Promise.resolve({ data: {} })), + delete: vi.fn(() => Promise.resolve({ data: {} })), + }, +})); + +const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn(() => () => undefined) })); +vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({ + ...(await importOriginal()), + toast: toastMock, +})); + +const QueryEditorStub = { + name: "QueryEditor", + props: ["query", "editorId", "language"], + emits: ["update:query"], + template: '
', +}; + +interface ToolsetForm { + state: { + values: Record & { + mcp: { url: string; headers: { key: string; value: string }[] }; + }; + }; + setFieldValue: (k: string, v: unknown) => void; + getFieldMeta: (k: string) => { errors?: unknown[] } | undefined; + handleSubmit: () => Promise; +} + +const ORG = store.state.selectedOrganization.identifier; +const EMAIL = store.state.userInfo.email; +const newScope: EditorDraftScope = { + kind: "ai_toolset", + org: ORG, + entityId: null, + userEmail: EMAIL, +}; +const editScope: EditorDraftScope = { ...newScope, entityId: "ts1" }; + +const serverToolset = (headers: Record = {}) => ({ + id: "ts1", + name: "github_mcp", + kind: "mcp", + description: "GitHub tools", + data: { + url: "https://api.example.com/mcp/", + timeout_seconds: 30, + headers: { Authorization: "Bearer saved-token", "X-Team": "sre-team", ...headers }, + }, +}); + +const Stub = { render: () => h("div") }; + +let router: Router; +let wrapper: VueWrapper | null = null; + +const mountPage = async (query: Record = { action: "add" }) => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/ai_toolsets", name: "aiToolsets", component: AiToolsets }, + { path: "/logs", name: "logs", component: Stub }, + ], + }); + await router.push({ name: "aiToolsets", query: { org_identifier: ORG, ...query } }); + wrapper = mount( + { render: () => h(RouterView) }, + { + attachTo: document.body, + global: { + plugins: [router, store, i18n], + provide: { store }, + stubs: { QueryEditor: QueryEditorStub }, + }, + }, + ); + await flushPromises(); +}; + +const remount = async (query?: Record) => { + wrapper?.unmount(); + wrapper = null; + await mountPage(query); +}; + +const form = () => + (wrapper!.findComponent(AddAiToolset).vm as unknown as { form: ToolsetForm }).form; + +const edit = async (values: Record) => { + window.dispatchEvent(new Event("pointerdown")); + Object.entries(values).forEach(([field, value]) => form().setFieldValue(field, value)); + await nextTick(); +}; + +// The list's own loading toast is not a notice about the toolset. +const noticeVariants = () => + toastMock.mock.calls + .map(([arg]) => (arg as { variant: string }).variant) + .filter((variant) => variant !== "loading"); + +describe("AddAiToolset drafts", () => { + beforeEach(() => { + localStorage.clear(); + queryClient.clear(); + vi.mocked(aiToolsetsService.list).mockResolvedValue({ + data: { toolsets: [{ id: "ts1", name: "github_mcp", kind: "mcp", description: "" }] }, + } as never); + vi.mocked(aiToolsetsService.get).mockResolvedValue({ data: serverToolset() } as never); + vi.mocked(aiToolsetsService.create).mockClear(); + vi.mocked(aiToolsetsService.update).mockClear(); + toastMock.mockClear(); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("never writes header, env or credential file values to storage", async () => { + await mountPage(); + await edit({ + name: "ops_mcp", + "mcp.url": "https://mcp.example.com/", + "mcp.headers": [ + { key: "Authorization", value: "Bearer s3cr3t", visible: false }, + { key: "X-Tenant", value: "tenant-42", visible: true }, + ], + "cli.env": [{ key: "GH_TOKEN", value: "ghp_abcdef", visible: false }], + "cli.credFiles": [{ key: "KUBECONFIG", value: "apiVersion: v1\ntoken: kube-secret" }], + }); + window.dispatchEvent(new Event("pagehide")); + + const stored = localStorage.getItem(editorDraftKey(newScope)) ?? ""; + expect(stored).toContain("X-Tenant"); + expect(stored).toContain("https://mcp.example.com/"); + for (const secret of ["s3cr3t", "tenant-42", "ghp_abcdef", "kube-secret"]) { + expect(stored).not.toContain(secret); + } + expect(stored).not.toContain("visible"); + + await remount(); + const note = byTest("editor-draft-credentials-not-saved")?.textContent ?? ""; + expect(note).toContain("environment variable GH_TOKEN"); + expect(note).toContain("credential file KUBECONFIG"); + }); + + it("names a header value with no header name in lowercase, as part of the sentence", async () => { + await mountPage(); + await edit({ + name: "ops_mcp", + "mcp.headers": [{ key: "", value: "unnamed-secret", visible: false }], + }); + window.dispatchEvent(new Event("pagehide")); + + await remount(); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your change to the headers was not kept.", + ); + }); + + it("counts a name typed with no key or pointer event", async () => { + await mountPage(); + await fillField("ai-toolset-name-input", "filled_toolset"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("filled_toolset"); + }); + + it("a password visibility toggle alone is not an unsaved change", async () => { + await mountPage({ action: "edit", id: "ts1" }); + await edit({ "mcp.headers[0].visible": true }); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(editScope)).toBeNull(); + + click("ai-toolset-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(router.currentRoute.value.query.action).toBeUndefined(); + }); + + it("continues a new toolset's draft, and Start fresh or Escape answer the offer", async () => { + await mountPage(); + await edit({ name: "draft_tool", description: "From the draft" }); + window.dispatchEvent(new Event("pagehide")); + + await remount(); + expect(byTest("editor-draft-restore-message")?.textContent).toContain( + "unsaved changes to this AI toolset", + ); + pressEscape(); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(newScope)).not.toBeNull(); + + await remount(); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values).toMatchObject({ + name: "draft_tool", + description: "From the draft", + }); + + await remount(); + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + expect(readEditorDraft(newScope)).toBeNull(); + expect(form().state.values.name).toBe(""); + }); + + it("an edit draft refills unchanged headers by name and blocks Save on a changed one", async () => { + await mountPage({ action: "edit", id: "ts1" }); + expect(form().state.values.mcp.headers.map((row) => row.key)).toEqual([ + "Authorization", + "X-Team", + ]); + await edit({ + description: "Edited", + "mcp.headers": [ + { key: "X-Team", value: "sre-team", visible: false }, + { key: "Authorization", value: "Bearer new-token", visible: false }, + ], + }); + window.dispatchEvent(new Event("pagehide")); + expect(localStorage.getItem(editorDraftKey(editScope))).not.toContain("new-token"); + + await remount({ action: "edit", id: "ts1" }); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your change to the Authorization header was not kept.", + ); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values.description).toBe("Edited"); + expect(form().state.values.mcp.headers).toEqual([ + { key: "X-Team", value: "sre-team", visible: false }, + { key: "Authorization", value: "", visible: false }, + ]); + expect(form().getFieldMeta("mcp.headers[1].value")?.errors?.length).toBeGreaterThan(0); + + await form().handleSubmit(); + await flushPromises(); + expect(aiToolsetsService.update).not.toHaveBeenCalled(); + + await edit({ "mcp.headers[1].value": "Bearer typed-again" }); + await form().handleSubmit(); + await flushPromises(); + expect(aiToolsetsService.update).toHaveBeenCalledTimes(1); + expect(vi.mocked(aiToolsetsService.update).mock.calls[0][2]).toMatchObject({ + data: { headers: { "X-Team": "sre-team", Authorization: "Bearer typed-again" } }, + }); + }); + + it("never stores an endpoint URL that carries a credential, and refills it when unchanged", async () => { + vi.mocked(aiToolsetsService.get).mockResolvedValue({ + data: { + ...serverToolset(), + data: { ...serverToolset().data, url: "https://mcp.example.com/sse?api_key=saved-key" }, + }, + } as never); + await mountPage({ action: "edit", id: "ts1" }); + await edit({ description: "Edited" }); + window.dispatchEvent(new Event("pagehide")); + expect(localStorage.getItem(editorDraftKey(editScope))).not.toContain("saved-key"); + + await remount({ action: "edit", id: "ts1" }); + expect(byTest("editor-draft-changed-on-server")).toBeNull(); + expect(byTest("editor-draft-credentials-not-saved")).toBeNull(); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values).toMatchObject({ + description: "Edited", + mcp: { url: "https://mcp.example.com/sse?api_key=saved-key" }, + }); + }); + + it("blanks a changed credential URL, names it in the restore note and blocks Save until re-entered", async () => { + await mountPage({ action: "edit", id: "ts1" }); + await edit({ "mcp.url": "https://bot:typed-token@mcp.example.com/sse" }); + window.dispatchEvent(new Event("pagehide")); + expect(localStorage.getItem(editorDraftKey(editScope))).not.toContain("typed-token"); + + await remount({ action: "edit", id: "ts1" }); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your change to the Endpoint URL was not kept.", + ); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values.mcp.url).toBe(""); + expect(form().getFieldMeta("mcp.url")?.errors?.length).toBeGreaterThan(0); + + await form().handleSubmit(); + await flushPromises(); + expect(aiToolsetsService.update).not.toHaveBeenCalled(); + + await edit({ "mcp.url": "https://mcp.example.com/sse" }); + await form().handleSubmit(); + await flushPromises(); + expect(aiToolsetsService.update).toHaveBeenCalledTimes(1); + }); + + it("warns when the toolset changed on the server", async () => { + await mountPage({ action: "edit", id: "ts1" }); + await edit({ description: "Edited" }); + window.dispatchEvent(new Event("pagehide")); + + await remount({ action: "edit", id: "ts1" }); + expect(byTest("editor-draft-changed-on-server")).toBeNull(); + + vi.mocked(aiToolsetsService.get).mockResolvedValue({ + data: serverToolset({ "X-Region": "eu" }), + } as never); + await remount({ action: "edit", id: "ts1" }); + expect(byTest("editor-draft-changed-on-server")).not.toBeNull(); + }); + + it("drops the draft of a toolset that no longer exists with one notice, and goes back", async () => { + writeEditorDraft(editScope, { data: { name: "github_mcp" } }); + vi.mocked(aiToolsetsService.get).mockRejectedValue({ response: { status: 404 } }); + await mountPage({ action: "edit", id: "ts1" }); + + expect(readEditorDraft(editScope)).toBeNull(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(noticeVariants()).toEqual(["info"]); + expect(router.currentRoute.value.query.action).toBeUndefined(); + }); + + it("a deleted toolset with no draft shows the load error and goes back", async () => { + vi.mocked(aiToolsetsService.get).mockRejectedValue({ response: { status: 404 } }); + await mountPage({ action: "edit", id: "ts1" }); + + expect(noticeVariants()).toEqual(["error"]); + expect(router.currentRoute.value.query.action).toBeUndefined(); + }); + + it("a not-kept header does not block Save once the toolset is switched to another kind", async () => { + await mountPage(); + await edit({ + name: "switch_kind", + "mcp.url": "https://mcp.example.com/", + "mcp.headers": [{ key: "Authorization", value: "Bearer s3cr3t", visible: false }], + }); + window.dispatchEvent(new Event("pagehide")); + + await remount(); + click("editor-draft-continue"); + await flushPromises(); + await form().handleSubmit(); + await flushPromises(); + expect(aiToolsetsService.create).not.toHaveBeenCalled(); + + await edit({ kind: "cli", "cli.command": "kubectl" }); + await form().handleSubmit(); + await flushPromises(); + expect(aiToolsetsService.create).toHaveBeenCalledTimes(1); + }); + + it("marks a not-kept credential file under its editor until it is filled again", async () => { + vi.mocked(aiToolsetsService.get).mockResolvedValue({ + data: { + id: "ts1", + name: "kube_cli", + kind: "cli", + description: "", + data: { + command: "kubectl", + timeout_seconds: 30, + max_output_bytes: 100000, + env: {}, + credential_files: { KUBECONFIG: "apiVersion: v1" }, + }, + }, + } as never); + await mountPage({ action: "edit", id: "ts1" }); + await edit({ "cli.credFiles[0].value": "apiVersion: v2" }); + window.dispatchEvent(new Event("pagehide")); + + await remount({ action: "edit", id: "ts1" }); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain("KUBECONFIG"); + click("editor-draft-continue"); + await flushPromises(); + expect(byTest("ai-toolset-cred-file-reenter-0")).not.toBeNull(); + expect(byTest("ai-toolset-cred-file-reenter-0")?.classList).toContain("text-status-error-text"); + + await edit({ "cli.credFiles[0].value": "apiVersion: v3" }); + await flushPromises(); + expect(byTest("ai-toolset-cred-file-reenter-0")).toBeNull(); + }); + + it("Cancel asks; Keep draft returns to the list with a dot on Add, Discard drops it", async () => { + await mountPage(); + await edit({ name: "kept_tool" }); + click("ai-toolset-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + // A slow list reload must not remount the editor and offer the draft just kept. + let releaseList!: () => void; + vi.mocked(aiToolsetsService.list).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseList = () => + resolve({ + data: { toolsets: [{ id: "ts1", name: "github_mcp", kind: "mcp", description: "" }] }, + } as never); + }), + ); + clickLeave("o-dialog-secondary-btn"); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(wrapper!.findComponent(AddAiToolset).exists()).toBe(false); + releaseList(); + await flushPromises(); + + expect(router.currentRoute.value.query.action).toBeUndefined(); + expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("kept_tool"); + const add = wrapper!.find('[data-test="ai-toolsets-add-btn"]'); + expect(add.find('[data-test="ai-toolsets-add-draft-dot"]').exists()).toBe(true); + expect(add.attributes("aria-label")).toBe("New AI Toolset · unsaved draft"); + + await remount(); + click("editor-draft-continue"); + await flushPromises(); + click("ai-toolset-back-btn"); + await flushPromises(); + clickLeave("o-dialog-neutral-btn"); + await flushPromises(); + expect(router.currentRoute.value.query.action).toBeUndefined(); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("Save in the leave dialog saves and goes where the user was headed", async () => { + await mountPage(); + await edit({ name: "saved_tool", "mcp.url": "https://mcp.example.com/" }); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(aiToolsetsService.create).toHaveBeenCalledTimes(1); + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("a normal save drops the draft and returns to the list without asking", async () => { + await mountPage(); + await edit({ name: "saved_tool", "mcp.url": "https://mcp.example.com/" }); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)).not.toBeNull(); + + await form().handleSubmit(); + await vi.waitFor(() => expect(router.currentRoute.value.query.action).toBeUndefined()); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("marks a toolset row with a draft and drops the draft when the toolset is deleted", async () => { + writeEditorDraft(editScope, { data: { name: "github_mcp" } }); + await mountPage({}); + expect(byTest("ai-toolsets-github_mcp-draft-dot")).not.toBeNull(); + + const page = wrapper!.findComponent(AiToolsets).vm as unknown as { + confirmDeleteToolset: (row: { id: string; name: string }) => void; + deleteToolset: () => void; + }; + page.confirmDeleteToolset({ id: "ts1", name: "github_mcp" }); + page.deleteToolset(); + await flushPromises(); + expect(readEditorDraft(editScope)).toBeNull(); + }); +}); diff --git a/web/src/components/ai_toolsets/AddAiToolset.vue b/web/src/components/ai_toolsets/AddAiToolset.vue index a6a3b5c52b..0897e3042a 100644 --- a/web/src/components/ai_toolsets/AddAiToolset.vue +++ b/web/src/components/ai_toolsets/AddAiToolset.vue @@ -19,7 +19,7 @@ along with this program. If not, see . :title="isEditing ? t('aiToolset.update') : t('aiToolset.add')" :back="{ label: t('aiToolset.header'), - onClick: () => $emit('cancel:hideform'), + onClick: closeEditor, dataTest: 'ai-toolset-back-btn', }" bleed @@ -273,6 +273,13 @@ along with this program. If not, see . :query="cred.value" @update:query="(v: string) => setCredValue(idx, v)" /> +
+ {{ t("common.editorDraft.reenterField") }} +
{{ t("aiToolset.addCredFile") }} @@ -320,21 +327,30 @@ along with this program. If not, see . variant="outline" size="sm-action" :disabled="isSubmitting" - @click="$emit('cancel:hideform')" + @click="closeEditor" > {{ t("common.cancel") }} + diff --git a/web/src/components/common/UpdatePasswordDialog.spec.ts b/web/src/components/common/UpdatePasswordDialog.spec.ts index 9f06c6e2ce..852ed37c97 100644 --- a/web/src/components/common/UpdatePasswordDialog.spec.ts +++ b/web/src/components/common/UpdatePasswordDialog.spec.ts @@ -43,9 +43,31 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: (...args: unknown[]) => toastMock(...args), })); +vi.mock("@/utils/sessionServices", async (importOriginal) => ({ + ...(await importOriginal()), + stopSessionServices: vi.fn(), +})); + const pushMock = vi.fn(); +// Runs the global guards like vue-router does, so the shared sign-out flow can finish. +const routerGuards: ((to: { path: string }) => unknown)[] = []; vi.mock("vue-router", () => ({ - useRouter: () => ({ push: pushMock }), + useRouter: () => ({ + push: async (path: string) => { + pushMock(path); + for (const guard of [...routerGuards]) { + // A guard returning false aborts the navigation, which push reports as a failure. + if ((await guard({ path })) === false) return { type: "aborted" }; + } + return undefined; + }, + beforeEach: (guard: (to: { path: string }) => unknown) => { + routerGuards.push(guard); + return () => { + if (routerGuards.includes(guard)) routerGuards.splice(routerGuards.indexOf(guard), 1); + }; + }, + }), })); const invalidateLoginDataMock = vi.fn(); @@ -60,6 +82,16 @@ vi.mock("@/utils/zincutils", async (importOriginal) => { }); import passwordPolicyService from "@/services/passwordPolicy"; +import { + cancelDraftSignOut, + draftSignOutDiscardOnly, + draftSignOutForced, + resumeDraftSession, +} from "@/utils/drafts/draftSession"; +import { stopSessionServices } from "@/utils/sessionServices"; +import { readEditorDraft, writeEditorDraft } from "@/utils/drafts/editorDraftStore"; + +const realLocation = window.location; import userService from "@/services/users"; const STRICT_COMPLEXITY = { @@ -97,8 +129,11 @@ const mountDialog = () => { const { open, close, isOpen } = usePasswordReset(); describe("UpdatePasswordDialog", () => { + const assign = vi.fn(); + beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal("location", { ...window.location, assign }); resetPasswordComplexityCache(); close(); (passwordPolicyService.getComplexity as any).mockResolvedValue({ data: STRICT_COMPLEXITY }); @@ -109,6 +144,11 @@ describe("UpdatePasswordDialog", () => { wrappers.forEach((wrapper) => wrapper.unmount()); wrappers = []; close(); + vi.stubGlobal("location", realLocation); + // A sign-out that reached the reload leaves its flags and the signed-out user behind, as the reload would clear them. + cancelDraftSignOut(); + resumeDraftSession(); + localStorage.clear(); }); it("renders nothing until the user is flagged", async () => { @@ -270,6 +310,68 @@ describe("UpdatePasswordDialog", () => { ); }); + it("signs out with editors offering only Discard, and stays open when an editor keeps the user", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + let sawDiscardOnly = false; + const editorGuard = (to: { path: string }) => { + if (to.path !== "/logout") return true; + sawDiscardOnly = draftSignOutDiscardOnly.value; + return false; + }; + routerGuards.unshift(editorGuard); + try { + await (wrapper.vm as unknown as { signOut: () => Promise }).signOut(); + await flushPromises(); + } finally { + routerGuards.splice(routerGuards.indexOf(editorGuard), 1); + } + + expect(sawDiscardOnly).toBe(true); + expect(draftSignOutDiscardOnly.value).toBe(false); + expect(invalidateLoginDataMock).not.toHaveBeenCalled(); + expect(stopSessionServices).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + expect(isOpen.value).toBe(true); + }); + + it("confirms discarding the user's drafts before its Sign out, as the menu's Sign out does", async () => { + const scope = { kind: "slo" as const, org: "acme", entityId: null, userEmail: "maya@acme.io" }; + writeEditorDraft(scope, { data: { name: "unsaved" } }); + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await wrapper.find('[data-test="o-dialog-neutral-btn"]').trigger("click"); + await flushPromises(); + expect(pushMock).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test="editor-draft-signout-message"]').text()).toBe( + "You have 1 unsaved draft in this browser. Signing out discards it.", + ); + expect(readEditorDraft(scope)).not.toBeNull(); + + await wrapper.find('[data-test="editor-draft-signout-confirm"]').trigger("click"); + await flushPromises(); + expect(pushMock).toHaveBeenCalledWith("/logout"); + expect(assign).toHaveBeenCalledTimes(1); + resumeDraftSession(); + expect(readEditorDraft(scope)).toBeNull(); + localStorage.clear(); + }); + + it("signs out at once from its Sign out when the user has no drafts", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await wrapper.find('[data-test="o-dialog-neutral-btn"]').trigger("click"); + await flushPromises(); + expect(wrapper.find('[data-test="editor-draft-signout-dialog"]').exists()).toBe(false); + expect(pushMock).toHaveBeenCalledWith("/logout"); + expect(assign).toHaveBeenCalledTimes(1); + }); + it("signs the user out on success, because the cookie is the old password", async () => { open("policy_tightened"); const wrapper = mountDialog(); @@ -283,7 +385,63 @@ describe("UpdatePasswordDialog", () => { await flushPromises(); expect(invalidateLoginDataMock).toHaveBeenCalled(); + expect(stopSessionServices).toHaveBeenCalledTimes(1); expect(pushMock).toHaveBeenCalledWith("/logout"); + expect(assign).toHaveBeenCalledTimes(1); + expect(isOpen.value).toBe(false); + }); + + it("keeps the user's drafts once the password has changed, as an expired session does", async () => { + const scope = { kind: "slo" as const, org: "acme", entityId: null, userEmail: "maya@acme.io" }; + writeEditorDraft(scope, { data: { name: "unsaved" } }); + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await (wrapper.vm as unknown as { submit: (v: UpdatePasswordForm) => Promise }).submit({ + old_password: "old-secret", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + + expect(assign).toHaveBeenCalledTimes(1); + resumeDraftSession(); + expect(readEditorDraft(scope)?.data).toEqual({ name: "unsaved" }); + localStorage.clear(); + }); + + it("cannot be kept in the old session once the password has changed", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + let sawForced = false; + const editorGuard = (to: { path: string }) => { + if (to.path !== "/logout") return true; + sawForced = draftSignOutForced.value; + return false; + }; + routerGuards.unshift(editorGuard); + const vm = wrapper.vm as unknown as { + submit: ( + values: Record<"old_password" | "new_password" | "confirm_password", string>, + ) => Promise; + }; + try { + await vm.submit({ + old_password: "old-secret", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + } finally { + routerGuards.splice(routerGuards.indexOf(editorGuard), 1); + } + + expect(sawForced).toBe(true); + expect(invalidateLoginDataMock).toHaveBeenCalled(); + expect(stopSessionServices).toHaveBeenCalledTimes(1); + expect(assign).toHaveBeenCalledTimes(1); expect(isOpen.value).toBe(false); }); diff --git a/web/src/components/common/UpdatePasswordDialog.vue b/web/src/components/common/UpdatePasswordDialog.vue index 147ef985f4..c61a2efd40 100644 --- a/web/src/components/common/UpdatePasswordDialog.vue +++ b/web/src/components/common/UpdatePasswordDialog.vue @@ -29,7 +29,7 @@ along with this program. If not, see . :secondary-button-label="dismissible ? t('common.cancel') : undefined" :primary-button-label="t('passwordReset.submit')" form-id="update-password-form" - @click:neutral="signOut" + @click:neutral="requestSignOut" @click:secondary="close" @update:open="(open: boolean) => !open && close()" > @@ -83,10 +83,15 @@ along with this program. If not, see . + diff --git a/web/src/components/common/drafts/EditorDraftDialogs.spec.ts b/web/src/components/common/drafts/EditorDraftDialogs.spec.ts new file mode 100644 index 0000000000..4741f9e950 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftDialogs.spec.ts @@ -0,0 +1,83 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { reactive } from "vue"; +import { raw } from "@/types/i18n"; +import type { EditorDraftController } from "@/composables/useEditorDraft"; +import EditorDraftDialogs from "./EditorDraftDialogs.vue"; +import EditorDraftRestoreDialog from "./EditorDraftRestoreDialog.vue"; +import EditorDraftLeaveDialog from "./EditorDraftLeaveDialog.vue"; + +const controller = (): EditorDraftController => ({ + start: vi.fn(), + confirmLeave: vi.fn(), + markSaved: vi.fn(), + discard: vi.fn(), + hasUnsavedChanges: vi.fn(() => false), + storageAvailable: true, + state: reactive({ + entityLabel: raw("pipeline"), + restoreTitle: raw("Unsaved draft"), + restoreOpen: true, + restoreSavedAt: 42, + changedOnServer: true, + credentialsNotSaved: false, + leaveOpen: false, + leaveSaving: true, + canKeepDraft: false, + }), + chooseRestore: vi.fn(), + chooseLeave: vi.fn(), +}); + +describe("EditorDraftDialogs", () => { + it("binds both dialogs to the controller's state and choices", async () => { + const draft = controller(); + const wrapper = mount(EditorDraftDialogs, { + props: { draft }, + global: { stubs: { EditorDraftRestoreDialog: true, EditorDraftLeaveDialog: true } }, + }); + + const restore = wrapper.findComponent(EditorDraftRestoreDialog); + expect(restore.props()).toMatchObject({ + open: true, + savedAt: 42, + entityLabel: "pipeline", + title: "Unsaved draft", + changedOnServer: true, + credentialsNotSaved: false, + }); + const leave = wrapper.findComponent(EditorDraftLeaveDialog); + expect(leave.props()).toMatchObject({ + open: false, + entityLabel: "pipeline", + saving: true, + canKeepDraft: false, + }); + + restore.vm.$emit("continue"); + restore.vm.$emit("fresh"); + restore.vm.$emit("dismiss"); + leave.vm.$emit("choose", "keep"); + expect(vi.mocked(draft.chooseRestore).mock.calls).toEqual([ + ["continue"], + ["fresh"], + ["dismiss"], + ]); + expect(draft.chooseLeave).toHaveBeenCalledWith("keep"); + }); +}); diff --git a/web/src/components/common/drafts/EditorDraftDialogs.vue b/web/src/components/common/drafts/EditorDraftDialogs.vue new file mode 100644 index 0000000000..16a9c960d6 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftDialogs.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/web/src/components/common/drafts/EditorDraftDot.spec.ts b/web/src/components/common/drafts/EditorDraftDot.spec.ts new file mode 100644 index 0000000000..44906085d8 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftDot.spec.ts @@ -0,0 +1,172 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { defineComponent, h, ref } from "vue"; +import { raw } from "@/types/i18n"; +import OButton from "@/lib/core/Button/OButton.vue"; +import { createStore } from "vuex"; +import EditorDraftDot from "./EditorDraftDot.vue"; + +const store = createStore({ state: { timezone: "UTC" } }); + +describe("EditorDraftDot", () => { + it("names a row's draft with its time", () => { + const wrapper = mount(EditorDraftDot, { + props: { savedAt: Date.UTC(2026, 9, 21, 14, 32), dataTest: "row-dot" }, + global: { plugins: [store] }, + }); + const dot = wrapper.get('[data-test="row-dot"]'); + expect(dot.attributes("role")).toBe("img"); + expect(dot.attributes("aria-label")).toMatch(/^Unsaved draft · .*2026/); + expect(dot.classes()).toContain("bg-button-pending-bg"); + }); + + it("pins a silent dot to a button corner", () => { + const wrapper = mount(EditorDraftDot, { + props: { corner: true }, + global: { plugins: [store] }, + }); + const dot = wrapper.get('[data-test="editor-draft-dot"]'); + expect(dot.attributes("aria-hidden")).toBe("true"); + expect(dot.attributes("role")).toBeUndefined(); + expect(dot.classes()).toEqual(expect.arrayContaining(["absolute", "-end-1", "-top-1"])); + }); + + describe("corner dot tooltip on its host button", () => { + let wrapper: VueWrapper | null = null; + + beforeEach(() => { + // jsdom has no layout, so a box is faked for every element except one CSS would hide. + vi.spyOn(Element.prototype, "getClientRects").mockImplementation(function (this: Element) { + const hidden = !!this.closest(".hidden"); + return { length: hidden ? 0 : 1, item: () => null } as unknown as DOMRectList; + }); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const mountOnButton = (dotProps: Record = {}) => { + const Host = defineComponent({ + setup: () => () => + h( + OButton, + { variant: "primary", "data-test": "host-btn", "aria-label": raw("New alert") }, + () => [raw("New alert"), h(EditorDraftDot, { corner: true, ...dotProps })], + ), + }); + wrapper = mount(Host, { attachTo: document.body, global: { plugins: [store] } }); + return document.body.querySelector('[data-test="host-btn"]')!; + }; + + const tooltipText = () => + document.body.querySelector('[data-test="o-tooltip-content"]')?.textContent ?? null; + + it("opens on hovering the button, dated, and closes on leave", async () => { + vi.useFakeTimers(); + const button = mountOnButton({ savedAt: Date.UTC(2026, 9, 21, 14, 32) }); + await flushPromises(); + expect(button.tagName).toBe("BUTTON"); + + button.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(800); + await flushPromises(); + expect(tooltipText()).toMatch(/Unsaved draft · .*2026/); + + button.dispatchEvent(new MouseEvent("mouseleave")); + await flushPromises(); + expect(tooltipText()).toBeNull(); + }); + + it("opens on keyboard focus of the button, undated when no time is known", async () => { + vi.useFakeTimers(); + const button = mountOnButton(); + await flushPromises(); + button.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + vi.advanceTimersByTime(800); + await flushPromises(); + expect(tooltipText()).toContain("Unsaved draft"); + expect(tooltipText()).not.toContain("·"); + }); + + it("shows nothing for a dot CSS hides, e.g. md:hidden at desktop", async () => { + vi.useFakeTimers(); + const button = mountOnButton({ class: "hidden" }); + await flushPromises(); + button.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(800); + await flushPromises(); + expect(tooltipText()).toBeNull(); + }); + + it("follows a tooltip prop that turns off and back on while mounted", async () => { + vi.useFakeTimers(); + const on = ref(true); + const Host = defineComponent({ + setup: () => () => + h("button", { "data-test": "toggle-btn" }, [ + h(EditorDraftDot, { corner: true, tooltip: on.value }), + ]), + }); + wrapper = mount(Host, { attachTo: document.body, global: { plugins: [store] } }); + await flushPromises(); + const button = document.body.querySelector('[data-test="toggle-btn"]')!; + const hover = async () => { + button.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(800); + await flushPromises(); + }; + + await hover(); + expect(tooltipText()).toContain("Unsaved draft"); + on.value = false; + await flushPromises(); + expect(tooltipText()).toBeNull(); + await hover(); + expect(tooltipText()).toBeNull(); + + button.dispatchEvent(new MouseEvent("mouseleave")); + on.value = true; + await flushPromises(); + await hover(); + expect(tooltipText()).toContain("Unsaved draft"); + }); + + it("shows nothing with tooltip off, and keeps a single root so host classes apply", async () => { + vi.useFakeTimers(); + const Host = defineComponent({ + setup: () => () => + h("button", { "data-test": "plain-btn" }, [ + h(EditorDraftDot, { corner: true, tooltip: false, class: "md:hidden" }), + ]), + }); + wrapper = mount(Host, { attachTo: document.body, global: { plugins: [store] } }); + await flushPromises(); + const dot = document.body.querySelector('[data-test="editor-draft-dot"]'); + expect(dot?.classList.contains("md:hidden")).toBe(true); + const button = document.body.querySelector('[data-test="plain-btn"]')!; + button.dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(800); + await flushPromises(); + expect(tooltipText()).toBeNull(); + }); + }); +}); diff --git a/web/src/components/common/drafts/EditorDraftDot.vue b/web/src/components/common/drafts/EditorDraftDot.vue new file mode 100644 index 0000000000..670bb81e67 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftDot.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/web/src/components/common/drafts/EditorDraftInfoHint.vue b/web/src/components/common/drafts/EditorDraftInfoHint.vue new file mode 100644 index 0000000000..04a89328af --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftInfoHint.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/web/src/components/common/drafts/EditorDraftLeaveDialog.spec.ts b/web/src/components/common/drafts/EditorDraftLeaveDialog.spec.ts new file mode 100644 index 0000000000..5c338a73b9 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftLeaveDialog.spec.ts @@ -0,0 +1,181 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { raw } from "@/types/i18n"; +import EditorDraftLeaveDialog from "./EditorDraftLeaveDialog.vue"; + +let wrapper: VueWrapper | null = null; + +const mountDialog = async (props: Record = {}) => { + wrapper = mount(EditorDraftLeaveDialog, { + attachTo: document.body, + props: { open: true, entityLabel: raw("report"), canKeepDraft: true, ...props }, + }); + await flushPromises(); + return wrapper; +}; + +const button = (id: string) => + document.body.querySelector( + `[data-test="editor-draft-leave-dialog"] [data-test="${id}"]`, + ); + +const click = (id: string) => { + const target = button(id); + if (!target) throw new Error(`no ${id} in the leave dialog`); + target.click(); +}; + +const messageText = () => + document.body.querySelector('[data-test="editor-draft-leave-message"]')?.textContent ?? ""; + +const pressEscape = async () => { + const content = document.body.querySelector('[data-test="editor-draft-leave-dialog"]'); + content?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + await flushPromises(); +}; + +describe("EditorDraftLeaveDialog", () => { + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("asks about the entity and offers save, keep draft and discard", async () => { + const dialog = await mountDialog(); + const root = document.body.querySelector('[data-test="editor-draft-leave-dialog"]'); + expect(root?.textContent).toContain("Leave with unsaved changes?"); + expect(messageText()).toContain("Save the report"); + expect(messageText()).toBe( + "Save the report, keep your changes as a draft in this browser, or discard them.", + ); + + click("editor-draft-leave-save"); + click("o-dialog-secondary-btn"); + click("o-dialog-neutral-btn"); + expect(dialog.emitted("choose")).toEqual([["save"], ["keep"], ["discard"]]); + expect(button("o-dialog-neutral-btn")?.textContent?.trim()).toBe("Discard"); + }); + + it("states how long a kept draft lasts behind the info icon, only when Keep is offered", async () => { + await mountDialog(); + const hint = document.body.querySelector('[data-test="editor-draft-leave-retention"]'); + expect(hint?.tagName).toBe("BUTTON"); + expect(hint?.getAttribute("aria-label")).toBe( + "Drafts are kept in this browser for 7 days, or until you sign out. Only the 20 newest of each kind are kept.", + ); + wrapper?.unmount(); + + await mountDialog({ canKeepDraft: false }); + expect(document.body.querySelector('[data-test="editor-draft-leave-retention"]')).toBeNull(); + }); + + it("says in the dialog text that signing out discards the other drafts, and explains it behind the info icon", async () => { + await mountDialog({ canKeepDraft: false, signingOut: true, otherDraftCount: 2 }); + expect(messageText()).toContain("Save the report or discard your changes."); + expect(messageText()).not.toContain("draft"); + expect( + document.body.querySelector('[data-test="editor-draft-leave-sign-out-others"]')?.textContent, + ).toContain("Signing out also discards 2 other unsaved drafts in this browser."); + const hint = document.body.querySelector('[data-test="editor-draft-leave-sign-out"]'); + expect(hint?.tagName).toBe("BUTTON"); + expect(hint?.getAttribute("aria-label")).toBe("Drafts are not kept when you sign out."); + expect(button("o-dialog-secondary-btn")).toBeNull(); + expect(button("o-dialog-neutral-btn")).not.toBeNull(); + }); + + it("says why there is no Keep when the sign-out leaves no other draft", async () => { + await mountDialog({ canKeepDraft: false, signingOut: true }); + expect( + document.body + .querySelector('[data-test="editor-draft-leave-sign-out"]') + ?.getAttribute("aria-label"), + ).toBe("Drafts are not kept when you sign out."); + expect( + document.body.querySelector('[data-test="editor-draft-leave-sign-out-others"]'), + ).toBeNull(); + }); + + it("hides keep draft when drafts cannot be stored", async () => { + await mountDialog({ canKeepDraft: false }); + expect(button("o-dialog-secondary-btn")).toBeNull(); + expect(button("editor-draft-leave-save")).not.toBeNull(); + expect(document.body.querySelector('[data-test="editor-draft-leave-sign-out"]')).toBeNull(); + }); + + it("offers a Stay button beside Discard when the item cannot be saved", async () => { + const dialog = await mountDialog({ canKeepDraft: false, canSave: false, signingOut: true }); + expect(messageText()).toContain("or stay on this page"); + expect(button("editor-draft-leave-save")).toBeNull(); + expect(button("o-dialog-secondary-btn")?.textContent?.trim()).toBe("Stay"); + click("o-dialog-secondary-btn"); + click("o-dialog-neutral-btn"); + expect(dialog.emitted("choose")).toEqual([["stay"], ["discard"]]); + }); + + it("cannot be closed or stayed in during a forced sign-out", async () => { + const dialog = await mountDialog({ + canKeepDraft: false, + canSave: false, + forced: true, + signingOut: true, + otherDraftCount: 2, + }); + expect(messageText()).toBe( + "Your password changed, so you are being signed out and this report cannot be saved. Your changes to it will be discarded.", + ); + expect(button("o-dialog-close-btn")).toBeNull(); + expect(button("o-dialog-secondary-btn")).toBeNull(); + expect(button("editor-draft-leave-save")).toBeNull(); + expect(document.body.querySelector('[data-test="editor-draft-leave-sign-out"]')).toBeNull(); + expect( + document.body.querySelector('[data-test="editor-draft-leave-sign-out-others"]'), + ).toBeNull(); + await pressEscape(); + expect(dialog.emitted("choose")).toBeUndefined(); + expect(document.body.querySelector('[data-test="editor-draft-leave-dialog"]')).not.toBeNull(); + click("o-dialog-neutral-btn"); + expect(dialog.emitted("choose")).toEqual([["discard"]]); + }); + + it("shows the host's own message in place of the default copy", async () => { + await mountDialog({ canKeepDraft: false, message: raw("Save it as a server draft.") }); + expect(messageText().trim()).toBe("Save it as a server draft."); + }); + + it("stays on close", async () => { + const dialog = await mountDialog(); + click("o-dialog-close-btn"); + await flushPromises(); + expect(dialog.emitted("choose")).toEqual([["stay"]]); + }); + + it("stays on Escape when the sign-out is not forced", async () => { + const dialog = await mountDialog({ canSave: false, canKeepDraft: false }); + await pressEscape(); + expect(dialog.emitted("choose")?.at(-1)).toEqual(["stay"]); + }); + + it("is persistent and disables its buttons while saving", async () => { + const dialog = await mountDialog({ saving: true }); + expect(dialog.findComponent({ name: "ODialog" }).props("persistent")).toBe(true); + expect(button("o-dialog-neutral-btn")?.hasAttribute("disabled")).toBe(true); + expect(button("o-dialog-secondary-btn")?.hasAttribute("disabled")).toBe(true); + }); +}); diff --git a/web/src/components/common/drafts/EditorDraftLeaveDialog.vue b/web/src/components/common/drafts/EditorDraftLeaveDialog.vue new file mode 100644 index 0000000000..e7a2aba380 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftLeaveDialog.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/web/src/components/common/drafts/EditorDraftRestoreDialog.spec.ts b/web/src/components/common/drafts/EditorDraftRestoreDialog.spec.ts new file mode 100644 index 0000000000..4d4027885f --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftRestoreDialog.spec.ts @@ -0,0 +1,132 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { createStore } from "vuex"; +import { raw } from "@/types/i18n"; +import EditorDraftRestoreDialog from "./EditorDraftRestoreDialog.vue"; + +const store = createStore({ state: { timezone: "UTC" } }); +let wrapper: VueWrapper | null = null; + +const savedAt = Date.UTC(2026, 9, 21, 14, 32); + +const mountDialog = async (props: Record = {}) => { + wrapper = mount(EditorDraftRestoreDialog, { + attachTo: document.body, + props: { open: true, savedAt, entityLabel: raw("alert template"), ...props }, + global: { plugins: [store] }, + }); + await flushPromises(); + return wrapper; +}; + +const byTest = (id: string) => document.body.querySelector(`[data-test="${id}"]`); + +describe("EditorDraftRestoreDialog", () => { + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("names the entity and the draft time to the minute", async () => { + await mountDialog(); + expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("Unsaved draft"); + const message = byTest("editor-draft-restore-message")?.textContent ?? ""; + expect(message).toContain("unsaved changes to this alert template from"); + expect(message).toContain("2026"); + expect(message).toMatch(/2:32|14:32/); + }); + + it("emits continue and fresh from its buttons", async () => { + const dialog = await mountDialog(); + byTest("editor-draft-continue")?.click(); + document.body + .querySelector( + '[data-test="editor-draft-restore-dialog"] [data-test="o-dialog-secondary-btn"]', + ) + ?.click(); + expect(dialog.emitted("continue")).toHaveLength(1); + expect(dialog.emitted("fresh")).toHaveLength(1); + }); + + it("emits dismiss when closed", async () => { + const dialog = await mountDialog(); + byTest("o-dialog-close-btn")?.click(); + await flushPromises(); + expect(dialog.emitted("dismiss")).toHaveLength(1); + }); + + it("shows the server-change and credentials notes only when set", async () => { + await mountDialog(); + expect(byTest("editor-draft-changed-on-server")).toBeNull(); + expect(byTest("editor-draft-credentials-not-saved")).toBeNull(); + wrapper?.unmount(); + + await mountDialog({ changedOnServer: true, credentialsNotSaved: true }); + expect(byTest("editor-draft-changed-on-server")?.textContent).toContain( + "This alert template was changed", + ); + expect(byTest("editor-draft-credentials-not-saved")).not.toBeNull(); + }); + + it("uses a custom title", async () => { + await mountDialog({ title: raw("Unsaved local changes") }); + expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("Unsaved local changes"); + }); + + it("lists every notice in one banner, a warning when the item changed on the server", async () => { + await mountDialog({ changedOnServer: true, credentialsNotSaved: true }); + const banners = document.body.querySelectorAll( + '[data-test="editor-draft-restore-dialog"] [role="alert"], [data-test="editor-draft-restore-dialog"] [role="status"]', + ); + expect(banners).toHaveLength(1); + expect(byTest("editor-draft-restore-notices")?.querySelectorAll("li")).toHaveLength(2); + expect(byTest("editor-draft-restore-notices")?.getAttribute("role")).toBe("alert"); + wrapper?.unmount(); + + await mountDialog({ credentialsNotSaved: true }); + expect(byTest("editor-draft-restore-notices")?.getAttribute("role")).toBe("status"); + expect(byTest("editor-draft-restore-notices")?.querySelectorAll("li")).toHaveLength(1); + }); + + it("shows the editor's note in the notices, since it is what Continue will do", async () => { + await mountDialog({ note: raw("Continuing generates a new key.") }); + const note = byTest("editor-draft-restore-note"); + expect(note?.tagName).toBe("LI"); + expect(note?.textContent?.trim()).toBe("Continuing generates a new key."); + expect(byTest("editor-draft-restore-notices")?.querySelectorAll("li")).toHaveLength(1); + expect(byTest("editor-draft-restore-notices")?.getAttribute("role")).toBe("status"); + }); + + it("avoids the word draft in its notices for an item with a server-side draft", async () => { + await mountDialog({ changedOnServer: true, credentialsNotSaved: true, serverDraftItem: true }); + const notices = byTest("editor-draft-restore-notices")?.textContent ?? ""; + expect(notices).toContain("after your changes were kept in this browser"); + expect(notices).toContain("are not kept in this browser"); + expect(notices).not.toContain("draft"); + }); + + it("names the changed fields that were not kept", async () => { + await mountDialog({ + credentialsNotSaved: true, + notKeptFields: [raw("token"), raw("X-Api-Key header")], + }); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your changes to the token and X-Api-Key header were not kept.", + ); + }); +}); diff --git a/web/src/components/common/drafts/EditorDraftRestoreDialog.vue b/web/src/components/common/drafts/EditorDraftRestoreDialog.vue new file mode 100644 index 0000000000..db2205a4e4 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftRestoreDialog.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/web/src/components/common/drafts/EditorDraftSignOutDialog.spec.ts b/web/src/components/common/drafts/EditorDraftSignOutDialog.spec.ts new file mode 100644 index 0000000000..fe2bd91f0f --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftSignOutDialog.spec.ts @@ -0,0 +1,66 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import EditorDraftSignOutDialog from "./EditorDraftSignOutDialog.vue"; + +let wrapper: VueWrapper | null = null; + +const mountDialog = async (count: number) => { + wrapper = mount(EditorDraftSignOutDialog, { + attachTo: document.body, + props: { open: true, count }, + }); + await flushPromises(); + return wrapper; +}; + +const inDialog = (id: string) => + document.body.querySelector( + `[data-test="editor-draft-signout-dialog"] [data-test="${id}"]`, + ); + +describe("EditorDraftSignOutDialog", () => { + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("says how many drafts signing out discards, in the plural", async () => { + await mountDialog(3); + expect( + document.body.querySelector('[data-test="editor-draft-signout-message"]')?.textContent, + ).toContain("You have 3 unsaved drafts in this browser. Signing out discards them."); + }); + + it("uses the singular for one draft", async () => { + await mountDialog(1); + expect( + document.body.querySelector('[data-test="editor-draft-signout-message"]')?.textContent, + ).toContain("You have 1 unsaved draft in this browser. Signing out discards it."); + }); + + it("confirms with Sign out and closes with Cancel", async () => { + const dialog = await mountDialog(2); + inDialog("editor-draft-signout-confirm")?.click(); + expect(dialog.emitted("confirm")).toHaveLength(1); + expect(dialog.emitted("update:open")?.[0]).toEqual([false]); + + inDialog("o-dialog-secondary-btn")?.click(); + expect(dialog.emitted("confirm")).toHaveLength(1); + expect(dialog.emitted("update:open")).toHaveLength(2); + }); +}); diff --git a/web/src/components/common/drafts/EditorDraftSignOutDialog.vue b/web/src/components/common/drafts/EditorDraftSignOutDialog.vue new file mode 100644 index 0000000000..9b6278f109 --- /dev/null +++ b/web/src/components/common/drafts/EditorDraftSignOutDialog.vue @@ -0,0 +1,57 @@ + + + + diff --git a/web/src/components/common/drafts/useDraftLabel.ts b/web/src/components/common/drafts/useDraftLabel.ts new file mode 100644 index 0000000000..af10b8c9c6 --- /dev/null +++ b/web/src/components/common/drafts/useDraftLabel.ts @@ -0,0 +1,31 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useStore } from "vuex"; +import { useI18nTyped, type I18nText } from "@/types/i18n"; +import { formatEditorDraftTime } from "@/utils/drafts/editorDraftStore"; + +/** A host's own tooltip text for its draft: "{label} · unsaved draft · {date}", undated when no time is known. */ +export const useDraftLabel = () => { + const { t, locale } = useI18nTyped(); + const store = useStore(); + return (label: I18nText, savedAt: number | null): I18nText => + savedAt === null + ? t("common.editorDraft.newDot", { label }) + : t("common.editorDraft.newDotDated", { + label, + date: formatEditorDraftTime(savedAt, locale.value, store.state.timezone || undefined), + }); +}; diff --git a/web/src/components/dashboards/panelDraft/PanelLeaveDialog.spec.ts b/web/src/components/dashboards/panelDraft/PanelLeaveDialog.spec.ts new file mode 100644 index 0000000000..96626a044f --- /dev/null +++ b/web/src/components/dashboards/panelDraft/PanelLeaveDialog.spec.ts @@ -0,0 +1,87 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import PanelLeaveDialog from "./PanelLeaveDialog.vue"; + +let wrapper: VueWrapper | null = null; + +const mountDialog = async (props: Record = {}) => { + wrapper = mount(PanelLeaveDialog, { + attachTo: document.body, + props: { open: true, canKeepDraft: true, ...props }, + }); + await flushPromises(); + return wrapper; +}; + +const inDialog = (id: string) => + document.body.querySelector( + `[data-test="dashboard-panel-leave-dialog"] [data-test="${id}"]`, + ); + +describe("PanelLeaveDialog", () => { + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("matches the editor's leave copy and buttons", async () => { + const dialog = await mountDialog(); + expect(inDialog("dashboard-panel-leave-message")?.textContent?.trim()).toBe( + "Save the panel, keep your changes as a draft in this browser, or discard them.", + ); + expect(inDialog("o-dialog-neutral-btn")?.textContent?.trim()).toBe("Discard"); + inDialog("o-dialog-secondary-btn")!.click(); + expect(dialog.emitted("choose")).toEqual([["keep"]]); + }); + + it("tells how long a kept draft lasts behind the info icon, only when Keep is offered", async () => { + await mountDialog(); + expect(inDialog("dashboard-panel-leave-retention")?.getAttribute("aria-label")).toBe( + "Drafts are kept in this browser for 7 days, or until you sign out. Only the 20 newest of each kind are kept.", + ); + wrapper?.unmount(); + await mountDialog({ canKeepDraft: false }); + expect(inDialog("dashboard-panel-leave-retention")).toBeNull(); + }); + + it("says in the dialog text that signing out discards the other drafts, and explains it behind the info icon", async () => { + await mountDialog({ canKeepDraft: false, signingOut: true, otherDraftCount: 3 }); + expect(inDialog("o-dialog-secondary-btn")).toBeNull(); + expect(inDialog("dashboard-panel-leave-sign-out-others")?.textContent?.trim()).toBe( + "Signing out also discards 3 other unsaved drafts in this browser.", + ); + expect(inDialog("dashboard-panel-leave-sign-out")?.getAttribute("aria-label")).toBe( + "Drafts are not kept when you sign out.", + ); + }); + + it("shows no discard line when the sign-out leaves no other draft", async () => { + await mountDialog({ canKeepDraft: false, signingOut: true, otherDraftCount: 0 }); + expect(inDialog("dashboard-panel-leave-sign-out-others")).toBeNull(); + }); + + it("offers Stay beside Discard when the panel cannot be saved", async () => { + const dialog = await mountDialog({ canKeepDraft: false, canSave: false, signingOut: true }); + expect(inDialog("dashboard-panel-leave-save")).toBeNull(); + const stay = inDialog("o-dialog-secondary-btn"); + expect(stay?.textContent?.trim()).toBe("Stay"); + stay!.click(); + inDialog("o-dialog-neutral-btn")!.click(); + expect(dialog.emitted("choose")).toEqual([["stay"], ["discard"]]); + }); +}); diff --git a/web/src/components/dashboards/panelDraft/PanelLeaveDialog.vue b/web/src/components/dashboards/panelDraft/PanelLeaveDialog.vue index 061473584d..875edb6eb8 100644 --- a/web/src/components/dashboards/panelDraft/PanelLeaveDialog.vue +++ b/web/src/components/dashboards/panelDraft/PanelLeaveDialog.vue @@ -21,34 +21,77 @@ along with this program. If not, see . size="sm" :persistent="saving" :title="t('dashboard.panelDraft.leaveTitle')" - :primary-button-label="t('panel.save')" + :primary-button-label="canSave ? t('panel.save') : undefined" primary-button-data-test="dashboard-panel-leave-save" :primary-button-loading="saving" - :secondary-button-label="canKeepDraft ? t('dashboard.panelDraft.keepDraft') : undefined" - :neutral-button-label="t('panel.discard')" + :secondary-button-label="secondaryLabel" + :neutral-button-label="t('common.editorDraft.discard')" neutral-button-variant="ghost-destructive" data-test="dashboard-panel-leave-dialog" @update:open="onOpenChange" @click:primary="emit('choose', 'save')" - @click:secondary="emit('choose', 'keep')" + @click:secondary="emit('choose', canKeepDraft ? 'keep' : 'stay')" @click:neutral="emit('choose', 'discard')" > -

- {{ t("dashboard.panelDraft.leaveMessage") }} -

+
+
+

+ {{ + !canSave + ? t("dashboard.panelDraft.leaveMessageDiscardOnly") + : canKeepDraft + ? t("dashboard.panelDraft.leaveMessage") + : t("dashboard.panelDraft.leaveMessageNoDraft") + }} +

+

+ {{ + t( + "common.editorDraft.signOutDiscardsOthers", + { count: otherDraftCount }, + otherDraftCount, + ) + }} +

+
+ + +
diff --git a/web/src/components/dashboards/settings/GeneralSettings.spec.ts b/web/src/components/dashboards/settings/GeneralSettings.spec.ts index bbac1e5671..c7f503a919 100644 --- a/web/src/components/dashboards/settings/GeneralSettings.spec.ts +++ b/web/src/components/dashboards/settings/GeneralSettings.spec.ts @@ -42,6 +42,12 @@ import i18n from "@/locales"; import store from "@/test/unit/helpers/store"; import { getDashboard, updateDashboard } from "@/utils/commons"; import { createRouter, createWebHistory } from "vue-router"; +import { defineComponent, h } from "vue"; +import { + createDashboardSettingsDraftContext, + provideDashboardSettingsDraft, + type DashboardSettingsDraftContext, +} from "@/components/dashboards/settings/dashboardSettingsDraft"; const mockDashboardData = { dashboardId: "dashboard-1", @@ -813,4 +819,91 @@ describe("GeneralSettings", () => { expect(form.vm.form.state.values.name).toBe("ABC"); }); }); + + describe("Draft section", () => { + const absoluteRange = { startTime: 1672531200000, endTime: 1672617599000, type: "absolute" }; + // The real picker re-emits an absolute range in microseconds. + const echoedRange = { + startTime: 1672531200000000, + endTime: 1672617599000000, + relativeTimePeriod: "15m", + valueType: "absolute", + }; + let context: DashboardSettingsDraftContext; + + const mountWithDraftContext = async () => { + context = createDashboardSettingsDraftContext(() => undefined); + const Host = defineComponent({ + setup() { + provideDashboardSettingsDraft(context); + return () => h(GeneralSettings); + }, + }); + const host = mount(Host, { + attachTo: document.body, + global: { + plugins: [i18n, store, router], + stubs: { + DashboardHeader: { template: "
", props: ["title"] }, + DateTimePickerDashboard: { + template: '
', + props: { modelValue: Object }, + emits: ["update:modelValue"], + }, + }, + }, + }); + await flushPromises(); + return host; + }; + const picker = () => wrapper.findComponent('[data-test="datetime-picker"]'); + + beforeEach(() => { + vi.mocked(getDashboard).mockResolvedValue({ + ...mockDashboardData, + defaultDatetimeDuration: absoluteRange, + }); + }); + + it("an untouched form never counts as edited, however late the picker re-emits its range", async () => { + wrapper = await mountWithDraftContext(); + await picker().vm.$emit("update:modelValue", { ...echoedRange }); + await flushPromises(); + await picker().vm.$emit("update:modelValue", { ...echoedRange }); + await flushPromises(); + + expect(context.pending.general).toBeNull(); + }); + + it("a range the user picks after the picker settled is an edit", async () => { + wrapper = await mountWithDraftContext(); + await picker().vm.$emit("update:modelValue", { ...echoedRange }); + await flushPromises(); + + window.dispatchEvent(new Event("pointerdown")); + await picker().vm.$emit("update:modelValue", { + startTime: 0, + endTime: 0, + relativeTimePeriod: "1h", + valueType: "relative", + }); + await flushPromises(); + + expect(context.pending.general?.dateTime).toMatchObject({ + relativeTimePeriod: "1h", + valueType: "relative", + }); + }); + + it("a title filled with no key or pointer event stays an edit while the picker re-emits", async () => { + wrapper = await mountWithDraftContext(); + const form = wrapper.findComponent({ name: "OForm" }); + form.vm.form.setFieldValue("name", "Filled title"); + await flushPromises(); + await picker().vm.$emit("update:modelValue", { ...echoedRange }); + await flushPromises(); + + expect(context.pending.general?.values.name).toBe("Filled title"); + }); + }); }); diff --git a/web/src/components/dashboards/settings/GeneralSettings.vue b/web/src/components/dashboards/settings/GeneralSettings.vue index 83374733f0..34a3985a24 100644 --- a/web/src/components/dashboards/settings/GeneralSettings.vue +++ b/web/src/components/dashboards/settings/GeneralSettings.vue @@ -48,6 +48,7 @@ along with this program. If not, see . . diff --git a/web/src/components/oncall/scheduleDrafts.ts b/web/src/components/oncall/scheduleDrafts.ts new file mode 100644 index 0000000000..646efce4f1 --- /dev/null +++ b/web/src/components/oncall/scheduleDrafts.ts @@ -0,0 +1,95 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { computed } from "vue"; +import { useStore } from "vuex"; +import { + deleteEditorDraftsFor, + listEditorDrafts, + type EditorDraftKeyParts, +} from "@/utils/drafts/editorDraftStore"; + +const KIND = "oncall_schedule" as const; +const CLONE_PREFIX = "clone:"; + +/** One draft per rotation, under its team, so opening one rotation never offers another's work. */ +export const rotationDraftId = (teamId: string, rotationId: string): string => + `${teamId}/${rotationId}`; + +/** A new rotation's slot under its team; a duplicate gets its own slot per source rotation. */ +export const newRotationVariant = (teamId: string, sourceId?: string | null): string => + sourceId ? `${teamId}/${CLONE_PREFIX}${sourceId}` : `${teamId}/new`; + +// The rotation an entry is about: the edited rotation, a duplicate's source, or null for a plain new one. +const rotationOf = (entry: EditorDraftKeyParts, prefix: string): string | null => { + if (entry.entityId !== null) return entry.entityId.slice(prefix.length); + const rest = (entry.variant ?? "").slice(prefix.length); + return rest.startsWith(CLONE_PREFIX) ? rest.slice(CLONE_PREFIX.length) : null; +}; + +// Every rotation draft's key starts with its team id, which holds no slash. +const teamOf = (entry: EditorDraftKeyParts): string | null => { + const key = entry.entityId ?? entry.variant ?? ""; + const slash = key.indexOf("/"); + return slash > 0 ? key.slice(0, slash) : null; +}; + +/** Reactive: when the newest of a team's rotation drafts was written, for the team's dots; storage is read once per draft revision. */ +export const useScheduleDraftTimes = () => { + const store = useStore(); + const newestByTeam = computed(() => { + const newest = new Map(); + const entries = listEditorDrafts( + KIND, + String(store.state.selectedOrganization?.identifier ?? ""), + String(store.state.userInfo?.email ?? ""), + ); + for (const entry of entries) { + const teamId = teamOf(entry); + if (teamId !== null) newest.set(teamId, Math.max(newest.get(teamId) ?? 0, entry.savedAt)); + } + return newest; + }); + return (teamId: string): number | null => newestByTeam.value.get(teamId) ?? null; +}; + +/** Drops every user's edit of a deleted rotation and every duplicate of it. */ +export const dropRotationDrafts = (target: { org: string; teamId: string; rotationId: string }) => + deleteEditorDraftsFor({ + kind: KIND, + org: target.org, + entityId: rotationDraftId(target.teamId, target.rotationId), + variant: newRotationVariant(target.teamId, target.rotationId), + }); + +/** Drops every user's drafts of a team's rotations, or with `liveRotationIds` only those whose rotation is gone. */ +export const dropScheduleDrafts = (target: { + org: string; + teamId: string; + liveRotationIds?: Set; +}): void => { + const { org, teamId, liveRotationIds } = target; + const prefix = `${teamId}/`; + deleteEditorDraftsFor({ + kind: KIND, + org, + match: (draft) => { + if (!(draft.entityId ?? draft.variant ?? "").startsWith(prefix)) return false; + if (!liveRotationIds) return true; + const rotationId = rotationOf(draft, prefix); + return rotationId !== null && !liveRotationIds.has(rotationId); + }, + }); +}; diff --git a/web/src/components/pipeline/PipelineEditor.drafts.spec.ts b/web/src/components/pipeline/PipelineEditor.drafts.spec.ts new file mode 100644 index 0000000000..65628dafe8 --- /dev/null +++ b/web/src/components/pipeline/PipelineEditor.drafts.spec.ts @@ -0,0 +1,615 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { h, nextTick } from "vue"; +import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router"; +import pipelineService from "@/services/pipelines"; +import store from "@/test/unit/helpers/store"; +import i18n from "@/locales"; +import { pipelineObj } from "@/plugins/pipelines/useDnD"; +import { + beginDraftSignOut, + cancelDraftSignOut, + hashDraftValue, + readEditorDraft, + writeEditorDraft, + type EditorDraftScope, +} from "@/utils/drafts/editorDraftStore"; +import { dismissAll, toastRecords } from "@/lib/feedback/Toast/useToast"; +import { + byTest, + click, + clickIn, + clickLeave, + clickRestore, + pressEscape, +} from "@/test/unit/helpers/draftDom"; +import { fillField } from "@/test/unit/helpers/fillField"; +import PipelineEditor from "./PipelineEditor.vue"; +import { projectPipeline, type PipelineDraftData } from "./pipelineDraft"; + +vi.mock("@vue-flow/core", () => ({ + MarkerType: { ArrowClosed: "arrowclosed" }, + useVueFlow: () => ({ + getSelectedEdges: { value: [] }, + removeEdges: vi.fn(), + screenToFlowCoordinate: (p: { x: number; y: number }) => p, + onNodesInitialized: () => ({ off: vi.fn() }), + updateNode: vi.fn(), + }), +})); +vi.mock("@/plugins/pipelines/PipelineFlow.vue", () => ({ + default: { name: "PipelineFlow", render: () => null }, +})); +vi.mock("@/components/pipeline/NodeForm/Stream.vue", async () => { + const { h: render } = await import("vue"); + return { + default: { + name: "StreamNode", + render: () => render("div", { "data-test": "stub-stream-form" }), + }, + }; +}); +vi.mock("@/components/pipeline/NodeForm/Query.vue", () => ({ + default: { name: "QueryForm", render: () => null }, +})); +vi.mock("@/components/pipeline/NodeForm/Condition.vue", () => ({ + default: { name: "ConditionForm", render: () => null }, +})); +vi.mock("@/components/pipeline/NodeForm/AssociateFunction.vue", async () => { + const { h: render } = await import("vue"); + return { + default: { + name: "AssociateFunction", + render: () => render("div", { "data-test": "stub-function-form" }), + }, + }; +}); +vi.mock("@/components/pipeline/NodeForm/ExternalDestination.vue", () => ({ + default: { name: "ExternalDestination", render: () => null }, +})); +vi.mock("@/components/common/JsonEditor.vue", () => ({ + default: { name: "JsonEditor", render: () => null }, +})); +vi.mock("@/services/pipelines", async (importOriginal) => { + const { overlayServiceMock } = await import("@/test/unit/helpers/mockService"); + return overlayServiceMock(await importOriginal(), { + default: { + getPipelines: vi.fn(() => Promise.resolve({ data: { list: [] } })), + createPipeline: vi.fn(() => Promise.resolve({ data: {} })), + updatePipeline: vi.fn(() => Promise.resolve({ data: {} })), + }, + }); +}); +vi.mock("@/services/jstransform", async (importOriginal) => { + const { overlayServiceMock } = await import("@/test/unit/helpers/mockService"); + return overlayServiceMock(await importOriginal(), { + default: { list: vi.fn(() => Promise.resolve({ data: { list: [] } })) }, + }); +}); +vi.mock("@/composables/usePipelines", () => ({ + default: () => ({ + getUsedStreamsList: vi.fn(() => Promise.resolve([])), + getPipelineDestinations: vi.fn(() => Promise.resolve([])), + }), +})); +vi.mock("@/composables/useStreams", () => ({ + default: () => ({ getStreams: vi.fn(() => Promise.resolve({ list: [] })) }), +})); +vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) })); +vi.mock("@/composables/contextProviders", () => ({ + contextRegistry: { register: vi.fn(), unregister: vi.fn(), setActive: vi.fn() }, + createPipelinesContextProvider: vi.fn(() => ({})), +})); + +const ORG = store.state.selectedOrganization.identifier; +const EMAIL = store.state.userInfo.email; +const newScope: EditorDraftScope = { kind: "pipeline", org: ORG, entityId: null, userEmail: EMAIL }; +const editScope: EditorDraftScope = { ...newScope, entityId: "p1" }; + +const streamNode = (id: string, ioType: "input" | "output", stream: string) => ({ + id, + type: ioType, + io_type: ioType, + position: { x: 10, y: ioType === "input" ? 0 : 200 }, + data: { node_type: "stream", stream_name: stream, stream_type: "logs", org_id: ORG }, +}); + +const draftData = (overrides: Partial = {}): PipelineDraftData => ({ + name: "draft_pipe", + description: "", + source: { source_type: "realtime" }, + nodes: [streamNode("n1", "input", "in"), streamNode("n2", "output", "out")], + edges: [{ id: "en1-n2", source: "n1", target: "n2" }], + drawer: null, + ...overrides, +}); + +const serverPipeline = () => ({ + pipeline_id: "p1", + version: 7, + enabled: true, + paused_at: null, + org: ORG, + name: "parse_nginx", + description: "", + source: { source_type: "realtime", org_id: ORG, stream_name: "in", stream_type: "logs" }, + nodes: [ + { ...streamNode("n1", "input", "in"), type: undefined, is_disabled: false }, + { ...streamNode("n2", "output", "out"), type: undefined, is_disabled: false }, + ], + edges: [{ id: "en1-n2", source: "n1", target: "n2" }], +}); + +let router: Router; +let wrapper: VueWrapper | null = null; +const logoutEntered = vi.fn(() => true); + +const mountEditor = async (path: string) => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/pipeline/pipelines", + name: "pipelines", + component: { render: () => h(RouterView) }, + children: [ + { path: "add", name: "createPipeline", component: PipelineEditor }, + { path: "edit", name: "pipelineEditor", component: PipelineEditor }, + ], + }, + { path: "/logs", name: "logs", component: { render: () => h("div") } }, + { + path: "/logout", + name: "logout", + component: { render: () => h("div") }, + beforeEnter: logoutEntered, + }, + ], + }); + await router.push(path); + wrapper = mount( + { render: () => h(RouterView) }, + { + attachTo: document.body, + global: { + plugins: [router, i18n], + provide: { store }, + stubs: { NodePalette: true, StepPickerDialog: true, OTooltip: true }, + }, + }, + ); + await flushPromises(); + return wrapper; +}; + +const firstInput = () => window.dispatchEvent(new Event("pointerdown")); + +const addValidPipeline = async (name = "my_pipe") => { + firstInput(); + pipelineObj.currentSelectedPipeline.name = name; + pipelineObj.currentSelectedPipeline.nodes = [ + streamNode("n1", "input", "in"), + streamNode("n2", "output", "out"), + ]; + pipelineObj.currentSelectedPipeline.edges = [{ id: "en1-n2", source: "n1", target: "n2" }]; + await nextTick(); +}; + +describe("PipelineEditor drafts", () => { + beforeEach(() => { + localStorage.clear(); + pipelineObj.dirtyFlag = false; + pipelineObj.dialog.show = false; + pipelineObj.dialog.name = ""; + for (const id of ["o2-page-actions", "o2-page-title"]) { + const el = document.createElement("div"); + el.id = id; + document.body.appendChild(el); + } + vi.mocked(pipelineService.createPipeline).mockClear(); + vi.mocked(pipelineService.updatePipeline).mockClear(); + vi.mocked(pipelineService.getPipelines).mockImplementation(() => + Promise.resolve({ data: { list: [serverPipeline()] } } as never), + ); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + document.body.innerHTML = ""; + }); + + it("writes a new pipeline's nodes, edges and name when the page hides", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline("my_pipe"); + window.dispatchEvent(new Event("pagehide")); + + const draft = readEditorDraft(newScope); + expect(draft?.data.name).toBe("my_pipe"); + expect(draft?.data.nodes.map((n) => n.id)).toEqual(["n1", "n2"]); + expect(draft?.data.edges).toEqual([{ id: "en1-n2", source: "n1", target: "n2" }]); + expect(draft?.data.drawer).toBeNull(); + }); + + it("continues a draft after the reset, with decorated edges and the open node drawer", async () => { + writeEditorDraft(newScope, { + data: draftData({ + drawer: { + type: "stream", + nodeId: "n2", + isEditNode: true, + stagedNode: null, + pendingSource: null, + }, + }), + }); + await mountEditor("/pipeline/pipelines/add"); + + expect(byTest("editor-draft-restore-message")?.textContent).toContain( + "unsaved changes to this pipeline", + ); + click("editor-draft-continue"); + await flushPromises(); + + const current = pipelineObj.currentSelectedPipeline; + expect(current.name).toBe("draft_pipe"); + expect(current.nodes.map((n: { id: string }) => n.id)).toEqual(["n1", "n2"]); + expect(current.edges[0]).toMatchObject({ id: "en1-n2", type: "custom", animated: true }); + expect(pipelineObj.hasInputNode).toBe(true); + expect(pipelineObj.dialog).toMatchObject({ show: true, name: "stream" }); + expect(pipelineObj.isEditNode).toBe(true); + expect(pipelineObj.currentSelectedNodeData.id).toBe("n2"); + expect(byTest("stub-stream-form")).not.toBeNull(); + }); + + it("reopens the drawer of a node that was being added, with its pending edge", async () => { + const staged = { + id: "n3", + type: "default", + io_type: "default", + position: { x: 5, y: 5 }, + data: { node_type: "function" }, + }; + writeEditorDraft(newScope, { + data: draftData({ + drawer: { + type: "function", + nodeId: "n3", + isEditNode: false, + stagedNode: staged, + pendingSource: "n1", + }, + }), + }); + await mountEditor("/pipeline/pipelines/add"); + click("editor-draft-continue"); + await flushPromises(); + + expect(pipelineObj.currentSelectedNodeData).toEqual(staged); + expect(pipelineObj.pendingEdge).toEqual({ source: "n1" }); + expect(pipelineObj.isEditNode).toBe(false); + expect(byTest("stub-function-form")).not.toBeNull(); + }); + + it("keys an edited pipeline's draft by its id and hashes only its editable fields", async () => { + await mountEditor("/pipeline/pipelines/edit?id=p1"); + firstInput(); + pipelineObj.currentSelectedPipeline.nodes[1].position = { x: 99, y: 99 }; + pipelineObj.dirtyFlag = true; + await nextTick(); + window.dispatchEvent(new Event("pagehide")); + + const server = serverPipeline(); + server.nodes.forEach((node) => Object.assign(node, { type: node.io_type })); + const draft = readEditorDraft(editScope); + expect(draft?.data.nodes[1].position).toEqual({ x: 99, y: 99 }); + expect(draft?.baseHash).toBe(hashDraftValue(projectPipeline(server))); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("warns when the pipeline changed on the server after the draft was written", async () => { + writeEditorDraft(editScope, { data: draftData(), baseHash: "00000000" }); + await mountEditor("/pipeline/pipelines/edit?id=p1"); + + expect(byTest("editor-draft-changed-on-server")).not.toBeNull(); + click("editor-draft-continue"); + await flushPromises(); + expect(pipelineObj.dirtyFlag).toBe(true); + }); + + it("drops the draft of a pipeline that no longer exists", async () => { + writeEditorDraft({ ...newScope, entityId: "gone" }, { data: draftData() }); + await mountEditor("/pipeline/pipelines/edit?id=gone"); + + expect(readEditorDraft({ ...newScope, entityId: "gone" })).toBeNull(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(router.currentRoute.value.name).toBe("pipelines"); + }); + + it("drafts a name filled with no key or pointer event, and Cancel asks", async () => { + const pageTitle = document.createElement("div"); + pageTitle.id = "o2-page-title"; + document.body.appendChild(pageTitle); + try { + await mountEditor("/pipeline/pipelines/add"); + // A name alone is no unsaved pipeline; it counts once the canvas has steps. + pipelineObj.currentSelectedPipeline.nodes = [ + streamNode("n1", "input", "in"), + streamNode("n2", "output", "out"), + ]; + await nextTick(); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)).toBeNull(); + click("pipeline-editor-name-input-trigger"); + await flushPromises(); + await fillField("pipeline-editor-name-input-input", "autofilled_pipe"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)?.data.name).toBe("autofilled_pipe"); + + click("add-pipeline-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + expect(router.currentRoute.value.name).toBe("createPipeline"); + } finally { + wrapper?.unmount(); + wrapper = null; + pageTitle.remove(); + } + }); + + it("Escape on the restore dialog keeps the draft, Start fresh drops it", async () => { + writeEditorDraft(newScope, { data: draftData() }); + await mountEditor("/pipeline/pipelines/add"); + pressEscape(); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(pipelineObj.currentSelectedPipeline.name).toBe(""); + expect(readEditorDraft(newScope)).not.toBeNull(); + + wrapper!.unmount(); + await mountEditor("/pipeline/pipelines/add"); + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + expect(pipelineObj.currentSelectedPipeline.name).toBe(""); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("Cancel with unsaved changes asks, and Discard drops the draft and leaves", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + window.dispatchEvent(new Event("pagehide")); + + click("add-pipeline-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + expect(router.currentRoute.value.name).toBe("createPipeline"); + + clickLeave("o-dialog-neutral-btn"); + await flushPromises(); + expect(router.currentRoute.value.name).toBe("pipelines"); + expect(readEditorDraft(newScope)).toBeNull(); + expect(pipelineObj.dialog.show).toBe(false); + }); + + it("Save in the leave dialog saves without opening the list, then continues the navigation", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1); + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("Save in the leave dialog of a basic pipeline asks first, then goes where the user was headed", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + pipelineObj.currentSelectedPipeline.nodes = [ + streamNode("n1", "input", "same"), + streamNode("n2", "output", "same"), + ]; + await nextTick(); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + expect(router.currentRoute.value.name).toBe("createPipeline"); + expect(pipelineService.createPipeline).not.toHaveBeenCalled(); + + clickIn("confirm-dialog", "o-dialog-primary-btn"); + await flushPromises(); + await vi.waitFor(() => expect(router.currentRoute.value.name).toBe("logs")); + expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + const makeBasic = async () => { + await addValidPipeline(); + pipelineObj.currentSelectedPipeline.nodes = [ + streamNode("n1", "input", "same"), + streamNode("n2", "output", "same"), + ]; + await nextTick(); + }; + + it("a sign-out stopped at the basic-pipeline prompt saves on confirm but never goes on to /logout", async () => { + await mountEditor("/pipeline/pipelines/add"); + await makeBasic(); + logoutEntered.mockClear(); + + beginDraftSignOut(); + const signOut = router.push("/logout"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await signOut; + cancelDraftSignOut(); + await flushPromises(); + expect(router.currentRoute.value.name).toBe("createPipeline"); + + clickIn("confirm-dialog", "o-dialog-primary-btn"); + await vi.waitFor(() => expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1)); + await flushPromises(); + expect(logoutEntered).not.toHaveBeenCalled(); + expect(router.currentRoute.value.path).not.toBe("/logout"); + }); + + it("a refused leave is not resumed by the prompt once the user has moved on", async () => { + await mountEditor("/pipeline/pipelines/add"); + await makeBasic(); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await router.push("/pipeline/pipelines/add?step=2"); + await flushPromises(); + + clickIn("confirm-dialog", "o-dialog-primary-btn"); + await vi.waitFor(() => expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1)); + await flushPromises(); + expect(router.currentRoute.value.name).toBe("createPipeline"); + }); + + it("the toolbar Save of a basic pipeline still opens the list after the prompt", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + pipelineObj.currentSelectedPipeline.nodes = [ + streamNode("n1", "input", "same"), + streamNode("n2", "output", "same"), + ]; + await nextTick(); + // A stayed leave must not leave its destination behind for a later prompt. + const stayed = router.push("/logs"); + await flushPromises(); + clickLeave("o-dialog-close-btn"); + await stayed; + await flushPromises(); + + click("add-pipeline-save-btn"); + const confirm = () => + document.body.querySelector( + '[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]', + ); + await vi.waitFor(() => expect(confirm()).not.toBeNull()); + confirm()!.click(); + await vi.waitFor(() => expect(router.currentRoute.value.name).toBe("pipelines")); + expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1); + }); + + it("Save in the leave dialog keeps the user in the editor when the pipeline is invalid", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(""); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(pipelineService.createPipeline).not.toHaveBeenCalled(); + expect(router.currentRoute.value.name).toBe("createPipeline"); + }); + + it("Discard on a route leave resets the shared pipeline state, so the next open starts clean", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline("dropped_pipe"); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("o-dialog-neutral-btn"); + await navigation; + await flushPromises(); + + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)).toBeNull(); + expect(pipelineObj.currentSelectedPipeline.name).toBe(""); + expect(pipelineObj.currentSelectedPipeline.nodes).toEqual([]); + expect(pipelineObj.currentSelectedPipeline.edges).toEqual([]); + expect(pipelineObj.dirtyFlag).toBe(false); + }); + + it("Keep draft lets a route leave go and keeps the work", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline("kept_pipe"); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("o-dialog-secondary-btn"); + await navigation; + + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)?.data.name).toBe("kept_pipe"); + expect(pipelineObj.currentSelectedPipeline.name).toBe(""); + }); + + it("a failed save shows the server's message and keeps the draft", async () => { + dismissAll(); + vi.mocked(pipelineService.createPipeline).mockRejectedValueOnce({ + response: { status: 400, data: { message: "Stream out is missing" } }, + }); + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + window.dispatchEvent(new Event("pagehide")); + + click("add-pipeline-save-btn"); + await vi.waitFor(() => expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1)); + await flushPromises(); + + const errors = toastRecords.filter((r) => r.variant === "error").map((r) => r.message); + expect(errors).toContain("Stream out is missing"); + expect(router.currentRoute.value.name).toBe("createPipeline"); + expect(readEditorDraft(newScope)).not.toBeNull(); + }); + + it("a forbidden save shows no error toast", async () => { + dismissAll(); + vi.mocked(pipelineService.createPipeline).mockRejectedValueOnce({ + response: { status: 403, data: { message: "Forbidden" } }, + }); + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + + click("add-pipeline-save-btn"); + await vi.waitFor(() => expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1)); + await flushPromises(); + + expect(toastRecords.some((r) => r.variant === "error")).toBe(false); + }); + + it("a normal save drops the draft and opens the list without asking", async () => { + await mountEditor("/pipeline/pipelines/add"); + await addValidPipeline(); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)).not.toBeNull(); + + click("add-pipeline-save-btn"); + await vi.waitFor(() => expect(router.currentRoute.value.name).toBe("pipelines")); + + expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1); + expect(router.currentRoute.value.name).toBe("pipelines"); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(readEditorDraft(newScope)).toBeNull(); + }); +}); diff --git a/web/src/components/pipeline/PipelineEditor.spec.js b/web/src/components/pipeline/PipelineEditor.spec.js index 3bc99f09c4..35c20ec8de 100644 --- a/web/src/components/pipeline/PipelineEditor.spec.js +++ b/web/src/components/pipeline/PipelineEditor.spec.js @@ -25,7 +25,7 @@ import router from "@/test/unit/helpers/router"; import i18n from "@/locales"; vi.mock("@/lib/feedback/Toast/useToast", () => ({ - toast: vi.fn(), + toast: vi.fn(() => vi.fn()), })); const mockAddNode = vi.fn(); @@ -229,7 +229,6 @@ describe("PipelineEditor", () => { } if (wrapper) { try { - wrapper.vm.confirmDialogMeta.show = false; wrapper.vm.showJsonEditorDialog = false; wrapper.vm.confirmDialogBasicPipeline = false; } catch (_) { @@ -274,10 +273,6 @@ describe("PipelineEditor", () => { expect(wrapper.vm.metaForm.state.values.name).toBe("test-pipeline"); }); - it("initializes confirmDialogMeta as hidden", () => { - expect(wrapper.vm.confirmDialogMeta.show).toBe(false); - }); - it("exposes correct data attributes", () => { // These buttons are inside , query from document expect(document.querySelector('[data-test="pipeline-json-edit-btn"]')).not.toBeNull(); @@ -360,36 +355,16 @@ describe("PipelineEditor", () => { expect(wrapper.vm.editingStreamRouteName).toBe(""); }); - it("resetConfirmDialog clears all confirmDialogMeta fields", () => { - wrapper.vm.confirmDialogMeta.show = true; - wrapper.vm.confirmDialogMeta.title = "Title"; - wrapper.vm.confirmDialogMeta.message = "Message"; - wrapper.vm.confirmDialogMeta.data = { x: 1 }; - - wrapper.vm.resetConfirmDialog(); - - expect(wrapper.vm.confirmDialogMeta.show).toBe(false); - expect(wrapper.vm.confirmDialogMeta.title).toBe(""); - expect(wrapper.vm.confirmDialogMeta.message).toBe(""); - expect(wrapper.vm.confirmDialogMeta.data).toBeNull(); - }); - - it("openCancelDialog opens confirm dialog when pipeline is dirty", () => { - mockPipelineObj.dirtyFlag = true; - wrapper.vm.openCancelDialog(); - expect(wrapper.vm.confirmDialogMeta.show).toBe(true); - expect(wrapper.vm.confirmDialogMeta.title).toBeTruthy(); - }); - it("openCancelDialog navigates directly when pipeline is not dirty", async () => { const routerPushSpy = vi.spyOn(router, "push").mockImplementation(() => Promise.resolve()); mockPipelineObj.dirtyFlag = false; mockPipelineObj.currentSelectedPipeline.nodes = []; mockPipelineObj.isEditPipeline = true; - wrapper.vm.openCancelDialog(); + await wrapper.vm.openCancelDialog(); - expect(wrapper.vm.confirmDialogMeta.show).toBe(false); + expect(document.querySelector('[data-test="editor-draft-leave-dialog"]')).toBeNull(); + expect(routerPushSpy).toHaveBeenCalledWith(expect.objectContaining({ name: "pipelines" })); routerPushSpy.mockRestore(); }); @@ -543,6 +518,14 @@ describe("PipelineEditor", () => { const result = wrapper.vm.beforeUnloadHandler(e); expect(result).toBeTruthy(); }); + + it("counts a new pipeline with a single node as unsaved, like the route guard", () => { + mockPipelineObj.dirtyFlag = false; + mockPipelineObj.isEditPipeline = false; + mockPipelineObj.currentSelectedPipeline.nodes = [{ id: "1" }]; + const e = { returnValue: null }; + expect(wrapper.vm.beforeUnloadHandler(e)).toBeTruthy(); + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -702,7 +685,7 @@ describe("PipelineEditor", () => { expect(wrapper.vm.confirmDialogBasicPipeline).toBe(true); }); - it("sets isPipelineSaving to true when all validations pass and calls onSubmitPipeline", async () => { + it("saves when all validations pass and resolves once the save has finished", async () => { mockPipelineObj.currentSelectedPipeline.name = "my-pipeline"; mockPipelineObj.currentSelectedPipeline.nodes = [ { @@ -720,9 +703,11 @@ describe("PipelineEditor", () => { ]; mockPipelineObj.currentSelectedPipeline.edges = [{ source: "n1", target: "n2" }]; - await wrapper.vm.savePipeline(); + const saved = await wrapper.vm.savePipeline(); - expect(wrapper.vm.isPipelineSaving).toBe(true); + expect(saved).toBe(true); + expect(pipelineService.createPipeline).toHaveBeenCalledTimes(1); + expect(wrapper.vm.isPipelineSaving).toBe(false); }); }); @@ -808,7 +793,6 @@ describe("PipelineEditor", () => { "savePipeline", "savePipelineJson", "openCancelDialog", - "resetConfirmDialog", "resetDialog", "openJsonEditor", "findMissingEdges", diff --git a/web/src/components/pipeline/PipelineEditor.vue b/web/src/components/pipeline/PipelineEditor.vue index 078c29e8bf..413ac450ba 100644 --- a/web/src/components/pipeline/PipelineEditor.vue +++ b/web/src/components/pipeline/PipelineEditor.vue @@ -44,7 +44,7 @@ along with this program. If not, see . size="sm-action" :loading="isPipelineSaving" :disabled="isPipelineSaving" - @click="savePipeline" + @click="savePipeline()" >{{ t("common.save") }} @@ -186,13 +186,6 @@ along with this program. If not, see . @saveJson="savePipelineJson" /> - . @update:cancel="resetBasicDialog" v-model="confirmDialogBasicPipeline" /> + diff --git a/web/src/components/reports/ReportHistory.vue b/web/src/components/reports/ReportHistory.vue index 204fbc994f..f40a66e636 100644 --- a/web/src/components/reports/ReportHistory.vue +++ b/web/src/components/reports/ReportHistory.vue @@ -22,6 +22,7 @@ along with this program. If not, see . show-report-column show-cached-tile :running="running" + :report-folders="reportFolders" @run-now="openRunNow" > @@ -191,9 +202,16 @@ along with this program. If not, see . . @select="duplicate(row)" > {{ t("rum.analytics.events.duplicate") }} + permission.value === "write"); const canCreate = computed(() => canWrite.value && events.value.length < MAX_EVENTS_PER_APP); const readOnlyTip = computed(() => t("rum.analytics.events.readOnly")); -const duplicateTip = computed(() => { +const draftLabel = useDraftLabel(); + +// Per row: a Duplicate with a kept copy says so, with the copy's date, in its own tooltip. +const duplicateTip = (ev: NamedEvent): I18nText => { if (!canWrite.value) return readOnlyTip.value; if (!canCreate.value) return t("rum.analytics.events.capReached", { max: MAX_EVENTS_PER_APP }); - return t("rum.analytics.events.duplicate"); -}); + return duplicateDraftLabel(ev, t("rum.analytics.events.duplicate")); +}; +// The corner dot is presentational, so an action that reopens a kept copy says so, dated, in its own name. +const duplicateDraftLabel = (ev: NamedEvent, label: I18nText): I18nText => + hasDuplicateDraft(ev) ? draftLabel(label, duplicateDraftSavedAt(ev)) : label; const filtered = computed(() => { const term = filter.value.trim().toLowerCase(); @@ -498,7 +538,24 @@ const openEdit = (ev: NamedEvent) => { const duplicate = (ev: NamedEvent) => { if (!canCreate.value) return; const name = t("rum.analytics.events.copyName", { name: raw(ev.name) }); - void openNew(namedEventHandoffState({ draft: { name, rules: ev.rules } })); + void openNew(namedEventHandoffState({ draft: { name, rules: ev.rules }, source: ev.id })); +}; + +const newEventDrafts = useNewEventDraftTimes(); +const duplicateDraftSavedAt = (ev: NamedEvent) => + newEventDrafts.savedAt(namedEventDraftVariant(app.value, { draft: null, source: ev.id })); +const hasDuplicateDraft = (ev: NamedEvent) => duplicateDraftSavedAt(ev) !== null; + +// A duplicate's draft is keyed by its source, so every user's copy goes with the source. +const dropDrafts = (ids: string[]) => { + for (const entityId of ids) { + deleteEditorDraftsFor({ + kind: "rum_named_event", + org: org(), + entityId, + variant: namedEventDraftVariant(app.value, { draft: null, source: entityId }), + }); + } }; const funnelNames = (funnels: FunnelUsage[]) => @@ -532,6 +589,7 @@ const confirmRemove = async (ev: NamedEvent, funnels: FunnelUsage[]): Promise 0); // A funnel saved with this event after the usage check: ask again, naming it. if (inUse) return confirmRemove(ev, inUse); + dropDrafts([ev.id]); return true; } catch { // useNamedEvents has already toasted, read-only included. @@ -590,6 +648,7 @@ const removeSelected = async () => { const inUse = chosen.map((ev, i) => ({ ev, funnels: used[i] })).filter((u) => u.funnels.length); if (!(await confirmBulk(chosen.length, inUse))) return; const gone = await deleteChosen(chosen, inUse); + dropDrafts(gone); selectedIds.value = selectedIds.value.filter((id) => !gone.includes(id)); } finally { bulkDeleting.value = false; diff --git a/web/src/components/settings/AddRegexPattern.drafts.spec.ts b/web/src/components/settings/AddRegexPattern.drafts.spec.ts new file mode 100644 index 0000000000..b50434f16c --- /dev/null +++ b/web/src/components/settings/AddRegexPattern.drafts.spec.ts @@ -0,0 +1,348 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { h } from "vue"; +import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router"; +import i18n from "@/locales"; +import store from "@/test/unit/helpers/store"; +import { fillField } from "@/test/unit/helpers/fillField"; +import { queryClient } from "@/composables/query/queryClient"; +import regexPatternService from "@/services/regex_pattern"; +import { + hashDraftValue, + readEditorDraft, + writeEditorDraft, + type EditorDraftScope, +} from "@/utils/drafts/editorDraftStore"; +import { byTest, click, clickIn, clickLeave, clickRestore } from "@/test/unit/helpers/draftDom"; +import RegexPatternList from "./RegexPatternList.vue"; +import AddRegexPattern from "./AddRegexPattern.vue"; + +vi.mock("@/services/regex_pattern", async (importOriginal) => { + const { overlayServiceMock } = await import("@/test/unit/helpers/mockService"); + return overlayServiceMock(await importOriginal(), { + default: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + bulkDelete: vi.fn(), + test: vi.fn(), + }, + }); +}); +vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn(() => vi.fn()) })); + +const service = regexPatternService as unknown as Record>; + +const newScope: EditorDraftScope = { + kind: "regex_pattern", + org: "default", + entityId: null, + userEmail: "example@gmail.com", +}; +const ssnId = "7378809949452783616"; +const editScope: EditorDraftScope = { ...newScope, entityId: ssnId }; + +const ssn = { + id: ssnId, + name: "American Social Security Number", + pattern: "\\b\\d{3}[-\\s]?\\d{2}[-\\s]?\\d{4}\\b", + description: "An American identification number", + created_at: 1759245383609043, + updated_at: 1759254260673538, +}; +const nginx = { + id: "7363029241765576704", + name: "nginx_parser", + pattern: "(?P\\S+) - (?P\\S+)", + description: "", + created_at: 1755482969705767, + updated_at: 1755482969705767, +}; + +const draftData = (patch: Record = {}) => ({ + name: "email format", + pattern: "^[\\w.-]+@[\\w.-]+\\.\\w{2,}$", + description: "", + testString: "root@openobserve.ai", + ...patch, +}); + +let router: Router; +let wrapper: VueWrapper | null = null; + +const mountPage = async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/settings/regex_patterns", name: "regexPatterns", component: RegexPatternList }, + { path: "/logs", name: "logs", component: { render: () => h("div") } }, + ], + }); + await router.push({ name: "regexPatterns" }); + wrapper = mount( + { render: () => h(RouterView) }, + { attachTo: document.body, global: { plugins: [i18n, store, router] } }, + ); + await flushPromises(); +}; + +interface ListVm { + createRegexPattern: () => void; + editRegexPattern: (row: unknown) => void; + confirmDeleteRegexPattern: (row: unknown) => void; + deleteRegexPattern: () => Promise; + handleSelectedIdsUpdate: (ids: string[]) => void; + bulkDeleteRegexPatterns: () => Promise; + showAddRegexPatternDialog: { show: boolean }; +} + +const list = () => wrapper!.findComponent(RegexPatternList).vm as unknown as ListVm; +const drawerOpen = () => list().showAddRegexPatternDialog.show; +const regexForm = () => + ( + wrapper!.findComponent(AddRegexPattern).vm as unknown as { + addRegexPatternForm: { form: { state: { values: Record } } }; + } + ).addRegexPatternForm.form; + +const openNew = async () => { + list().createRegexPattern(); + await flushPromises(); +}; +const openEdit = async () => { + list().editRegexPattern(ssn); + await flushPromises(); +}; + +// A click into the field, then typed text: the path every user takes, apart from autofill. +const clickAndType = async (dataTest: string, value: string) => { + window.dispatchEvent(new Event("pointerdown")); + await fillField(dataTest, value); +}; + +describe("AddRegexPattern drafts", () => { + beforeEach(() => { + localStorage.clear(); + queryClient.clear(); + vi.clearAllMocks(); + service.list.mockResolvedValue({ data: { patterns: [ssn, nginx] } }); + service.create.mockResolvedValue({ status: 200, data: {} }); + service.update.mockResolvedValue({ status: 200, data: {} }); + service.delete.mockResolvedValue({ data: {} }); + service.bulkDelete.mockResolvedValue({ data: { successful: [ssnId], unsuccessful: [] } }); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("drafts a pattern filled into the drawer", async () => { + await mountPage(); + await openNew(); + await fillField("add-regex-pattern-name-input", "email format"); + await fillField("add-regex-pattern-input", "^[\\w.-]+@[\\w.-]+$"); + window.dispatchEvent(new Event("pagehide")); + + expect(readEditorDraft(newScope)?.data).toEqual({ + name: "email format", + pattern: "^[\\w.-]+@[\\w.-]+$", + description: "", + testString: "", + }); + }); + + it("offers the draft when the drawer opens, and Continue fills the form", async () => { + writeEditorDraft(newScope, { data: draftData() }); + await mountPage(); + await openNew(); + + expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("regex pattern"); + click("editor-draft-continue"); + await flushPromises(); + + expect(regexForm().state.values).toMatchObject(draftData()); + }); + + it("Start fresh drops the draft", async () => { + writeEditorDraft(newScope, { data: draftData() }); + await mountPage(); + await openNew(); + + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + + expect(regexForm().state.values.name).toBe(""); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("Escape on the restore dialog keeps the draft and the drawer", async () => { + writeEditorDraft(newScope, { data: draftData() }); + await mountPage(); + await openNew(); + + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await flushPromises(); + + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(drawerOpen()).toBe(true); + expect(regexForm().state.values.name).toBe(""); + expect(readEditorDraft(newScope)).not.toBeNull(); + }); + + it("an edited pattern's draft is keyed by its id and keeps its locked name", async () => { + await mountPage(); + await openEdit(); + await clickAndType("add-regex-pattern-input", "\\b\\d{3}-\\d{2}-\\d{4}\\b"); + window.dispatchEvent(new Event("pagehide")); + + const draft = readEditorDraft<{ pattern: string; name: string }>(editScope); + expect(draft?.data).toMatchObject({ + name: ssn.name, + pattern: "\\b\\d{3}-\\d{2}-\\d{4}\\b", + }); + expect(draft?.baseHash).toBe( + hashDraftValue({ name: ssn.name, pattern: ssn.pattern, description: ssn.description }), + ); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("Cancel asks, and Discard closes the drawer and drops the draft", async () => { + await mountPage(); + await openNew(); + await clickAndType("add-regex-pattern-name-input", "not_kept"); + window.dispatchEvent(new Event("pagehide")); + + clickIn("regex-pattern-list-add-regex-pattern-drawer", "o-drawer-secondary-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + expect(drawerOpen()).toBe(true); + + clickLeave("o-dialog-neutral-btn"); + await flushPromises(); + expect(drawerOpen()).toBe(false); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("the close button asks too, and Keep draft closes the drawer with the work kept", async () => { + await mountPage(); + await openNew(); + await clickAndType("add-regex-pattern-name-input", "kept_for_later"); + + clickIn("regex-pattern-list-add-regex-pattern-drawer", "o-drawer-close-btn"); + await flushPromises(); + expect(drawerOpen()).toBe(true); + clickLeave("o-dialog-secondary-btn"); + await flushPromises(); + + expect(drawerOpen()).toBe(false); + expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("kept_for_later"); + }); + + it("a clean drawer closes without asking", async () => { + await mountPage(); + await openNew(); + + clickIn("regex-pattern-list-add-regex-pattern-drawer", "o-drawer-close-btn"); + await flushPromises(); + + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(drawerOpen()).toBe(false); + }); + + it("Save in the leave dialog saves without closing the drawer itself", async () => { + await mountPage(); + await openNew(); + await clickAndType("add-regex-pattern-name-input", "saved_from_leave"); + await clickAndType("add-regex-pattern-input", "\\d+"); + + clickIn("regex-pattern-list-add-regex-pattern-drawer", "o-drawer-secondary-btn"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await vi.waitFor(() => expect(service.create).toHaveBeenCalledTimes(1)); + await flushPromises(); + + expect(drawerOpen()).toBe(false); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("leaving the page with the drawer open asks first", async () => { + await mountPage(); + await openNew(); + await clickAndType("add-regex-pattern-name-input", "half_done"); + + const navigation = router.push("/logs"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-secondary-btn"); + await navigation; + + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft<{ name: string }>(newScope)?.data.name).toBe("half_done"); + }); + + it("a normal save drops the draft and closes the drawer", async () => { + await mountPage(); + await openEdit(); + await clickAndType("add-regex-pattern-input", "\\d{9}"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(editScope)).not.toBeNull(); + + clickIn("regex-pattern-list-add-regex-pattern-drawer", "o-drawer-primary-btn"); + await vi.waitFor(() => expect(service.update).toHaveBeenCalledTimes(1)); + await flushPromises(); + + expect(drawerOpen()).toBe(false); + expect(readEditorDraft(editScope)).toBeNull(); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + }); + + it("the list marks the New pattern button and the row that hold drafts", async () => { + writeEditorDraft(newScope, { data: draftData() }); + writeEditorDraft(editScope, { data: draftData({ name: ssn.name }) }); + await mountPage(); + + expect(byTest("regex-pattern-list-add-draft-dot")).not.toBeNull(); + expect(byTest("regex-pattern-list-add-pattern-btn")?.getAttribute("aria-label")).toContain( + "unsaved draft", + ); + await vi.waitFor(() => expect(byTest(`regex-pattern-list-${ssnId}-draft-dot`)).not.toBeNull()); + expect(byTest(`regex-pattern-list-${nginx.id}-draft-dot`)).toBeNull(); + }); + + it("deleting a pattern drops its drafts, singly and in bulk", async () => { + const nginxScope = { ...editScope, entityId: nginx.id }; + writeEditorDraft(editScope, { data: draftData() }); + writeEditorDraft(nginxScope, { data: draftData() }); + await mountPage(); + + list().confirmDeleteRegexPattern(nginx); + await list().deleteRegexPattern(); + await flushPromises(); + expect(readEditorDraft(nginxScope)).toBeNull(); + expect(readEditorDraft(editScope)).not.toBeNull(); + + list().handleSelectedIdsUpdate([ssnId]); + await list().bulkDeleteRegexPatterns(); + await flushPromises(); + expect(readEditorDraft(editScope)).toBeNull(); + }); +}); diff --git a/web/src/components/settings/AddRegexPattern.spec.ts b/web/src/components/settings/AddRegexPattern.spec.ts index 67885092d5..11a95c4731 100644 --- a/web/src/components/settings/AddRegexPattern.spec.ts +++ b/web/src/components/settings/AddRegexPattern.spec.ts @@ -390,7 +390,7 @@ describe("AddRegexPattern", () => { describe("Button interactions", () => { it("should emit close event when handleClose runs", async () => { const wrapper = createWrapper(); - wrapper.vm.handleClose(); + await wrapper.vm.handleClose(); expect(wrapper.emitted("close")).toBeTruthy(); expect(wrapper.emitted("update:open")).toBeTruthy(); }); @@ -399,6 +399,7 @@ describe("AddRegexPattern", () => { const wrapper = createWrapper(); const drawer = wrapper.findComponent({ name: "ODrawer" }); await drawer.vm.$emit("click:secondary"); + await flushPromises(); expect(wrapper.emitted("close")).toBeTruthy(); }); diff --git a/web/src/components/settings/AddRegexPattern.vue b/web/src/components/settings/AddRegexPattern.vue index c4fd15c2ac..d7696c4011 100644 --- a/web/src/components/settings/AddRegexPattern.vue +++ b/web/src/components/settings/AddRegexPattern.vue @@ -27,6 +27,7 @@ along with this program. If not, see . " :secondary-button-label="t('regex_patterns.cancel')" form-id="add-regex-pattern-form" + :close-guard="guardClose" @click:secondary="handleClose" data-test="add-regex-pattern-drawer" > @@ -238,11 +239,12 @@ along with this program. If not, see . + diff --git a/web/src/components/settings/ModelPricingList.vue b/web/src/components/settings/ModelPricingList.vue index 0b0d406143..eefd085f43 100644 --- a/web/src/components/settings/ModelPricingList.vue +++ b/web/src/components/settings/ModelPricingList.vue @@ -73,10 +73,21 @@ along with this program. If not, see . {{ t("modelPricing.newModel") }} + @@ -184,15 +195,22 @@ along with this program. If not, see . - {{ row.name }} - +
+ {{ row.name }} + + +
. @select="duplicateModel(row)" > {{ t("modelPricing.actionDuplicate") }} + . + > + + + @@ -657,7 +697,7 @@ import { modelPricingQuery } from "@/services/model_pricing.queries"; import { modelPricingKeys } from "@/services/model_pricing.querykeys"; import { queryClient } from "@/composables/query/queryClient"; import { ref, computed, onBeforeMount, onActivated, watch } from "vue"; -import { raw, useI18nTyped, type I18nKey } from "@/types/i18n"; +import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n"; import { useStore } from "vuex"; import useTheme from "@/composables/useTheme"; import { useRouter } from "vue-router"; @@ -686,6 +726,10 @@ import UtcHoursBar from "@/components/settings/UtcHoursBar.vue"; import OCode from "@/lib/core/Code/OCode.vue"; import OText from "@/lib/core/Typography/OText.vue"; import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue"; +import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue"; +import { useDraftLabel } from "@/components/common/drafts/useDraftLabel"; +import { useEditorDraftIds } from "@/composables/useEditorDraft"; +import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore"; import { operatorSymbol, formatTierRule, @@ -704,6 +748,22 @@ const router = useRouter(); const qTableRef = ref(null); const orgIdentifier = computed(() => store.state.selectedOrganization?.identifier || ""); +const pricingDrafts = useEditorDraftIds("model_pricing"); +const hasDuplicateDraft = (row: { id: string }) => + pricingDrafts.hasNewVariant(`duplicate:${row.id}`); +const duplicateDraftSavedAt = (row: { id: string }) => + pricingDrafts.savedAtVariant(`duplicate:${row.id}`); +const draftLabel = useDraftLabel(); +// The corner dot is presentational, so an action that reopens a kept copy says so, dated, in its own name. +const duplicateActionLabel = (row: { id: string }, label: I18nText): I18nText => + hasDuplicateDraft(row) ? draftLabel(label, duplicateDraftSavedAt(row)) : label; +// A copy of a deleted model has nothing left to come from, so its draft goes too. +const dropPricingDrafts = (ids: string[]) => { + const org = orgIdentifier.value; + for (const id of ids) { + deleteEditorDraftsFor({ kind: "model_pricing", org, entityId: id, variant: `duplicate:${id}` }); + } +}; const modelsQuery = useQuery(() => Object.assign(modelPricingQuery(orgIdentifier.value), { enabled: !!orgIdentifier.value }), @@ -1098,6 +1158,7 @@ function confirmDelete(model: any) { onConfirm: async () => { try { await modelPricingService.delete(orgIdentifier.value, model.id); + dropPricingDrafts([model.id]); toast({ variant: "success", message: t("modelPricing.modelPricingDeleted"), @@ -1188,6 +1249,7 @@ function confirmDeleteSelected() { const modelName = modelEntry?.name || id; try { await modelPricingService.delete(orgIdentifier.value, id); + dropPricingDrafts([id]); successCount++; } catch (e: any) { notifyError(t("modelPricing.errDeleteNamed", { name: modelName }), e); diff --git a/web/src/components/settings/RegexPatternList.vue b/web/src/components/settings/RegexPatternList.vue index 71ba55eaab..b4444f488e 100644 --- a/web/src/components/settings/RegexPatternList.vue +++ b/web/src/components/settings/RegexPatternList.vue @@ -36,9 +36,19 @@ along with this program. If not, see . data-test="regex-pattern-list-add-pattern-btn" variant="primary" size="sm" + :aria-label=" + regexDrafts.hasNew + ? t('common.editorDraft.newDot', { label: t('regex_patterns.create_pattern') }) + : undefined + " @click="createRegexPattern" - >{{ t("regex_patterns.create_pattern") }} + >{{ t("regex_patterns.create_pattern") }} +
. " /> + @@ -271,6 +291,10 @@ import { COL } from "@/lib/core/Table/OTable.types"; import { useShortcuts } from "@/lib/vue-shortcut-manager"; import { isInputFocused } from "@/utils/keyboardShortcuts"; import { useListViewState } from "@/composables/useListViewState"; +import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue"; +import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue"; +import { useEditorDraftIds } from "@/composables/useEditorDraft"; +import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore"; export default defineComponent({ name: "RegexPatternList", @@ -288,6 +312,8 @@ export default defineComponent({ OTable, OCodeCell, OTimeCell, + OTruncatedText, + EditorDraftDot, }, setup() { const viewState = useListViewState("regex-patterns", { @@ -414,6 +440,13 @@ export default defineComponent({ const showImportRegexPatternDialog = ref(false); + const regexDrafts = useEditorDraftIds("regex_pattern"); + const dropRegexDrafts = (ids: string[]) => { + const org = store.state.selectedOrganization.identifier; + for (const id of ids) + deleteEditorDraftsFor({ kind: "regex_pattern", org, entityId: String(id) }); + }; + const showAddRegexPatternDialog = ref({ show: false, data: {} as any, @@ -509,6 +542,7 @@ export default defineComponent({ // Drop the row from the cache first so it disappears now, not when the // refetch lands; the forced reload re-persists the corrected list. const deletedId = deleteDialog.value.data; + dropRegexDrafts([deletedId]); queryClient.setQueriesData( { queryKey: regexPatternKeys.all(store.state.selectedOrganization.identifier) }, (list: any) => @@ -607,6 +641,7 @@ export default defineComponent({ { ids: patternIds }, ); const { successful, unsuccessful } = res.data; + dropRegexDrafts(successful); if (successful.length > 0 && unsuccessful.length === 0) { toast({ @@ -657,6 +692,7 @@ export default defineComponent({ ]); return { + regexDrafts, refreshRegexPatterns, t, store, diff --git a/web/src/components/settings/announcementDrafts.ts b/web/src/components/settings/announcementDrafts.ts index 6991ad3d7b..7416a740d8 100644 --- a/web/src/components/settings/announcementDrafts.ts +++ b/web/src/components/settings/announcementDrafts.ts @@ -112,6 +112,11 @@ export function newBannerId(prefix = "banner"): string { return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } +/** The banner's editor-draft key, its id; a legacy banner stored without one has none, as nothing else about it survives an edit. */ +export function bannerDraftKey(stored: Record | null | undefined): string | null { + return typeof stored?.id === "string" && stored.id ? stored.id : null; +} + /** Largest first, as the API requires them in a compound span like `"1h30m"`. */ const DURATION_UNITS: [string, number][] = [ ["y", 31_536_000_000], diff --git a/web/src/components/synthetic-monitoring/MonitorTable.spec.ts b/web/src/components/synthetic-monitoring/MonitorTable.spec.ts index 81f5b47469..a34a54a9ee 100644 --- a/web/src/components/synthetic-monitoring/MonitorTable.spec.ts +++ b/web/src/components/synthetic-monitoring/MonitorTable.spec.ts @@ -17,7 +17,9 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { mount, VueWrapper } from "@vue/test-utils"; import i18n from "@/locales"; import MonitorTable from "./MonitorTable.vue"; +import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue"; import { mockMonitorList } from "@/test/unit/mockData/synthetics"; +import store from "@/test/unit/helpers/store"; // ── Stubs ─────────────────────────────────────────────────────────────── @@ -143,7 +145,7 @@ function mountMonitorTable(props: Record = {}) { ...props, }, global: { - plugins: [i18n], + plugins: [i18n, store], stubs: { OTable: OTableStub, OButton: OButtonStub, @@ -186,6 +188,14 @@ describe("MonitorTable", () => { // ── OTable prop forwarding ──────────────────────────────────────────── + describe("draft dots", () => { + it("marks only the rows that have an unsaved editor draft", () => { + wrapper = mountMonitorTable({ draftSavedAt: { "mon-tcp-1": Date.UTC(2026, 9, 1, 9, 30) } }); + expect(wrapper.find('[data-test="monitor-table-mon-tcp-1-draft-dot"]').exists()).toBe(true); + expect(wrapper.find('[data-test="monitor-table-mon-http-1-draft-dot"]').exists()).toBe(false); + }); + }); + describe("OTable integration", () => { it("should pass data prop to OTable", () => { wrapper = mountMonitorTable(); @@ -669,14 +679,20 @@ describe("MonitorTable", () => { // ── Name cell ────────────────────────────────────────────────────── describe("name cell", () => { - it("renders OTooltip wrapping the name with cursor-pointer when name is present", () => { + it("renders the name as cut text that tooltips itself only while cut", () => { wrapper = mountMonitorTable(); - // Per row: name=idx+0, url=idx+1, locations=idx+2, then 5 action OTooltips. - const tooltips = wrapper.findAllComponents({ name: "OTooltipStub" }); - expect(tooltips.length).toBeGreaterThanOrEqual(3); - const nameTooltip = tooltips[0]; - expect(nameTooltip.props("content")).toBe("HTTP Health Check"); - expect(nameTooltip.find("span.cursor-pointer").exists()).toBe(true); + const name = wrapper.findComponent(OTruncatedText); + expect(name.text()).toBe("HTTP Health Check"); + expect(name.classes()).toContain("cursor-pointer"); + expect(name.attributes("data-test")).toBe("monitor-table-name-mon-http-1"); + // The only tooltip in the cell is the cut-text one, never an always-on repeat of the name. + const nameTooltip = name.findComponent({ name: "OTooltipStub" }); + expect(nameTooltip.attributes("overflow-only")).toBeDefined(); + expect( + wrapper + .findAllComponents({ name: "OTooltipStub" }) + .some((tip) => tip.props("content") === "HTTP Health Check"), + ).toBe(false); }); it("renders em-dash fallback span without OTooltip when name is absent", () => { diff --git a/web/src/components/synthetic-monitoring/MonitorTable.vue b/web/src/components/synthetic-monitoring/MonitorTable.vue index a4874a193a..61388e45f5 100644 --- a/web/src/components/synthetic-monitoring/MonitorTable.vue +++ b/web/src/components/synthetic-monitoring/MonitorTable.vue @@ -72,25 +72,27 @@ along with this program. If not, see . @@ -613,6 +615,8 @@ import MutedChip from "@/components/alerts/downtimes/MutedChip.vue"; import MuteMenuItems from "@/components/alerts/downtimes/MuteMenuItems.vue"; import QuickMuteDialog from "@/components/alerts/downtimes/QuickMuteDialog.vue"; import { useRowMute } from "@/composables/downtimes/useRowMute"; +import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue"; +import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue"; type Mode = "all" | "browser"; @@ -666,6 +670,8 @@ const props = withDefaults( * human label. Falls back to the raw id when a location isn't in the map * (e.g. deleted since the check last ran). */ locationNames?: Record; + /** Check id to when its unsaved editor draft was written. */ + draftSavedAt?: Record; pageSize?: number; currentPage?: number; sortBy?: string; @@ -682,6 +688,7 @@ const props = withDefaults( bulkActionLoading: false, hasFilters: false, locationNames: () => ({}), + draftSavedAt: () => ({}), }, ); diff --git a/web/src/components/synthetics/CheckTypePicker.spec.ts b/web/src/components/synthetics/CheckTypePicker.spec.ts index 6e3e727296..96ec965086 100644 --- a/web/src/components/synthetics/CheckTypePicker.spec.ts +++ b/web/src/components/synthetics/CheckTypePicker.spec.ts @@ -13,8 +13,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { describe, it, expect } from "vitest"; -import { mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { createStore } from "vuex"; import i18n from "@/locales"; import CheckTypePicker from "@/components/synthetics/CheckTypePicker.vue"; import type { SyntheticCheckType } from "@/types/synthetics"; @@ -453,3 +454,76 @@ describe("CheckTypePicker.vue", () => { }); }); }); + +describe("CheckTypePicker drafts (real cards)", () => { + let wrapper: VueWrapper | null = null; + const store = createStore({ state: { timezone: "UTC" } }); + const savedAt = Date.UTC(2026, 9, 21, 14, 32); + + beforeEach(() => { + // jsdom has no layout; every element gets a box so the dot counts as visible. + vi.spyOn(Element.prototype, "getClientRects").mockReturnValue({ + length: 1, + item: () => null, + } as unknown as DOMRectList); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const mountReal = () => { + wrapper = mount(CheckTypePicker, { + props: { variant: "modal", layout: "row", draftSavedAt: { http: savedAt, tcp: null } }, + attachTo: document.body, + global: { plugins: [i18n, store] }, + }); + return wrapper; + }; + + const card = (type: string) => + document.body.querySelector( + `[data-test="check-type-picker-row-card-${type}"] button`, + )!; + + it("marks only the type that holds a draft, and names it on the card", async () => { + mountReal(); + await flushPromises(); + expect( + card("http").querySelector('[data-test="check-type-picker-row-card-http-draft-dot"]'), + ).not.toBeNull(); + expect( + document.body.querySelector('[data-test="check-type-picker-row-card-tcp-draft-dot"]'), + ).toBeNull(); + expect(card("http").getAttribute("aria-label")).toMatch(/· unsaved draft · .*2026/); + expect(card("tcp").hasAttribute("aria-label")).toBe(false); + expect(card("http").querySelector("[title]")).toBeNull(); + }); + + it("keeps the drafted card's description in its accessible description", async () => { + mountReal(); + await flushPromises(); + const describedBy = card("http").getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + const description = document.getElementById(describedBy!); + const visible = card("http").querySelector(".text-text-secondary")?.textContent?.trim(); + expect(visible).toBeTruthy(); + expect(description?.textContent?.trim()).toBe(visible); + expect(card("tcp").hasAttribute("aria-describedby")).toBe(false); + }); + + it("shows the draft's date when the user hovers that card", async () => { + vi.useFakeTimers(); + mountReal(); + await flushPromises(); + card("http").dispatchEvent(new MouseEvent("mouseenter")); + vi.advanceTimersByTime(800); + await flushPromises(); + expect(document.body.querySelector('[data-test="o-tooltip-content"]')?.textContent).toMatch( + /Unsaved draft · .*2026/, + ); + }); +}); diff --git a/web/src/components/synthetics/CheckTypePicker.vue b/web/src/components/synthetics/CheckTypePicker.vue index 89398f4a9e..e66c0e4e0b 100644 --- a/web/src/components/synthetics/CheckTypePicker.vue +++ b/web/src/components/synthetics/CheckTypePicker.vue @@ -15,7 +15,10 @@ along with this program. If not, see . --> diff --git a/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.drafts.spec.ts b/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.drafts.spec.ts new file mode 100644 index 0000000000..8ebe7cb96f --- /dev/null +++ b/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.drafts.spec.ts @@ -0,0 +1,571 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { h, nextTick } from "vue"; +import { createStore } from "vuex"; +import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router"; +import i18n from "@/locales"; +import OForm from "@/lib/forms/Form/OForm.vue"; +import remoteTasksService, { type RemoteTask } from "@/services/remote-tasks.service"; +import { toast } from "@/lib/feedback/Toast/useToast"; +import { + hashDraftValue, + readEditorDraft, + writeEditorDraft, + type EditorDraftScope, +} from "@/utils/drafts/editorDraftStore"; +import { fillField } from "@/test/unit/helpers/fillField"; +import { blankSecretLists } from "@/utils/drafts/redactDraftSecrets"; +import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom"; +import RemoteTaskFormPage from "./RemoteTaskFormPage.vue"; +import { + remoteTaskFormDefaults, + remoteTaskToFormValues, + type RemoteTaskFormValues, +} from "./RemoteTaskForm.schema"; + +vi.mock("@/services/remote-tasks.service", () => ({ + default: { + create: vi.fn(), + saveDraft: vi.fn(), + testConnection: vi.fn(), + testCandidate: vi.fn(), + testRun: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }, +})); +vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn() })); + +interface FormApi { + setFieldValue: (field: string, value: unknown) => void; + handleSubmit: () => Promise; + state: { values: RemoteTaskFormValues }; +} + +const newScope: EditorDraftScope = { + kind: "remote_task", + org: "acme", + entityId: null, + userEmail: "me@x.io", +}; +const editScope: EditorDraftScope = { ...newScope, entityId: "head-1" }; + +const storedTask: RemoteTask = { + id: "row-1", + orgId: "acme", + entityId: "head-1", + version: 2, + isDraft: false, + isReferenceable: true, + taskRef: "summarizer@2", + name: "summarizer", + description: "calls the summarizer", + endpoint: "https://tasks.example.com/run", + httpMethod: "POST", + auth: { type: "none", usesSecret: false }, + customHeaders: [{ key: "X-Team", value: "search", usesSecret: false }], + contentType: "application/json", + responseSchema: "$.output", + timeoutMs: 60_000, + maxAttempts: 3, + maxConcurrency: 4, + signing: { enabled: false, usesSecret: false }, + verificationStatus: "verified", + isActive: true, + createdAt: 1, + updatedAt: 2, +} as RemoteTask; + +const store = createStore({ + state: { + theme: "light", + selectedOrganization: { identifier: "acme" }, + userInfo: { email: "me@x.io" }, + timezone: "UTC", + }, +}); + +const Blank = { render: () => h("div") }; +let router: Router; +let wrapper: VueWrapper | null = null; + +const mountAt = async (path: string) => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/ai/remote-tasks", name: "aiRemoteTasks", component: Blank }, + { path: "/ai/remote-tasks/new", name: "aiRemoteTaskCreate", component: RemoteTaskFormPage }, + { + path: "/ai/remote-tasks/:id/edit", + name: "aiRemoteTaskEdit", + component: RemoteTaskFormPage, + }, + { path: "/ai/remote-tasks/:id", name: "aiRemoteTaskDetail", component: Blank }, + { path: "/logs", name: "logs", component: Blank }, + ], + }); + await router.push(path); + wrapper = mount( + { render: () => h(RouterView) }, + { + attachTo: document.body, + global: { plugins: [router, store, i18n], stubs: { RemoteTaskTestPanel: true } }, + }, + ); + await flushPromises(); +}; + +/** The stored task's hash as the draft records it: with every header value blanked. */ +const storedTaskHash = () => { + const values = remoteTaskToFormValues(storedTask); + blankSecretLists(values, ["/headers"]); + return hashDraftValue(values); +}; + +const form = () => (wrapper!.findComponent(OForm).vm as unknown as { form: FormApi }).form; + +const edit = async (field: string, value: unknown) => { + window.dispatchEvent(new Event("pointerdown")); + form().setFieldValue(field, value); + await nextTick(); +}; + +const published = { published: true, versionBumped: true, task: { version: 1 }, report: null }; + +describe("RemoteTaskFormPage drafts", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.mocked(remoteTasksService.get).mockResolvedValue(storedTask); + vi.mocked(remoteTasksService.create).mockResolvedValue({ entityId: "head-9" } as never); + vi.mocked(remoteTasksService.saveDraft).mockResolvedValue({} as never); + vi.mocked(remoteTasksService.testConnection).mockResolvedValue(published as never); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + it("drafts a new task filled with no key or pointer event, and leaving asks", async () => { + await mountAt("/ai/remote-tasks/new"); + await fillField("ai-remote-task-form-name-input", "summarizer"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)?.data.name).toBe("summarizer"); + + const navigation = router.push("/logs"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-close-btn"); + await navigation; + expect(router.currentRoute.value.name).toBe("aiRemoteTaskCreate"); + }); + + it("names the restore as local changes and continues the draft with its secrets blank", async () => { + writeEditorDraft(newScope, { + data: { ...remoteTaskFormDefaults(), name: "restored", authType: "bearer", token: "" }, + redactedPaths: ["/token"], + changedRedactedPaths: ["/token"], + }); + await mountAt("/ai/remote-tasks/new"); + + const dialog = byTest("editor-draft-restore-dialog"); + expect(dialog?.textContent).toContain("Unsaved local changes"); + expect(dialog?.textContent).toContain("unsaved changes to this remote task"); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your change to the token was not kept.", + ); + + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values).toMatchObject({ name: "restored", authType: "bearer", token: "" }); + }); + + it("start fresh drops the draft and opens a blank form", async () => { + writeEditorDraft(newScope, { data: { ...remoteTaskFormDefaults(), name: "old" } }); + await mountAt("/ai/remote-tasks/new"); + + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("escape opens a blank form and keeps the draft", async () => { + writeEditorDraft(newScope, { data: { ...remoteTaskFormDefaults(), name: "old" } }); + await mountAt("/ai/remote-tasks/new"); + + pressEscape(); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(newScope)?.data.name).toBe("old"); + }); + + it("never stores the token, the signing key or any header value, whatever the header's name", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "signed"); + await edit("authType", "bearer"); + await edit("token", "tok-123"); + await edit("signingEnabled", true); + await flushPromises(); + await edit("headers", [ + { key: "Authorization", value: "Bearer abc", usesSecret: false }, + { key: "Cookie", value: "sid=s3cr3t", usesSecret: false }, + ]); + const generated = form().state.values.signingKey; + expect(generated).not.toBe(""); + window.dispatchEvent(new Event("pagehide")); + + const stored = JSON.stringify(localStorage); + expect(stored).not.toContain("tok-123"); + expect(stored).not.toContain(generated); + expect(stored).not.toContain("Bearer abc"); + expect(stored).not.toContain("sid=s3cr3t"); + const headers = readEditorDraft(newScope)?.data.headers; + expect(headers?.map((header) => header.key)).toEqual(["Authorization", "Cookie"]); + }); + + it("a continued signed draft gets a fresh signing key and says so beside it", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "signed"); + await edit("signingEnabled", true); + await flushPromises(); + const first = form().state.values.signingKey; + window.dispatchEvent(new Event("pagehide")); + wrapper!.unmount(); + wrapper = null; + + await mountAt("/ai/remote-tasks/new"); + expect(byTest("editor-draft-credentials-not-saved")).toBeNull(); + expect(byTest("editor-draft-restore-note")?.textContent).toContain( + "Signing keys are not kept in this browser", + ); + expect(byTest("editor-draft-continue")?.textContent).toContain("Continue editing"); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values.signingEnabled).toBe(true); + expect(form().state.values.signingKey).not.toBe(""); + expect(form().state.values.signingKey).not.toBe(first); + const notice = byTest("ai-remote-task-form-signing-key-regenerated"); + expect(notice?.textContent?.trim()).toBe( + "This is a new signing key. The one in your draft was not kept.", + ); + // The notice sits under the key and its copy hint, so "copy it into your service" is said once. + const key = byTest("ai-remote-task-form-signing-key")!; + expect(key.compareDocumentPosition(notice!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(notice?.textContent).not.toContain("copy"); + + click("ai-remote-task-form-signing-key-regenerate"); + await flushPromises(); + expect(byTest("ai-remote-task-form-signing-key-regenerated")).toBeNull(); + }); + + it("never stores an endpoint that carries a token, and names it when it was not kept", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run?api_key=s3cr3t"); + window.dispatchEvent(new Event("pagehide")); + expect(JSON.stringify(localStorage)).not.toContain("s3cr3t"); + expect(readEditorDraft(newScope)?.data.endpoint).toBe(""); + wrapper!.unmount(); + wrapper = null; + + await mountAt("/ai/remote-tasks/new"); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your change to the endpoint URL was not kept.", + ); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values).toMatchObject({ name: "summarizer", endpoint: "" }); + }); + + it("keeps a plain endpoint in the draft", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)?.data.endpoint).toBe( + "https://tasks.example.com/run", + ); + }); + + it("switching auth to none releases a token that was not kept", async () => { + writeEditorDraft(newScope, { + data: { + ...remoteTaskFormDefaults(), + name: "summarizer", + endpoint: "https://tasks.example.com/run", + authType: "bearer", + token: "", + }, + redactedPaths: ["/token"], + changedRedactedPaths: ["/token"], + }); + await mountAt("/ai/remote-tasks/new"); + click("editor-draft-continue"); + await flushPromises(); + + await edit("authType", "none"); + await form().handleSubmit(); + await flushPromises(); + expect(remoteTasksService.create).toHaveBeenCalledTimes(1); + }); + + it("a failed publish that keeps the registered head moves the draft to that task", async () => { + vi.mocked(remoteTasksService.testConnection).mockResolvedValue({ + published: false, + error: "500 from upstream", + } as never); + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)).not.toBeNull(); + + await form().handleSubmit(); + await flushPromises(); + expect(remoteTasksService.delete).not.toHaveBeenCalled(); + expect(readEditorDraft(newScope)).toBeNull(); + + await edit("description", "after the failed test"); + window.dispatchEvent(new Event("pagehide")); + const headScope = { ...newScope, entityId: "head-9" }; + expect(readEditorDraft(headScope)?.data.description).toBe( + "after the failed test", + ); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("a rejected save of the kept head keeps the edits drafted and leaving still asks", async () => { + vi.mocked(remoteTasksService.testConnection).mockResolvedValue({ + published: false, + error: "500 from upstream", + } as never); + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + await form().handleSubmit(); + await flushPromises(); + const headScope = { ...newScope, entityId: "head-9" }; + + vi.mocked(remoteTasksService.saveDraft).mockRejectedValue({ + response: { status: 409, data: { message: "Remote task name already exists" } }, + }); + await edit("name", "taken"); + await form().handleSubmit(); + await flushPromises(); + + expect(remoteTasksService.saveDraft).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ variant: "error", message: "Remote task name already exists" }), + ); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(headScope)?.data.name).toBe("taken"); + + const navigation = router.push("/logs"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-close-btn"); + await navigation; + expect(router.currentRoute.value.name).toBe("aiRemoteTaskCreate"); + }); + + it("a failed publish that rolls the head back keeps the new-task draft", async () => { + vi.mocked(remoteTasksService.testConnection).mockResolvedValue({ + published: false, + error: "401", + } as never); + vi.mocked(remoteTasksService.delete).mockResolvedValue(undefined as never); + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + await edit("authType", "bearer"); + await edit("token", "tok-123"); + window.dispatchEvent(new Event("pagehide")); + + await form().handleSubmit(); + await flushPromises(); + expect(remoteTasksService.delete).toHaveBeenCalledWith("acme", "head-9"); + expect(readEditorDraft(newScope)).not.toBeNull(); + expect(readEditorDraft({ ...newScope, entityId: "head-9" })).toBeNull(); + }); + + it("a changed secret header comes back blank and Save is blocked until it is retyped", async () => { + writeEditorDraft(newScope, { + data: { + ...remoteTaskFormDefaults(), + name: "headers", + endpoint: "https://tasks.example.com/run", + headers: [{ key: "X-Api-Key", value: "", usesSecret: false }], + }, + redactedPaths: ["/headers/0/value"], + redactedLabels: { "/headers/0/value": "X-Api-Key" }, + changedRedactedPaths: ["/headers/0/value"], + }); + await mountAt("/ai/remote-tasks/new"); + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain("X-Api-Key header"); + click("editor-draft-continue"); + await flushPromises(); + + await form().handleSubmit(); + await flushPromises(); + expect(remoteTasksService.create).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Header value is required"); + + await edit("headers", [{ key: "X-Api-Key", value: "key-new", usesSecret: false }]); + await form().handleSubmit(); + await flushPromises(); + expect(remoteTasksService.create).toHaveBeenCalledTimes(1); + }); + + it("save from the leave dialog publishes without opening the detail page, then goes on", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(remoteTasksService.create).toHaveBeenCalledTimes(1); + expect(remoteTasksService.testConnection).toHaveBeenCalledTimes(1); + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("save from the leave dialog stays when the test connection fails", async () => { + vi.mocked(remoteTasksService.testConnection).mockResolvedValue({ + published: false, + error: "500 from upstream", + } as never); + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(router.currentRoute.value.name).toBe("aiRemoteTaskCreate"); + }); + + it("keep draft leaves and keeps the work; discard leaves and drops it", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "kept"); + let navigation = router.push("/logs"); + await flushPromises(); + clickLeave("o-dialog-secondary-btn"); + await navigation; + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)?.data.name).toBe("kept"); + + await router.push("/ai/remote-tasks/new"); + await flushPromises(); + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + await edit("name", "dropped"); + navigation = router.push("/logs"); + await flushPromises(); + clickLeave("o-dialog-neutral-btn"); + await navigation; + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("cancel with unsaved changes asks before going back to the list", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "to discard"); + + click("ai-remote-task-form-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-neutral-btn"); + await flushPromises(); + expect(router.currentRoute.value.name).toBe("aiRemoteTasks"); + }); + + it("a normal publish drops the draft and opens the detail page without asking", async () => { + await mountAt("/ai/remote-tasks/new"); + await edit("name", "summarizer"); + await edit("endpoint", "https://tasks.example.com/run"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(newScope)).not.toBeNull(); + + await form().handleSubmit(); + await flushPromises(); + expect(router.currentRoute.value.name).toBe("aiRemoteTaskDetail"); + expect(readEditorDraft(newScope)).toBeNull(); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + }); + + it("edit mode keys the draft by task and hashes the task as the form shows it", async () => { + await mountAt("/ai/remote-tasks/head-1/edit"); + await edit("description", "edited"); + window.dispatchEvent(new Event("pagehide")); + + const draft = readEditorDraft(editScope); + expect(draft?.data.description).toBe("edited"); + expect(draft?.baseHash).toBe(storedTaskHash()); + expect(readEditorDraft(newScope)).toBeNull(); + }); + + it("warns when the task changed on the server, but not when only its run state did", async () => { + writeEditorDraft(editScope, { + data: { ...remoteTaskToFormValues(storedTask), description: "mine" }, + baseHash: storedTaskHash(), + }); + vi.mocked(remoteTasksService.get).mockResolvedValue({ + ...storedTask, + updatedAt: 99, + verificationStatus: "failed", + } as RemoteTask); + await mountAt("/ai/remote-tasks/head-1/edit"); + expect(byTest("editor-draft-restore-dialog")).not.toBeNull(); + expect(byTest("editor-draft-changed-on-server")).toBeNull(); + wrapper!.unmount(); + wrapper = null; + + vi.mocked(remoteTasksService.get).mockResolvedValue({ + ...storedTask, + endpoint: "https://tasks.example.com/v2", + } as RemoteTask); + await mountAt("/ai/remote-tasks/head-1/edit"); + expect(byTest("editor-draft-changed-on-server")).not.toBeNull(); + }); + + it("opening the draft of a deleted task drops it with an info toast", async () => { + writeEditorDraft(editScope, { data: remoteTaskToFormValues(storedTask) }); + vi.mocked(remoteTasksService.get).mockRejectedValue({ response: { status: 404 } }); + await mountAt("/ai/remote-tasks/head-1/edit"); + + expect(readEditorDraft(editScope)).toBeNull(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "info", + message: "This remote task no longer exists, so its unsaved draft was discarded.", + }), + ); + expect(toast).not.toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); + expect(router.currentRoute.value.name).toBe("aiRemoteTasks"); + }); +}); diff --git a/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.spec.ts b/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.spec.ts index 970ab3f839..abd9bd8d64 100644 --- a/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.spec.ts +++ b/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.spec.ts @@ -9,6 +9,8 @@ const push = vi.fn(); vi.mock("vue-router", () => ({ useRoute: () => route, useRouter: () => ({ push }), + onBeforeRouteLeave: vi.fn(), + onBeforeRouteUpdate: vi.fn(), })); vi.mock("vuex", () => ({ useStore: () => ({ state: { selectedOrganization: { identifier: "acme" } } }), diff --git a/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.vue b/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.vue index 51b5982661..2c8e5efb38 100644 --- a/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.vue +++ b/web/src/enterprise/components/AIObservability/RemoteTaskFormPage.vue @@ -17,7 +17,7 @@ {{ headerShape }} + + {{ t("aiObservability.remoteTasks.form.signingKeyRegenerated") }} +
@@ -439,7 +448,7 @@ size="sm-action" :disabled="isSubmitting" data-test="ai-remote-task-form-cancel-btn" - @click="goBack" + @click="cancel" > {{ t("common.cancel") }} @@ -472,6 +481,7 @@ + @@ -484,6 +494,7 @@ import { raw, useI18nTyped, type I18nText } from "@/types/i18n"; import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue"; import OButton from "@/lib/core/Button/OButton.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; +import OBanner from "@/lib/feedback/Banner/OBanner.vue"; import OForm from "@/lib/forms/Form/OForm.vue"; import { useOForm } from "@/lib/forms/Form/useOForm"; import OFormInput from "@/lib/forms/Input/OFormInput.vue"; @@ -505,6 +516,8 @@ import remoteTasksService, { type RemoteTaskVerificationReport, } from "@/services/remote-tasks.service"; import RemoteTaskTestPanel from "./RemoteTaskTestPanel.vue"; +import EditorDraftDialogs from "@/components/common/drafts/EditorDraftDialogs.vue"; +import { useEditorDraft, useEditorDraftIds } from "@/composables/useEditorDraft"; import { ATTEMPTS_MAX, ATTEMPTS_MIN, @@ -591,6 +604,10 @@ const form = useOForm({ const submitIntent = ref<"publish" | "draft">("publish"); function onSubmit(values: RemoteTaskFormValues) { + if (taskDraft.pendingReentries().some((path) => secretInUse(path, values))) { + toast({ variant: "error", message: t("common.editorDraft.reenterBeforeSave") }); + return; + } return submitIntent.value === "draft" ? saveDraftOnly(values) : publish(values); } @@ -629,6 +646,8 @@ const storedTarget = reactive({ version: 0, endpoint: "" }); const signingKey = form.useStore((state: any) => String(state.values.signingKey ?? "")); const signingKeyCopied = ref(false); +/** Set when a continued draft got a new key, since drafts never keep the one the operator may have copied. */ +const signingKeyRegenerated = ref(false); const headerShape = raw(SIGNATURE_HEADER_SHAPE); // A key exists from the instant signing is switched on, so the operator can put @@ -639,18 +658,21 @@ watch(signingEnabled, (enabled) => { if (!enabled) { form.setFieldValue("signingKey", ""); signingKeyCopied.value = false; + signingKeyRegenerated.value = false; } }); function regenerateSigningKey() { form.setFieldValue("signingKey", generateSigningKey()); signingKeyCopied.value = false; + signingKeyRegenerated.value = false; } async function copySigningKey() { try { await navigator.clipboard.writeText(signingKey.value); signingKeyCopied.value = true; + signingKeyRegenerated.value = false; } catch { signingKeyCopied.value = false; } @@ -730,6 +752,10 @@ function goBack() { void router.push(aiRemoteTasksRoute(orgId.value)); } +async function cancel() { + if (await taskDraft.confirmLeave()) goBack(); +} + /** * Test the form exactly as it stands. `POST /tasks/test` takes the whole * candidate, so nothing is registered, no secret row is written and no version @@ -842,6 +868,8 @@ async function publish(values: RemoteTaskFormValues) { testReport.value = null; let entityId = draftEntityId.value || routeEntityId.value; + // Only a head that took this attempt's values may stand in for the local draft. + let headHoldsValues = false; try { if (!entityId) { @@ -849,11 +877,13 @@ async function publish(values: RemoteTaskFormValues) { entityId = created.entityId; draftEntityId.value = entityId; draftHasSecret.value = valuesHaveSecret(values); + headHoldsValues = true; } else if (!draftHasSecret.value) { await saveRemoteTaskDraft.mutateAsync({ entityId, payload: toDraftPayload(values, draftFromVersion.value), }); + headHoldsValues = true; } const result = await testRemoteTaskConnection.mutateAsync({ @@ -870,6 +900,7 @@ async function publish(values: RemoteTaskFormValues) { message: t("aiObservability.remoteTasks.form.publishFailed"), }); if (mode.value === "create" && draftHasSecret.value) await rollbackDraft(entityId); + if (headHoldsValues) keepDraftOnRegisteredHead(); return; } @@ -883,6 +914,11 @@ async function publish(values: RemoteTaskFormValues) { }), }); + if (leaveSave) { + leaveSave.saved = true; + return; + } + taskDraft.markSaved(); void router.push(aiRemoteTaskDetailRoute(orgId.value, entityId)); } catch (error: any) { testState.value = "failed"; @@ -893,9 +929,15 @@ async function publish(values: RemoteTaskFormValues) { message: raw(error?.response?.data?.message) || t("aiObservability.remoteTasks.form.createError"), }); + if (headHoldsValues) keepDraftOnRegisteredHead(); } } +/** A failed publish whose head kept these values moves the local draft to that task, or a later Continue would register the name twice. */ +function keepDraftOnRegisteredHead() { + if (mode.value === "create" && draftEntityId.value) taskDraft.markSaved(); +} + /** Best effort: leaving debris is worse than a silent failure here, but a failed * cleanup must not overwrite the test error the operator needs to read. */ async function rollbackDraft(entityId: string) { @@ -926,6 +968,11 @@ async function saveDraftOnly(values: RemoteTaskFormValues) { draftHasSecret.value = valuesHaveSecret(values); } toast({ variant: "success", message: t("aiObservability.remoteTasks.form.draftSuccess") }); + if (leaveSave) { + leaveSave.saved = true; + return; + } + taskDraft.markSaved(); void router.push(aiRemoteTaskDetailRoute(orgId.value, draftEntityId.value)); } catch (error: any) { toast({ @@ -936,14 +983,14 @@ async function saveDraftOnly(values: RemoteTaskFormValues) { } } -async function loadForEdit() { - if (mode.value !== "edit" || !orgId.value) return; +async function loadForEdit(): Promise { + if (!orgId.value) return false; try { const task: RemoteTask = await remoteTasksService.get(orgId.value, routeEntityId.value); if (!canEditRemoteTask(task)) { toast({ variant: "error", message: t("aiObservability.remoteTasks.statusHint.retired") }); goBack(); - return; + return false; } draftFromVersion.value = task.isDraft ? undefined : task.version; const holdsSecret = @@ -954,16 +1001,99 @@ async function loadForEdit() { testsStoredVersion.value = holdsSecret && !task.isDraft; Object.assign(storedTarget, { version: task.version, endpoint: task.endpoint }); unpublishedSecret.value = holdsSecret && task.isDraft; + serverTask.value = remoteTaskToFormValues(task); form.reset(remoteTaskToFormValues(task)); + return true; } catch (error: any) { - toast({ - variant: "error", - message: - raw(error?.response?.data?.message) || t("aiObservability.remoteTasks.detail.loadError"), - }); + // Opening the draft of a task that is gone drops it and says so, in place of the load error. + if (error?.response?.status === 404 && taskDrafts.ids.has(routeEntityId.value)) { + await taskDraft.start(); + } else { + toast({ + variant: "error", + message: + raw(error?.response?.data?.message) || t("aiObservability.remoteTasks.detail.loadError"), + }); + } goBack(); + return false; } } -onMounted(loadForEdit); +// Set while the leave dialog's Save runs, so a successful save stays on the page for the pending navigation. +let leaveSave: { saved: boolean } | null = null; +/** The loaded task in form shape: hashed for "changed on server", and null once it is gone. */ +const serverTask = ref(null); +const taskDrafts = useEditorDraftIds("remote_task"); +const formValues = form.useStore((state) => state.values); + +function secretFieldLabel(path: string, data: RemoteTaskFormValues): I18nText | null { + const header = /^\/headers\/(\d+)\/value$/.exec(path); + if (header) { + return t("common.editorDraft.headerField", { + name: data.headers[Number(header[1])]?.key ?? "", + }); + } + if (path === "/token") return t("common.editorDraft.tokenField"); + if (path === "/password") return t("common.editorDraft.passwordField"); + if (path === "/endpoint") return t("common.editorDraft.endpointUrlField"); + return null; +} + +/** Whether a blanked secret is still sent with the current auth choice. */ +function secretInUse(path: string, values: RemoteTaskFormValues): boolean { + if (path === "/token") + return values.authType === "bearer" || values.authType === "api_key_header"; + if (path === "/password") return values.authType === "basic"; + return true; +} + +function describeRedacted(paths: string[], data: RemoteTaskFormValues): I18nText[] { + return paths.flatMap((path) => secretFieldLabel(path, data) ?? []); +} + +const taskDraft = useEditorDraft({ + kind: "remote_task", + entityLabel: () => t("common.editorDraft.entities.remote_task"), + // "Draft" already names the registry's unpublished server version. + serverDraftItem: true, + restoreNote: (data) => + data.signingEnabled && mode.value === "create" + ? t("aiObservability.remoteTasks.form.signingKeyNotKept") + : null, + // The leave dialog's Save runs the publish flow, not "Save as Draft". + saveLabel: () => t("aiObservability.remoteTasks.form.submit"), + entityId: () => routeEntityId.value || draftEntityId.value || null, + // The HMAC signing key is never drafted; the signing watcher generates a new one on Continue. + snapshot: () => ({ + ...formValues.value, + headers: formValues.value.headers.map((header) => ({ ...header })), + signingKey: "", + }), + apply: (data) => { + form.reset({ ...remoteTaskFormDefaults(), ...data, signingKey: "" }); + signingKeyRegenerated.value = mode.value === "create" && data.signingEnabled; + }, + base: () => (mode.value === "edit" ? serverTask.value : undefined), + save: async () => { + const run = { saved: false }; + leaveSave = run; + submitIntent.value = "publish"; + try { + await form.handleSubmit(); + } finally { + leaveSave = null; + } + return run.saved; + }, + describeRedacted, + // Any header can carry a credential (Cookie, X-Auth, …), so no header value is drafted. + secretLists: ["/headers"], + secretUrls: ["/endpoint"], +}); + +onMounted(async () => { + if (mode.value === "edit" && !(await loadForEdit())) return; + await taskDraft.start(); +}); diff --git a/web/src/enterprise/components/OnlineEvals.drafts.spec.ts b/web/src/enterprise/components/OnlineEvals.drafts.spec.ts new file mode 100644 index 0000000000..d9f80a3a8d --- /dev/null +++ b/web/src/enterprise/components/OnlineEvals.drafts.spec.ts @@ -0,0 +1,736 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { h, nextTick } from "vue"; +import { createStore } from "vuex"; +import { RouterView, createMemoryHistory, createRouter, type Router } from "vue-router"; +import i18n from "@/locales"; +import OForm from "@/lib/forms/Form/OForm.vue"; +import ConfirmDialog from "@/components/ConfirmDialog.vue"; +import onlineEvalsService, { type EvalJob, type Scorer } from "@/services/online-evals.service"; +import { queryClient } from "@/composables/query/queryClient"; +import { toast } from "@/lib/feedback/Toast/useToast"; +import { + hashDraftValue, + readEditorDraft, + writeEditorDraft, + type EditorDraftScope, +} from "@/utils/drafts/editorDraftStore"; +import { fillField } from "@/test/unit/helpers/fillField"; +import { byTest, click, clickLeave, clickRestore, pressEscape } from "@/test/unit/helpers/draftDom"; +import OnlineEvals from "./OnlineEvals.vue"; +import EvalJobList from "./onlineEvals/EvalJobList.vue"; + +vi.mock("@/services/online-evals.service", async (importOriginal) => { + const { overlayServiceMock } = await import("@/test/unit/helpers/mockService"); + return overlayServiceMock(await importOriginal(), { + default: { + providers: { list: vi.fn() }, + scoreConfigs: { list: vi.fn(), versions: vi.fn() }, + scorers: { list: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + jobs: { list: vi.fn(), create: vi.fn(), update: vi.fn(), activate: vi.fn(), delete: vi.fn() }, + }, + }); +}); +vi.mock("@/services/gen-ai-agent-mapping.service", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual.default, listAgents: vi.fn().mockResolvedValue({ agents: [] }) }, + }; +}); +const { streamsList } = vi.hoisted(() => ({ + streamsList: { + current: (): Promise => Promise.resolve({ list: [{ name: "default" }] }), + }, +})); +vi.mock("@/composables/useStreams", () => ({ + default: () => ({ + getStreams: vi.fn(() => streamsList.current()), + getStream: vi.fn().mockResolvedValue({ schema: [{ name: "name", type: "Utf8" }] }), + }), +})); +vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn() })); + +interface FormApi { + setFieldValue: (field: string, value: unknown) => void; + handleSubmit: () => Promise; + state: { values: Record }; +} + +interface JobDraftData { + values: Record; +} + +interface ScorerDraftData { + values: Record & { customHeaders: { key: string; value: string }[] }; +} + +const scope = ( + kind: "eval_job" | "eval_scorer", + entityId: string | null, + variant?: string, +): EditorDraftScope => ({ kind, org: "acme", entityId, userEmail: "me@x.io", variant }); + +const job: EvalJob = { + id: "job-1", + name: "Span quality", + description: "", + stream: "default", + targetScope: "span", + filterCondition: { type: "all" }, + scorers: [{ id: "s1", version: null }], + samplingMode: "rate", + samplingValue: 0.1, + status: "active", + version: 1, + createdAt: 1, + updatedAt: 2, +} as EvalJob; + +const scorer: Scorer = { + id: "s1", + entityId: "s1", + name: "Correctness", + version: 1, + scorerType: "llm_judge", + description: "", + template: "Judge {{ input }}", + params: { provider_id: "p1" }, +} as Scorer; + +const store = createStore({ + state: { + theme: "light", + selectedOrganization: { identifier: "acme" }, + userInfo: { email: "me@x.io" }, + timezone: "UTC", + zoConfig: {}, + }, +}); + +const Blank = { render: () => h("div") }; +let router: Router; +let wrapper: VueWrapper | null = null; + +const mountAt = async (query: string) => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/ai/evaluations", + name: "aiEvaluations", + component: OnlineEvals, + props: { hideTabBar: true }, + }, + { path: "/logs", name: "logs", component: Blank }, + { path: "/settings/llm", name: "llmProviders", component: Blank }, + ], + }); + await router.push(`/ai/evaluations?${query}`); + wrapper = mount( + { render: () => h(RouterView) }, + { + attachTo: document.body, + global: { + plugins: [router, store, i18n], + stubs: { + QualityPage: true, + DateTimePickerDashboard: true, + AgentScopeCascade: true, + JobScorerPicker: true, + JobFilterBuilder: true, + JobInputMapping: true, + JobPreviewPanel: true, + ScorerTestPanel: true, + }, + }, + }, + ); + await flushPromises(); +}; + +const remount = async (query: string) => { + wrapper?.unmount(); + wrapper = null; + await mountAt(query); +}; + +const form = () => (wrapper!.findComponent(OForm).vm as unknown as { form: FormApi }).form; + +const edit = async (field: string, value: unknown) => { + window.dispatchEvent(new Event("pointerdown")); + form().setFieldValue(field, value); + await nextTick(); +}; + +describe("OnlineEvals editor drafts", () => { + beforeEach(() => { + localStorage.clear(); + queryClient.clear(); + streamsList.current = () => Promise.resolve({ list: [{ name: "default" }] }); + vi.clearAllMocks(); + vi.mocked(onlineEvalsService.providers.list).mockResolvedValue([ + { id: "p1", name: "OpenAI" }, + ] as never); + vi.mocked(onlineEvalsService.scoreConfigs.list).mockResolvedValue([]); + vi.mocked(onlineEvalsService.scorers.list).mockResolvedValue([scorer]); + vi.mocked(onlineEvalsService.jobs.list).mockResolvedValue([job]); + vi.mocked(onlineEvalsService.jobs.update).mockResolvedValue({} as never); + vi.mocked(onlineEvalsService.jobs.create).mockResolvedValue({ id: "job-9" } as never); + vi.mocked(onlineEvalsService.scorers.update).mockResolvedValue({} as never); + vi.mocked(onlineEvalsService.scorers.create).mockResolvedValue({} as never); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + }); + + describe("evaluation jobs", () => { + it("drafts a new job filled with no key or pointer event, and a tab switch asks", async () => { + await mountAt("tab=jobs&action=add"); + await fillField("job-form-name-input", "nightly"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(scope("eval_job", null))?.data.values.name).toBe( + "nightly", + ); + + const navigation = router.push("/ai/evaluations?tab=scorers"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-close-btn"); + await navigation; + expect(router.currentRoute.value.query).toMatchObject({ tab: "jobs", action: "add" }); + expect(byTest("job-form-title")).not.toBeNull(); + }); + + it("continues a new job's draft under the local-changes title", async () => { + await mountAt("tab=jobs&action=add"); + await edit("name", "nightly"); + await edit("samplingValue", "0.5"); + window.dispatchEvent(new Event("pagehide")); + await remount("tab=jobs&action=add"); + + expect(byTest("editor-draft-restore-dialog")?.textContent).toContain("Unsaved local changes"); + expect(byTest("editor-draft-restore-message")?.textContent).toContain("evaluation job"); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values).toMatchObject({ name: "nightly", samplingValue: "0.5" }); + }); + + it("a continued new job's draft brings back its stream even when the stream list arrives later", async () => { + await mountAt("tab=jobs&action=add"); + await edit("name", "nightly"); + await edit("stream", "default"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(scope("eval_job", null))?.data.values.stream).toBe( + "default", + ); + let streamsArrive: (value: unknown) => void = () => {}; + streamsList.current = () => new Promise((resolve) => (streamsArrive = resolve)); + await remount("tab=jobs&action=add"); + click("editor-draft-continue"); + await flushPromises(); + streamsArrive({ list: [{ name: "default" }, { name: "agents" }] }); + await flushPromises(); + + expect(form().state.values).toMatchObject({ name: "nightly", stream: "default" }); + expect(byTest("job-form-stream-select")?.textContent).toContain("default"); + }); + + it("start fresh drops the draft; escape keeps it", async () => { + writeEditorDraft(scope("eval_job", null), { + data: { values: { name: "old" }, endSignalGroup: null }, + }); + await mountAt("tab=jobs&action=add"); + pressEscape(); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(scope("eval_job", null))).not.toBeNull(); + + await remount("tab=jobs&action=add"); + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(scope("eval_job", null))).toBeNull(); + }); + + it("a continued trace job keeps its own completion window and span selectors", async () => { + writeEditorDraft(scope("eval_job", null), { + data: { + values: { + name: "traces", + description: "", + stream: "default", + streamType: "traces", + targetScope: "trace", + idleWindowSecs: 77, + maxAgeSecs: 777, + scorerIds: [], + samplingMode: "all", + samplingValue: "", + filterGroup: { filterType: "group", logicalOperator: "AND", conditions: [] }, + }, + endSignalGroup: { filterType: "group", logicalOperator: "AND", conditions: [] }, + inputMappings: {}, + spanSelectors: [ + { + id: "sel-1", + name: "tool spans", + filterCondition: {}, + fieldMode: "default", + fields: [], + maximumSpans: 3, + }, + ], + spanSelectorBindings: {}, + scorerVersions: {}, + }, + }); + await mountAt("tab=jobs&action=add"); + click("editor-draft-continue"); + await flushPromises(); + + expect(form().state.values).toMatchObject({ + targetScope: "trace", + idleWindowSecs: 77, + maxAgeSecs: 777, + }); + window.dispatchEvent(new Event("pagehide")); + const stored = readEditorDraft<{ spanSelectors: { name: string }[] }>( + scope("eval_job", null), + ); + expect(stored?.data.spanSelectors.map((selector) => selector.name)).toEqual(["tool spans"]); + }); + + it("edit mode keys the draft by job id and hashes only what the form edits", async () => { + await mountAt("tab=jobs&action=update&id=job-1"); + await fillField("job-form-description-input", "edited"); + window.dispatchEvent(new Event("pagehide")); + const draft = readEditorDraft(scope("eval_job", "job-1")); + expect(draft?.data.values.description).toBe("edited"); + const { + status: _status, + version: _version, + createdAt: _created, + updatedAt: _updated, + ...definition + } = job; + expect(draft?.baseHash).toBe(hashDraftValue(definition)); + + vi.mocked(onlineEvalsService.jobs.list).mockResolvedValue([ + { ...job, status: "paused", updatedAt: 99, version: 2 }, + ]); + queryClient.clear(); + await remount("tab=jobs&action=update&id=job-1"); + expect(byTest("editor-draft-restore-dialog")).not.toBeNull(); + expect(byTest("editor-draft-changed-on-server")).toBeNull(); + + vi.mocked(onlineEvalsService.jobs.list).mockResolvedValue([{ ...job, stream: "other" }]); + queryClient.clear(); + await remount("tab=jobs&action=update&id=job-1"); + expect(byTest("editor-draft-changed-on-server")).not.toBeNull(); + }); + + it("save from the leave dialog updates the job, reloads the list and goes on", async () => { + await mountAt("tab=jobs&action=update&id=job-1"); + await edit("description", "edited"); + const listCalls = vi.mocked(onlineEvalsService.jobs.list).mock.calls.length; + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(onlineEvalsService.jobs.update).toHaveBeenCalledWith( + "acme", + "job-1", + expect.objectContaining({ description: "edited" }), + ); + expect(vi.mocked(onlineEvalsService.jobs.list).mock.calls.length).toBeGreaterThan(listCalls); + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(scope("eval_job", "job-1"))).toBeNull(); + }); + + it("keep draft leaves and keeps the work", async () => { + await mountAt("tab=jobs&action=update&id=job-1"); + await edit("description", "kept"); + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("o-dialog-secondary-btn"); + await navigation; + + expect(router.currentRoute.value.name).toBe("logs"); + expect( + readEditorDraft(scope("eval_job", "job-1"))?.data.values.description, + ).toBe("kept"); + }); + + it("cancel asks, and discard closes the form and drops the draft", async () => { + await mountAt("tab=jobs&action=update&id=job-1"); + await edit("description", "gone"); + window.dispatchEvent(new Event("pagehide")); + + click("job-form-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-neutral-btn"); + await flushPromises(); + + expect(router.currentRoute.value.query.action).toBeUndefined(); + expect(byTest("job-form-title")).toBeNull(); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(readEditorDraft(scope("eval_job", "job-1"))).toBeNull(); + }); + + it("a normal save drops the draft and closes the form without asking", async () => { + await mountAt("tab=jobs&action=update&id=job-1"); + await edit("description", "saved"); + window.dispatchEvent(new Event("pagehide")); + + await form().handleSubmit(); + await flushPromises(); + expect(onlineEvalsService.jobs.update).toHaveBeenCalledTimes(1); + expect(router.currentRoute.value.query.action).toBeUndefined(); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(readEditorDraft(scope("eval_job", "job-1"))).toBeNull(); + }); + + it("dots New job and the job's row for this user's drafts", async () => { + writeEditorDraft(scope("eval_job", null), { data: { values: { name: "new" } } }); + writeEditorDraft(scope("eval_job", "job-1"), { data: { values: { name: "Span quality" } } }); + await mountAt("tab=jobs"); + + expect(byTest("eval-job-list-add-draft-dot")).not.toBeNull(); + expect(byTest("eval-job-list-add-btn")?.getAttribute("aria-label")).toBe( + "New job · unsaved draft", + ); + // The table renders its rows a tick after the list arrives. + await vi.waitFor(() => expect(byTest("eval-job-list-job-1-draft-dot")).not.toBeNull()); + }); + + it("deleting a job drops its draft; a bulk delete drops only the deleted ones", async () => { + vi.mocked(onlineEvalsService.jobs.list).mockResolvedValue([ + job, + { ...job, id: "job-2", name: "second" }, + { ...job, id: "job-3", name: "third" }, + ]); + for (const id of ["job-1", "job-2", "job-3"]) { + writeEditorDraft(scope("eval_job", id), { data: { values: {} } }); + } + vi.mocked(onlineEvalsService.jobs.delete).mockImplementation(((_org: string, id: string) => + id === "job-3" ? Promise.reject(new Error("locked")) : Promise.resolve()) as never); + await mountAt("tab=jobs"); + const list = wrapper!.findComponent(EvalJobList); + const confirmDialog = wrapper!.findComponent(ConfirmDialog); + + list.vm.$emit("delete", job); + await flushPromises(); + confirmDialog.vm.$emit("update:ok"); + await flushPromises(); + expect(readEditorDraft(scope("eval_job", "job-1"))).toBeNull(); + + list.vm.$emit("delete-bulk", ["job-2", "job-3"]); + await flushPromises(); + confirmDialog.vm.$emit("update:ok"); + await flushPromises(); + expect(readEditorDraft(scope("eval_job", "job-2"))).toBeNull(); + expect(readEditorDraft(scope("eval_job", "job-3"))).not.toBeNull(); + }); + + it("opening the draft of a deleted job drops it with an info toast", async () => { + writeEditorDraft(scope("eval_job", "job-gone"), { data: { values: {} } }); + await mountAt("tab=jobs&action=update&id=job-gone"); + + expect(readEditorDraft(scope("eval_job", "job-gone"))).toBeNull(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "info", + message: "This evaluation job no longer exists, so its unsaved draft was discarded.", + }), + ); + }); + + it("keeps the draft when the job list failed to load", async () => { + writeEditorDraft(scope("eval_job", "job-1"), { data: { values: {} } }); + vi.mocked(onlineEvalsService.jobs.list).mockRejectedValue(new Error("offline")); + await mountAt("tab=jobs&action=update&id=job-1"); + expect(readEditorDraft(scope("eval_job", "job-1"))).not.toBeNull(); + }); + + it("a job missing only from the cached list is re-read, kept and opened", async () => { + await mountAt("tab=jobs"); + vi.mocked(onlineEvalsService.jobs.list).mockResolvedValue([ + job, + { ...job, id: "job-new", name: "made in another tab" }, + ]); + writeEditorDraft(scope("eval_job", "job-new"), { + data: { values: { name: "made in another tab" } }, + }); + + await router.push("/ai/evaluations?tab=jobs&action=update&id=job-new"); + await flushPromises(); + + expect(readEditorDraft(scope("eval_job", "job-new"))).not.toBeNull(); + expect(toast).not.toHaveBeenCalledWith(expect.objectContaining({ variant: "info" })); + expect(byTest("job-form-title")).not.toBeNull(); + expect(byTest("editor-draft-restore-dialog")).not.toBeNull(); + }); + }); + + describe("scorers", () => { + it("keeps a new scorer's draft per type and dots New scorer", async () => { + await mountAt("tab=scorers&action=add&scorer_type=llm_judge"); + await fillField("scorer-form-name-input", "judge"); + window.dispatchEvent(new Event("pagehide")); + expect( + readEditorDraft(scope("eval_scorer", null, "llm_judge"))?.data.values.name, + ).toBe("judge"); + expect(readEditorDraft(scope("eval_scorer", null, "remote"))).toBeNull(); + + const navigation = router.push("/ai/evaluations?tab=scorers"); + await flushPromises(); + clickLeave("o-dialog-secondary-btn"); + await navigation; + await flushPromises(); + expect(byTest("scorers-list-add-draft-dot")).not.toBeNull(); + expect(byTest("scorers-list-add-btn")?.getAttribute("aria-label")).toBe( + "New scorer · unsaved draft", + ); + + await router.push("/ai/evaluations?tab=scorers&action=add&scorer_type=remote"); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + }); + + it("never stores a remote scorer's token or any custom header value, but keeps the API-key header name", async () => { + await mountAt("tab=scorers&action=add&scorer_type=remote"); + await edit("name", "remote judge"); + await edit("authType", "api_key"); + await edit("authApiKeyToken", "tok-123"); + await edit("authApiKeyHeaderName", "X-Judge-Key"); + await edit("customHeaders", [ + { key: "X-Api-Key", value: "hdr-secret" }, + { key: "X-Auth", value: "plain-cred" }, + ]); + window.dispatchEvent(new Event("pagehide")); + + const stored = JSON.stringify(localStorage); + expect(stored).not.toContain("tok-123"); + expect(stored).not.toContain("hdr-secret"); + const draft = readEditorDraft(scope("eval_scorer", null, "remote")); + expect(draft?.data.values.authApiKeyHeaderName).toBe("X-Judge-Key"); + expect(stored).not.toContain("plain-cred"); + expect(draft?.data.values.customHeaders.map((header) => header.key)).toEqual([ + "X-Api-Key", + "X-Auth", + ]); + }); + + it("a changed secret header comes back blank, is named, and blocks Save until retyped", async () => { + await mountAt("tab=scorers&action=add&scorer_type=remote"); + await edit("name", "remote judge"); + await edit("remoteEndpoint", "https://judge.example.com"); + await edit("customHeaders", [{ key: "X-Api-Key", value: "hdr-secret" }]); + window.dispatchEvent(new Event("pagehide")); + await remount("tab=scorers&action=add&scorer_type=remote"); + + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "X-Api-Key header", + ); + click("editor-draft-continue"); + await flushPromises(); + + await form().handleSubmit(); + await flushPromises(); + expect(onlineEvalsService.scorers.create).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "error", + message: "Re-enter the changes that were not kept in the draft before saving.", + }), + ); + + await edit("customHeaders", [{ key: "X-Api-Key", value: "hdr-new" }]); + await form().handleSubmit(); + await flushPromises(); + expect(onlineEvalsService.scorers.create).toHaveBeenCalledTimes(1); + }); + + it("never stores a remote endpoint that carries a token, names it, and blocks Save until retyped", async () => { + await mountAt("tab=scorers&action=add&scorer_type=remote"); + await edit("name", "remote judge"); + await edit("remoteEndpoint", "https://judge.example.com/score?token=s3cr3t"); + window.dispatchEvent(new Event("pagehide")); + expect(JSON.stringify(localStorage)).not.toContain("s3cr3t"); + await remount("tab=scorers&action=add&scorer_type=remote"); + + expect(byTest("editor-draft-credentials-not-saved")?.textContent).toContain( + "Your change to the URL was not kept.", + ); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values.remoteEndpoint).toBe(""); + + await edit("remoteEndpoint", "https://judge.example.com/score"); + await form().handleSubmit(); + await flushPromises(); + expect(onlineEvalsService.scorers.create).toHaveBeenCalledTimes(1); + }); + + it("switching a remote scorer's auth to none releases a token that was not kept", async () => { + await mountAt("tab=scorers&action=add&scorer_type=remote"); + await edit("name", "remote judge"); + await edit("remoteEndpoint", "https://judge.example.com"); + await edit("authType", "bearer"); + await edit("authBearerToken", "tok-123"); + window.dispatchEvent(new Event("pagehide")); + await remount("tab=scorers&action=add&scorer_type=remote"); + click("editor-draft-continue"); + await flushPromises(); + expect(form().state.values.authBearerToken).toBe(""); + + await edit("authType", ""); + await form().handleSubmit(); + await flushPromises(); + expect(onlineEvalsService.scorers.create).toHaveBeenCalledTimes(1); + }); + + it("an edited remote scorer's base hash leaves out its endpoint token and header values", async () => { + const remoteScorer = (endpoint: string, header: string) => + ({ + ...scorer, + scorerType: "remote", + template: "", + params: { + endpoint, + http_method: "POST", + custom_headers: [{ key: "X-Api-Key", value: header }], + }, + }) as Scorer; + const draftHashFor = async (row: Scorer) => { + localStorage.clear(); + queryClient.clear(); + vi.mocked(onlineEvalsService.scorers.list).mockResolvedValue([row]); + await remount("tab=scorers&action=update&id=s1"); + await edit("description", "sharper"); + window.dispatchEvent(new Event("pagehide")); + return readEditorDraft(scope("eval_scorer", "s1"))?.baseHash; + }; + + const first = await draftHashFor( + remoteScorer("https://judge.example.com/score?token=s3cr3t", "hdr-a"), + ); + const rotated = await draftHashFor( + remoteScorer("https://judge.example.com/score?token=r0tated", "hdr-b"), + ); + const renamed = await draftHashFor({ + ...remoteScorer("https://judge.example.com/score?token=s3cr3t", "hdr-a"), + name: "Renamed", + }); + + expect(first).toBeTruthy(); + expect(rotated).toBe(first); + expect(renamed).not.toBe(first); + }); + + it("start fresh drops a scorer's draft; escape keeps it", async () => { + writeEditorDraft(scope("eval_scorer", null, "llm_judge"), { + data: { values: { name: "old judge" } }, + }); + await mountAt("tab=scorers&action=add&scorer_type=llm_judge"); + pressEscape(); + await flushPromises(); + expect(byTest("editor-draft-restore-dialog")).toBeNull(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(scope("eval_scorer", null, "llm_judge"))).not.toBeNull(); + + await remount("tab=scorers&action=add&scorer_type=llm_judge"); + clickRestore("o-dialog-secondary-btn"); + await flushPromises(); + expect(form().state.values.name).toBe(""); + expect(readEditorDraft(scope("eval_scorer", null, "llm_judge"))).toBeNull(); + }); + + it("cancel asks, and discard closes the scorer form and drops the draft", async () => { + await mountAt("tab=scorers&action=update&id=s1"); + await edit("description", "gone"); + window.dispatchEvent(new Event("pagehide")); + expect(readEditorDraft(scope("eval_scorer", "s1"))).not.toBeNull(); + + click("scorer-form-cancel-btn"); + await flushPromises(); + expect(byTest("editor-draft-leave-dialog")).not.toBeNull(); + clickLeave("o-dialog-neutral-btn"); + await flushPromises(); + + expect(router.currentRoute.value.query.action).toBeUndefined(); + expect(byTest("scorer-form-title")).toBeNull(); + expect(byTest("editor-draft-leave-dialog")).toBeNull(); + expect(readEditorDraft(scope("eval_scorer", "s1"))).toBeNull(); + }); + + it("save from the leave dialog updates the scorer and goes on", async () => { + await mountAt("tab=scorers&action=update&id=s1"); + await edit("description", "sharper"); + + const navigation = router.push("/logs"); + await flushPromises(); + clickLeave("editor-draft-leave-save"); + await navigation; + await flushPromises(); + + expect(onlineEvalsService.scorers.update).toHaveBeenCalledWith( + "acme", + "s1", + expect.objectContaining({ description: "sharper" }), + ); + expect(router.currentRoute.value.name).toBe("logs"); + expect(readEditorDraft(scope("eval_scorer", "s1"))).toBeNull(); + }); + + it("dots a scorer's row, drops it on delete, and drops a deleted scorer's draft on open", async () => { + writeEditorDraft(scope("eval_scorer", "s1"), { data: { values: {} } }); + writeEditorDraft(scope("eval_scorer", "s-gone"), { + data: { values: {} }, + }); + vi.mocked(onlineEvalsService.scorers.delete).mockResolvedValue(undefined as never); + await mountAt("tab=scorers"); + // The table renders its rows a tick after the list arrives. + await vi.waitFor(() => expect(byTest("scorer-list-s1-draft-dot")).not.toBeNull()); + + wrapper!.findComponent({ name: "ScorerList" }).vm.$emit("delete", scorer); + await flushPromises(); + wrapper!.findComponent(ConfirmDialog).vm.$emit("update:ok"); + await flushPromises(); + expect(readEditorDraft(scope("eval_scorer", "s1"))).toBeNull(); + + await router.push("/ai/evaluations?tab=scorers&action=update&id=s-gone"); + await flushPromises(); + expect(readEditorDraft(scope("eval_scorer", "s-gone"))).toBeNull(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "info", + message: "This scorer no longer exists, so its unsaved draft was discarded.", + }), + ); + }); + }); +}); diff --git a/web/src/enterprise/components/OnlineEvals.vue b/web/src/enterprise/components/OnlineEvals.vue index 171a20eccb..ef0808d17a 100644 --- a/web/src/enterprise/components/OnlineEvals.vue +++ b/web/src/enterprise/components/OnlineEvals.vue @@ -23,6 +23,7 @@ the Free Software Foundation, either version 3 of the License, or :score-config-versions="scoreConfigVersions" :is-refreshing-providers="isRefreshingProviders" @saved="handleSaved" + @persisted="reloadLists()" @cancel="closeFormPage" @request-versions="(id) => ensureScoreConfigVersions(orgId, id)" @refresh-providers="refreshProviders" @@ -34,6 +35,7 @@ the Free Software Foundation, either version 3 of the License, or :row="jobFormRow" :scorers="scorers" @saved="handleSaved" + @persisted="reloadLists()" @cancel="closeFormPage" /> {{ addButtonLabel }} + @@ -410,6 +434,7 @@ import { showError } from "./onlineEvals/utils/evalFormat"; import { buildCrossNavigationQuery, computeViewState, + findRowById, parseTabFromRoute, rowIdOf as rowIdOfPure, type ActiveTab, @@ -430,6 +455,9 @@ import EvalJobDetail from "./onlineEvals/forms/EvalJobDetail.vue"; import ScorerFormPage from "./onlineEvals/forms/ScorerFormPage.vue"; import JobFormPage from "./onlineEvals/forms/JobFormPage.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue"; +import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue"; +import { useEditorDraftIds } from "@/composables/useEditorDraft"; +import { deleteEditorDraftsFor, type EditorDraftKind } from "@/utils/drafts/editorDraftStore"; import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue"; import ScoreConfigLibrary from "./onlineEvals/ScoreConfigLibrary.vue"; import ScorerLibrary from "./onlineEvals/ScorerLibrary.vue"; @@ -527,6 +555,22 @@ const scorerLibraryImporting = ref(false); const scorerLibraryRef = ref | null>(null); const importingEntity = ref<"scoreConfigs" | "scorers" | null>(null); +const jobDrafts = useEditorDraftIds("eval_job"); +const scorerDrafts = useEditorDraftIds("eval_scorer"); +const SCORER_TYPES: ScorerType[] = ["llm_judge", "remote"]; +// A new scorer's draft is kept per type, so the button shows either type's draft. +const hasNewScorerDraft = computed(() => + SCORER_TYPES.some((type) => scorerDrafts.hasNewVariant(type)), +); +const newScorerDraftSavedAt = computed(() => { + const times = SCORER_TYPES.flatMap((type) => scorerDrafts.savedAtVariant(type) ?? []); + return times.length ? Math.max(...times) : null; +}); +const DRAFT_KIND_BY_TAB: Partial> = { + jobs: "eval_job", + scorers: "eval_scorer", +}; + const { jobs, scorers, @@ -1170,6 +1214,7 @@ function syncFromRoute() { switch (state.kind) { case "none": + void dropDeletedItemDraft(); return; case "viewScoreConfig": viewRow.value = state.row; @@ -1212,6 +1257,39 @@ watch( () => syncFromRoute(), ); +function dropDrafts(tab: ActiveTab, ids: string[]) { + const kind = DRAFT_KIND_BY_TAB[tab]; + if (!kind) return; + for (const id of ids) deleteEditorDraftsFor({ kind, org: orgId.value, entityId: id }); +} + +/** Opening the edit form of an item that is gone drops its draft and says so, once a fresh read of its list confirms it. */ +async function dropDeletedItemDraft() { + const { action, id, tab: tabQuery } = route.query; + const tab = parseTabFromRoute(tabQuery); + const kind = DRAFT_KIND_BY_TAB[tab]; + if (action !== "update" || typeof id !== "string" || !kind) return; + const drafts = tab === "jobs" ? jobDrafts : scorerDrafts; + if (!drafts.ids.has(id)) return; + // The cached list may predate the item (made in another tab within its stale time). + await reloadLists(true); + if (route.query.action !== "update" || route.query.id !== id) return; + if (findRowById(tab, id, rowsByTab.value)) { + syncFromRoute(); + return; + } + const listKey = + tab === "jobs" ? onlineEvalKeys.jobs(orgId.value) : onlineEvalKeys.scorers(orgId.value); + if (queryClient.getQueryState(listKey)?.status !== "success") return; + deleteEditorDraftsFor({ kind, org: orgId.value, entityId: id }); + toast({ + variant: "info", + message: t("common.editorDraft.itemDeleted", { + entity: t(`common.editorDraft.entities.${kind}`), + }), + }); +} + function deleteRow(row: AnyRow) { pendingDeleteRow.value = row; pendingDeleteTab.value = activeTab.value; @@ -1249,6 +1327,7 @@ async function performDelete() { ? entityId(row as Scorer) : (row as EvalJob).id; await deleteEvalEntity.mutateAsync({ tab, id }); + dropDrafts(tab, [id]); toast({ variant: "success", @@ -1273,6 +1352,10 @@ async function performBulkJobsDelete() { const results = await Promise.allSettled( ids.map((id) => deleteEvalEntity.mutateAsync({ tab: "jobs", id })), ); + dropDrafts( + "jobs", + ids.filter((_, index) => results[index].status === "fulfilled"), + ); const failed = results.filter((r) => r.status === "rejected").length; if (failed > 0) { showError( diff --git a/web/src/enterprise/components/onlineEvals/EvalJobList.vue b/web/src/enterprise/components/onlineEvals/EvalJobList.vue index 7c1f149551..4e48a7f5e3 100644 --- a/web/src/enterprise/components/onlineEvals/EvalJobList.vue +++ b/web/src/enterprise/components/onlineEvals/EvalJobList.vue @@ -88,6 +88,18 @@ + + @@ -262,6 +274,9 @@ import { formatDate } from "@/utils/date"; import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue"; import EvalListShell from "./EvalListShell.vue"; import { useNumberedRows } from "./composables/useNumberedRows"; +import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue"; +import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue"; +import { useEditorDraftIds } from "@/composables/useEditorDraft"; const props = defineProps<{ rows: EvalJob[]; @@ -299,6 +314,7 @@ function canPause(status: EvalJobStatus): boolean { } const { t } = useI18nTyped(); +const jobDrafts = useEditorDraftIds("eval_job"); const statusFilter = ref(null); const selectedIds = ref([]); diff --git a/web/src/enterprise/components/onlineEvals/ScorerList.vue b/web/src/enterprise/components/onlineEvals/ScorerList.vue index 0d9b39333a..7e3ea5ea07 100644 --- a/web/src/enterprise/components/onlineEvals/ScorerList.vue +++ b/web/src/enterprise/components/onlineEvals/ScorerList.vue @@ -118,6 +118,18 @@ + + @@ -236,6 +248,9 @@ import { entityId, scorerTypeOf, valueOf } from "./utils/evalEntity"; import EvalListShell from "./EvalListShell.vue"; import OTag from "@/lib/core/Badge/OTag.vue"; import { useNumberedRows } from "./composables/useNumberedRows"; +import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue"; +import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue"; +import { useEditorDraftIds } from "@/composables/useEditorDraft"; const props = defineProps<{ rows: Scorer[]; @@ -268,6 +283,7 @@ const emit = defineEmits<{ }>(); const { t } = useI18nTyped(); +const scorerDrafts = useEditorDraftIds("eval_scorer"); const typeFilter = ref(null); const selectedIds = ref([]); diff --git a/web/src/enterprise/components/onlineEvals/forms/JobFormPage.vue b/web/src/enterprise/components/onlineEvals/forms/JobFormPage.vue index 515fe702be..a8b76dba08 100644 --- a/web/src/enterprise/components/onlineEvals/forms/JobFormPage.vue +++ b/web/src/enterprise/components/onlineEvals/forms/JobFormPage.vue @@ -7,7 +7,7 @@ {{ t("onlineEvals.buttons.cancel") }} @@ -318,11 +318,12 @@ +