mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
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:
co-authored by
Neha P
github-actions[bot]
parent
8c7bb7cde6
commit
07e159549d
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+152
@@ -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');
|
||||
});
|
||||
});
|
||||
+165
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "مجموعة الحالة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "条件グループ",
|
||||
|
||||
@@ -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": "조건 그룹",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Группа условий",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "条件组",
|
||||
|
||||
@@ -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": "條件群組",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user