fix(alerts): ask which query mode to save and fix stale edit data (#15090)

Co-authored-by: Neha P <neha@openobserve.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sai Nikhil
2026-10-08 17:51:57 +00:00
committed by GitHub
co-authored by Neha P github-actions[bot]
parent 8c7bb7cde6
commit 07e159549d
35 changed files with 2405 additions and 22 deletions
@@ -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",
+2 -1
View File
@@ -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": [
{
@@ -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": []
},
@@ -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');
}
}
+2
View File
@@ -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);
@@ -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 "<stream>"` 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 "<stream>"` 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 "<stream>"`. 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();
});
});
@@ -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');
});
});
@@ -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 <stream> 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);
});
});
+789 -7
View File
@@ -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<HTMLElement>(`[data-test="alert-save-mode-${name}"]`);
const dialogButton = (which: "primary" | "secondary") =>
document.querySelector<HTMLElement>(
`[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");
});
});
});
+105
View File
@@ -588,6 +588,47 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:isEditing="beingUpdated"
/>
</ODrawer>
<ODialog
data-test="alert-save-mode-dialog"
v-model:open="saveModeDialogOpen"
size="sm"
:title="t('alerts.saveModeDialog.title')"
persistent
:show-close="false"
:secondary-button-label="t('alerts.cancel')"
:primary-button-label="
t('alerts.saveModeDialog.saveWithMode', { mode: queryModeName(saveModePick) })
"
@click:secondary="saveModeDialogOpen = false"
@click:primary="saveWithPickedMode"
>
<div class="flex flex-col gap-3 text-sm">
<p data-test="alert-save-mode-lead">{{ saveModeLead }}</p>
<OToggleGroup v-model="saveModePick" class="self-start" data-test="alert-save-mode-options">
<OToggleGroupItem
v-for="mode in saveModeChoices ?? []"
:key="mode"
:value="mode"
size="sm"
:data-test="`alert-save-mode-option-${mode}`"
>
<template #icon-left>
<OIcon v-if="mode === 'custom'" name="build" size="sm" />
<OIcon v-else-if="mode === 'sql'" name="database" size="sm" />
<OIcon v-else name="show-chart" size="sm" />
</template>
{{ queryModeName(mode) }}
</OToggleGroupItem>
</OToggleGroup>
<p v-if="saveModeInfo" class="text-status-warning-text" data-test="alert-save-mode-warning">
{{ saveModeInfo }}
</p>
<p class="text-text-secondary text-xs" data-test="alert-save-mode-note">
{{ saveModeNote }}
</p>
</div>
</ODialog>
</template>
<script lang="ts">
@@ -617,6 +658,7 @@ import AnomalySummary from "@/components/anomaly_detection/AnomalySummary.vue";
import { useAlertForm, defaultAlertValue } from "@/composables/useAlertForm";
import { anomalyNoticeBadgeKeys } from "@/components/anomaly_detection/steps/AnomalyDetectionConfig.schema";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInlineEdit from "@/lib/forms/InlineEdit/OFormInlineEdit.vue";
@@ -677,6 +719,7 @@ export default defineComponent({
OToggleGroup,
OToggleGroupItem,
ODrawer,
ODialog,
OTag,
OTooltip,
OForm,
@@ -899,8 +942,66 @@ export default defineComponent({
// anomaly/composite tab switch.
const onAlertTypeChange = (value: unknown) => {
alertForm.setF("is_real_time", value);
// Realtime runs Builder only. The query text and Compare-with-Past windows
// stay in the form for a switch back; the payload never sends windows here.
if (value === "true" && alertForm.formData.value.query_condition?.type !== "custom") {
alertForm.setF("query_condition.type", "custom");
}
};
// ── Save-mode dialog copy ────────────────────────────────────────────────
// Mode names match the QueryConfig mode toggle.
const queryModeName = (mode: string) =>
mode === "sql"
? raw("SQL")
: mode === "promql"
? raw("PromQL")
: alertForm.t("alerts.queryBuilder");
// Names the modes that are set up; an empty selected mode is named first.
const saveModeLead = computed(() => {
const selected = alertForm.formData.value.query_condition?.type || "custom";
const [first, second, third] = alertForm.queryModesWithContent.value.map(queryModeName);
if (alertForm.queryModesWithContent.value.includes(selected)) {
return third
? alertForm.t("alerts.saveModeDialog.leadAllSetUp", { first, second, third })
: alertForm.t("alerts.saveModeDialog.leadBothSetUp", { first, second });
}
return second
? alertForm.t("alerts.saveModeDialog.leadSelectedEmptyTwoOthers", {
selected: queryModeName(selected),
first,
second,
})
: alertForm.t("alerts.saveModeDialog.leadSelectedEmptyOneOther", {
selected: queryModeName(selected),
other: first,
});
});
// Only warns, and only when the picked mode is empty by the same rule as the lead.
const saveModeInfo = computed(() => {
const pick = alertForm.saveModePick.value;
if (alertForm.queryModesWithContent.value.includes(pick)) return null;
return pick === "custom"
? alertForm.t("alerts.saveModeDialog.noFilters", {
stream: alertForm.formData.value.stream_name,
})
: alertForm.t("alerts.saveModeDialog.noQuery");
});
// Says what runs: the picked mode only. Names the other modes that hold content.
const saveModeNote = computed(() => {
const pick = alertForm.saveModePick.value;
const [first, second] = alertForm.queryModesWithContent.value
.filter((mode) => mode !== pick)
.map(queryModeName);
const params = { pick: queryModeName(pick), first, second, other: first };
if (second) return alertForm.t("alerts.saveModeDialog.noteTwoOthersUnused", params);
if (first) return alertForm.t("alerts.saveModeDialog.noteOneOtherUnused", params);
return alertForm.t("alerts.saveModeDialog.noteOnlyPick", params);
});
// ── Smart alert name ─────────────────────────────────────────────────────
// A new alert names itself after what it actually watches ("k8s_logs where
// status >= 500") and keeps re-deriving that as the stream and condition
@@ -931,6 +1032,10 @@ export default defineComponent({
goBackToAlertsList,
onStreamTypeChange,
onAlertTypeChange,
queryModeName,
saveModeLead,
saveModeInfo,
saveModeNote,
activeEvaluationStatus,
isCompositeMode,
availableCompositeChildren,
@@ -77,6 +77,8 @@ import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import AlertService from "@/services/alerts";
import { queryClient } from "@/composables/query/queryClient";
import { alertKeys } from "@/services/alerts.querykeys";
import { toast } from "@/lib/feedback/Toast/useToast";
import TemplateService from "@/services/alert_templates";
import DestinationService from "@/services/alert_destination";
@@ -917,6 +919,23 @@ describe("AlertList - router query behaviors", () => {
expect(pushedQuery.action).toBeUndefined();
expect(pushedQuery.alert_id).toBeUndefined();
});
// The editor opens an alert from its detail cache (getAlertById), and a save
// reaches the list through update:list → refreshList.
it("refreshList marks cached alert copies stale so reopening the editor shows the save", async () => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
const key = alertKeys.detail(store.state.selectedOrganization.identifier, "a1");
queryClient.setQueryData(key, { id: "a1", name: "Alert 1" });
try {
await wrapper.vm.refreshList("default");
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
} finally {
queryClient.removeQueries({ queryKey: key, exact: true });
}
});
});
describe("AlertList - pagination restoration", () => {
+8
View File
@@ -2713,6 +2713,14 @@ export default defineComponent({
//this is done to avoid multiple api calls , when we assign the folderId before fetching it will trigger the watch and it will fetch the alerts again
//and we dont need to fetch the alerts again because we are already fetching the alerts in the getAlertsFn
const resolvedFolderId = folderId || activeFolderId.value || "default";
// An editor save lands here, and the editor opens alerts from their detail
// cache (getAlertById): mark those stale, or a reopen within a minute shows
// the pre-save copy.
void queryClient.invalidateQueries({
queryKey: alertKeys.all(store.state.selectedOrganization.identifier),
predicate: (q: any) => q.queryKey[3] === "detail",
refetchType: "none",
});
// Always fetch the latest alerts for the folder from backend
await getAlertsFn(store, resolvedFolderId, "", true, "", true);
// Re-apply active search/filter on the freshly fetched data
+88 -4
View File
@@ -56,6 +56,7 @@ import {
import { convertDateToTimestamp } from "@/utils/date";
import { generateSqlQuery } from "@/utils/alerts/alertQueryBuilder";
import { isUnaryOperator } from "@/utils/alerts/conditionsFormatter";
import { modesWithContent } from "@/utils/alerts/alertCondition";
import {
validateInputs as validateInputsUtil,
validateSqlQuery as validateSqlQueryUtil,
@@ -957,7 +958,8 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
const isSyncingStreamFromSql = ref(false);
const debouncedSyncStreamFromSql = debounce(async (sql: string) => {
if (!sql || !parser || isSyncingStreamFromSql.value) return;
// An edit keeps its stream: the field is locked and the backend never updates it.
if (!sql || !parser || isSyncingStreamFromSql.value || beingUpdated.value) return;
// parse() is exponential in paren nesting depth — skip a pathologically
// nested query rather than freeze the tab. Losing this convenience sync
// is fine; the user can still pick the stream from the dropdown.
@@ -967,9 +969,14 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
const fromStream = parsed?.ast?.from?.[0]?.table as string | undefined;
if (fromStream && fromStream !== formData.value.stream_name) {
isSyncingStreamFromSql.value = true;
setF("stream_name", fromStream);
await updateStreamFields(fromStream);
isSyncingStreamFromSql.value = false;
try {
setF("stream_name", fromStream);
await updateStreamFields(fromStream);
} finally {
// updateStreamFields throws for a stream that does not exist; a flag
// left set would skip every later sync.
isSyncingStreamFromSql.value = false;
}
}
} catch {
// ignore parse errors while user is mid-typing
@@ -2135,6 +2142,75 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
}
};
// ── Save-mode dialog ──────────────────────────────────────────────────────
// Only the selected query mode runs. When another runnable mode also holds
// content (or the selected one is empty while another is not), Save asks
// which mode the alert uses. Scheduled alerts only: Realtime runs Builder
// only, and anomaly/composite alerts have no query mode.
const queryModesWithContent = computed(() => {
const modes = modesWithContent({
sql: formData.value.query_condition?.sql,
promql: formData.value.query_condition?.promql,
conditions: formData.value.query_condition?.conditions,
streamType: formData.value.stream_type,
});
// An aggregation with no filters is still a Builder query, but only when
// Builder is selected: a new metrics alert's default avg is not content.
if (
(formData.value.query_condition?.type || "custom") === "custom" &&
isAggregationEnabled.value &&
!modes.includes("custom")
)
modes.unshift("custom");
return modes;
});
const saveModeChoices = computed(() => {
if (formData.value.is_real_time !== "false") return null;
const selected = formData.value.query_condition?.type || "custom";
const choices = (["custom", "sql", "promql"] as const).filter(
(mode) => mode === selected || queryModesWithContent.value.includes(mode),
);
return choices.length > 1 ? choices : null;
});
const saveModeDialogOpen = ref(false);
const saveModePick = ref<"custom" | "sql" | "promql">("custom");
// The user's last pick, so a save that fails validation does not ask again.
const confirmedSaveMode = ref<"custom" | "sql" | "promql" | null>(null);
// True while the dialog's own Save submits, so performSave does not ask again.
let savingPickedMode = false;
// Opens the dialog instead of saving. performSave calls it, so every submit
// asks: the footer Save button, and Enter in the name field (OInlineEdit
// calls form.requestSubmit(), which never goes through handleSave).
const askForSaveMode = () => {
if (savingPickedMode || !saveModeChoices.value) return false;
const selected = formData.value.query_condition?.type || "custom";
const selectedHasContent = queryModesWithContent.value.includes(selected);
if (selected === confirmedSaveMode.value && selectedHasContent) return false;
saveModePick.value = selected;
saveModeDialogOpen.value = true;
return true;
};
// The schema picks its rules from `_meta.tab`, which QueryConfig only syncs
// through watchers, so set it here too and let them settle before submitting.
// The guard stops a second click while the dialog plays its exit animation.
const saveWithPickedMode = async () => {
if (!saveModeDialogOpen.value) return;
saveModeDialogOpen.value = false;
confirmedSaveMode.value = saveModePick.value;
setF("query_condition.type", saveModePick.value);
setF("_meta.tab", saveModePick.value);
await nextTick();
savingPickedMode = true;
try {
await handleSave();
} finally {
savingPickedMode = false;
}
};
// Imperative pre-save gates RE-HOMED from QueryConfig.validate(), which returns
// true early in DESCENDANT mode — so its non-schema query-text gates (empty
// SQL / empty PromQL / aggregate-column toast) no longer fire. Re-home them
@@ -2449,6 +2525,7 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
await saveAnomalyDetection();
return;
}
if (askForSaveMode()) return;
await onSubmit();
};
@@ -2506,6 +2583,8 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
// response shape, etc.) rather than showing NaN.
data.pending_period_sec = Math.round((Number(data.pending_period_sec) || 0) / 60);
isAggregationEnabled.value = !!data.query_condition?.aggregation;
// The saved type is the user's earlier answer; ask again only on a switch.
confirmedSaveMode.value = data.query_condition?.type ?? null;
if (data.query_condition?.promql_condition) {
if (!data.query_condition.promql_condition.column) {
@@ -3268,6 +3347,11 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) {
saveAlertJson,
applyAlertPrefill,
handleSave,
queryModesWithContent,
saveModeChoices,
saveModeDialogOpen,
saveModePick,
saveWithPickedMode,
onSubmit,
saveAnomalyDetection,
previewAlert,
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "كان سيفعل ذلك",
"wouldNotTrigger": "لم يكن ذلك يفعل",
"queryBuilder": "باني",
"saveModeDialog": {
"title": "اختر وضع الاستعلام",
"leadAllSetUp": "{first} و{second} و{third} جميعها مُهيأة.",
"leadBothSetUp": "{first} و{second} كلاهما مُهيأ.",
"leadSelectedEmptyOneOther": "{selected} محدد لكنه فارغ. {other} مُهيأ.",
"leadSelectedEmptyTwoOthers": "{selected} محدد لكنه فارغ. {first} و{second} مُهيآن.",
"noFilters": "لا توجد عوامل تصفية، لذا تُحتسب كل صفوف {stream}",
"noQuery": "لم تتم كتابة أي استعلام بعد",
"noteOneOtherUnused": "يعمل وضع {pick} فقط. لا يُستخدم وضع {other}.",
"noteOnlyPick": "يعمل وضع {pick} فقط.",
"noteTwoOthersUnused": "يعمل وضع {pick} فقط. لا يُستخدَم وضعا {first} و{second}.",
"saveWithMode": "حفظ بوضع {mode}"
},
"conditions": {
"condition": "الحالة",
"conditionGroup": "مجموعة الحالة",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "WÜRDE AUSLÖSEN",
"wouldNotTrigger": "WÜRDE NICHT AUSLÖSEN",
"queryBuilder": "Baumeister",
"saveModeDialog": {
"title": "Abfragemodus wählen",
"leadAllSetUp": "{first}, {second} und {third} sind alle eingerichtet.",
"leadBothSetUp": "{first} und {second} sind beide eingerichtet.",
"leadSelectedEmptyOneOther": "{selected} ist ausgewählt, aber leer. {other} ist eingerichtet.",
"leadSelectedEmptyTwoOthers": "{selected} ist ausgewählt, aber leer. {first} und {second} sind eingerichtet.",
"noFilters": "Keine Filter, daher zählt jede Zeile in {stream}",
"noQuery": "Noch keine Abfrage geschrieben",
"noteOneOtherUnused": "Nur der {pick}-Modus wird ausgeführt. Der {other}-Modus wird nicht verwendet.",
"noteOnlyPick": "Nur der {pick}-Modus wird ausgeführt.",
"noteTwoOthersUnused": "Nur der {pick}-Modus wird ausgeführt. Die Modi {first} und {second} werden nicht verwendet.",
"saveWithMode": "Mit {mode}-Modus speichern"
},
"conditions": {
"condition": "Zustand",
"conditionGroup": "Konditionsgruppe",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "WOULD TRIGGER",
"wouldNotTrigger": "WOULD NOT TRIGGER",
"queryBuilder": "Builder",
"saveModeDialog": {
"title": "Choose Query Mode",
"leadAllSetUp": "{first}, {second} and {third} are all set up.",
"leadBothSetUp": "{first} and {second} are both set up.",
"leadSelectedEmptyOneOther": "{selected} is selected but empty. {other} is set up.",
"leadSelectedEmptyTwoOthers": "{selected} is selected but empty. {first} and {second} are set up.",
"noFilters": "No filters, so every row in {stream} counts",
"noQuery": "No query written yet",
"noteOneOtherUnused": "Only {pick} mode runs. {other} mode is not used.",
"noteOnlyPick": "Only {pick} mode runs.",
"noteTwoOthersUnused": "Only {pick} mode runs. {first} and {second} modes are not used.",
"saveWithMode": "Save with {mode} Mode"
},
"conditions": {
"condition": "Condition",
"conditionGroup": "Condition Group",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "DESENCADENARÍA",
"wouldNotTrigger": "NO SE ACTIVARÍA",
"queryBuilder": "Constructor",
"saveModeDialog": {
"title": "Elegir modo de consulta",
"leadAllSetUp": "{first}, {second} y {third} están todos configurados.",
"leadBothSetUp": "{first} y {second} están ambos configurados.",
"leadSelectedEmptyOneOther": "{selected} está seleccionado pero vacío. {other} está configurado.",
"leadSelectedEmptyTwoOthers": "{selected} está seleccionado pero vacío. {first} y {second} están configurados.",
"noFilters": "Sin filtros, por lo que cuenta cada fila de {stream}",
"noQuery": "Aún no se ha escrito ninguna consulta",
"noteOneOtherUnused": "Solo se ejecuta el modo {pick}. El modo {other} no se usa.",
"noteOnlyPick": "Solo se ejecuta el modo {pick}.",
"noteTwoOthersUnused": "Solo se ejecuta el modo {pick}. Los modos {first} y {second} no se usan.",
"saveWithMode": "Guardar con modo {mode}"
},
"conditions": {
"condition": "Condición",
"conditionGroup": "Grupo de condiciones",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "DÉCLENCHERAIT",
"wouldNotTrigger": "NE DÉCLENCHERAIT PAS",
"queryBuilder": "Constructeur",
"saveModeDialog": {
"title": "Choisir le mode de requête",
"leadAllSetUp": "{first}, {second} et {third} sont tous configurés.",
"leadBothSetUp": "{first} et {second} sont tous deux configurés.",
"leadSelectedEmptyOneOther": "{selected} est sélectionné mais vide. {other} est configuré.",
"leadSelectedEmptyTwoOthers": "{selected} est sélectionné mais vide. {first} et {second} sont configurés.",
"noFilters": "Aucun filtre, donc chaque ligne de {stream} compte",
"noQuery": "Aucune requête écrite pour l'instant",
"noteOneOtherUnused": "Seul le mode {pick} s'exécute. Le mode {other} n'est pas utilisé.",
"noteOnlyPick": "Seul le mode {pick} s'exécute.",
"noteTwoOthersUnused": "Seul le mode {pick} s'exécute. Les modes {first} et {second} ne sont pas utilisés.",
"saveWithMode": "Enregistrer avec le mode {mode}"
},
"conditions": {
"condition": "État",
"conditionGroup": "Groupe de conditions",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "INNESCHEREBBE",
"wouldNotTrigger": "NON SI INNESCHEREBBE",
"queryBuilder": "Costruttore",
"saveModeDialog": {
"title": "Scegli la modalità query",
"leadAllSetUp": "{first}, {second} e {third} sono tutti configurati.",
"leadBothSetUp": "{first} e {second} sono entrambi configurati.",
"leadSelectedEmptyOneOther": "{selected} è selezionato ma vuoto. {other} è configurato.",
"leadSelectedEmptyTwoOthers": "{selected} è selezionato ma vuoto. {first} e {second} sono configurati.",
"noFilters": "Nessun filtro, quindi ogni riga in {stream} viene conteggiata",
"noQuery": "Nessuna query ancora scritta",
"noteOneOtherUnused": "Viene eseguita solo la modalità {pick}. La modalità {other} non viene utilizzata.",
"noteOnlyPick": "Viene eseguita solo la modalità {pick}.",
"noteTwoOthersUnused": "Viene eseguita solo la modalità {pick}. Le modalità {first} e {second} non vengono utilizzate.",
"saveWithMode": "Salva con la modalità {mode}"
},
"conditions": {
"condition": "Condizione",
"conditionGroup": "Gruppo di condizioni",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "トリガーする",
"wouldNotTrigger": "トリガーされない",
"queryBuilder": "ビルダー",
"saveModeDialog": {
"title": "クエリモードを選択",
"leadAllSetUp": "{first}、{second}、{third}はすべて設定済みです。",
"leadBothSetUp": "{first}と{second}はどちらも設定済みです。",
"leadSelectedEmptyOneOther": "{selected}が選択されていますが空です。{other}は設定済みです。",
"leadSelectedEmptyTwoOthers": "{selected}が選択されていますが空です。{first}と{second}は設定済みです。",
"noFilters": "フィルターがないため、{stream}のすべての行が対象になります",
"noQuery": "クエリがまだ記述されていません",
"noteOneOtherUnused": "{pick}モードのみ実行されます。{other}モードは使用されません。",
"noteOnlyPick": "{pick}モードのみ実行されます。",
"noteTwoOthersUnused": "{pick}モードのみ実行されます。{first}モードと{second}モードは使用されません。",
"saveWithMode": "{mode}モードで保存"
},
"conditions": {
"condition": "コンディション",
"conditionGroup": "条件グループ",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "트리거할 것이다",
"wouldNotTrigger": "트리거하지 않을 것입니다",
"queryBuilder": "건축업자",
"saveModeDialog": {
"title": "쿼리 모드 선택",
"leadAllSetUp": "{first}, {second}, {third} 모두 설정되었습니다.",
"leadBothSetUp": "{first} 및 {second} 모두 설정되었습니다.",
"leadSelectedEmptyOneOther": "{selected}이(가) 선택되었지만 비어 있습니다. {other}이(가) 설정되었습니다.",
"leadSelectedEmptyTwoOthers": "{selected}이(가) 선택되었지만 비어 있습니다. {first} 및 {second}이(가) 설정되었습니다.",
"noFilters": "필터가 없으므로 {stream}의 모든 행이 집계됩니다",
"noQuery": "아직 작성된 쿼리가 없습니다",
"noteOneOtherUnused": "{pick} 모드만 실행됩니다. {other} 모드는 사용되지 않습니다.",
"noteOnlyPick": "{pick} 모드만 실행됩니다.",
"noteTwoOthersUnused": "{pick} 모드만 실행됩니다. {first} 및 {second} 모드는 사용되지 않습니다.",
"saveWithMode": "{mode} 모드로 저장"
},
"conditions": {
"condition": "상태",
"conditionGroup": "조건 그룹",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "ZOU TRIGGEREN",
"wouldNotTrigger": "ZOU NIET ACTIVEREN",
"queryBuilder": "Bouwer",
"saveModeDialog": {
"title": "Querymodus kiezen",
"leadAllSetUp": "{first}, {second} en {third} zijn allemaal ingesteld.",
"leadBothSetUp": "{first} en {second} zijn beide ingesteld.",
"leadSelectedEmptyOneOther": "{selected} is geselecteerd maar leeg. {other} is ingesteld.",
"leadSelectedEmptyTwoOthers": "{selected} is geselecteerd maar leeg. {first} en {second} zijn ingesteld.",
"noFilters": "Geen filters, dus elke rij in {stream} telt mee",
"noQuery": "Nog geen query geschreven",
"noteOneOtherUnused": "Alleen de modus {pick} wordt uitgevoerd. De modus {other} wordt niet gebruikt.",
"noteOnlyPick": "Alleen de modus {pick} wordt uitgevoerd.",
"noteTwoOthersUnused": "Alleen de modus {pick} wordt uitgevoerd. De modi {first} en {second} worden niet gebruikt.",
"saveWithMode": "Opslaan met modus {mode}"
},
"conditions": {
"condition": "Conditie",
"conditionGroup": "Conditiegroep",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "WYWOŁAŁOBY ALERT",
"wouldNotTrigger": "NIE WYWOŁAŁOBY",
"queryBuilder": "Kreator",
"saveModeDialog": {
"title": "Wybierz tryb zapytania",
"leadAllSetUp": "{first}, {second} i {third} są skonfigurowane.",
"leadBothSetUp": "{first} i {second} są skonfigurowane.",
"leadSelectedEmptyOneOther": "{selected} jest wybrany, ale pusty. {other} jest skonfigurowany.",
"leadSelectedEmptyTwoOthers": "{selected} jest wybrany, ale pusty. {first} i {second} są skonfigurowane.",
"noFilters": "Brak filtrów, więc liczy się każdy wiersz w {stream}",
"noQuery": "Nie zapisano jeszcze zapytania",
"noteOneOtherUnused": "Działa tylko tryb {pick}. Tryb {other} nie jest używany.",
"noteOnlyPick": "Działa tylko tryb {pick}.",
"noteTwoOthersUnused": "Działa tylko tryb {pick}. Tryby {first} i {second} nie są używane.",
"saveWithMode": "Zapisz w trybie {mode}"
},
"conditions": {
"condition": "Warunek",
"conditionGroup": "Grupa warunków",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "DESENCADEARIA",
"wouldNotTrigger": "NÃO ACIONARIA",
"queryBuilder": "Construtor",
"saveModeDialog": {
"title": "Escolher modo de consulta",
"leadAllSetUp": "{first}, {second} e {third} estão todos configurados.",
"leadBothSetUp": "{first} e {second} estão ambos configurados.",
"leadSelectedEmptyOneOther": "{selected} está selecionado, mas vazio. {other} está configurado.",
"leadSelectedEmptyTwoOthers": "{selected} está selecionado, mas vazio. {first} e {second} estão configurados.",
"noFilters": "Sem filtros, então cada linha em {stream} conta",
"noQuery": "Nenhuma consulta escrita ainda",
"noteOneOtherUnused": "Apenas o modo {pick} é executado. O modo {other} não é usado.",
"noteOnlyPick": "Apenas o modo {pick} é executado.",
"noteTwoOthersUnused": "Apenas o modo {pick} é executado. Os modos {first} e {second} não são usados.",
"saveWithMode": "Salvar com o modo {mode}"
},
"conditions": {
"condition": "Condição",
"conditionGroup": "Grupo de condições",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "БУДЕТ ЗАПУЩЕНО",
"wouldNotTrigger": "НЕ БУДЕТ ЗАПУЩЕНО",
"queryBuilder": "Конструктор",
"saveModeDialog": {
"title": "Выберите режим запроса",
"leadAllSetUp": "{first}, {second} и {third} настроены.",
"leadBothSetUp": "{first} и {second} настроены.",
"leadSelectedEmptyOneOther": "Выбран {selected}, но он пуст. {other} настроен.",
"leadSelectedEmptyTwoOthers": "Выбран {selected}, но он пуст. {first} и {second} настроены.",
"noFilters": "Фильтры отсутствуют, поэтому учитывается каждая строка в {stream}",
"noQuery": "Запрос ещё не написан",
"noteOneOtherUnused": "Выполняется только режим {pick}. Режим {other} не используется.",
"noteOnlyPick": "Выполняется только режим {pick}.",
"noteTwoOthersUnused": "Выполняется только режим {pick}. Режимы {first} и {second} не используются.",
"saveWithMode": "Сохранить в режиме {mode}"
},
"conditions": {
"condition": "Условие",
"conditionGroup": "Группа условий",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "TETİKLEYECEKTİ",
"wouldNotTrigger": "TETİKLEMEYECEK",
"queryBuilder": "inşaatçı",
"saveModeDialog": {
"title": "Sorgu Modunu Seçin",
"leadAllSetUp": "{first}, {second} ve {third} tümü ayarlandı.",
"leadBothSetUp": "{first} ve {second} ikisi de ayarlandı.",
"leadSelectedEmptyOneOther": "{selected} seçildi ancak boş. {other} ayarlandı.",
"leadSelectedEmptyTwoOthers": "{selected} seçildi ancak boş. {first} ve {second} ayarlandı.",
"noFilters": "Filtre yok, bu nedenle {stream} içindeki her satır sayılır",
"noQuery": "Henüz sorgu yazılmadı",
"noteOneOtherUnused": "Yalnızca {pick} modu çalışır. {other} modu kullanılmaz.",
"noteOnlyPick": "Yalnızca {pick} modu çalışır.",
"noteTwoOthersUnused": "Yalnızca {pick} modu çalışır. {first} ve {second} modları kullanılmaz.",
"saveWithMode": "{mode} Modu ile Kaydet"
},
"conditions": {
"condition": "Durum",
"conditionGroup": "Durum Grubu",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "SẼ KÍCH HOẠT",
"wouldNotTrigger": "SẼ KHÔNG KÍCH HOẠT",
"queryBuilder": "Trình xây dựng",
"saveModeDialog": {
"title": "Chọn chế độ truy vấn",
"leadAllSetUp": "{first}, {second} và {third} đều đã được thiết lập.",
"leadBothSetUp": "{first} và {second} đều đã được thiết lập.",
"leadSelectedEmptyOneOther": "{selected} được chọn nhưng trống. {other} đã được thiết lập.",
"leadSelectedEmptyTwoOthers": "{selected} được chọn nhưng trống. {first} và {second} đã được thiết lập.",
"noFilters": "Không có bộ lọc, nên mọi dòng trong {stream} đều được tính",
"noQuery": "Chưa có truy vấn nào được viết",
"noteOneOtherUnused": "Chỉ chế độ {pick} chạy. Chế độ {other} không được sử dụng.",
"noteOnlyPick": "Chỉ chế độ {pick} chạy.",
"noteTwoOthersUnused": "Chỉ chế độ {pick} chạy. Chế độ {first} và {second} không được sử dụng.",
"saveWithMode": "Lưu với chế độ {mode}"
},
"conditions": {
"condition": "Điều kiện",
"conditionGroup": "Nhóm điều kiện",
+13
View File
@@ -8079,6 +8079,19 @@
"wouldTrigger": "会触发",
"wouldNotTrigger": "不会触发",
"queryBuilder": "生成器",
"saveModeDialog": {
"title": "选择查询模式",
"leadAllSetUp": "{first}、{second} 和 {third} 均已设置。",
"leadBothSetUp": "{first} 和 {second} 均已设置。",
"leadSelectedEmptyOneOther": "已选择 {selected} 但内容为空。{other} 已设置。",
"leadSelectedEmptyTwoOthers": "已选择 {selected} 但内容为空。{first} 和 {second} 已设置。",
"noFilters": "没有过滤器,因此 {stream} 中的每一行都会计入",
"noQuery": "尚未编写查询",
"noteOneOtherUnused": "仅运行 {pick} 模式。{other} 模式未使用。",
"noteOnlyPick": "仅运行 {pick} 模式。",
"noteTwoOthersUnused": "仅运行 {pick} 模式。{first} 和 {second} 模式未使用。",
"saveWithMode": "以 {mode} 模式保存"
},
"conditions": {
"condition": "状况",
"conditionGroup": "条件组",
+13
View File
@@ -8074,6 +8074,19 @@
"wouldTrigger": "會觸發",
"wouldNotTrigger": "不會觸發",
"queryBuilder": "建構器",
"saveModeDialog": {
"title": "選擇查詢模式",
"leadAllSetUp": "{first}、{second} 和 {third} 都已設定完成。",
"leadBothSetUp": "{first} 和 {second} 都已設定完成。",
"leadSelectedEmptyOneOther": "已選取 {selected},但內容為空。{other} 已設定完成。",
"leadSelectedEmptyTwoOthers": "已選取 {selected},但內容為空。{first} 和 {second} 已設定完成。",
"noFilters": "沒有篩選條件,因此 {stream} 中的每一列都會計入",
"noQuery": "尚未撰寫查詢",
"noteOneOtherUnused": "只會執行 {pick} 模式。{other} 模式不會使用。",
"noteOnlyPick": "只會執行 {pick} 模式。",
"noteTwoOthersUnused": "只會執行 {pick} 模式。{first} 和 {second} 模式不會使用。",
"saveWithMode": "以 {mode} 模式儲存"
},
"conditions": {
"condition": "條件",
"conditionGroup": "條件群組",
+109
View File
@@ -19,6 +19,8 @@ import {
alertConditionText,
alertPeriodMinutes,
alertWarningConditionText,
countCompleteConditions,
modesWithContent,
} from "@/utils/alerts/alertCondition";
describe("alertConditionText", () => {
@@ -65,6 +67,24 @@ describe("alertConditionText", () => {
expect(alertConditionText(null)).toBe("—");
expect(alertConditionText({})).toBe("—");
});
/// Only the selected mode runs. A Builder alert keeps the SQL typed in the
/// other tab, but that SQL never runs, so it is not the condition.
describe("query mode", () => {
it("does not show the stored SQL of a Builder alert as its condition", () => {
expect(
alertConditionText({
query_condition: { type: "custom", sql: "SELECT 1 WHERE x", aggregation: null },
}),
).toBe("—");
});
it("still shows the SQL of a SQL alert, and of an alert with no type", () => {
const sql = "SELECT 1 WHERE x";
expect(alertConditionText({ query_condition: { type: "sql", sql } })).toBe(sql);
expect(alertConditionText({ query_condition: { sql } })).toBe(sql);
});
});
});
describe("alertWarningConditionText", () => {
@@ -99,3 +119,92 @@ describe("alertPeriodMinutes", () => {
expect(alertPeriodMinutes({})).toBe(null);
});
});
const group = (conditions: unknown[]) => ({
filterType: "group",
logicalOperator: "AND",
groupId: "g",
conditions,
});
const row = (column: string, operator: string, value?: unknown) => ({
filterType: "condition",
column,
operator,
value,
});
describe("countCompleteConditions", () => {
it("counts nothing in an empty tree", () => {
expect(countCompleteConditions(group([]))).toBe(0);
});
it("does not count a half-filled row", () => {
expect(countCompleteConditions(group([row("level", "=")]))).toBe(0);
});
/// A unary operator takes no value, so the row is complete without one.
it("counts a unary operator without a value", () => {
expect(countCompleteConditions(group([row("level", "is_null")]))).toBe(1);
});
it("counts a complete condition inside a nested group", () => {
expect(
countCompleteConditions(
group([row("level", "=", "error"), group([row("code", ">", 0), row("host", "=")])]),
),
).toBe(2);
});
it("counts nothing for a missing tree", () => {
expect(countCompleteConditions(undefined)).toBe(0);
expect(countCompleteConditions(null)).toBe(0);
});
});
describe("modesWithContent", () => {
const builder = group([row("level", "=", "error")]);
it("is empty when no mode holds content", () => {
expect(modesWithContent({})).toEqual([]);
});
it("does not count whitespace-only SQL", () => {
expect(modesWithContent({ sql: " \n " })).toEqual([]);
});
/// The form writes the starter query into an empty SQL tab and keeps it
/// after a stream change, so it is not content for any stream.
it("does not count the starter query, for any stream", () => {
expect(modesWithContent({ sql: 'SELECT * FROM "logs"' })).toEqual([]);
expect(modesWithContent({ sql: 'SELECT * FROM "other"' })).toEqual([]);
});
it("counts real SQL", () => {
expect(modesWithContent({ sql: "SELECT * FROM \"logs\" WHERE level = 'error'" })).toEqual([
"sql",
]);
});
it("counts a Builder with one complete condition", () => {
expect(modesWithContent({ conditions: builder })).toEqual(["custom"]);
});
it("never returns PromQL on a logs stream", () => {
expect(modesWithContent({ promql: "up", streamType: "logs" })).toEqual([]);
});
it("returns PromQL on a metrics stream", () => {
expect(modesWithContent({ promql: "up", streamType: "metrics" })).toEqual(["promql"]);
});
it("orders Builder, SQL, PromQL", () => {
expect(
modesWithContent({
promql: "up",
sql: "SELECT 1",
conditions: builder,
streamType: "metrics",
}),
).toEqual(["custom", "sql", "promql"]);
});
});
+43 -2
View File
@@ -22,6 +22,8 @@
* trusting either.
*/
import { isUnaryOperator } from "@/utils/alerts/conditionsFormatter";
const EMPTY = "—";
const isBlank = (v: unknown) => v === undefined || v === null || v === "";
@@ -33,7 +35,7 @@ function queryConditionOf(alert: any) {
/**
* The critical condition — `avg(latency) > 500`, a PromQL comparison, or the
* raw SQL when the rule is not an aggregation. `—` when the alert is unknown.
* raw SQL of a non-Builder rule with no aggregation. `—` when the alert is unknown.
*/
export function alertConditionText(alert: any): string {
const qc = queryConditionOf(alert);
@@ -44,7 +46,8 @@ export function alertConditionText(alert: any): string {
return isBlank(pc?.value) ? EMPTY : `${pc.operator || ""} ${pc.value}`.trim();
}
const agg = qc?.aggregation;
if (!agg) return qc?.sql || EMPTY;
// A Builder alert can still store SQL from another mode; it never runs.
if (!agg) return qc?.type === "custom" ? EMPTY : qc?.sql || EMPTY;
const fn = agg.function || "";
const col = agg.having?.column || "";
const op = agg.having?.operator || "";
@@ -69,3 +72,41 @@ export function alertPeriodMinutes(alert: any): number | null {
const period = Number(alert?.trigger_condition?.period);
return Number.isFinite(period) && period > 0 ? period : null;
}
type QueryMode = "custom" | "sql" | "promql";
/** The SQL the form writes into an empty SQL tab; it is not the user's query. */
const STARTER_SQL = /^SELECT \* FROM "[^"]*"$/;
/** Complete Builder conditions (column, operator, value unless unary), nested groups included. */
export function countCompleteConditions(tree: any): number {
if (!Array.isArray(tree?.conditions)) return 0;
let count = 0;
for (const item of tree.conditions) {
if (item?.filterType === "group") {
count += countCompleteConditions(item);
} else if (
item?.column &&
item.operator &&
(!isBlank(item.value) || isUnaryOperator(item.operator))
) {
count++;
}
}
return count;
}
/** The modes of a scheduled alert that hold content, in the order Builder, SQL, PromQL. */
export function modesWithContent(input: {
sql?: string;
promql?: string;
conditions?: unknown;
streamType?: string;
}): QueryMode[] {
const modes: QueryMode[] = [];
if (countCompleteConditions(input.conditions) > 0) modes.push("custom");
const sql = input.sql?.trim();
if (sql && !STARTER_SQL.test(sql)) modes.push("sql");
if (input.streamType === "metrics" && input.promql?.trim()) modes.push("promql");
return modes;
}
+31 -2
View File
@@ -323,13 +323,42 @@ describe("alertPayload", () => {
expect(payload.query_condition.promql_condition).toBeNull();
});
it("should clear sql for promql tab", () => {
// A PromQL alert never runs its SQL (the backend reads `sql` only for SQL
// alerts), so the stored query survives the save, as it does for Builder.
it("keeps the stored sql for promql tab", () => {
const formData = createBaseFormData();
formData.query_condition.sql = 'SELECT count(*) FROM "cpu_usage"';
const context = createBaseContext({ getSelectedTab: { value: "promql" } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.sql).toBe("");
expect(payload.query_condition.sql).toBe('SELECT count(*) FROM "cpu_usage"');
});
// Compare-with-Past windows only run with SQL; the form keeps them on other tabs.
describe("multi_time_range", () => {
const windows = [{ offSet: "1h" }];
const withWindows = (type: string) => {
const formData = createBaseFormData();
return {
...formData,
query_condition: { ...formData.query_condition, type, multi_time_range: windows },
};
};
it.each(["custom", "promql"])("sends no windows for a %s alert", (type) => {
const context = createBaseContext({ getSelectedTab: { value: type } });
const payload = getAlertPayload(withWindows(type), context);
expect(payload.query_condition.multi_time_range).toEqual([]);
});
it("keeps the windows for a sql alert", () => {
const payload = getAlertPayload(withWindows("sql"), createBaseContext());
expect(payload.query_condition.multi_time_range).toEqual(windows);
});
});
it("should base64 encode vrl_function when present", () => {
+3 -4
View File
@@ -41,6 +41,7 @@ export interface PayloadFormData {
promql_condition?: any;
sql: string;
vrl_function?: string | null;
multi_time_range?: any[] | null;
/**
* The SLO burn/budget condition. The backend enforces
* `query_type == "slo"` IFF this is present, in BOTH directions, so a
@@ -126,6 +127,8 @@ export const getAlertPayload = (formData: PayloadFormData, context: PayloadConte
payload.context_attributes = {} as any;
payload.query_condition.type = payload.is_real_time ? "custom" : formData.query_condition.type;
// Compare-with-Past windows only run with SQL; never send them for another type.
if (payload.query_condition.type !== "sql") payload.query_condition.multi_time_range = [];
formData.context_attributes.forEach((attr: any) => {
if (attr.key?.trim() && attr.value?.trim()) payload.context_attributes[attr.key] = attr.value;
@@ -213,10 +216,6 @@ export const getAlertPayload = (formData: PayloadFormData, context: PayloadConte
payload.query_condition.promql_condition = null;
}
if (getSelectedTab.value === "promql") {
payload.query_condition.sql = "";
}
// Feature 5 (§6b.6). The backend enforces `query_type == slo` IFF
// `slo_condition` is present, in BOTH directions, so the two must be kept in
// lockstep here:
@@ -80,3 +80,34 @@ describe("generateAlertSummary in Forecast mode", () => {
expect(text).not.toContain("forecast to rise");
});
});
describe("generateAlertSummary: Compare-with-Past windows", () => {
// Returns the key, so a test asserts which sentence the summary chose.
const keyT = (key: string) => key;
const scheduledAlert = (type: string) => ({
stream_name: "default",
stream_type: "logs",
is_real_time: "false",
query_condition: {
type,
sql: 'SELECT count(*) FROM "default"',
multi_time_range: [{ offSet: "1h" }],
},
trigger_condition: { period: 10, operator: ">=", threshold: 3, frequency: 10, silence: 10 },
destinations: [],
});
it("lists the windows for a SQL alert", () => {
expect(generateAlertSummary(scheduledAlert("sql"), [], keyT)).toContain(
"alerts.summary.timeRangeCount",
);
});
// The payload sends windows only with SQL, so the summary must not promise them.
it("leaves out windows that a Builder alert does not save", () => {
expect(generateAlertSummary(scheduledAlert("custom"), [], keyT)).not.toContain(
"alerts.summary.timeRangeCount",
);
});
});
@@ -149,8 +149,9 @@ export function generateAlertSummary(
if (formData.trigger_condition?.period) {
let period: string;
let fieldId: string;
// Check if multi-time range comparison is enabled
// Compare-with-Past windows are saved only with SQL (getAlertPayload).
if (
formData.query_condition?.type === "sql" &&
formData.query_condition?.multi_time_range &&
formData.query_condition.multi_time_range.length > 0
) {