[release-11.6.4] Alerting: Fix group interval override when adding new rules (#107496)

* Alerting: Fix group interval override when adding new rules (#107324)

* Fix group interval override when adding new rules to existing groups

* Fix lint errors

* Update tests snapshots

* Update tests snapshots

* Fix GrafanaGroupLoader

(cherry picked from commit 3a38832ff6)

* Update tests
This commit is contained in:
Konrad Lalik
2025-07-07 12:30:56 +02:00
committed by GitHub
parent 3a4bda5604
commit 3b95e22331
11 changed files with 123 additions and 11 deletions
@@ -4,6 +4,7 @@ exports[`Moving a Data source managed rule should move a rule in a namespace to
[
{
"body": {
"interval": "1m",
"name": "group-1",
"rules": [
{
@@ -49,6 +50,7 @@ exports[`Moving a Data source managed rule should move a rule in an existing gro
[
{
"body": {
"interval": "1m",
"name": "entirely new group name",
"rules": [
{
@@ -190,6 +192,7 @@ exports[`Moving a Grafana managed rule should move a rule from an existing group
[
{
"body": {
"interval": "1m",
"name": "empty-group",
"rules": [
{
@@ -4,6 +4,7 @@ exports[`Updating a Data source managed rule should be able to move a rule if ta
[
{
"body": {
"interval": "1m",
"name": "a new group",
"rules": [
{
@@ -144,7 +145,7 @@ exports[`Updating a Grafana managed rule should move a rule in to another group
[
{
"body": {
"interval": "1m",
"interval": "5m",
"name": "grafana-group-2",
"rules": [
{
@@ -6,7 +6,7 @@ import { PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { notFoundToNullOrThrow } from '../../api/util';
import { ruleGroupReducer } from '../../reducers/ruler/ruleGroups';
import { addRuleAction, ruleGroupReducer } from '../../reducers/ruler/ruleGroups';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi;
@@ -57,6 +57,10 @@ export function useProduceNewRuleGroup() {
.unwrap()
.catch(notFoundToNullOrThrow);
if (latestRuleGroupDefinition && addRuleAction.match(action)) {
action.payload.interval = latestRuleGroupDefinition.interval;
}
const newRuleGroupDefinition = ruleGroupReducer(
latestRuleGroupDefinition ?? createBlankRuleGroup(ruleGroup.groupName),
action
@@ -9,8 +9,8 @@ import { PostableRuleDTO } from 'app/types/unified-alerting-dto';
import { setupMswServer } from '../../mockApi';
import { grantUserPermissions } from '../../mocks';
import {
grafanaRulerGroupName,
grafanaRulerGroupName2,
grafanaRulerGroup,
grafanaRulerGroup2,
grafanaRulerNamespace,
grafanaRulerRule,
} from '../../mocks/grafanaRulerApi';
@@ -41,7 +41,7 @@ describe('Updating a Grafana managed rule', () => {
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
groupName: grafanaRulerGroup.name,
namespaceName: grafanaRulerNamespace.uid,
};
@@ -71,13 +71,13 @@ describe('Updating a Grafana managed rule', () => {
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
groupName: grafanaRulerGroup.name,
namespaceName: grafanaRulerNamespace.uid,
};
const targetRuleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName2,
groupName: grafanaRulerGroup2.name,
namespaceName: grafanaRulerNamespace.uid,
};
@@ -110,7 +110,7 @@ describe('Updating a Grafana managed rule', () => {
it('should fail if the rule does not exist in the group', async () => {
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
groupName: grafanaRulerGroup.name,
namespaceName: grafanaRulerNamespace.uid,
};
@@ -69,7 +69,7 @@ export const grafanaRulerGroup: RulerRuleGroupDTO = {
export const grafanaRulerGroup2: RulerRuleGroupDTO = {
name: grafanaRulerGroupName2,
interval: '1m',
interval: '5m',
rules: [grafanaRulerRule],
};
@@ -92,6 +92,11 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
}
const matchingGroup = namespace.find((group) => group.name === groupName);
if (!matchingGroup) {
return HttpResponse.json({ message: 'group does not exist' }, { status: 404 });
}
return HttpResponse.json<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
@@ -53,6 +53,11 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
}
const matchingGroup = namespace.find((group) => group.name === groupName);
if (!matchingGroup) {
return HttpResponse.json({ message: 'group does not exist' }, { status: 404 });
}
return HttpResponse.json<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
@@ -9,6 +9,8 @@ import { hashRulerRule } from '../../utils/rule-id';
import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, rulerRuleType } from '../../utils/rules';
// rule-scoped actions
// TOOD The interval field only make sense when adding a rule to a new rule group.
// We need to split these into distinct actions and introduce a separete addNewRuleGroupAction.
export const addRuleAction = createAction<{ rule: PostableRuleDTO; groupName?: string; interval?: string }>(
'ruleGroup/rules/add'
);
@@ -8,7 +8,7 @@ import { AccessControlAction } from 'app/types';
import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { GROUP_3, NAMESPACE_2 } from '../mocks/mimirRulerApi';
import { GROUP_3, GROUP_4, NAMESPACE_2 } from '../mocks/mimirRulerApi';
import { mimirDataSource } from '../mocks/server/configure';
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
import { captureRequests, serializeRequests } from '../mocks/server/events';
@@ -86,4 +86,52 @@ describe('RuleEditor cloud', () => {
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
});
it('should keep existing rule interval duration when attaching new rules', async () => {
const { user } = renderRuleEditor();
const removeExpressionsButtons = await screen.findAllByLabelText(/Remove expression/);
expect(removeExpressionsButtons).toHaveLength(2);
// Needs to wait for feature discovery API call to finish - Check if ruler enabled
expect(await screen.findByText('Data source-managed')).toBeInTheDocument();
const switchToCloudButton = screen.getByText('Data source-managed');
expect(switchToCloudButton).toBeInTheDocument();
expect(switchToCloudButton).toBeEnabled();
await user.click(switchToCloudButton);
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText(/Remove expression/)).toHaveLength(0);
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toBeInTheDocument();
const dataSourceSelect = await ui.inputs.dataSource.find();
await user.click(dataSourceSelect);
await user.click(screen.getByText(MIMIR_DATASOURCE_UID));
await user.type(await ui.inputs.expr.find(), 'up == 1');
await user.type(ui.inputs.name.get(), 'my great new rule with 3m interval');
await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2);
await clickSelectOption(ui.inputs.group.get(), GROUP_4);
await user.type(ui.inputs.annotationValue(0).get(), 'some summary');
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
await user.click(ui.buttons.addLabel.get());
// save and check what was sent to backend
const capture = captureRequests();
await user.click(ui.buttons.saveAndExit.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
const saveRequest = serializedRequests.find((req) => req.method === 'POST');
expect(saveRequest).toBeDefined();
expect(saveRequest?.body).toMatchObject({ interval: '3m' });
});
});
@@ -10,7 +10,7 @@ import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/s
import { AccessControlAction } from 'app/types';
import { grantUserPermissions, mockDataSource } from '../mocks';
import { grafanaRulerGroup } from '../mocks/grafanaRulerApi';
import { grafanaRulerGroup, grafanaRulerGroup2 } from '../mocks/grafanaRulerApi';
import { captureRequests, serializeRequests } from '../mocks/server/events';
import { setupDataSources } from '../testSetup/datasources';
@@ -104,4 +104,47 @@ describe('RuleEditor grafana managed rules', () => {
await user.click(ui.buttons.preview.get());
expect(await screen.findByText(/you cannot use time series data as an alert condition/i)).toBeInTheDocument();
});
it('should keep existing group interval when creating new rule in existing group', async () => {
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
const { user } = renderRuleEditor();
await user.type(await ui.inputs.name.find(), 'my great new rule');
await user.click(await screen.findByRole('button', { name: /select folder/i }));
await user.click(await screen.findByLabelText(/folder a/i));
// Select the existing group with 5m interval
const groupInput = await ui.inputs.group.find();
await user.click(await byRole('combobox').find(groupInput));
await clickSelectOption(groupInput, grafanaRulerGroup2.name);
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
// Set pending period to none (0s) to avoid validation errors
const pendingPeriodInput = await ui.inputs.pendingPeriod.find();
await user.clear(pendingPeriodInput);
await user.type(pendingPeriodInput, '0s');
await user.click(ui.buttons.saveAndExit.get());
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
// Verify that the existing group's 5m interval is preserved
const saveRequest = serializedRequests.find((req) => req.method === 'POST');
expect(saveRequest).toBeDefined();
expect(saveRequest?.body).toMatchObject({
name: grafanaRulerGroup2.name,
interval: '5m', // The existing group's interval should be preserved
rules: expect.arrayContaining([
expect.objectContaining({
annotations: expect.objectContaining({
description: 'some description',
}),
for: '0s',
}),
]),
});
});
});
@@ -22,6 +22,7 @@ export const ui = {
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
namespace: byTestId('namespace-picker'),
group: byTestId('group-picker'),
pendingPeriod: byRole('textbox', { name: /^pending period/i }),
annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`),
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
labelKey: (idx: number) => byTestId(`label-key-${idx}`),