From 07e159549d5fffe4ca595641061941d7ea2ee96a Mon Sep 17 00:00:00 2001 From: Sai Nikhil Date: Thu, 8 Oct 2026 17:51:57 +0000 Subject: [PATCH] fix(alerts): ask which query mode to save and fix stale edit data (#15090) Co-authored-by: Neha P Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 13 + tests/ui-testing/ci-matrix/ci_matrix.json | 3 +- .../ci-matrix/ci_matrix_regression.json | 4 +- .../alertsPages/alertSaveQueryModePage.js | 300 +++++++ tests/ui-testing/pages/page-manager.js | 2 + .../Alerts/alerts-save-query-mode.spec.js | 332 ++++++++ .../alerts-15091-alert-edit-flow.spec.js | 152 ++++ ...lerts-15096-query-mode-regressions.spec.js | 165 ++++ web/src/components/alerts/AddAlert.spec.ts | 796 +++++++++++++++++- web/src/components/alerts/AddAlert.vue | 105 +++ web/src/components/alerts/AlertList.spec.ts | 19 + web/src/components/alerts/AlertList.vue | 8 + web/src/composables/useAlertForm.ts | 92 +- web/src/locales/languages/ar-SA.json | 13 + web/src/locales/languages/de-DE.json | 13 + web/src/locales/languages/en-US.json | 13 + web/src/locales/languages/es-ES.json | 13 + web/src/locales/languages/fr-FR.json | 13 + web/src/locales/languages/it-IT.json | 13 + web/src/locales/languages/ja-JP.json | 13 + web/src/locales/languages/ko-KR.json | 13 + web/src/locales/languages/nl-NL.json | 13 + web/src/locales/languages/pl-PL.json | 13 + web/src/locales/languages/pt-PT.json | 13 + web/src/locales/languages/ru-RU.json | 13 + web/src/locales/languages/tr-TR.json | 13 + web/src/locales/languages/vi-VN.json | 13 + web/src/locales/languages/zh-CN.json | 13 + web/src/locales/languages/zh-TW.json | 13 + web/src/utils/alerts/alertCondition.spec.ts | 109 +++ web/src/utils/alerts/alertCondition.ts | 45 +- web/src/utils/alerts/alertPayload.spec.ts | 33 +- web/src/utils/alerts/alertPayload.ts | 7 +- .../alerts/alertSummaryGenerator.spec.ts | 31 + web/src/utils/alerts/alertSummaryGenerator.ts | 3 +- 35 files changed, 2405 insertions(+), 22 deletions(-) create mode 100644 tests/ui-testing/pages/alertsPages/alertSaveQueryModePage.js create mode 100644 tests/ui-testing/playwright-tests/Alerts/alerts-save-query-mode.spec.js create mode 100644 tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15091-alert-edit-flow.spec.js create mode 100644 tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15096-query-mode-regressions.spec.js diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index ae8f133e86..8766e1708e 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -4054,6 +4054,19 @@ "save": "1509f561f2416598", "saveAndTrain": "2bf7d54ce123febc", "saveFailed": "1f66c6e1e593787a", + "saveModeDialog": { + "leadAllSetUp": "0bdbfbb80e187241", + "leadBothSetUp": "267350111c561540", + "leadSelectedEmptyOneOther": "2b2d30f6e075336b", + "leadSelectedEmptyTwoOthers": "0a3345fcc01c8a71", + "noFilters": "96c8b3fa4cf3587a", + "noQuery": "97d5475c65b35722", + "noteOneOtherUnused": "faad628cce64f090", + "noteOnlyPick": "506a81c55e1142d1", + "noteTwoOthersUnused": "6813e4a974855ad8", + "saveWithMode": "fd2731b3f634b600", + "title": "c468c48773dae39c" + }, "scheduled": "4724f344c1c0e4a1", "searcHistory": "8958c07d9eb556e0", "search": "0f5d99b5057b55e3", diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index 9e2f815dc5..1dfe37a700 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -27,7 +27,8 @@ "alertsPagination.spec.js", "alertTemplatesPagination.spec.js", "alertDestinationsPagination.spec.js", - "alerts-destinations-usage.spec.js" + "alerts-destinations-usage.spec.js", + "alerts-save-query-mode.spec.js" ], "disabled": [ { diff --git a/tests/ui-testing/ci-matrix/ci_matrix_regression.json b/tests/ui-testing/ci-matrix/ci_matrix_regression.json index 9625b69171..db8882ad36 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix_regression.json +++ b/tests/ui-testing/ci-matrix/ci_matrix_regression.json @@ -45,7 +45,9 @@ "alerts-from-dashboard-panel.spec.js", "alerts-query-editor-search-endpoint.spec.js", "alerts-13243-prebuilt-form-reuse.spec.js", - "alerts-14796-destination-usage.spec.js" + "alerts-14796-destination-usage.spec.js", + "alerts-15091-alert-edit-flow.spec.js", + "alerts-15096-query-mode-regressions.spec.js" ], "disabled": [] }, diff --git a/tests/ui-testing/pages/alertsPages/alertSaveQueryModePage.js b/tests/ui-testing/pages/alertsPages/alertSaveQueryModePage.js new file mode 100644 index 0000000000..280b9cdcee --- /dev/null +++ b/tests/ui-testing/pages/alertsPages/alertSaveQueryModePage.js @@ -0,0 +1,300 @@ +// Copyright 2026 OpenObserve Inc. + +import { expect } from '@playwright/test'; +const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); + +/** + * AlertSaveQueryModePage — the "Choose Query Mode" dialog Save opens when more + * than one query mode is in play, plus the bits of the alert editor those tests + * drive (the mode toggle, the SQL editor, the stream/destination selects). + * + * Owns the alert-save-mode-* selectors so specs stay selector-free. + */ +export class AlertSaveQueryModePage { + constructor(page) { + this.page = page; + this.locators = { + // ── the dialog ────────────────────────────────────────────────────── + dialog: '[data-test="alert-save-mode-dialog"]', + lead: '[data-test="alert-save-mode-lead"]', + warning: '[data-test="alert-save-mode-warning"]', + note: '[data-test="alert-save-mode-note"]', + primaryBtn: '[data-test="alert-save-mode-dialog"] [data-test="o-dialog-primary-btn"]', + secondaryBtn: '[data-test="alert-save-mode-dialog"] [data-test="o-dialog-secondary-btn"]', + + // ── the editor ────────────────────────────────────────────────────── + submit: '[data-test="add-alert-submit-btn"]', + nameTrigger: '[data-test="add-alert-name-input-trigger"]', + nameInput: '[data-test="add-alert-name-input-input"]', + streamTrigger: '[data-test="add-alert-stream-name-select-dropdown-trigger"]', + destinationTrigger: '[data-test="alert-destinations-select-trigger"]', + selectedOption: '[role="option"][aria-selected="true"]', + addCondition: '[data-test="alert-conditions-add-condition-btn"]', + conditionColumn: '[data-test="alert-conditions-select-column-trigger"]', + conditionValue: '[data-test="alert-conditions-value-input-field"]', + threshold: '[data-test="alert-trigger-threshold-input-field"]', + realtimeTab: '[data-test="add-alert-type-tab-true"]', + scheduledTab: '[data-test="add-alert-type-tab-false"]', + alertRulesTab: '[data-test="add-alert-tab-condition"]', + advancedTab: '[data-test="add-alert-tab-advanced"]', + streamTypeTrigger: '[data-test="add-alert-stream-type-select-dropdown-trigger"]', + // Compare-with-Past lives on the Advanced tab, not Alert Rules. + multiWindowAdd: '[data-test="multi-time-range-alerts-add-btn"]', + multiWindowDelete: '[data-test="multi-time-range-alerts-delete-btn"]', + modeToggle: '[data-test="step2-query-tabs"]', + // Two nodes carry this: the visible query editor and the hidden anomaly preview. + queryEditor: '[data-test="query-editor"]:visible', + detailCondition: '[data-test="alerts-alertconfigsummary-condition"]', + }; + } + + modeOption(mode) { + return `[data-test="alert-save-mode-option-${mode}"]`; + } + + modeTab(mode) { + return `[data-test="query-mode-${mode}"]`; + } + + // ── navigation ───────────────────────────────────────────────────────── + + async openEditor(alertId, { folder = 'default' } = {}) { + await this.page.goto(`/web/alerts?org_identifier=${getOrgIdentifier()}&action=update&alert_id=${alertId}&folder=${folder}`); + await expect(this.page.locator(this.locators.submit)).toBeVisible({ timeout: 30000 }); + // The form shell renders before the alert is fetched, and a Save fired in + // that window reads an unpopulated form. The stream select is empty until + // the alert lands, so it doubles as the "data is in" signal. + await expect(this.page.locator(this.locators.streamTrigger)) + .not.toHaveAttribute('data-test-selected-value', '', { timeout: 30000 }); + testLogger.info('Opened alert editor', { alertId }); + } + + async openNewAlert({ folder = 'default' } = {}) { + await this.page.goto(`/web/alerts?org_identifier=${getOrgIdentifier()}&action=add&folder=${folder}`); + await expect(this.page.locator(this.locators.submit)).toBeVisible({ timeout: 30000 }); + } + + async openDetailConfiguration(alertId, { folder = 'default' } = {}) { + await this.page.goto(`/web/alerts/detail/${alertId}?org_identifier=${getOrgIdentifier()}&folder=${folder}`); + await this.page.getByRole('tab', { name: 'Configuration' }).click(); + } + + // ── form input ───────────────────────────────────────────────────────── + + /** Reka's listbox renders into a portal, so options are addressed by role. */ + async #pickFromDropdown(trigger, optionName) { + await this.page.locator(trigger).click(); + const choice = this.page.getByRole('option', { name: optionName, exact: true }); + await choice.waitFor({ state: 'visible', timeout: 15000 }); + await choice.click(); + } + + async selectStream(streamName) { + await this.#pickFromDropdown(this.locators.streamTrigger, streamName); + } + + /** Multi-select: the popover stays open after a pick, so it needs dismissing. */ + async selectDestination(destinationName) { + await this.#pickFromDropdown(this.locators.destinationTrigger, destinationName); + await this.page.keyboard.press('Escape'); + } + + /** Clears the destination by re-picking the already-selected option. */ + async clearDestination() { + await this.page.locator(this.locators.destinationTrigger).click(); + await this.page.locator(this.locators.selectedOption).first().click(); + await this.page.keyboard.press('Escape'); + } + + async addBuilderCondition(column, value) { + await this.page.locator(this.locators.addCondition).click(); + await this.#pickFromDropdown(this.locators.conditionColumn, column); + await this.page.locator(this.locators.conditionValue).fill(value); + } + + /** The name field is an inline edit: open it, then type. Enter submits the form. */ + async setName(name, { submit = false } = {}) { + await this.page.locator(this.locators.nameTrigger).click(); + await this.page.locator(this.locators.nameInput).fill(name); + if (submit) await this.page.locator(this.locators.nameInput).press('Enter'); + } + + async setThreshold(value) { + await this.page.locator(this.locators.threshold).fill(value); + } + + async selectQueryMode(mode) { + await this.page.locator(this.modeTab(mode)).click(); + } + + async selectRealtime() { + await this.page.locator(this.locators.realtimeTab).click(); + } + + async selectScheduled() { + await this.page.locator(this.locators.scheduledTab).click(); + } + + async selectStreamType(streamType) { + await this.#pickFromDropdown(this.locators.streamTypeTrigger, streamType); + } + + async openAdvancedTab() { + await this.page.locator(this.locators.advancedTab).click(); + await expect(this.page.locator(this.locators.multiWindowAdd)).toBeVisible({ timeout: 20000 }); + } + + /** Compare-with-Past reference window. Requires the Advanced tab to be open. */ + async addCompareWithPastWindow() { + const before = await this.page.locator(this.locators.multiWindowDelete).count(); + await this.page.locator(this.locators.multiWindowAdd).click(); + await expect(this.page.locator(this.locators.multiWindowDelete)).toHaveCount(before + 1, { + timeout: 20000, + }); + } + + async expectCompareWithPastWindowCount(count) { + await expect(this.page.locator(this.locators.multiWindowDelete)).toHaveCount(count, { + timeout: 20000, + }); + } + + async clickSave() { + await this.page.locator(this.locators.submit).click(); + } + + /** + * Monaco auto-closes quotes, so typing a query key by key turns `FROM "x"` + * into `FROM "x""` and the parser gives up — which silently kills the stream + * sync some tests assert on. Filling the backing textarea sets the model in + * one go and skips the auto-close entirely. A fill that lands before Monaco + * has wired up its model is dropped without error, so the write is retried + * until the new FROM table is on screen. + */ + async setSql(sql) { + const editor = this.page.locator(this.locators.queryEditor).first(); + await editor.waitFor({ state: 'visible', timeout: 20000 }); + const input = editor.locator('textarea.inputarea'); + await input.waitFor({ state: 'attached', timeout: 20000 }); + const table = (sql.match(/FROM\s+"([^"]+)"/i) || [])[1]; + await expect(async () => { + await editor.locator('.view-lines').click(); + await input.fill(sql); + await expect(editor).toContainText(table, { timeout: 5000 }); + }).toPass({ timeout: 40000 }); + // Click away afterwards: the stream-name sync runs off the committed value, + // which is why #15091/#15092 both say "click outside the editor and wait". + await this.page.locator(this.locators.alertRulesTab).click(); + } + + // ── dialog actions ───────────────────────────────────────────────────── + + async pickSaveMode(mode) { + await this.page.locator(this.modeOption(mode)).click(); + await expect(this.page.locator(this.modeOption(mode))).toHaveAttribute('data-state', 'on'); + } + + /** + * The dialog closes before the request is sent — `saveWithPickedMode` clears + * the open flag, then awaits a tick and submits — so a dialog-hidden wait + * returns while the PUT is still in flight and a caller reading the alert + * back sees the pre-save copy. The editor closing is the signal that the save + * actually round-tripped. + */ + async confirmSaveMode() { + await this.page.locator(this.locators.primaryBtn).click(); + await expect(this.page.locator(this.locators.dialog)).toBeHidden({ timeout: 30000 }); + await expect(this.page.locator(this.locators.submit)).toBeHidden({ timeout: 30000 }); + } + + async cancelSaveMode() { + await this.page.locator(this.locators.secondaryBtn).click(); + await expect(this.page.locator(this.locators.dialog)).toBeHidden(); + } + + // ── assertions ───────────────────────────────────────────────────────── + + async expectDialogOpen() { + await expect(this.page.locator(this.locators.dialog)).toBeVisible({ timeout: 15000 }); + } + + async expectDialogClosed() { + await expect(this.page.locator(this.locators.dialog)).toBeHidden(); + } + + async expectLead(text) { + await expect(this.page.locator(this.locators.lead)).toHaveText(text); + } + + async expectWarning(text) { + await expect(this.page.locator(this.locators.warning)).toHaveText(text); + } + + async expectNoWarning() { + await expect(this.page.locator(this.locators.warning)).toHaveCount(0); + } + + async expectNote(text) { + await expect(this.page.locator(this.locators.note)).toHaveText(text); + } + + async expectPrimaryButtonLabel(text) { + await expect(this.page.locator(this.locators.primaryBtn)).toHaveText(text); + } + + async expectSaveModeSelected(mode) { + await expect(this.page.locator(this.modeOption(mode))).toHaveAttribute('data-state', 'on'); + } + + async expectQueryModeSelected(mode) { + await expect(this.page.locator(this.modeTab(mode))).toHaveAttribute('data-state', 'on'); + } + + async expectModeToggleHidden() { + await expect(this.page.locator(this.locators.modeToggle)).toBeHidden(); + } + + async expectOnEditor() { + await expect(this.page.locator(this.locators.submit)).toBeVisible(); + } + + /** + * The dialog is the only thing between Save and the request, so "no dialog" + * is asserted by the save completing: the editor closes back to the list. + */ + async expectSavedWithoutDialog() { + await this.expectDialogClosed(); + await expect(this.page.locator(this.locators.submit)).toBeHidden({ timeout: 30000 }); + } + + async expectStreamName(streamName) { + await expect(this.page.locator(this.locators.streamTrigger)).toHaveText(streamName, { timeout: 20000 }); + } + + async expectThreshold(value) { + // The field repaints from the fetched alert, so allow for the round trip. + await expect(this.page.locator(this.locators.threshold)).toHaveValue(value, { timeout: 20000 }); + } + + async expectSqlContains(text) { + await expect(this.page.locator(this.locators.queryEditor).first()) + .toContainText(text, { timeout: 20000 }); + } + + async expectValidationToast() { + await expect( + this.page.getByText('Please fix the highlighted fields before saving.').first(), + ).toBeVisible({ timeout: 15000 }); + } + + async expectDetailConditionEmpty() { + const condition = this.page.locator(this.locators.detailCondition); + await expect(condition).toContainText('—'); + await expect(condition).not.toContainText('SELECT'); + } + + async expectDetailConditionShowsSql() { + await expect(this.page.locator(this.locators.detailCondition)).toContainText('SELECT'); + } +} diff --git a/tests/ui-testing/pages/page-manager.js b/tests/ui-testing/pages/page-manager.js index b8d5d79ded..8d4a69d774 100644 --- a/tests/ui-testing/pages/page-manager.js +++ b/tests/ui-testing/pages/page-manager.js @@ -32,6 +32,7 @@ import { AlertsPage } from "./alertsPages/alertsPage.js"; import { AlertLibraryPage } from "./alertsPages/alertLibraryPage.js"; import { AlertHistoryPage } from "./alertsPages/alertHistoryPage.js"; import { AlertDetailPage } from "./alertsPages/alertDetailPage.js"; +import { AlertSaveQueryModePage } from "./alertsPages/alertSaveQueryModePage.js"; import { CompositeAlertsPage } from "./alertsPages/compositeAlertsPage.js"; import { IncidentsPage } from "./alertsPages/incidentsPage.js"; import { SloListPage } from "./sloPages/sloListPage.js"; @@ -184,6 +185,7 @@ class PageManager { this.alertLibraryPage = new AlertLibraryPage(page); this.alertHistoryPage = new AlertHistoryPage(page); this.alertDetailPage = new AlertDetailPage(page); + this.alertSaveQueryModePage = new AlertSaveQueryModePage(page); this.compositeAlertsPage = new CompositeAlertsPage(page); this.incidentsPage = new IncidentsPage(page); diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-save-query-mode.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-save-query-mode.spec.js new file mode 100644 index 0000000000..d3cbf0b70d --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-save-query-mode.spec.js @@ -0,0 +1,332 @@ +// Copyright 2026 OpenObserve Inc. + +/** + * Alerts — the Choose Query Mode dialog + * + * Only the SELECTED query mode runs, but every mode's text is stored on the + * alert. Before #15090 an empty Builder saved over a stored SQL query matched + * every row in the stream while the SQL never ran (#15037). Save now asks which + * mode the alert uses whenever more than one is in play — "in play" meaning the + * selected mode plus every mode that holds content. + * + * The rule that decides whether the dialog opens is the easy thing to get wrong: + * `confirmedSaveMode` is seeded from the alert's SAVED type, and `askForSaveMode` + * bails when the selected mode both matches it and holds content. So an edit that + * does not change the mode never asks, even when another mode holds content. One + * test below pins that negative on purpose — a change that makes an unchanged + * edit start prompting would be a regression, not a fix. + * + * Content is judged by `modesWithContent`: a complete Builder condition counts, + * and SQL counts unless it is exactly the `SELECT * FROM ""` starter the + * form writes into an empty SQL tab. + * + * The edit-flow fixes that shipped alongside the dialog (#15091, #15092, #15093) + * live in RegressionSet/Alerts/alerts-15091-alert-edit-flow.spec.js. + */ + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const { + STREAM, DEST, uniq, + simpleAlert, createAlert, findAlertId, getAlert, + deleteAlertInFolder, seedAlertFixturesOnce, +} = require('../utils/alerts-api-helpers.js'); + +// A real query, not the `SELECT * FROM ""` starter, so it counts as content. +const STORED_SQL = `SELECT histogram(_timestamp) as ts, count(*) as cnt FROM "${STREAM}" WHERE status='CRITICAL' GROUP BY ts`; + +const builderCondition = () => ({ + version: 2, + conditions: { + filterType: 'group', + logicalOperator: 'AND', + conditions: [ + { filterType: 'condition', column: 'city', operator: '=', value: 'paris', logicalOperator: 'AND' }, + ], + }, +}); + +/** Builder is selected but empty, while a real SQL query sits in the other tab — the #15037 shape. */ +function builderEmptyWithSql(name) { + const a = simpleAlert(name); + a.query_condition.type = 'custom'; + a.query_condition.sql = STORED_SQL; + return a; +} + +/** SQL is selected and a Builder condition is also set up — both modes hold content. */ +function sqlWithBuilderCondition(name) { + const a = simpleAlert(name); + a.query_condition.type = 'sql'; + a.query_condition.sql = STORED_SQL; + a.query_condition.conditions = builderCondition(); + return a; +} + +test.describe('Alerts — Choose Query Mode on save', { + tag: ['@alerts', '@alertsSaveQueryMode'], +}, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + let created = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + pm = new PageManager(page); + created = []; + await seedAlertFixturesOnce(page); + await navigateToBase(page); + }); + + test.afterEach(async ({ page }) => { + for (const id of [...created].reverse()) { + await deleteAlertInFolder(page, id, 'default'); + } + }); + + // Every fixture name carries the `auto_` prefix cleanup.spec.js sweeps, so an + // interrupted run leaves nothing behind for the next one to trip over. + async function seed(page, payload) { + const response = await createAlert(page, payload); + expect(response.status(), await response.text()).toBe(200); + const id = await findAlertId(page, payload.name); + expect(id, `seeded alert ${payload.name} must be listable`).toBeTruthy(); + created.push(id); + return id; + } + + async function trackCreated(page, name) { + const id = await findAlertId(page, name); + expect(id, `alert ${name} must exist after save`).toBeTruthy(); + created.push(id); + return id; + } + + test('an empty Builder over stored SQL asks, and the SQL pick saves type sql', async ({ page }) => { + const name = uniq('auto_qm_sqlpick'); + const id = await seed(page, builderEmptyWithSql(name)); + + await pm.alertSaveQueryModePage.openEditor(id); + // The editor must open on the mode the alert was saved with. + await pm.alertSaveQueryModePage.expectQueryModeSelected('custom'); + + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + + // Builder is selected but holds nothing, so it is named first and warned about. + await pm.alertSaveQueryModePage.expectLead('Builder is selected but empty. SQL is set up.'); + await pm.alertSaveQueryModePage.expectSaveModeSelected('custom'); + await pm.alertSaveQueryModePage.expectWarning(`No filters, so every row in ${STREAM} counts`); + await pm.alertSaveQueryModePage.expectNote('Only Builder mode runs. SQL mode is not used.'); + + await pm.alertSaveQueryModePage.pickSaveMode('sql'); + // Switching the pick re-words the consequence and drops the empty-Builder warning. + await pm.alertSaveQueryModePage.expectNote('Only SQL mode runs.'); + await pm.alertSaveQueryModePage.expectNoWarning(); + await pm.alertSaveQueryModePage.expectPrimaryButtonLabel('Save with SQL Mode'); + + await pm.alertSaveQueryModePage.confirmSaveMode(); + + const saved = await getAlert(page, id); + expect(saved.query_condition.type).toBe('sql'); + expect(saved.query_condition.sql).toBe(STORED_SQL); + // An edit never retargets the stream. + expect(saved.stream_name).toBe(STREAM); + }); + + test('the Builder pick saves type custom and leaves the stored SQL in place', async ({ page }) => { + const name = uniq('auto_qm_builderpick'); + const id = await seed(page, builderEmptyWithSql(name)); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + + await pm.alertSaveQueryModePage.expectPrimaryButtonLabel('Save with Builder Mode'); + await pm.alertSaveQueryModePage.confirmSaveMode(); + + const saved = await getAlert(page, id); + expect(saved.query_condition.type).toBe('custom'); + // Builder mode keeps the other tab's text rather than destroying it. + expect(saved.query_condition.sql).toBe(STORED_SQL); + }); + + test('Cancel saves nothing and asks again on the next Save', async ({ page }) => { + const name = uniq('auto_qm_cancel'); + const id = await seed(page, builderEmptyWithSql(name)); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + + await pm.alertSaveQueryModePage.cancelSaveMode(); + // Cancel abandons the save outright — still on the editor, mode untouched. + await pm.alertSaveQueryModePage.expectOnEditor(); + await pm.alertSaveQueryModePage.expectQueryModeSelected('custom'); + + const afterCancel = await getAlert(page, id); + expect(afterCancel.query_condition.type).toBe('custom'); + + // Cancel records no answer, so the next Save has to ask again. + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + }); + + test('an edit that does not change the mode never asks, even with both modes set up', async ({ page }) => { + const name = uniq('auto_qm_unchanged'); + const id = await seed(page, sqlWithBuilderCondition(name)); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.expectQueryModeSelected('sql'); + + // SQL is selected, matches the saved type and holds content: the user already + // answered this question when they saved it, so Save must go straight through. + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + + const saved = await getAlert(page, id); + expect(saved.query_condition.type).toBe('sql'); + }); + + test('switching the mode on that same alert asks, and names both modes', async ({ page }) => { + const name = uniq('auto_qm_switch'); + const id = await seed(page, sqlWithBuilderCondition(name)); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.selectQueryMode('custom'); + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + + // Both hold content, so neither is called empty and there is no warning. + await pm.alertSaveQueryModePage.expectLead('Builder and SQL are both set up.'); + await pm.alertSaveQueryModePage.expectNoWarning(); + await pm.alertSaveQueryModePage.expectNote('Only Builder mode runs. SQL mode is not used.'); + // The toggle starts on the selected mode and never moves on its own. + await pm.alertSaveQueryModePage.expectSaveModeSelected('custom'); + + await pm.alertSaveQueryModePage.confirmSaveMode(); + expect((await getAlert(page, id)).query_condition.type).toBe('custom'); + }); + + test('Enter in the alert name opens the dialog on a new alert', async ({ page }) => { + const name = uniq('auto_qm_enter'); + + await pm.alertSaveQueryModePage.openNewAlert(); + await pm.alertSaveQueryModePage.selectStream(STREAM); + await pm.alertSaveQueryModePage.addBuilderCondition('city', 'paris'); + await pm.alertSaveQueryModePage.selectDestination(DEST); + // Opening the SQL tab after the stream is already chosen leaves it empty. + await pm.alertSaveQueryModePage.selectQueryMode('sql'); + + // Enter goes through OFormInlineEdit's requestSubmit(), which bypasses the + // Save button's handler entirely — it has to reach the same gate. + await pm.alertSaveQueryModePage.setName(name, { submit: true }); + + await pm.alertSaveQueryModePage.expectDialogOpen(); + await pm.alertSaveQueryModePage.expectLead('SQL is selected but empty. Builder is set up.'); + await pm.alertSaveQueryModePage.expectWarning('No query written yet'); + await pm.alertSaveQueryModePage.expectNote('Only SQL mode runs. Builder mode is not used.'); + + // Nothing is sent while the question is open. + expect(await findAlertId(page, name)).toBeFalsy(); + }); + + test('field errors come before the dialog', async ({ page }) => { + const name = uniq('auto_qm_fielderr'); + const id = await seed(page, builderEmptyWithSql(name)); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.clearDestination(); + + await pm.alertSaveQueryModePage.clickSave(); + // The form's own validation wins: no point asking which query to run when + // the alert cannot be saved at all. + await pm.alertSaveQueryModePage.expectDialogClosed(); + await pm.alertSaveQueryModePage.expectValidationToast(); + + await pm.alertSaveQueryModePage.selectDestination(DEST); + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + }); + + test('a Builder-only new alert saves without asking', async ({ page }) => { + const name = uniq('auto_qm_builderonly'); + + await pm.alertSaveQueryModePage.openNewAlert(); + await pm.alertSaveQueryModePage.selectStream(STREAM); + await pm.alertSaveQueryModePage.addBuilderCondition('city', 'paris'); + await pm.alertSaveQueryModePage.selectDestination(DEST); + // The SQL tab is never opened, so only one mode is ever in play. + await pm.alertSaveQueryModePage.setName(name, { submit: true }); + + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + + const id = await trackCreated(page, name); + expect((await getAlert(page, id)).query_condition.type).toBe('custom'); + }); + + test('the auto-filled starter SQL does not count as a second mode', async ({ page }) => { + const name = uniq('auto_qm_starter'); + + await pm.alertSaveQueryModePage.openNewAlert(); + // Opening the SQL tab BEFORE the stream is what makes the form auto-write + // `SELECT * FROM ""`. The other order leaves the tab empty. + await pm.alertSaveQueryModePage.selectQueryMode('sql'); + await pm.alertSaveQueryModePage.selectStream(STREAM); + await pm.alertSaveQueryModePage.expectSqlContains(`SELECT * FROM "${STREAM}"`); + + await pm.alertSaveQueryModePage.selectQueryMode('custom'); + await pm.alertSaveQueryModePage.addBuilderCondition('city', 'paris'); + await pm.alertSaveQueryModePage.selectDestination(DEST); + await pm.alertSaveQueryModePage.setName(name, { submit: true }); + + // The starter is boilerplate the form wrote, not a query the user chose. + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + + const id = await trackCreated(page, name); + expect((await getAlert(page, id)).query_condition.type).toBe('custom'); + }); + + test('a Realtime alert never asks and saves as custom', async ({ page }) => { + const name = uniq('auto_qm_realtime'); + + await pm.alertSaveQueryModePage.openNewAlert(); + await pm.alertSaveQueryModePage.selectStream(STREAM); + await pm.alertSaveQueryModePage.selectQueryMode('sql'); + await pm.alertSaveQueryModePage.setSql(STORED_SQL); + + // Realtime runs Builder only, so the mode toggle goes away and the question + // stops applying — even though the SQL text is still carried in the form. + await pm.alertSaveQueryModePage.selectRealtime(); + await pm.alertSaveQueryModePage.expectModeToggleHidden(); + + await pm.alertSaveQueryModePage.addBuilderCondition('city', 'paris'); + await pm.alertSaveQueryModePage.selectDestination(DEST); + await pm.alertSaveQueryModePage.setName(name, { submit: true }); + + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + + const id = await trackCreated(page, name); + const saved = await getAlert(page, id); + expect(saved.is_real_time).toBe(true); + expect(saved.query_condition.type).toBe('custom'); + // Compare-with-Past only runs with SQL, so it is never sent for another type. + expect(saved.query_condition.multi_time_range).toEqual([]); + }); + + test('the detail page shows no condition for a Builder alert that still stores SQL', async ({ page }) => { + const builderId = await seed(page, builderEmptyWithSql(uniq('auto_qm_detbuilder'))); + const sqlId = await seed(page, sqlWithBuilderCondition(uniq('auto_qm_detsql'))); + + // The stored SQL never runs on a Builder alert, so showing it here would + // claim the alert watches something it does not. + await pm.alertSaveQueryModePage.openDetailConfiguration(builderId); + await pm.alertSaveQueryModePage.expectDetailConditionEmpty(); + + // A SQL alert still shows the query it actually evaluates. + await pm.alertSaveQueryModePage.openDetailConfiguration(sqlId); + await pm.alertSaveQueryModePage.expectDetailConditionShowsSql(); + }); +}); diff --git a/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15091-alert-edit-flow.spec.js b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15091-alert-edit-flow.spec.js new file mode 100644 index 0000000000..6212836293 --- /dev/null +++ b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15091-alert-edit-flow.spec.js @@ -0,0 +1,152 @@ +// Copyright 2026 OpenObserve Inc. + +/** + * Alerts — edit-flow regressions fixed in #15090 + * + * Three separate bugs, all in how the alert editor reconciles what the user is + * looking at with what is actually stored: + * + * #15091 — In Edit Alert the Stream Name field is locked, but a change to the + * SQL `FROM` table still moved it. The form then sent the new stream, + * the backend ignored it and kept the old one, and the saved alert + * showed one stream while querying another. + * #15092 — In Add Alert the stream field follows the query's `FROM` table. A + * table that does not exist made the lookup fail with the in-flight + * flag still set, so every later `FROM` change was skipped until the + * page reloaded. + * #15093 — The editor reads the alert from a detail cache that stays fresh for + * 60 seconds, and a save from the list did not invalidate it. Reopening + * within the minute showed the pre-save copy, and saving again wrote + * those stale values back over the edit. + * + * The Choose Query Mode dialog that shipped in the same PR is covered by + * Alerts/alerts-save-query-mode.spec.js. + */ + +const { test, expect, navigateToBase } = require('../../utils/enhanced-baseFixtures.js'); +const testLogger = require('../../utils/test-logger.js'); +const PageManager = require('../../../pages/page-manager.js'); +const { + STREAM, uniq, + simpleAlert, createAlert, findAlertId, getAlert, + deleteAlertInFolder, seedAlertFixturesOnce, +} = require('../../utils/alerts-api-helpers.js'); + +// A second real stream to retarget the FROM table at. Ingested by global setup +// on every run, so it exists on any server the suite is pointed at. +const OTHER_STREAM = 'e2e_automate'; +const MISSING_STREAM = 'auto_qm_stream_that_does_not_exist'; + +const STORED_SQL = `SELECT histogram(_timestamp) as ts, count(*) as cnt FROM "${STREAM}" WHERE status='CRITICAL' GROUP BY ts`; + +/** SQL mode with a Builder condition also present, so a mode-preserving save never prompts. */ +function sqlAlert(name) { + const a = simpleAlert(name); + a.query_condition.type = 'sql'; + a.query_condition.sql = STORED_SQL; + a.query_condition.conditions = { + version: 2, + conditions: { + filterType: 'group', + logicalOperator: 'AND', + conditions: [ + { filterType: 'condition', column: 'city', operator: '=', value: 'paris', logicalOperator: 'AND' }, + ], + }, + }; + return a; +} + +test.describe('Alerts — edit-flow regressions (#15091, #15092, #15093)', { + tag: ['@alerts', '@alertsEditFlowRegression', '@regression'], +}, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + let created = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + pm = new PageManager(page); + created = []; + await seedAlertFixturesOnce(page); + await navigateToBase(page); + }); + + test.afterEach(async ({ page }) => { + for (const id of [...created].reverse()) { + await deleteAlertInFolder(page, id, 'default'); + } + }); + + // Every fixture name carries the `auto_` prefix cleanup.spec.js sweeps. + async function seed(page, payload) { + const response = await createAlert(page, payload); + expect(response.status(), await response.text()).toBe(200); + const id = await findAlertId(page, payload.name); + expect(id, `seeded alert ${payload.name} must be listable`).toBeTruthy(); + created.push(id); + return id; + } + + test('#15091 editing the SQL FROM table leaves the locked stream alone', async ({ page }) => { + const id = await seed(page, sqlAlert(uniq('auto_qm_15091'))); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.expectStreamName(STREAM); + + // On a new alert the stream field follows the query's FROM table. On an edit + // the field is locked and the backend ignores a stream change, so following + // it would only desynchronise what the user sees from what is stored. + // + // Column list, not `SELECT *`: the form rejects star queries outright, and + // that gate would block the save before the assertions below mean anything. + await pm.alertSaveQueryModePage.setSql( + `SELECT histogram(_timestamp) as ts, count(*) as cnt FROM "${OTHER_STREAM}" GROUP BY ts`, + ); + await page.waitForTimeout(2000); // past the 1s sync debounce + + await pm.alertSaveQueryModePage.expectStreamName(STREAM); + + await pm.alertSaveQueryModePage.clickSave(); + // Selected mode is unchanged and holds content, so this saves without asking. + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + expect((await getAlert(page, id)).stream_name).toBe(STREAM); + }); + + test('#15092 the stream name keeps following the query after a stream that does not exist', async ({ page }) => { + await pm.alertSaveQueryModePage.openNewAlert(); + await pm.alertSaveQueryModePage.selectStream(STREAM); + await pm.alertSaveQueryModePage.selectQueryMode('sql'); + + // The lookup for a missing stream fails. Before the fix the in-flight flag + // stayed set, so every later FROM change was skipped for the rest of the page. + await pm.alertSaveQueryModePage.setSql(`SELECT count(*) FROM "${MISSING_STREAM}"`); + await pm.alertSaveQueryModePage.expectStreamName(MISSING_STREAM); + + // The field is set before the stream lookup is awaited, so the name above + // appears while the failing request is still in flight — and a second edit + // arriving in that window is dropped by the in-flight guard rather than + // queued. Let the failure land first, so this test exercises the fix (the + // flag is reset in a finally) and not the guard. + await page.waitForTimeout(3000); + + await pm.alertSaveQueryModePage.setSql(`SELECT count(*) FROM "${OTHER_STREAM}"`); + await pm.alertSaveQueryModePage.expectStreamName(OTHER_STREAM); + }); + + test('#15093 reopening right after a save shows the saved values', async ({ page }) => { + const id = await seed(page, sqlAlert(uniq('auto_qm_15093'))); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.setThreshold('4'); + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + + // The editor reads the alert from a detail cache that stays fresh for 60s. + // Reopening immediately is the whole point: before the fix this showed the + // pre-save copy, and saving again wrote the stale value back over the edit. + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.expectThreshold('4'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15096-query-mode-regressions.spec.js b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15096-query-mode-regressions.spec.js new file mode 100644 index 0000000000..0bd109114f --- /dev/null +++ b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-15096-query-mode-regressions.spec.js @@ -0,0 +1,165 @@ +// Copyright 2026 OpenObserve Inc. + +/** + * Alerts — the three bugs found while testing the Choose Query Mode dialog + * + * #15096 — The dialog contradicted itself. An "Alert if" aggregation counts as + * Builder content for the lead ("Builder and SQL are both set up."), + * but the warning underneath was decided by filter conditions alone, + * so it still read "No filters, so every row in {stream} counts". Both + * cannot be true, and the warning was the false one: the alert fires + * on the aggregation, not on every row. + * #15097 — Switching a new alert to Realtime deleted any Compare-with-Past + * windows, and switching back did not restore them. The SQL text + * survived the same round trip, so the two inputs were treated + * inconsistently. The clear was never needed: getAlertPayload already + * forces `multi_time_range: []` for any non-SQL type. + * #15098 — Saving a metrics alert in PromQL mode deleted its stored SQL + * outright, while a Builder-mode save left it alone. The dialog says + * "SQL mode is not used", which reads as "kept but not evaluated" — + * what Builder does, not what PromQL did. + * + * The dialog itself is covered by Alerts/alerts-save-query-mode.spec.js, and the + * edit-flow fixes from the same PR by alerts-15091-alert-edit-flow.spec.js. + */ + +const { test, expect, navigateToBase } = require('../../utils/enhanced-baseFixtures.js'); +const testLogger = require('../../utils/test-logger.js'); +const PageManager = require('../../../pages/page-manager.js'); +const { ensureMetricsIngested } = require('../../utils/shared-metrics-setup.js'); +const { + STREAM, DEST, uniq, + simpleAlert, createAlert, findAlertId, getAlert, + deleteAlertInFolder, seedAlertFixturesOnce, +} = require('../../utils/alerts-api-helpers.js'); + +// Created by ensureMetricsIngested(), the same stream alerts-metrics-notification uses. +const METRICS_STREAM = 'cpu_usage'; + +const STORED_SQL = `SELECT histogram(_timestamp) as ts, count(*) as cnt FROM "${STREAM}" WHERE status='CRITICAL' GROUP BY ts`; +const METRICS_SQL = `SELECT histogram(_timestamp) as ts, count(*) as cnt FROM "${METRICS_STREAM}" GROUP BY ts`; + +/** + * SQL selected, and Builder holding an aggregation but NO filter conditions. + * Switching to Builder is what opens the dialog on this shape. + */ +function aggregationOnlyBuilderWithSql(name) { + const a = simpleAlert(name); + a.query_condition.type = 'sql'; + a.query_condition.sql = STORED_SQL; + a.query_condition.aggregation = { + group_by: [], + function: 'avg', + having: { column: 'latency', operator: '>=', value: 1 }, + }; + return a; +} + +/** A metrics alert carrying PromQL, a Builder condition and a stored SQL query. */ +function metricsAlertWithSql(name) { + const a = simpleAlert(name); + a.stream_type = 'metrics'; + a.stream_name = METRICS_STREAM; + a.query_condition.type = 'promql'; + a.query_condition.promql = METRICS_STREAM; + a.query_condition.promql_condition = { column: 'value', operator: '>=', value: 1 }; + a.query_condition.sql = METRICS_SQL; + return a; +} + +test.describe('Alerts — query-mode regressions (#15096, #15097, #15098)', { + tag: ['@alerts', '@alertsQueryModeRegression', '@regression'], +}, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + let created = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + pm = new PageManager(page); + created = []; + await seedAlertFixturesOnce(page); + await navigateToBase(page); + }); + + test.afterEach(async ({ page }) => { + for (const id of [...created].reverse()) { + await deleteAlertInFolder(page, id, 'default'); + } + }); + + // Every fixture name carries the `auto_` prefix cleanup.spec.js sweeps. + async function seed(page, payload) { + const response = await createAlert(page, payload); + expect(response.status(), await response.text()).toBe(200); + const id = await findAlertId(page, payload.name); + expect(id, `seeded alert ${payload.name} must be listable`).toBeTruthy(); + created.push(id); + return id; + } + + test('#15096 an aggregation with no filters is not called empty', async ({ page }) => { + const id = await seed(page, aggregationOnlyBuilderWithSql(uniq('auto_qm_15096'))); + + await pm.alertSaveQueryModePage.openEditor(id); + // Switching the mode is what makes the dialog ask; an unchanged edit never does. + await pm.alertSaveQueryModePage.selectQueryMode('custom'); + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectDialogOpen(); + + // The lead counts the aggregation as Builder content... + await pm.alertSaveQueryModePage.expectLead('Builder and SQL are both set up.'); + await pm.alertSaveQueryModePage.expectSaveModeSelected('custom'); + // ...so the warning must not then call the same mode empty. Before the fix it + // read "No filters, so every row in counts", which is both a + // contradiction and untrue: the alert fires on avg(latency) >= 1. + await pm.alertSaveQueryModePage.expectNoWarning(); + await pm.alertSaveQueryModePage.expectNote('Only Builder mode runs. SQL mode is not used.'); + + await pm.alertSaveQueryModePage.confirmSaveMode(); + expect((await getAlert(page, id)).query_condition.type).toBe('custom'); + }); + + test('#15097 a Realtime round trip keeps the Compare-with-Past windows', async ({ page }) => { + await pm.alertSaveQueryModePage.openNewAlert(); + await pm.alertSaveQueryModePage.selectStream(STREAM); + await pm.alertSaveQueryModePage.selectQueryMode('sql'); + + await pm.alertSaveQueryModePage.openAdvancedTab(); + await pm.alertSaveQueryModePage.addCompareWithPastWindow(); + await pm.alertSaveQueryModePage.expectCompareWithPastWindowCount(1); + + // Realtime runs Builder only, so the section goes away while it is selected. + await pm.alertSaveQueryModePage.selectRealtime(); + await pm.alertSaveQueryModePage.selectScheduled(); + await pm.alertSaveQueryModePage.openAdvancedTab(); + + // Before the fix the window was gone for good: cleared on the way to Realtime + // and never restored. The payload guard already keeps windows off a realtime + // alert, so clearing the form only ever cost the user their work. + await pm.alertSaveQueryModePage.expectCompareWithPastWindowCount(1); + // The type still resets to Builder — that part was never the bug. + await pm.alertSaveQueryModePage.expectQueryModeSelected('custom'); + }); + + test('#15098 a PromQL save keeps the stored SQL', async ({ page }) => { + await ensureMetricsIngested(); + const id = await seed(page, metricsAlertWithSql(uniq('auto_qm_15098'))); + + // The seed is the precondition: the alert really does carry both. + expect((await getAlert(page, id)).query_condition.sql).toBe(METRICS_SQL); + + await pm.alertSaveQueryModePage.openEditor(id); + await pm.alertSaveQueryModePage.expectQueryModeSelected('promql'); + // PromQL is selected and holds content, so this saves without asking. + await pm.alertSaveQueryModePage.clickSave(); + await pm.alertSaveQueryModePage.expectSavedWithoutDialog(); + + const saved = await getAlert(page, id); + expect(saved.query_condition.type).toBe('promql'); + // Before the fix getAlertPayload blanked this whenever the PromQL tab was the + // selected one, so an untouched save destroyed the user's query. + expect(saved.query_condition.sql).toBe(METRICS_SQL); + }); +}); diff --git a/web/src/components/alerts/AddAlert.spec.ts b/web/src/components/alerts/AddAlert.spec.ts index ce3eb24141..9cd99bdf16 100644 --- a/web/src/components/alerts/AddAlert.spec.ts +++ b/web/src/components/alerts/AddAlert.spec.ts @@ -31,14 +31,23 @@ import router from "@/test/unit/helpers/router"; import i18n from "@/locales"; import { generateWhereClause } from "@/utils/alerts/alertQueryBuilder"; +// Shared so the SQL → stream-name sync tests can parse a FROM clause and fail a +// stream lookup; the defaults match the old per-call mocks. +const { getStreamMock, sqlParseMock } = vi.hoisted(() => ({ + getStreamMock: vi.fn(), + sqlParseMock: vi.fn(), +})); +const STREAM_SCHEMA = { + schema: [ + { name: "field1", type: "string" }, + { name: "field2", type: "int" }, + ], +}; +getStreamMock.mockResolvedValue(STREAM_SCHEMA); + vi.mock("@/composables/useStreams", () => ({ default: () => ({ - getStream: vi.fn().mockResolvedValue({ - schema: [ - { name: "field1", type: "string" }, - { name: "field2", type: "int" }, - ], - }), + getStream: getStreamMock, getStreams: vi.fn().mockResolvedValue({ list: [] }), }), })); @@ -56,7 +65,7 @@ vi.mock("@/composables/useParser", () => ({ default: () => ({ sqlParser: async () => ({ astify: vi.fn(() => ({ columns: [] })), - parse: vi.fn(), + parse: sqlParseMock, sqlify: vi.fn(), }), }), @@ -1147,4 +1156,777 @@ describe("AddAlert (OForm owner)", () => { ); }); }); + + // ── Query mode at Save ───────────────────────────────────────────────────── + // Only the selected mode runs. A Builder alert with no conditions matches + // every row, so SQL kept in the other tab must not save silently as Builder. + // These drive the real footer Save button and the real Alert Type toggle. + describe("query mode at Save", () => { + const SQL = `SELECT level FROM "default" WHERE level='CRITICAL'`; + const emptyTree = { filterType: "group", logicalOperator: "AND", groupId: "", conditions: [] }; + + const seedEmptyBuilder = (form: any, sql: string) => { + seedValidScheduled(form); + form.setFieldValue("query_condition.conditions", emptyTree); + form.setFieldValue("query_condition.sql", sql); + }; + + // Await the handler the footer Save button runs, so each save settles inside + // its own test. `form.state` read here does not follow the submit (its + // submissionAttempts stays 0 while the save runs), so polling isSubmitting + // passed at once and let a slow save leak into the next test. + const clickSave = async () => { + expect(wrapper.find('[data-test="add-alert-submit-btn"]').exists()).toBe(true); + await wrapper.vm.handleSave(); + await flushPromises(); + }; + + // Control for the next test: the same form saves once the SQL is gone, so + // the dialog is the only thing that can hold the save back. + it("saves an empty Builder alert when no other mode has content", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, ""); + + await clickSave(); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + }); + + it("asks which mode to use instead of saving an empty Builder over stored SQL", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, SQL); + + // The real footer button. The dialog opens once the schema passes. + await wrapper.find('[data-test="add-alert-submit-btn"]').trigger("click"); + await vi.waitFor(() => expect(wrapper.vm.saveModeDialogOpen).toBe(true), { timeout: 4000 }); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + // ODialog teleports to document.body, outside the wrapper. + expect(document.querySelector('[data-test="alert-save-mode-dialog"]')).not.toBeNull(); + }); + + // Enter in the alert name field calls form.requestSubmit() (OInlineEdit), + // which goes straight to the form's submit and never through handleSave. + it("asks which mode to use when the form is submitted with Enter", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, SQL); + + await wrapper.find("form").trigger("submit"); + await vi.waitFor(() => expect(wrapper.vm.saveModeDialogOpen).toBe(true), { timeout: 4000 }); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(document.querySelector('[data-test="alert-save-mode-dialog"]')).not.toBeNull(); + }); + + it("falls back to Builder and keeps the SQL when Alert Type switches to Realtime", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + await flushPromises(); + + await wrapper.find('[data-test="add-alert-type-tab-true"]').trigger("click"); + await flushPromises(); + + expect(form.state.values.is_real_time).toBe("true"); + expect(form.state.values.query_condition.type).toBe("custom"); + expect(form.state.values.query_condition.sql).toBe(SQL); + }); + + it("stays on Builder with the SQL kept when Alert Type switches back to Scheduled", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + await flushPromises(); + + await wrapper.find('[data-test="add-alert-type-tab-true"]').trigger("click"); + await flushPromises(); + await wrapper.find('[data-test="add-alert-type-tab-false"]').trigger("click"); + await flushPromises(); + + expect(form.state.values.is_real_time).toBe("false"); + expect(form.state.values.query_condition.type).toBe("custom"); + expect(form.state.values.query_condition.sql).toBe(SQL); + }); + + it("keeps the Compare-with-Past windows through a Realtime round trip", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.multi_time_range", [{ offSet: "1h" }]); + await flushPromises(); + + await wrapper.find('[data-test="add-alert-type-tab-true"]').trigger("click"); + await flushPromises(); + await wrapper.find('[data-test="add-alert-type-tab-false"]').trigger("click"); + await flushPromises(); + + expect(form.state.values.query_condition.multi_time_range).toEqual([{ offSet: "1h" }]); + }); + + // The windows stay in the form; the payload is what keeps them off a Realtime alert. + it("sends no Compare-with-Past windows when a SQL alert is saved as Realtime", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + form.setFieldValue("query_condition.multi_time_range", [{ offSet: "1h" }]); + await flushPromises(); + + await wrapper.find('[data-test="add-alert-type-tab-true"]').trigger("click"); + await flushPromises(); + await clickSave(); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + expect(savedPayload().is_real_time).toBe(true); + expect(savedPayload().query_condition.type).toBe("custom"); + expect(savedPayload().query_condition.multi_time_range).toEqual([]); + }); + + const builderTree = { + ...emptyTree, + conditions: [ + { + filterType: "condition", + column: "field1", + operator: "=", + value: "x", + logicalOperator: "AND", + }, + ], + }; + + const dialogEl = (name: string) => + document.querySelector(`[data-test="alert-save-mode-${name}"]`); + + const dialogButton = (which: "primary" | "secondary") => + document.querySelector( + `[data-test="alert-save-mode-dialog"] [data-test="o-dialog-${which}-btn"]`, + ); + + // Primary awaits the handler the dialog's Save runs (see clickSave); Cancel + // only closes the dialog. + const clickDialog = async (which: "primary" | "secondary") => { + expect(dialogButton(which)).not.toBeNull(); + if (which === "primary") await wrapper.vm.saveWithPickedMode(); + else dialogButton(which)!.click(); + await flushPromises(); + }; + + const savedPayload = () => (alertsService.create_by_alert_id as any).mock.calls[0][1]; + + const noteText = () => + dialogEl("dialog")?.querySelector('[data-test="alert-save-mode-note"]')?.textContent?.trim(); + + it("saves with SQL mode from the dialog when Builder is empty", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + let tabAtSave: unknown; + (alertsService.create_by_alert_id as any).mockImplementationOnce(() => { + tabAtSave = form.state.values._meta.tab; + return Promise.resolve({ data: { code: 200, message: "Alert saved" } }); + }); + + await clickSave(); + expect(dialogEl("dialog")?.textContent).toContain("Choose Query Mode"); + expect(dialogEl("lead")?.textContent?.trim()).toBe( + "Builder is selected but empty. SQL is set up.", + ); + expect(dialogEl("dialog")?.textContent).not.toContain("-line query"); + // The dialog opens on the mode selected in the form; only a click changes it. + expect(dialogEl("option-custom")?.getAttribute("data-state")).toBe("on"); + expect(dialogButton("primary")?.textContent).toContain("Save with Builder Mode"); + expect(dialogEl("warning")?.textContent).toContain("No filters, so every row in"); + expect(noteText()).toBe("Only Builder mode runs. SQL mode is not used."); + + dialogEl("option-sql")!.click(); + await flushPromises(); + expect(dialogButton("primary")?.textContent).toContain("Save with SQL Mode"); + // SQL holds a query, so the picked mode needs no warning. + expect(dialogEl("warning")).toBeNull(); + expect(noteText()).toBe("Only SQL mode runs."); + + // The real dialog button, waiting for the save it starts. + dialogButton("primary")!.click(); + await vi.waitFor(() => expect(alertsService.create_by_alert_id).toHaveBeenCalled(), { + timeout: 4000, + }); + await flushPromises(); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + expect(savedPayload().query_condition.type).toBe("sql"); + expect(tabAtSave).toBe("sql"); + expect(dialogEl("dialog")).toBeNull(); + }); + + it("saves as Builder when the dialog is saved without changing the pick", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, SQL); + + await clickSave(); + await clickDialog("primary"); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + expect(savedPayload().query_condition.type).toBe("custom"); + }); + + it("opens with SQL picked when SQL is selected and Builder also has a condition", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + + await clickSave(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(dialogEl("dialog")?.textContent).toContain("Builder and SQL are both set up."); + expect(dialogEl("option-sql")?.getAttribute("data-state")).toBe("on"); + }); + + it("saves as Builder when Builder is picked in the dialog", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + + await clickSave(); + expect(noteText()).toBe("Only SQL mode runs. Builder mode is not used."); + dialogEl("option-custom")!.click(); + await flushPromises(); + expect(noteText()).toBe("Only Builder mode runs. SQL mode is not used."); + expect(dialogEl("dialog")?.textContent).not.toContain("filter condition"); + expect(dialogButton("primary")?.textContent).toContain("Save with Builder Mode"); + + await clickDialog("primary"); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + expect(savedPayload().query_condition.type).toBe("custom"); + }); + + // SQL selected with a query, plus a Builder condition: the dialog asks. + const seedSqlAndBuilder = (form: any) => { + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + }; + + it("does not ask again after the user retries a save that failed with their pick", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedSqlAndBuilder(wrapper.vm.form); + (alertsService.create_by_alert_id as any).mockRejectedValueOnce(new Error("save failed")); + + await clickSave(); + dialogEl("option-custom")!.click(); + await flushPromises(); + await clickDialog("primary"); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + + await clickSave(); + + expect(dialogEl("dialog")).toBeNull(); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(2); + expect((alertsService.create_by_alert_id as any).mock.calls[1][1].query_condition.type).toBe( + "custom", + ); + }); + + // The schema runs first: a form with field errors shows them, not the dialog. + it("shows the invalid-form toast and no dialog when the form has field errors", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + form.setFieldValue("destinations", []); + + await clickSave(); + + expect(dialogEl("dialog")).toBeNull(); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "error", + message: i18n.global.t("alerts.messages.fixHighlightedFields"), + }), + ); + }); + + // "avg of took >= 500" over every row is a working Builder alert. + it("counts a Builder aggregation with no filters as content", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + form.setFieldValue("query_condition.aggregation", { + function: "avg", + group_by: [], + having: { column: "took", operator: ">=", value: 500 }, + }); + wrapper.vm.isAggregationEnabled = true; + + await clickSave(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(dialogEl("dialog")?.textContent).toContain("Builder and SQL are both set up."); + expect(dialogEl("option-custom")?.getAttribute("data-state")).toBe("on"); + }); + + // The warning and the lead line judge "empty" the same way. + it("does not warn about filters when the picked Builder has an Alert if aggregation", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + form.setFieldValue("query_condition.aggregation", { + function: "min", + group_by: [], + having: { column: "_timestamp", operator: ">=", value: 3 }, + }); + wrapper.vm.isAggregationEnabled = true; + + await clickSave(); + + expect(dialogEl("lead")?.textContent?.trim()).toBe("Builder and SQL are both set up."); + expect(dialogEl("option-custom")?.getAttribute("data-state")).toBe("on"); + expect(dialogEl("warning")).toBeNull(); + }); + + it("warns that no query is written when the picked SQL holds only the starter query", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", 'SELECT * FROM "_rundata"'); + form.setFieldValue("query_condition.conditions", builderTree); + + await clickSave(); + + expect(dialogEl("lead")?.textContent?.trim()).toBe( + "SQL is selected but empty. Builder is set up.", + ); + expect(dialogEl("option-sql")?.getAttribute("data-state")).toBe("on"); + expect(dialogEl("warning")?.textContent?.trim()).toBe("No query written yet"); + }); + + // The Compare-with-Past windows are SQL-only; the backend still runs them + // for a Builder alert, and the UI cannot show them off the SQL tab. + it("sends no Compare-with-Past windows when Builder is picked in the dialog", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + form.setFieldValue("query_condition.multi_time_range", [{ offSet: "1h" }]); + + await clickSave(); + dialogEl("option-custom")!.click(); + await flushPromises(); + await clickDialog("primary"); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + expect(savedPayload().query_condition.type).toBe("custom"); + expect(savedPayload().query_condition.multi_time_range).toEqual([]); + }); + + it("keeps the Compare-with-Past windows in the form when a Builder save fails", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedSqlAndBuilder(form); + form.setFieldValue("query_condition.multi_time_range", [{ offSet: "1h" }]); + (alertsService.create_by_alert_id as any).mockRejectedValueOnce(new Error("save failed")); + + await clickSave(); + dialogEl("option-custom")!.click(); + await flushPromises(); + await clickDialog("primary"); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + expect(form.state.values.query_condition.multi_time_range).toEqual([{ offSet: "1h" }]); + }); + + it("lists all three modes when Builder, SQL and PromQL all have content", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("stream_type", "metrics"); + form.setFieldValue("query_condition.type", "promql"); + form.setFieldValue("query_condition.promql", "up"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + + await clickSave(); + + expect(dialogEl("option-custom")).not.toBeNull(); + expect(dialogEl("option-sql")).not.toBeNull(); + expect(dialogEl("option-promql")).not.toBeNull(); + expect(dialogEl("dialog")?.textContent).toContain("Builder, SQL and PromQL are all set up."); + expect(noteText()).toBe("Only PromQL mode runs. Builder and SQL modes are not used."); + }); + + // The reported alert, as the GET returns it: Builder with no conditions + // over the SQL that was meant to run. + it("asks on Save when a loaded Builder alert has no conditions but stores SQL", async () => { + wrapper = mountAlert({ + isUpdated: true, + modelValue: { + name: "existing_alert", + stream_type: "logs", + stream_name: "default", + is_real_time: false, + query_condition: { + type: "custom", + conditions: { version: 2, conditions: { ...emptyTree, groupId: "g" } }, + sql: SQL, + }, + trigger_condition: { + period: 10, + operator: ">=", + frequency: 10, + cron: "", + threshold: 5, + silence: 10, + frequency_type: "minutes", + timezone: "UTC", + }, + destinations: ["email"], + }, + destinations: [{ name: "email" }], + }); + await flushPromises(); + + await clickSave(); + + expect(alertsService.update_by_alert_id).not.toHaveBeenCalled(); + expect(dialogEl("option-custom")?.getAttribute("data-state")).toBe("on"); + }); + + // A SQL alert saved through the dialog keeps its Builder filters, so without + // the stored type as the confirmed mode every later edit would ask again. + const mountSavedSqlAlert = () => + mountAlert({ + isUpdated: true, + modelValue: { + name: "existing_sql_alert", + description: "", + stream_type: "logs", + stream_name: "default", + is_real_time: false, + query_condition: { + type: "sql", + conditions: { version: 2, conditions: { ...builderTree, groupId: "g" } }, + sql: SQL, + }, + trigger_condition: { + period: 10, + operator: ">=", + frequency: 10, + cron: "", + threshold: 5, + silence: 10, + frequency_type: "minutes", + timezone: "UTC", + }, + destinations: ["email"], + }, + destinations: [{ name: "email" }], + }); + + it("saves an edit without asking when the stored mode is unchanged", async () => { + wrapper = mountSavedSqlAlert(); + await flushPromises(); + + await clickSave(); + + expect(wrapper.vm.saveModeDialogOpen).toBe(false); + expect(alertsService.update_by_alert_id).toHaveBeenCalledTimes(1); + }); + + it("asks on an edit that switches away from the stored mode", async () => { + wrapper = mountSavedSqlAlert(); + await flushPromises(); + wrapper.vm.form.setFieldValue("query_condition.type", "custom"); + + await clickSave(); + + expect(alertsService.update_by_alert_id).not.toHaveBeenCalled(); + expect(dialogEl("option-custom")?.getAttribute("data-state")).toBe("on"); + }); + + it("says no query is written when the picked SQL mode is empty", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", ""); + form.setFieldValue("query_condition.conditions", builderTree); + + await clickSave(); + expect(dialogEl("option-sql")?.getAttribute("data-state")).toBe("on"); + + expect(dialogEl("dialog")?.textContent).toContain("No query written yet"); + }); + + it("warns that every row counts when Builder is picked with no filters", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, SQL); + + await clickSave(); + expect(dialogEl("option-custom")?.getAttribute("data-state")).toBe("on"); + + expect(dialogEl("dialog")?.textContent).toContain("No filters, so every row in"); + }); + + // The dialog content stays mounted through its exit animation, so a quick + // second click can land on Save after the first save is done. jsdom drops + // the content at once, so the second click calls the button's handler. + it("saves once when the dialog Save is clicked twice quickly", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, SQL); + + await clickSave(); + await clickDialog("primary"); + await wrapper.vm.saveWithPickedMode(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + // The saved form is reset, so a second submit would fail with this toast. + expect(toast).not.toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); + }); + + it("saves a SQL alert without asking when only SQL has content", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + form.setFieldValue("query_condition.type", "sql"); + + await clickSave(); + + expect(dialogEl("dialog")).toBeNull(); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + }); + + it("saves nothing and changes nothing when the dialog is cancelled", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + + await clickSave(); + await clickDialog("secondary"); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(form.state.values.query_condition.type).toBe("custom"); + expect(dialogEl("dialog")).toBeNull(); + }); + + it("does not ask when the SQL tab holds only the starter query", async () => { + wrapper = mountAlert(); + await flushPromises(); + seedEmptyBuilder(wrapper.vm.form, `SELECT * FROM "_rundata"`); + + await clickSave(); + + expect(dialogEl("dialog")).toBeNull(); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + }); + + it("does not count the default metrics aggregation as Builder content", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, ""); + form.setFieldValue("stream_type", "metrics"); + form.setFieldValue("query_condition.aggregation", { + function: "avg", + group_by: [], + having: { column: "value", operator: ">=", value: 1 }, + }); + form.setFieldValue("query_condition.type", "promql"); + form.setFieldValue("query_condition.promql", "up"); + form.setFieldValue("query_condition.promql_condition", { operator: ">=", value: 1 }); + + await clickSave(); + + expect(dialogEl("dialog")).toBeNull(); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + }); + + it("does not ask for a Realtime alert that keeps SQL text", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedEmptyBuilder(form, SQL); + form.setFieldValue("is_real_time", "true"); + + await clickSave(); + + expect(dialogEl("dialog")).toBeNull(); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + }); + + // Anomaly and composite alerts have no query mode, so leftover query text never asks. + it("does not ask for an anomaly alert that keeps SQL and a Builder condition", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + form.setFieldValue("is_real_time", "anomaly"); + await flushPromises(); + form.setFieldValue("name", "anom_alert"); + form.setFieldValue("stream_type", "logs"); + form.setFieldValue("stream_name", "_rundata"); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + await flushPromises(); + wrapper.vm.anomalyConfig.alert_enabled = false; + wrapper.vm.anomalyConfig.query_mode = "filters"; + + await wrapper.vm.handleSave(); + await flushPromises(); + + expect(wrapper.vm.saveModeDialogOpen).toBe(false); + expect(anomalyDetectionService.create).toHaveBeenCalledTimes(1); + }); + + it("does not ask for a composite alert that keeps SQL and a Builder condition", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + form.setFieldValue("is_real_time", "composite"); + form.setFieldValue("name", "checkout_degraded"); + form.setFieldValue("stream_type", ""); + form.setFieldValue("stream_name", ""); + form.setFieldValue("destinations", ["pager"]); + form.setFieldValue("trigger_condition", { silence: 15 }); + form.setFieldValue("composite_condition", { + expression: "{id-a} && {id-b}", + warning_counts_as_firing: true, + stale_child_policy: "use_last_state", + }); + form.setFieldValue("children", [ + { alert_id: "id-a", name: "Error alert", accessible: true }, + { alert_id: "id-b", name: "Latency alert", accessible: true }, + ]); + form.setFieldValue("query_condition.sql", SQL); + form.setFieldValue("query_condition.conditions", builderTree); + + await wrapper.vm.handleSave(); + await flushPromises(); + + expect(wrapper.vm.saveModeDialogOpen).toBe(false); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + }); + }); + + // The stream-name field follows the FROM table of the SQL query, debounced + // 600 ms in useAlertForm. The clock is fake from before mount: a debounce + // timer started on real time would swallow later calls, and the no-change + // assertion would then pass without the sync ever running. + describe("SQL → stream-name sync", () => { + const fromTable = (sql: string) => ({ + ast: { from: [{ table: /FROM "([^"]+)"/i.exec(sql)?.[1] }] }, + }); + // Runs pending promises and any debounced sync (flushPromises would wait + // on the fake clock). + const settle = () => vi.advanceTimersByTimeAsync(700); + + beforeEach(() => { + vi.useFakeTimers(); + sqlParseMock.mockImplementation(fromTable); + }); + afterEach(() => { + vi.useRealTimers(); + sqlParseMock.mockReset(); + getStreamMock.mockReset(); + getStreamMock.mockResolvedValue(STREAM_SCHEMA); + }); + + it("follows the FROM table again after a stream that does not exist", async () => { + wrapper = mountAlert(); + await settle(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + getStreamMock.mockImplementation((name: string) => + name === "missing_stream" + ? Promise.reject(new Error("Stream not found")) + : Promise.resolve(STREAM_SCHEMA), + ); + + form.setFieldValue("query_condition.sql", 'SELECT count(*) FROM "missing_stream"'); + await settle(); + expect(form.state.values.stream_name).toBe("missing_stream"); + + form.setFieldValue("query_condition.sql", 'SELECT count(*) FROM "other_stream"'); + await settle(); + expect(form.state.values.stream_name).toBe("other_stream"); + }); + + it("keeps the saved stream when an edit changes the FROM table", async () => { + wrapper = mountAlert({ + isUpdated: true, + modelValue: { + name: "existing_sql_alert", + description: "", + stream_type: "logs", + stream_name: "default", + is_real_time: false, + query_condition: { type: "sql", sql: 'SELECT count(*) FROM "default"' }, + trigger_condition: { + period: 10, + operator: ">=", + frequency: 10, + cron: "", + threshold: 5, + silence: 10, + frequency_type: "minutes", + timezone: "UTC", + }, + destinations: ["email"], + }, + destinations: [{ name: "email" }], + }); + await settle(); + const form = wrapper.vm.form; + + form.setFieldValue("query_condition.sql", 'SELECT count(*) FROM "other_stream"'); + await settle(); + + expect(form.state.values.stream_name).toBe("default"); + }); + }); }); diff --git a/web/src/components/alerts/AddAlert.vue b/web/src/components/alerts/AddAlert.vue index bd98dfe76f..6982aa6c9e 100644 --- a/web/src/components/alerts/AddAlert.vue +++ b/web/src/components/alerts/AddAlert.vue @@ -588,6 +588,47 @@ along with this program. If not, see . :isEditing="beingUpdated" /> + + +
+

{{ saveModeLead }}

+ + + + {{ queryModeName(mode) }} + + +

+ {{ saveModeInfo }} +

+

+ {{ saveModeNote }} +

+
+