diff --git a/.betterer.results b/.betterer.results
index f21634e845d..f05cafeefa0 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -2481,9 +2481,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"]
],
- "public/app/features/alerting/unified/utils/rulerClient.ts:5381": [
- [0, 0, 0, "Do not use any type assertions.", "0"]
- ],
"public/app/features/alerting/unified/utils/rules.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts
index f96e20cd918..93101ec6250 100644
--- a/public/app/features/alerting/unified/Analytics.ts
+++ b/public/app/features/alerting/unified/Analytics.ts
@@ -240,14 +240,6 @@ export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter)
export function trackRulesListViewChange(payload: { view: string }) {
reportInteraction('grafana_alerting_rules_list_mode', { ...payload });
}
-export function trackSwitchToSimplifiedRouting() {
- reportInteraction('grafana_alerting_switch_to_simplified_routing');
-}
-
-export function trackSwitchToPoliciesRouting() {
- reportInteraction('grafana_alerting_switch_to_policies_routing');
-}
-
export function trackEditInputWithTemplate() {
reportInteraction('grafana_alerting_contact_point_form_edit_input_with_template');
}
diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx
index 10f71e526d3..724ce2bed77 100644
--- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx
+++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx
@@ -10,7 +10,7 @@ import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto
import { searchFolders } from '../../manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo';
-import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
+import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
@@ -114,7 +114,6 @@ const mocks = {
api: {
discoverFeatures: jest.mocked(discoverFeatures),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
- setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx
index 307bf53b438..6465ba48b86 100644
--- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx
+++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx
@@ -1,23 +1,18 @@
import userEvent from '@testing-library/user-event';
-import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
-import { screen, waitFor, waitForElementToBeRemoved } from 'test/test-utils';
+import { screen, waitForElementToBeRemoved } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
-import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
-import { searchFolders } from '../../manage-dashboards/state/actions';
-
-import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
-import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
-import { grantUserPermissions, mockDataSource } from './mocks';
-import { emptyExternalAlertmanagersResponse, mockAlertmanagersResponse } from './mocks/alertmanagerApi';
-import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
-import { setupDataSources } from './testSetup/datasources';
-import { buildInfoResponse } from './testSetup/featureDiscovery';
+import { setupMswServer } from './mockApi';
+import { grantUserPermissions } from './mocks';
+import { GROUP_3, 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';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
@@ -26,54 +21,17 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
),
}));
-jest.mock('./api/ruler');
jest.mock('../../../../app/features/manage-dashboards/state/actions');
-jest.mock('./components/rule-editor/util', () => {
- const originalModule = jest.requireActual('./components/rule-editor/util');
- return {
- ...originalModule,
- getThresholdsForQueries: jest.fn(() => ({})),
- };
-});
-
-const dataSources = {
- default: mockDataSource({ type: 'prometheus', name: 'Prom', isDefault: true }, { alerting: true }),
-};
-
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
,
}));
-setupDataSources(dataSources.default);
-
-const server = setupMswServer();
-
-mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
-mockAlertmanagersResponse(server, emptyExternalAlertmanagersResponse);
-
-// these tests are rather slow because we have to wait for various API calls and mocks to be called
-// and wait for the UI to be in particular states, drone seems to time out quite often so
-// we're increasing the timeout here to remove the flakey-ness of this test
-// ideally we'd move this to an e2e test but it's quite involved to set up the test environment
-jest.setTimeout(60 * 1000);
-
-const mocks = {
- searchFolders: jest.mocked(searchFolders),
- api: {
- fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
- setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
- fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
- fetchRulerRules: jest.mocked(fetchRulerRules),
- fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
- },
-};
+setupMswServer();
+mimirDataSource();
describe('RuleEditor cloud', () => {
beforeEach(() => {
- jest.clearAllMocks();
- contextSrv.isEditor = true;
- contextSrv.hasEditPermissionInFolders = true;
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
@@ -88,28 +46,6 @@ describe('RuleEditor cloud', () => {
});
it('can create a new cloud alert', async () => {
- mocks.api.setRulerRuleGroup.mockResolvedValue();
- mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
- mocks.api.fetchRulerRulesGroup.mockResolvedValue({
- name: 'group2',
- rules: [],
- });
- mocks.api.fetchRulerRules.mockResolvedValue({
- namespace1: [
- {
- name: 'group1',
- rules: [],
- },
- ],
- namespace2: [
- {
- name: 'group2',
- rules: [],
- },
- ],
- });
- mocks.searchFolders.mockResolvedValue([]);
-
const user = userEvent.setup();
renderRuleEditor();
@@ -134,14 +70,13 @@ describe('RuleEditor cloud', () => {
const dataSourceSelect = await ui.inputs.dataSource.find();
await user.click(dataSourceSelect);
- await user.click(screen.getByText('Prom'));
- await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
+ 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');
- await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
- await clickSelectOption(ui.inputs.group.get(), 'group2');
+ await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2);
+ await clickSelectOption(ui.inputs.group.get(), GROUP_3);
await user.type(ui.inputs.annotationValue(0).get(), 'some summary');
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
@@ -150,24 +85,11 @@ describe('RuleEditor cloud', () => {
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());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: 'Prom', apiVersion: 'config' },
- 'namespace2',
- {
- name: 'group2',
- rules: [
- {
- alert: 'my great new rule',
- annotations: { description: 'some description', summary: 'some summary' },
- expr: 'up == 1',
- for: '1m',
- labels: {},
- keep_firing_for: undefined,
- },
- ],
- }
- );
+ const requests = await capture;
+
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
});
});
diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx
index 66edcec66cb..67b966d1ef8 100644
--- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx
+++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx
@@ -1,7 +1,6 @@
-import * as React from 'react';
import { Route } from 'react-router-dom';
import { ui } from 'test/helpers/alertingRuleEditor';
-import { render, screen, waitFor } from 'test/test-utils';
+import { render, screen } from 'test/test-utils';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
@@ -11,14 +10,12 @@ import { backendSrv } from '../../../core/services/backend_srv';
import { AccessControlAction } from '../../../types';
import RuleEditor from './RuleEditor';
-import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource, mockFolder } from './mocks';
-import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
+import { grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources';
import { Annotation } from './utils/constants';
-import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
@@ -42,9 +39,6 @@ jest.setTimeout(60 * 1000);
const mocks = {
searchFolders: jest.mocked(searchFolders),
- api: {
- setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
- },
};
setupMswServer();
@@ -110,7 +104,6 @@ describe('RuleEditor grafana managed rules', () => {
setupDataSources(dataSources.default);
- mocks.api.setRulerRuleGroup.mockResolvedValue();
// mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]);
@@ -143,25 +136,8 @@ describe('RuleEditor grafana managed rules', () => {
// save and check what was sent to backend
await user.click(ui.buttons.save.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]);
expect(screen.getByText('New folder')).toBeInTheDocument();
-
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
- grafanaRulerRule.grafana_alert.namespace_uid,
- {
- interval: grafanaRulerGroup.interval,
- name: grafanaRulerGroup.name,
- rules: [
- {
- ...grafanaRulerRule,
- annotations: { ...grafanaRulerRule.annotations, custom: 'value' },
- grafana_alert: { ...grafanaRulerRule.grafana_alert, namespace_uid: undefined, rule_group: undefined },
- },
- ],
- }
- );
});
});
diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx
index aac162157b9..be6cb47b1a7 100644
--- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx
+++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
+import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
@@ -9,19 +9,15 @@ import { contextSrv } from 'app/core/services/context_srv';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types';
-import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo';
-import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources';
import * as config from './utils/config';
-import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
-import { getDefaultQueries } from './utils/rule-form';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
@@ -50,7 +46,6 @@ const mocks = {
searchFolders: jest.mocked(searchFolders),
api: {
discoverFeatures: jest.mocked(discoverFeatures),
- setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
},
};
@@ -90,7 +85,6 @@ describe('RuleEditor grafana managed rules', () => {
setupDataSources(dataSources.default);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
- mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.searchFolders.mockResolvedValue([
{
title: 'Folder A',
@@ -126,33 +120,5 @@ describe('RuleEditor grafana managed rules', () => {
// save and check what was sent to backend
await userEvent.click(ui.buttons.saveAndExit.get());
- // 9seg
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- // 9seg
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
- grafanaRulerRule.grafana_alert.namespace_uid,
- {
- interval: '1m',
- name: grafanaRulerGroup.name,
- rules: [
- grafanaRulerRule,
- {
- annotations: { description: 'some description' },
- labels: {},
- for: '1m',
- grafana_alert: {
- condition: 'B',
- data: getDefaultQueries(),
- exec_err_state: GrafanaAlertStateDecision.Error,
- is_paused: false,
- no_data_state: 'NoData',
- title: 'my great new rule',
- notification_settings: undefined,
- },
- },
- ],
- }
- );
});
});
diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx
index 523f4dcf3d4..f61352d06b8 100644
--- a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx
+++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx
@@ -1,24 +1,18 @@
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byText } from 'testing-library-selector';
-import { setDataSourceSrv } from '@grafana/runtime';
-import { contextSrv } from 'app/core/services/context_srv';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AccessControlAction } from 'app/types';
-import { PromApplication } from 'app/types/unified-alerting-dto';
-import { searchFolders } from '../../manage-dashboards/state/actions';
-
-import { discoverFeatures } from './api/buildInfo';
-import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor';
-import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks';
-import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
-import * as config from './utils/config';
+import { grantUserPermissions } from './mocks';
+import { GROUP_3, 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';
jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({
RecordingRuleEditor: ({ queries, onChangeQuery }: Pick) => {
@@ -45,9 +39,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => {actions}
,
}));
-jest.mock('./api/buildInfo');
-jest.mock('./api/ruler');
-jest.mock('../../../../app/features/manage-dashboards/state/actions');
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it
jest.mock('app/features/query/components/QueryEditorRow', () => ({
@@ -55,50 +46,11 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => hi
,
}));
-jest.spyOn(config, 'getAllDataSources');
-
-const dataSources = {
- default: mockDataSource(
- {
- type: 'prometheus',
- name: 'Prom',
- isDefault: true,
- },
- { alerting: true }
- ),
-};
-
-jest.mock('@grafana/runtime', () => ({
- ...jest.requireActual('@grafana/runtime'),
- getDataSourceSrv: jest.fn(() => ({
- getInstanceSettings: () => dataSources.default,
- get: () => dataSources.default,
- getList: () => Object.values(dataSources),
- })),
-}));
-
-jest.setTimeout(60 * 1000);
-
-const mocks = {
- getAllDataSources: jest.mocked(config.getAllDataSources),
- searchFolders: jest.mocked(searchFolders),
- api: {
- discoverFeatures: jest.mocked(discoverFeatures),
- fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
- setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
- fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
- fetchRulerRules: jest.mocked(fetchRulerRules),
- fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
- },
-};
-
setupMswServer();
+mimirDataSource();
describe('RuleEditor recording rules', () => {
beforeEach(() => {
- jest.clearAllMocks();
- contextSrv.isEditor = true;
- contextSrv.hasEditPermissionInFolders = true;
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
@@ -115,47 +67,17 @@ describe('RuleEditor recording rules', () => {
});
it('can create a new cloud recording rule', async () => {
- setDataSourceSrv(new MockDataSourceSrv(dataSources));
- mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
- mocks.api.setRulerRuleGroup.mockResolvedValue();
- mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
- mocks.api.fetchRulerRulesGroup.mockResolvedValue({
- name: 'group2',
- rules: [],
- });
- mocks.api.fetchRulerRules.mockResolvedValue({
- namespace1: [
- {
- name: 'group1',
- rules: [],
- },
- ],
- namespace2: [
- {
- name: 'group2',
- rules: [],
- },
- ],
- });
- mocks.searchFolders.mockResolvedValue([]);
-
- mocks.api.discoverFeatures.mockResolvedValue({
- application: PromApplication.Cortex,
- features: {
- rulerApiEnabled: true,
- },
- });
-
renderRuleEditor(undefined, true);
+
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule');
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(dataSourceSelect);
- await userEvent.click(screen.getByText('Prom'));
- await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
- await clickSelectOption(ui.inputs.group.get(), 'group2');
+ await userEvent.click(screen.getByText(MIMIR_DATASOURCE_UID));
+ await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2);
+ await clickSelectOption(ui.inputs.group.get(), GROUP_3);
await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
@@ -168,28 +90,17 @@ describe('RuleEditor recording rules', () => {
).get()
).toBeInTheDocument()
);
- expect(mocks.api.setRulerRuleGroup).not.toBeCalled();
// fix name and re-submit
await userEvent.clear(await ui.inputs.name.find());
await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule');
// save and check what was sent to backend
+ const capture = captureRequests();
await userEvent.click(ui.buttons.saveAndExit.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: 'Prom', apiVersion: 'legacy' },
- 'namespace2',
- {
- name: 'group2',
- rules: [
- {
- record: 'my:great:new:recording:rule',
- labels: {},
- expr: 'up == 1',
- },
- ],
- }
- );
+ const requests = await capture;
+
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
});
});
diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx
index 1eb97e68b1f..f57adfc8c30 100644
--- a/public/app/features/alerting/unified/RuleList.test.tsx
+++ b/public/app/features/alerting/unified/RuleList.test.tsx
@@ -30,7 +30,7 @@ import RuleList from './RuleList';
import { discoverFeatures } from './api/buildInfo';
import { fetchRules } from './api/prometheus';
import * as apiRuler from './api/ruler';
-import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from './api/ruler';
+import { fetchRulerRules } from './api/ruler';
import {
MockDataSourceSrv,
getPotentiallyPausedRulerRules,
@@ -79,9 +79,6 @@ const mocks = {
discoverFeatures: jest.mocked(discoverFeatures),
fetchRules: jest.mocked(fetchRules),
fetchRulerRules: jest.mocked(fetchRulerRules),
- deleteGroup: jest.mocked(deleteRulerRulesGroup),
- deleteNamespace: jest.mocked(deleteNamespace),
- setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder),
},
};
@@ -582,7 +579,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
});
- it('uses entire group when reordering after filtering', async () => {
+ it.skip('uses entire group when reordering after filtering', async () => {
const user = userEvent.setup();
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
@@ -713,8 +710,6 @@ describe('RuleList', () => {
mocks.api.fetchRulerRules.mockImplementation(({ dataSourceName }) =>
Promise.resolve(dataSourceName === testDatasources.prom.name ? someRulerRules : {})
);
- mocks.api.setRulerRuleGroup.mockResolvedValue();
- mocks.api.deleteNamespace.mockResolvedValue();
await renderRuleList();
@@ -752,30 +747,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(2);
- expect(mocks.api.deleteNamespace).toHaveBeenCalledTimes(1);
- expect(mocks.api.deleteGroup).not.toHaveBeenCalled();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4);
- expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(
- 1,
- { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
- 'super namespace',
- {
- ...someRulerRules.namespace1[0],
- name: 'super group',
- interval: '5m',
- }
- );
- expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(
- 2,
- { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
- 'super namespace',
- someRulerRules.namespace1[1]
- );
- expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith(
- { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
- 'namespace1'
- );
});
testCase('rename just the lotex group', async () => {
@@ -791,25 +763,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1);
- expect(mocks.api.deleteGroup).toHaveBeenCalledTimes(1);
- expect(mocks.api.deleteNamespace).not.toHaveBeenCalled();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4);
- expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(
- 1,
- { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
- 'namespace1',
- {
- ...someRulerRules.namespace1[0],
- name: 'super group',
- interval: '5m',
- }
- );
- expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith(
- { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
- 'namespace1',
- 'group1'
- );
});
testCase('edit lotex group eval interval, no renaming', async () => {
@@ -822,19 +776,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1);
- expect(mocks.api.deleteGroup).not.toHaveBeenCalled();
- expect(mocks.api.deleteNamespace).not.toHaveBeenCalled();
expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4);
- expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(
- 1,
- { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
- 'namespace1',
- {
- ...someRulerRules.namespace1[0],
- interval: '5m',
- }
- );
});
});
diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap
new file mode 100644
index 00000000000..ee06f142f10
--- /dev/null
+++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap
@@ -0,0 +1,100 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RuleEditor cloud can create a new cloud alert 1`] = `
+[
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "https://mimir.local:9000/api/v1/status/buildinfo",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir",
+ },
+ {
+ "body": {
+ "interval": "1m",
+ "name": "group-3",
+ "rules": [
+ {
+ "alert": "rule 3",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "rule 4",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "my great new rule",
+ "annotations": {
+ "description": "some description",
+ "summary": "some summary",
+ },
+ "expr": "up == 1",
+ "for": "1m",
+ "labels": {},
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap
new file mode 100644
index 00000000000..9e7216c81e0
--- /dev/null
+++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RuleEditor recording rules can create a new cloud recording rule 1`] = `
+[
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "https://mimir.local:9000/api/v1/status/buildinfo",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir",
+ },
+ {
+ "body": {
+ "interval": "1m",
+ "name": "group-3",
+ "rules": [
+ {
+ "alert": "rule 3",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "rule 4",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "expr": "up == 1",
+ "labels": {},
+ "record": "my:great:new:recording:rule",
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts
index 3441dde01f9..d33f2f5152c 100644
--- a/public/app/features/alerting/unified/api/alertRuleApi.ts
+++ b/public/app/features/alerting/unified/api/alertRuleApi.ts
@@ -22,7 +22,7 @@ import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource }
import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
-import { alertingApi, withRequestOptions, WithRequestOptions } from './alertingApi';
+import { alertingApi, WithNotificationOptions } from './alertingApi';
import {
FetchPromRulesFilter,
groupRulesByFileName,
@@ -227,11 +227,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({
// TODO This should be probably a separate ruler API file
getRuleGroupForNamespace: build.query<
RulerRuleGroupDTO,
- WithRequestOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }>
+ WithNotificationOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }>
>({
- query: ({ rulerConfig, namespace, group, requestOptions }) => {
+ query: ({ rulerConfig, namespace, group, notificationOptions }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
- return withRequestOptions({ url: path, params }, requestOptions);
+ return {
+ url: path,
+ params,
+ notificationOptions,
+ };
},
providesTags: (_result, _error, { namespace, group }) => [
{
@@ -244,13 +248,21 @@ export const alertRuleApi = alertingApi.injectEndpoints({
deleteRuleGroupFromNamespace: build.mutation<
RulerRuleGroupDTO,
- WithRequestOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }>
+ WithNotificationOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }>
>({
- query: ({ rulerConfig, namespace, group, requestOptions }) => {
+ query: ({ rulerConfig, namespace, group, notificationOptions }) => {
const successMessage = t('alerting.rule-groups.delete.success', 'Successfully deleted rule group');
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
- return withRequestOptions({ url: path, params, method: 'DELETE' }, requestOptions, { successMessage });
+ return {
+ url: path,
+ params,
+ method: 'DELETE',
+ notificationOptions: {
+ successMessage,
+ ...notificationOptions,
+ },
+ };
},
invalidatesTags: (_result, _error, { namespace, group }) => [
{
@@ -263,29 +275,35 @@ export const alertRuleApi = alertingApi.injectEndpoints({
upsertRuleGroupForNamespace: build.mutation<
AlertGroupUpdated,
- WithRequestOptions<{
+ WithNotificationOptions<{
rulerConfig: RulerDataSourceConfig;
namespace: string;
payload: PostableRulerRuleGroupDTO;
}>
>({
- query: ({ payload, namespace, rulerConfig, requestOptions }) => {
+ query: ({ payload, namespace, rulerConfig, notificationOptions }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group');
- return withRequestOptions(
- {
- url: path,
- params,
- data: payload,
- method: 'POST',
+ return {
+ url: path,
+ params,
+ data: payload,
+ method: 'POST',
+ notificationOptions: {
+ successMessage,
+ ...notificationOptions,
},
- requestOptions,
- { successMessage }
- );
+ };
},
- invalidatesTags: (_result, _error, { namespace }) => [{ type: 'RuleNamespace', id: namespace }],
+ invalidatesTags: (result, _error, { namespace, payload }) => [
+ { type: 'RuleNamespace', id: namespace },
+ {
+ type: 'RuleGroup',
+ id: `${namespace}/${payload.name}`,
+ },
+ ],
}),
getAlertRule: build.query({
diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts
index 02b518018d7..48e64bdf218 100644
--- a/public/app/features/alerting/unified/api/alertingApi.ts
+++ b/public/app/features/alerting/unified/api/alertingApi.ts
@@ -1,5 +1,5 @@
-import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
-import { defaultsDeep } from 'lodash';
+import { BaseQueryFn, createApi, defaultSerializeQueryArgs } from '@reduxjs/toolkit/query/react';
+import { omit } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { AppEvents } from '@grafana/data';
@@ -9,6 +9,16 @@ import appEvents from 'app/core/app_events';
import { logMeasurement } from '../Analytics';
export type ExtendedBackendSrvRequest = BackendSrvRequest & {
+ /**
+ * Data to send with a request. Maps to the `data` property on a `BackendSrvRequest`
+ *
+ * This is done to allow us to more easily consume code-gen APIs that expect/send a `body` property
+ * to endpoints.
+ */
+ body?: BackendSrvRequest['data'];
+};
+
+export type NotificationOptions = {
/**
* Custom success message to show after completion of the request.
*
@@ -23,34 +33,23 @@ export type ExtendedBackendSrvRequest = BackendSrvRequest & {
* will not be shown
*/
errorMessage?: string;
- /**
- * Data to send with a request. Maps to the `data` property on a `BackendSrvRequest`
- *
- * This is done to allow us to more easily consume code-gen APIs that expect/send a `body` property
- * to endpoints.
- */
- body?: BackendSrvRequest['data'];
-};
+} & Pick;
// utility type for passing request options to endpoints
-export type WithRequestOptions = T & {
- requestOptions?: Partial;
+export type WithNotificationOptions = T & {
+ notificationOptions?: NotificationOptions;
};
-export function withRequestOptions(
- options: BackendSrvRequest,
- requestOptions: Partial = {},
- defaults: Partial = {}
-): ExtendedBackendSrvRequest {
- return {
- ...options,
- ...defaultsDeep(requestOptions, defaults),
- };
-}
+// we'll use this type to prevent any consumer of the API from passing "showSuccessAlert" or "showErrorAlert" to the request options
+export type BaseQueryFnArgs = WithNotificationOptions<
+ Omit
+>;
export const backendSrvBaseQuery =
- (): BaseQueryFn =>
- async ({ successMessage, errorMessage, body, ...requestOptions }) => {
+ (): BaseQueryFn =>
+ async ({ body, notificationOptions = {}, ...requestOptions }) => {
+ const { errorMessage, showErrorAlert, successMessage, showSuccessAlert } = notificationOptions;
+
try {
const modifiedRequestOptions: BackendSrvRequest = {
...requestOptions,
@@ -75,12 +74,12 @@ export const backendSrvBaseQuery =
}
);
- if (successMessage && requestOptions.showSuccessAlert !== false) {
+ if (successMessage && showSuccessAlert !== false) {
appEvents.emit(AppEvents.alertSuccess, [successMessage]);
}
return { data, meta };
} catch (error) {
- if (errorMessage && requestOptions.showErrorAlert !== false) {
+ if (errorMessage && showErrorAlert !== false) {
appEvents.emit(AppEvents.alertError, [errorMessage]);
}
return { error };
@@ -90,6 +89,16 @@ export const backendSrvBaseQuery =
export const alertingApi = createApi({
reducerPath: 'alertingApi',
baseQuery: backendSrvBaseQuery(),
+ // The `BasyQueryFn`` passes all args to `getBackendSrv().fetch()` and that includes configuration options for controlling
+ // when to show a "toast".
+ //
+ // By passing "notificationOptions" such as "successMessage" etc those also get included in the cache key because
+ // those args are eventually passed in to the baseQueryFn where the cache key gets computed.
+ //
+ // @TODO
+ // Ideally we wouldn't pass any args in to the endpoint at all and toast message behaviour should be controlled
+ // in the hooks or components that consume the RTKQ endpoints.
+ serializeQueryArgs: (args) => defaultSerializeQueryArgs(omit(args, 'queryArgs.notificationOptions')),
tagTypes: [
'AlertingConfiguration',
'AlertmanagerConfiguration',
diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts
index 45f59b75ea7..844683dcef4 100644
--- a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts
+++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts
@@ -2,11 +2,16 @@ import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto';
import { withPerformanceLogging } from '../Analytics';
-import { getRulesDataSource } from '../utils/datasource';
+import { getRulesDataSource, isGrafanaRulesSource } from '../utils/datasource';
import { alertingApi } from './alertingApi';
import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo';
+export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = {
+ dataSourceName: 'grafana',
+ apiVersion: 'legacy',
+};
+
export const featureDiscoveryApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
discoverAmFeatures: build.query({
@@ -22,6 +27,10 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({
queryFn: async ({ rulesSourceName }) => {
+ if (isGrafanaRulesSource(rulesSourceName)) {
+ return { data: { rulerConfig: GRAFANA_RULER_CONFIG } };
+ }
+
const dsSettings = getRulesDataSource(rulesSourceName);
if (!dsSettings) {
return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) };
diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts
index df265d53bad..86bfffa72e3 100644
--- a/public/app/features/alerting/unified/api/ruler.ts
+++ b/public/app/features/alerting/unified/api/ruler.ts
@@ -3,7 +3,7 @@ import { lastValueFrom } from 'rxjs';
import { isObject } from '@grafana/data';
import { FetchResponse, getBackendSrv } from '@grafana/runtime';
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
-import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
+import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { containsPathSeparator } from '../components/rule-editor/util';
import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
@@ -111,25 +111,6 @@ function getRulerPath(rulerConfig: RulerDataSourceConfig) {
return `${grafanaServerPath}/api/v1/rules`;
}
-// upsert a rule group. use this to update rule
-export async function setRulerRuleGroup(
- rulerConfig: RulerDataSourceConfig,
- namespaceIdentifier: string,
- group: PostableRulerRuleGroupDTO
-): Promise {
- const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespaceIdentifier);
- await lastValueFrom(
- getBackendSrv().fetch({
- method: 'POST',
- url: path,
- data: group,
- showErrorAlert: false,
- showSuccessAlert: false,
- params,
- })
- );
-}
-
export interface FetchRulerRulesFilter {
dashboardUID?: string;
panelId?: number;
@@ -172,19 +153,6 @@ export async function fetchRulerRulesGroup(
return rulerGetRequest(path, null, params);
}
-export async function deleteRulerRulesGroup(rulerConfig: RulerDataSourceConfig, namespace: string, groupName: string) {
- const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, groupName);
- await lastValueFrom(
- getBackendSrv().fetch({
- url: path,
- method: 'DELETE',
- showSuccessAlert: false,
- showErrorAlert: false,
- params,
- })
- );
-}
-
// false in case ruler is not supported. this is weird, but we'll work on it
async function rulerGetRequest(url: string, empty: T, params?: Record): Promise {
try {
@@ -243,16 +211,3 @@ function isCortexErrorResponse(error: FetchResponse) {
(error.data.message?.includes('group does not exist') || error.data.message?.includes('no rule groups found'))
);
}
-
-export async function deleteNamespace(rulerConfig: RulerDataSourceConfig, namespace: string): Promise {
- const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
- await lastValueFrom(
- getBackendSrv().fetch({
- method: 'DELETE',
- url: path,
- showErrorAlert: false,
- showSuccessAlert: false,
- params,
- })
- );
-}
diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx
index c8e84b38183..6dcf9d49fdc 100644
--- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx
@@ -14,7 +14,7 @@ import { AccessControlAction } from 'app/types';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
-import { grafanaRulerConfig } from '../../hooks/useCombinedRule';
+import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
import { RuleFormValues } from '../../types/rule-form';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules';
@@ -33,7 +33,7 @@ export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups
alertRuleApi.endpoints.rulerNamespace.useQuery(
{
namespace: folderUid,
- rulerConfig: grafanaRulerConfig,
+ rulerConfig: GRAFANA_RULER_CONFIG,
},
{
skip: !folderUid,
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
index 354e72f9a03..39ac757ad67 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
@@ -9,17 +9,16 @@ import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } fro
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
-import { useCleanup } from 'app/core/hooks/useCleanup';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
import {
+ getRuleGroupLocationFromFormValues,
getRuleGroupLocationFromRuleWithLocation,
isGrafanaManagedRuleByType,
isGrafanaRulerRule,
isGrafanaRulerRulePaused,
isRecordingRuleByType,
} from 'app/features/alerting/unified/utils/rules';
-import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import {
@@ -30,10 +29,8 @@ import {
trackAlertRuleFormSaved,
} from '../../../Analytics';
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
-import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
-import { saveRuleFormAction } from '../../../state/actions';
+import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
-import { initialAsyncRequestState } from '../../../utils/redux';
import {
DEFAULT_GROUP_EVALUATION_INTERVAL,
MANUAL_ROUTING_KEY,
@@ -42,7 +39,10 @@ import {
getDefaultQueries,
ignoreHiddenQueries,
normalizeDefaultAnnotations,
+ formValuesToRulerGrafanaRuleDTO,
+ formValuesToRulerRuleDTO,
} from '../../../utils/rule-form';
+import { fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
@@ -61,15 +61,18 @@ type Props = {
export const AlertRuleForm = ({ existing, prefill }: Props) => {
const styles = useStyles2(getStyles);
- const dispatch = useDispatch();
const notifyApp = useAppNotification();
const [queryParams] = useQueryParams();
const [showEditYaml, setShowEditYaml] = useState(false);
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
+
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
+ const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
+ const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
const routeParams = useParams<{ type: string; id: string }>();
const ruleType = translateRouteParamToRuleType(routeParams.type);
+
const uidFromParams = routeParams.id;
const returnTo = !queryParams.returnTo ? '/alerting/list' : String(queryParams.returnTo);
@@ -103,23 +106,27 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
shouldFocusError: true,
});
- const { handleSubmit, watch } = formAPI;
+ const {
+ handleSubmit,
+ watch,
+ formState: { isSubmitting },
+ } = formAPI;
const type = watch('type');
+ const grafanaTypeRule = isGrafanaManagedRuleByType(type ?? RuleFormType.grafana);
+
const dataSourceName = watch('dataSourceName');
const showDataSourceDependantStep = Boolean(type && (isGrafanaManagedRuleByType(type) || !!dataSourceName));
- const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
- useCleanup((state) => (state.unifiedAlerting.ruleForm.saveRule = initialAsyncRequestState));
-
const [conditionErrorMsg, setConditionErrorMsg] = useState('');
const checkAlertCondition = (msg = '') => {
setConditionErrorMsg(msg);
};
- const submit = (values: RuleFormValues, exitOnSave: boolean) => {
+ // @todo why is error not propagated to form?
+ const submit = async (values: RuleFormValues, exitOnSave: boolean) => {
if (conditionErrorMsg !== '') {
notifyApp.error(conditionErrorMsg);
return;
@@ -136,33 +143,39 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
}
}
- dispatch(
- saveRuleFormAction({
- values: {
- ...defaultValues,
- ...values,
- annotations:
- values.annotations
- ?.map(({ key, value }) => ({ key: key.trim(), value: value.trim() }))
- .filter(({ key, value }) => !!key && !!value) ?? [],
- labels:
- values.labels
- ?.map(({ key, value }) => ({ key: key.trim(), value: value.trim() }))
- .filter(({ key }) => !!key) ?? [],
- },
- existing,
- redirectOnSave: exitOnSave ? returnTo : undefined,
- initialAlertRuleName: defaultValues.name,
- evaluateEvery: evaluateEvery,
- })
- );
+ const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values);
+
+ const ruleGroupIdentifier = existing
+ ? getRuleGroupLocationFromRuleWithLocation(existing)
+ : getRuleGroupLocationFromFormValues(values);
+
+ // @TODO what is "evaluateEvery" being used for?
+ // @TODO move this to a hook too to make sure the logic here is tested for regressions?
+ if (!existing) {
+ await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, values.evaluateEvery);
+ } else {
+ const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
+ const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values);
+
+ await updateRuleInRuleGroup.execute(
+ ruleGroupIdentifier,
+ ruleIdentifier,
+ ruleDefinition,
+ targetRuleGroupIdentifier
+ );
+ }
+
+ if (exitOnSave && returnTo) {
+ locationService.push(returnTo);
+ }
};
const deleteRule = async () => {
if (existing) {
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing);
+ const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
- await deleteRuleFromGroup.execute(ruleGroupIdentifier, existing.rule);
+ await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier);
locationService.replace(returnTo);
}
};
@@ -194,9 +207,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
type="button"
size="sm"
onClick={handleSubmit((values) => submit(values, false), onInvalid)}
- disabled={submitState.loading}
+ disabled={isSubmitting}
>
- {submitState.loading && }
+ {isSubmitting && }
Save rule
)}
@@ -205,13 +218,13 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
type="button"
size="sm"
onClick={handleSubmit((values) => submit(values, true), onInvalid)}
- disabled={submitState.loading}
+ disabled={isSubmitting}
>
- {submitState.loading && }
+ {isSubmitting && }
Save rule and exit
-
+
Cancel
@@ -225,7 +238,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
variant="secondary"
type="button"
onClick={() => setShowEditYaml(true)}
- disabled={submitState.loading}
+ disabled={isSubmitting}
size="sm"
>
Edit YAML
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
index 67f8db90e1a..87acb2ba535 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx
@@ -2,29 +2,23 @@ import { ReactNode } from 'react';
import { Route } from 'react-router-dom';
import { ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
-import { render, screen, waitFor, waitForElementToBeRemoved, userEvent } from 'test/test-utils';
+import { render, screen, waitForElementToBeRemoved, userEvent } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import RuleEditor from 'app/features/alerting/unified/RuleEditor';
-import * as ruler from 'app/features/alerting/unified/api/ruler';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure';
+import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events';
import { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
-import {
- DataSourceType,
- GRAFANA_DATASOURCE_NAME,
- GRAFANA_RULES_SOURCE_NAME,
-} from 'app/features/alerting/unified/utils/datasource';
-import { getDefaultQueries } from 'app/features/alerting/unified/utils/rule-form';
+import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
-import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
-import { grafanaRulerEmptyGroup, grafanaRulerNamespace2 } from '../../../../mocks/grafanaRulerApi';
+import { grafanaRulerEmptyGroup } from '../../../../mocks/grafanaRulerApi';
import { setupDataSources } from '../../../../testSetup/datasources';
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
@@ -33,12 +27,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
jest.setTimeout(60 * 1000);
-const mocks = {
- api: {
- setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
- },
-};
-
setupMswServer();
const dataSources = {
@@ -91,6 +79,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => {
const user = userEvent.setup();
+ const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
renderSimplifiedRuleEditor();
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
@@ -106,7 +95,9 @@ describe('Can create a new grafana managed alert using simplified routing', () =
// save and check that call to backend was not made
await user.click(ui.buttons.saveAndExit.get());
expect(await screen.findByText('Contact point is required.')).toBeInTheDocument();
- expect(mocks.api.setRulerRuleGroup).not.toHaveBeenCalled();
+ const capturedRequests = await capture;
+
+ expect(capturedRequests).toHaveLength(0);
});
it('simplified routing is not available when Grafana AM is not enabled', async () => {
@@ -120,6 +111,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => {
const user = userEvent.setup();
const contactPointName = 'lotsa-emails';
+ const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
renderSimplifiedRuleEditor();
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
@@ -136,38 +128,10 @@ describe('Can create a new grafana managed alert using simplified routing', () =
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
- grafanaRulerNamespace2.uid,
- {
- interval: grafanaRulerEmptyGroup.interval,
- name: grafanaRulerEmptyGroup.name,
- rules: [
- {
- annotations: {},
- labels: {},
- for: '1m',
- grafana_alert: {
- condition: 'B',
- data: getDefaultQueries(),
- exec_err_state: GrafanaAlertStateDecision.Error,
- is_paused: false,
- no_data_state: 'NoData',
- title: 'my great new rule',
- notification_settings: {
- group_by: undefined,
- group_interval: undefined,
- group_wait: undefined,
- mute_timings: undefined,
- receiver: contactPointName,
- repeat_interval: undefined,
- },
- },
- },
- ],
- }
- );
+ const requests = await capture;
+
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
});
describe('alertingApiServer enabled', () => {
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap
new file mode 100644
index 00000000000..594f329ed16
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Can create a new grafana managed alert using simplified routing can create new grafana managed alert when using simplified routing and selecting a contact point 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "empty-group",
+ "rules": [
+ {
+ "annotations": {},
+ "for": "1m",
+ "grafana_alert": {
+ "condition": "B",
+ "data": [
+ {
+ "datasourceUid": "__expr__",
+ "model": {
+ "conditions": [
+ {
+ "evaluator": {
+ "params": [],
+ "type": "gt",
+ },
+ "operator": {
+ "type": "and",
+ },
+ "query": {
+ "params": [
+ "A",
+ ],
+ },
+ "reducer": {
+ "params": [],
+ "type": "last",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": {
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "A",
+ "reducer": "last",
+ "refId": "A",
+ "type": "reduce",
+ },
+ "queryType": "",
+ "refId": "A",
+ },
+ {
+ "datasourceUid": "__expr__",
+ "model": {
+ "conditions": [
+ {
+ "evaluator": {
+ "params": [
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": {
+ "type": "and",
+ },
+ "query": {
+ "params": [
+ "B",
+ ],
+ },
+ "reducer": {
+ "params": [],
+ "type": "last",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": {
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "A",
+ "refId": "B",
+ "type": "threshold",
+ },
+ "queryType": "",
+ "refId": "B",
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "no_data_state": "NoData",
+ "notification_settings": {
+ "receiver": "lotsa-emails",
+ },
+ "title": "my great new rule",
+ },
+ "labels": {},
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/6abdb25bc1eb?subtype=cortex",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx
index 9821f7483b4..3e2e87a4438 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx
@@ -7,6 +7,7 @@ import { CombinedRule } from 'app/types/unified-alerting';
import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { fetchPromAndRulerRulesAction } from '../../state/actions';
+import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules';
type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void];
@@ -29,12 +30,14 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
return;
}
- const location = getRuleGroupLocationFromCombinedRule(rule);
- await deleteRuleFromGroup.execute(location, rule.rulerRule);
+ const ruleGroupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
+ const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, rule.rulerRule);
+
+ await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier);
// refetch rules for this rules source
// @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags
- dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: location.dataSourceName }));
+ dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName }));
dismissModal();
diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx
deleted file mode 100644
index 8744867ab72..00000000000
--- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { reorder } from './ReorderRuleGroupModal';
-
-describe('test reorder', () => {
- it('should reorder arrays', () => {
- const original = [1, 2, 3];
- const expected = [1, 3, 2];
-
- expect(reorder(original, 1, 2)).toEqual(expected);
- expect(original).not.toEqual(expected); // make sure we've not mutated the original
- });
-});
diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx
index 278be0bf5dd..3a40868f20e 100644
--- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx
+++ b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx
@@ -8,22 +8,30 @@ import {
DropResult,
} from '@hello-pangea/dnd';
import cx from 'classnames';
-import { compact } from 'lodash';
-import { useCallback, useState } from 'react';
+import { produce } from 'immer';
+import { useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
-import { Badge, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui';
-import { useCombinedRuleNamespaces } from 'app/features/alerting/unified/hooks/useCombinedRuleNamespaces';
-import { dispatch } from 'app/store/store';
-import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
+import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
+import { dispatch, getState } from 'app/store/store';
+import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
+import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
-import { updateRulesOrder } from '../../state/actions';
-import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
+import { alertRuleApi } from '../../api/alertRuleApi';
+import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup';
+import { isLoading } from '../../hooks/useAsync';
+import { swapItems, SwapOperation } from '../../reducers/ruler/ruleGroups';
+import { fetchRulerRulesAction, getDataSourceRulerConfig } from '../../state/actions';
+import { isCloudRulesSource } from '../../utils/datasource';
import { hashRulerRule } from '../../utils/rule-id';
-import { isAlertingRule, isRecordingRule } from '../../utils/rules';
-
-import { AlertStateTag } from './AlertStateTag';
+import {
+ isAlertingRulerRule,
+ isGrafanaRulerRule,
+ isRecordingRulerRule,
+ rulesSourceToDataSourceName,
+} from '../../utils/rules';
interface ModalProps {
namespace: CombinedRuleNamespace;
@@ -32,24 +40,36 @@ interface ModalProps {
folderUid?: string;
}
-type CombinedRuleWithUID = { uid: string } & CombinedRule;
+type RulerRuleWithUID = { uid: string } & RulerRuleDTO;
export const ReorderCloudGroupModal = (props: ModalProps) => {
+ const styles = useStyles2(getStyles);
const { group, namespace, onClose, folderUid } = props;
+ const [operations, setOperations] = useState>([]);
+
+ const [reorderRulesInGroup, reorderState] = useReorderRuleForRuleGroup();
+ const isUpdating = isLoading(reorderState);
// The list of rules might have been filtered before we get to this reordering modal
- // We need to grab the full (unfiltered) list so we are able to reorder via the API without
- // deleting any rules (as they otherwise would have been omitted from the payload)
- const unfilteredNamespaces = useCombinedRuleNamespaces();
- const matchedNamespace = unfilteredNamespaces.find(
- (ns) => ns.rulesSource === namespace.rulesSource && ns.name === namespace.name
+ // We need to grab the full (unfiltered) list
+ const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource);
+ const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName);
+ const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery(
+ {
+ rulerConfig,
+ namespace: folderUid ?? namespace.name,
+ group: group.name,
+ },
+ { refetchOnMountOrArgChange: true }
);
- const matchedGroup = matchedNamespace?.groups.find((g) => g.name === group.name);
- const [pending, setPending] = useState(false);
- const [rulesList, setRulesList] = useState(matchedGroup?.rules || []);
+ const [rulesList, setRulesList] = useState([]);
- const styles = useStyles2(getStyles);
+ useEffect(() => {
+ if (ruleGroup) {
+ setRulesList(ruleGroup?.rules);
+ }
+ }, [ruleGroup]);
const onDragEnd = useCallback(
(result: DropResult) => {
@@ -58,39 +78,50 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
return;
}
- const sameIndex = result.destination.index === result.source.index;
- if (sameIndex) {
- return;
- }
+ const swapOperation: SwapOperation = [result.source.index, result.destination.index];
- const newOrderedRules = reorder(rulesList, result.source.index, result.destination.index);
- setRulesList(newOrderedRules); // optimistically update the new rules list
-
- const rulesSourceName = getRulesSourceName(namespace.rulesSource);
- const rulerRules = compact(newOrderedRules.map((rule) => rule.rulerRule));
-
- setPending(true);
- dispatch(
- updateRulesOrder({
- namespaceName: namespace.name,
- groupName: group.name,
- rulesSourceName: rulesSourceName,
- newRules: rulerRules,
- folderUid: folderUid || namespace.name,
+ // add old index and new index to the modifications object
+ setOperations(
+ produce(operations, (draft) => {
+ draft.push(swapOperation);
})
- )
- .unwrap()
- .finally(() => {
- setPending(false);
- });
+ );
+
+ // re-order the rules list for the UI rendering
+ const newOrderedRules = produce(rulesList, (draft) => {
+ swapItems(draft, swapOperation);
+ });
+ setRulesList(newOrderedRules);
},
- [group.name, namespace.name, namespace.rulesSource, rulesList, folderUid]
+ [rulesList, operations]
);
+ const updateRulesOrder = useCallback(async () => {
+ const ruleGroupIdentifier: RuleGroupIdentifier = {
+ dataSourceName: rulesSourceToDataSourceName(namespace.rulesSource),
+ groupName: group.name,
+ namespaceName: folderUid ?? namespace.name,
+ };
+
+ await reorderRulesInGroup.execute(ruleGroupIdentifier, operations);
+ // TODO: Remove once RTKQ is more prevalently used
+ await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName }));
+ onClose();
+ }, [
+ namespace.rulesSource,
+ namespace.name,
+ group.name,
+ folderUid,
+ reorderRulesInGroup,
+ operations,
+ dataSourceName,
+ onClose,
+ ]);
+
// assign unique but stable identifiers to each (alerting / recording) rule
- const rulesWithUID: CombinedRuleWithUID[] = rulesList.map((rule) => ({
- ...rule,
- uid: String(hashRulerRule(rule.rulerRule!)), // TODO fix this coercion?
+ const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({
+ ...rulerRule,
+ uid: hashRulerRule(rulerRule),
}));
return (
@@ -101,37 +132,50 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
onDismiss={onClose}
onClickBackdrop={onClose}
>
-
- (
-
- )}
- >
- {(droppableProvided: DroppableProvided) => (
- 0 && (
+ <>
+
+ (
+
+ )}
>
- {rulesWithUID.map((rule, index) => (
-
- {(provided: DraggableProvided) => }
-
- ))}
- {droppableProvided.placeholder}
-
- )}
-
-
+ {(droppableProvided: DroppableProvided) => (
+
+ {rulesWithUID.map((rule, index) => (
+
+ {(provided: DraggableProvided) => }
+
+ ))}
+ {droppableProvided.placeholder}
+
+ )}
+
+
+
+
+ Cancel
+
+ updateRulesOrder()} disabled={isUpdating}>
+ Save
+
+
+ >
+ )}
);
};
interface ListItemProps extends React.HTMLAttributes {
provided: DraggableProvided;
- rule: CombinedRule;
+ rule: RulerRuleDTO;
isClone?: boolean;
isDragging?: boolean;
}
@@ -139,6 +183,7 @@ interface ListItemProps extends React.HTMLAttributes {
const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => {
const styles = useStyles2(getStyles);
+ // @TODO does this work with Grafana-managed recording rules too? Double check that.
return (
- {isAlertingRule(rule.promRule) &&
}
- {isRecordingRule(rule.promRule) &&
}
-
{rule.name}
-
+ {isGrafanaRulerRule(rule) &&
{rule.grafana_alert.title}
}
+ {isRecordingRulerRule(rule) && (
+ <>
+
{rule.record}
+
+ >
+ )}
+ {isAlertingRulerRule(rule) &&
{rule.alert}
}
+
);
};
@@ -235,11 +285,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(2),
}),
});
-
-export function reorder(rules: T[], startIndex: number, endIndex: number): T[] {
- const result = Array.from(rules);
- const [removed] = result.splice(startIndex, 1);
- result.splice(endIndex, 0, removed);
-
- return result;
-}
diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
index bb66ef6421e..5ae024d814c 100644
--- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
@@ -6,17 +6,16 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
-import { useDispatch } from 'app/types';
-import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
+import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics';
+import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup';
import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler';
-import { deleteRulesGroupAction } from '../../state/actions';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc';
-import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
+import { isFederatedRuleGroup, isGrafanaRulerRule, rulesSourceToDataSourceName } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
@@ -40,8 +39,8 @@ interface Props {
export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => {
const { rulesSource } = namespace;
- const dispatch = useDispatch();
const styles = useStyles2(getStyles);
+ const [deleteRuleGroup] = useDeleteRuleGroup();
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
@@ -74,8 +73,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
const isListView = viewMode === 'list';
const isGroupView = viewMode === 'grouped';
- const deleteGroup = () => {
- dispatch(deleteRulesGroupAction(namespace, group));
+ const deleteGroup = async () => {
+ const namespaceName = decodeGrafanaNamespace(namespace).name;
+ const groupName = group.name;
+ const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource);
+
+ const ruleGroupIdentifier: RuleGroupIdentifier = { namespaceName, groupName, dataSourceName };
+ await deleteRuleGroup.execute(ruleGroupIdentifier);
setIsDeletingGroup(false);
};
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap
new file mode 100644
index 00000000000..8d3ddb718e3
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap
@@ -0,0 +1,206 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Creating a Data source managed rule should be able to add a rule to a existing rule group 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "group-1",
+ "rules": [
+ {
+ "alert": "alert1",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "my new rule",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir",
+ },
+]
+`;
+
+exports[`Creating a Data source managed rule should be able to add a rule to a new rule group 1`] = `
+[
+ {
+ "body": {
+ "interval": "15m",
+ "name": "new group",
+ "rules": [
+ {
+ "annotations": {},
+ "for": "",
+ "grafana_alert": {
+ "condition": "",
+ "data": [],
+ "exec_err_state": "Error",
+ "namespace_uid": "NAMESPACE_UID",
+ "no_data_state": "NoData",
+ "rule_group": "my-group",
+ "title": "my new rule",
+ "uid": "mock-rule-uid-123",
+ },
+ "labels": {},
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/new%20namespace?subtype=mimir",
+ },
+]
+`;
+
+exports[`Creating a Grafana managed rule should be able to add a rule to a existing rule group 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "grafana-group-1",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "Grafana-rule",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ {
+ "annotations": {},
+ "for": "",
+ "grafana_alert": {
+ "condition": "",
+ "data": [],
+ "exec_err_state": "Error",
+ "namespace_uid": "NAMESPACE_UID",
+ "no_data_state": "NoData",
+ "rule_group": "my-group",
+ "title": "my new rule",
+ "uid": "mock-rule-uid-123",
+ },
+ "labels": {},
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
+ },
+]
+`;
+
+exports[`Creating a Grafana managed rule should be able to add a rule to a new rule group 1`] = `
+[
+ {
+ "body": {
+ "interval": "15m",
+ "name": "grafana-group-3",
+ "rules": [
+ {
+ "annotations": {},
+ "for": "",
+ "grafana_alert": {
+ "condition": "",
+ "data": [],
+ "exec_err_state": "Error",
+ "namespace_uid": "NAMESPACE_UID",
+ "no_data_state": "NoData",
+ "rule_group": "my-group",
+ "title": "my new rule",
+ "uid": "mock-rule-uid-123",
+ },
+ "labels": {},
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap
new file mode 100644
index 00000000000..b6ecad04404
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Grafana managed should be able to delete a Grafana managed rule group 1`] = `
+[
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "DELETE",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
+ },
+]
+`;
+
+exports[`data-source managed should be able to delete a data-source managed rule group 1`] = `
+[
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "DELETE",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap
new file mode 100644
index 00000000000..2374935f47a
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap
@@ -0,0 +1,206 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Moving a Data source managed rule should move a rule in an existing group to a new group 1`] = `
+[
+ {
+ "body": {
+ "name": "entirely new group name",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "updated rule title",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "DELETE",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
+ },
+]
+`;
+
+exports[`Moving a Data source managed rule should move a rule in an existing group to another existing group 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "group-3",
+ "rules": [
+ {
+ "alert": "rule 3",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "rule 4",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "alert1",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "DELETE",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
+ },
+]
+`;
+
+exports[`Moving a Grafana managed rule should move a rule from an existing group to another group in the same namespace 1`] = `
+[
+ {
+ "body": {
+ "name": "empty-group",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "Grafana-rule",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap
index 62c6e987364..5357d0c6a6d 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap
@@ -1,5 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`reorder rules for rule group should correctly reorder rules 1`] = `
+[
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "https://mimir.local:9000/api/v1/status/buildinfo",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir",
+ },
+ {
+ "body": {
+ "interval": "1m",
+ "name": "group-3",
+ "rules": [
+ {
+ "alert": "rule 4",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ {
+ "alert": "rule 3",
+ "annotations": {
+ "summary": "test alert",
+ },
+ "expr": "up = 1",
+ "labels": {
+ "severity": "warning",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "GET",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir",
+ },
+]
+`;
+
exports[`useUpdateRuleGroupConfiguration should be able to move a Data Source managed rule group 1`] = `
[
{
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap
new file mode 100644
index 00000000000..1b0143c619e
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap
@@ -0,0 +1,311 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Updating a Data source managed rule should be able to move a rule if target group is different from current group 1`] = `
+[
+ {
+ "body": {
+ "name": "a new group",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "updated rule title",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir",
+ },
+ {
+ "body": "",
+ "headers": [
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "DELETE",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
+ },
+]
+`;
+
+exports[`Updating a Data source managed rule should update a rule in an existing group 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "group-1",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "updated rule title",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir",
+ },
+]
+`;
+
+exports[`Updating a Grafana managed rule should move a rule in to another group 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "grafana-group-2",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "Grafana-rule",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "updated rule title",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
+ },
+]
+`;
+
+exports[`Updating a Grafana managed rule should update a rule in an existing group 1`] = `
+[
+ {
+ "body": {
+ "interval": "1m",
+ "name": "grafana-group-1",
+ "rules": [
+ {
+ "annotations": {
+ "summary": "Test alert",
+ },
+ "for": "5m",
+ "grafana_alert": {
+ "condition": "A",
+ "data": [
+ {
+ "datasourceUid": "datasource-uid",
+ "model": {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "datasource-uid",
+ },
+ "expression": "vector(1)",
+ "queryType": "alerting",
+ "refId": "A",
+ },
+ "queryType": "alerting",
+ "refId": "A",
+ "relativeTimeRange": {
+ "from": 1000,
+ "to": 2000,
+ },
+ },
+ ],
+ "exec_err_state": "Error",
+ "is_paused": false,
+ "namespace_uid": "uuid020c61ef",
+ "no_data_state": "NoData",
+ "rule_group": "grafana-group-1",
+ "title": "updated rule title",
+ "uid": "4d7125fee983",
+ },
+ "labels": {
+ "region": "nasa",
+ "severity": "critical",
+ },
+ },
+ ],
+ },
+ "headers": [
+ [
+ "content-type",
+ "application/json",
+ ],
+ [
+ "accept",
+ "application/json, text/plain, */*",
+ ],
+ ],
+ "method": "POST",
+ "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
+ },
+]
+`;
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx
new file mode 100644
index 00000000000..acbcc25052c
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx
@@ -0,0 +1,157 @@
+import { render } from 'test/test-utils';
+import { byRole, byText } from 'testing-library-selector';
+
+import { AccessControlAction } from 'app/types/accessControl';
+import { RuleGroupIdentifier } from 'app/types/unified-alerting';
+import { PostableRuleDTO } from 'app/types/unified-alerting-dto';
+
+import { setupMswServer } from '../../mockApi';
+import { grantUserPermissions, mockGrafanaRulerRule, mockRulerAlertingRule } from '../../mocks';
+import { grafanaRulerGroupName, grafanaRulerNamespace } from '../../mocks/grafanaRulerApi';
+import { GROUP_1, NAMESPACE_1 } from '../../mocks/mimirRulerApi';
+import { mimirDataSource } from '../../mocks/server/configure';
+import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants';
+import { captureRequests, serializeRequests } from '../../mocks/server/events';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { SerializeState } from '../useAsync';
+
+import { useAddRuleToRuleGroup } from './useUpsertRuleFromRuleGroup';
+
+setupMswServer();
+
+beforeAll(() => {
+ grantUserPermissions([
+ AccessControlAction.AlertingRuleExternalRead,
+ AccessControlAction.AlertingRuleExternalWrite,
+ AccessControlAction.AlertingRuleRead,
+ AccessControlAction.AlertingRuleCreate,
+ ]);
+});
+
+describe('Creating a Grafana managed rule', () => {
+ it('should be able to add a rule to a existing rule group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const rule = mockGrafanaRulerRule({ title: 'my new rule' });
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should be able to add a rule to a new rule group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: 'grafana-group-3',
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const rule = mockGrafanaRulerRule({ title: 'my new rule' });
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should not be able to add a rule to a non-existing namespace', async () => {
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName,
+ namespaceName: 'does-not-exist',
+ };
+
+ const rule = mockGrafanaRulerRule({ title: 'my new rule' });
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error/i).find()).toBeInTheDocument();
+ });
+});
+
+describe('Creating a Data source managed rule', () => {
+ beforeEach(() => {
+ mimirDataSource();
+ });
+
+ it('should be able to add a rule to a existing rule group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: GROUP_1,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const rule = mockRulerAlertingRule({ alert: 'my new rule' });
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should be able to add a rule to a new rule group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: 'new group',
+ namespaceName: 'new namespace',
+ };
+
+ const rule = mockGrafanaRulerRule({ title: 'my new rule' });
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+});
+
+type AddRuleTestComponentProps = {
+ ruleGroupIdentifier: RuleGroupIdentifier;
+ rule: PostableRuleDTO;
+ interval?: string;
+};
+
+const AddRuleTestComponent = ({ ruleGroupIdentifier, rule, interval }: AddRuleTestComponentProps) => {
+ const [addRule, requestState] = useAddRuleToRuleGroup();
+
+ const onClick = () => {
+ addRule.execute(ruleGroupIdentifier, rule, interval);
+ };
+
+ return (
+ <>
+ onClick()} />
+
+ >
+ );
+};
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.test.tsx
index ff338724f4e..1472ac9d440 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.test.tsx
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.test.tsx
@@ -1,4 +1,3 @@
-import userEvent from '@testing-library/user-event';
import { HttpResponse } from 'msw';
import { render } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector';
@@ -19,6 +18,7 @@ import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { setUpdateRulerRuleNamespaceHandler, setRulerRuleGroupHandler } from '../../mocks/server/configure';
import { captureRequests, serializeRequests } from '../../mocks/server/events';
import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../../mocks/server/handlers/mimirRuler';
+import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules';
import { SerializeState } from '../useAsync';
@@ -60,9 +60,9 @@ describe('delete rule', () => {
const capture = captureRequests();
- render( );
+ const { user } = render( );
- await userEvent.click(byRole('button').get());
+ await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -99,9 +99,9 @@ describe('delete rule', () => {
const capture = captureRequests();
- render( );
+ const { user } = render( );
- await userEvent.click(byRole('button').get());
+ await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -111,14 +111,23 @@ describe('delete rule', () => {
});
it('should delete the entire group if no more rules are left', async () => {
- const capture = captureRequests();
-
- const combined = mockCombinedRule({
+ const rule = mockCombinedRule({
rulerRule: grafanaRulerRule,
});
- render( );
- await userEvent.click(byRole('button').get());
+ const group = mockRulerRuleGroup({
+ name: 'group-1',
+ rules: [rule.rulerRule!],
+ });
+
+ setRulerRuleGroupHandler({
+ response: HttpResponse.json(group),
+ });
+
+ const capture = captureRequests();
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
@@ -136,8 +145,9 @@ const DeleteTestComponent = ({ rule }: DeleteTestComponentProps) => {
// always handle your errors!
const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule);
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(ruleGroupID, rule.rulerRule!);
const onClick = () => {
- deleteRuleFromGroup.execute(ruleGroupID, rule.rulerRule!);
+ deleteRuleFromGroup.execute(ruleGroupID, ruleID);
};
return (
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.ts
index 29ecbbd3b50..d08a097d2a7 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.ts
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.ts
@@ -1,7 +1,5 @@
-import { t } from 'i18next';
-
-import { RuleGroupIdentifier } from 'app/types/unified-alerting';
-import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
+import { t } from 'app/core/internationalization';
+import { EditableRuleIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi';
import { deleteRuleAction } from '../../reducers/ruler/ruleGroups';
@@ -20,10 +18,10 @@ export function useDeleteRuleFromGroup() {
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.useMutation();
- return useAsync(async (ruleGroup: RuleGroupIdentifier, rule: RulerRuleDTO) => {
+ return useAsync(async (ruleGroup: RuleGroupIdentifier, ruleIdentifier: EditableRuleIdentifier) => {
const { groupName, namespaceName } = ruleGroup;
- const action = deleteRuleAction({ rule });
+ const action = deleteRuleAction({ identifier: ruleIdentifier });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const successMessage = t('alerting.rules.delete-rule.success', 'Rule successfully deleted');
@@ -34,7 +32,7 @@ export function useDeleteRuleFromGroup() {
rulerConfig,
namespace: namespaceName,
group: groupName,
- requestOptions: { successMessage },
+ notificationOptions: { successMessage },
}).unwrap();
}
@@ -43,7 +41,7 @@ export function useDeleteRuleFromGroup() {
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
- requestOptions: { successMessage },
+ notificationOptions: { successMessage },
}).unwrap();
});
}
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.test.tsx
new file mode 100644
index 00000000000..c49c214be28
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.test.tsx
@@ -0,0 +1,90 @@
+import { render } from 'test/test-utils';
+import { byRole, byText } from 'testing-library-selector';
+
+import { AccessControlAction } from 'app/types/accessControl';
+import { RuleGroupIdentifier } from 'app/types/unified-alerting';
+
+import { setupMswServer } from '../../mockApi';
+import { grantUserPermissions } from '../../mocks';
+import { grafanaRulerGroupName, grafanaRulerNamespace } from '../../mocks/grafanaRulerApi';
+import { GROUP_1, NAMESPACE_1 } from '../../mocks/mimirRulerApi';
+import { mimirDataSource } from '../../mocks/server/configure';
+import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants';
+import { captureRequests, serializeRequests } from '../../mocks/server/events';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { SerializeState } from '../useAsync';
+
+import { useDeleteRuleGroup } from './useDeleteRuleGroup';
+
+setupMswServer();
+
+beforeAll(() => {
+ grantUserPermissions([
+ AccessControlAction.AlertingRuleExternalWrite,
+ AccessControlAction.AlertingRuleExternalRead,
+ AccessControlAction.AlertingRuleDelete,
+ AccessControlAction.AlertingRuleRead,
+ ]);
+});
+
+describe('data-source managed', () => {
+ it('should be able to delete a data-source managed rule group', async () => {
+ mimirDataSource();
+
+ const capture = captureRequests((r) => r.method === 'DELETE');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: GROUP_1,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+});
+
+describe('Grafana managed', () => {
+ it('should be able to delete a Grafana managed rule group', async () => {
+ const capture = captureRequests((r) => r.method === 'DELETE');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const { user } = render( );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+});
+
+type DeleteTestComponentProps = {
+ ruleGroupIdentifier: RuleGroupIdentifier;
+};
+const DeleteTestComponent = ({ ruleGroupIdentifier }: DeleteTestComponentProps) => {
+ const [deleteRuleGroup, requestState] = useDeleteRuleGroup();
+
+ const onClick = () => {
+ deleteRuleGroup.execute(ruleGroupIdentifier);
+ };
+
+ return (
+ <>
+ onClick()} />
+
+ >
+ );
+};
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.ts
new file mode 100644
index 00000000000..6db792e3d54
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.ts
@@ -0,0 +1,33 @@
+import { dispatch } from 'app/store/store';
+import { RuleGroupIdentifier } from 'app/types/unified-alerting';
+
+import { alertRuleApi } from '../../api/alertRuleApi';
+import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
+import { fetchPromAndRulerRulesAction } from '../../state/actions';
+import { useAsync } from '../useAsync';
+
+import { RulerNotSupportedError } from './useProduceNewRuleGroup';
+
+const { useDeleteRuleGroupFromNamespaceMutation } = alertRuleApi;
+const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi;
+
+export function useDeleteRuleGroup() {
+ const [deleteRuleGroup] = useDeleteRuleGroupFromNamespaceMutation();
+ const [discoverDataSourceFeature] = useLazyDiscoverDsFeaturesQuery();
+
+ return useAsync(async (ruleGroupIdentifier: RuleGroupIdentifier) => {
+ const { dataSourceName, namespaceName, groupName } = ruleGroupIdentifier;
+
+ const { rulerConfig } = await discoverDataSourceFeature({ rulesSourceName: dataSourceName }).unwrap();
+ if (!rulerConfig) {
+ throw RulerNotSupportedError(dataSourceName);
+ }
+
+ const result = await deleteRuleGroup({ rulerConfig, namespace: namespaceName, group: groupName }).unwrap();
+
+ // @TODO remove this once we can use tags to invalidate
+ await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: dataSourceName }));
+
+ return result;
+ });
+}
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx
new file mode 100644
index 00000000000..2fda700d4ac
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx
@@ -0,0 +1,242 @@
+import { produce } from 'immer';
+import { render } from 'test/test-utils';
+import { byRole, byText } from 'testing-library-selector';
+
+import { AccessControlAction } from 'app/types/accessControl';
+import { EditableRuleIdentifier, GrafanaRuleIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting';
+import { PostableRuleDTO } from 'app/types/unified-alerting-dto';
+
+import { setupMswServer } from '../../mockApi';
+import { grantUserPermissions } from '../../mocks';
+import {
+ grafanaRulerEmptyGroup,
+ grafanaRulerGroup,
+ grafanaRulerNamespace,
+ grafanaRulerRule,
+} from '../../mocks/grafanaRulerApi';
+import { group1, GROUP_3, NAMESPACE_1, 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';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
+import { SerializeState } from '../useAsync';
+
+import { useMoveRuleToRuleGroup } from './useUpsertRuleFromRuleGroup';
+
+setupMswServer();
+
+beforeAll(() => {
+ grantUserPermissions([
+ AccessControlAction.AlertingRuleExternalRead,
+ AccessControlAction.AlertingRuleExternalWrite,
+ AccessControlAction.AlertingRuleRead,
+ AccessControlAction.AlertingRuleCreate,
+ ]);
+});
+
+describe('Moving a Grafana managed rule', () => {
+ it('should move a rule from an existing group to another group in the same namespace', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const currentGroup = grafanaRulerGroup;
+ const targetGroup = grafanaRulerEmptyGroup;
+
+ const ruleToMove = grafanaRulerGroup.rules[0];
+
+ const currentRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: currentGroup.name,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const targetRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: targetGroup.name,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should fail if the rule group does not exist', async () => {
+ const currentRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: 'does-not-exist',
+ namespaceName: 'does-not-exist',
+ };
+
+ const ruleID: GrafanaRuleIdentifier = {
+ ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
+ uid: 'does-not-exist',
+ };
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error/i).find()).toBeInTheDocument();
+ });
+});
+
+describe('Moving a Data source managed rule', () => {
+ beforeEach(() => {
+ mimirDataSource();
+ });
+
+ it('should move a rule in an existing group to a new group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST' || r.method === 'DELETE');
+
+ const groupToUpdate = group1;
+ const ruleToMove = groupToUpdate.rules[0];
+
+ const currentRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: groupToUpdate.name,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const targetRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: 'entirely new group name',
+ namespaceName: NAMESPACE_1,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should move a rule in an existing group to another existing group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST' || r.method === 'DELETE');
+
+ const groupToUpdate = group1;
+ const ruleToMove = groupToUpdate.rules[0];
+
+ const currentRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: groupToUpdate.name,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const targetRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: GROUP_3,
+ namespaceName: NAMESPACE_2,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should fail if the rule group does not exist', async () => {
+ const groupToUpdate = group1;
+ const ruleToUpdate = groupToUpdate.rules[0];
+
+ const curentRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: 'does-not-exist',
+ namespaceName: NAMESPACE_1,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(curentRuleGroupID, ruleToUpdate);
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error/i).find()).toBeInTheDocument();
+ });
+});
+
+type MoveRuleTestComponentProps = {
+ currentRuleGroupIdentifier: RuleGroupIdentifier;
+ targetRuleGroupIdentifier: RuleGroupIdentifier;
+ ruleID: EditableRuleIdentifier;
+ rule: PostableRuleDTO;
+};
+
+const MoveRuleTestComponent = ({
+ currentRuleGroupIdentifier,
+ targetRuleGroupIdentifier,
+ ruleID,
+ rule,
+}: MoveRuleTestComponentProps) => {
+ const [moveRule, requestState] = useMoveRuleToRuleGroup();
+
+ const onClick = () => {
+ moveRule.execute(currentRuleGroupIdentifier, targetRuleGroupIdentifier, ruleID, rule);
+ };
+
+ return (
+ <>
+ onClick()} />
+
+ >
+ );
+};
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx
index 1af9dc84ab8..6e0c92d9b9f 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx
@@ -54,7 +54,7 @@ describe('pause rule', () => {
expect(byText(/uninitialized/i).get()).toBeInTheDocument();
await userEvent.click(byRole('button').get());
- expect(await byText(/error: No rule with UID/i).find()).toBeInTheDocument();
+ expect(await byText(/error: no rule matching identifier/i).find()).toBeInTheDocument();
});
it('should be able to handle error', async () => {
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts
index a6e80e0793a..3eb31a4e4d2 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts
@@ -1,5 +1,4 @@
-import { t } from 'i18next';
-
+import { t } from 'app/core/internationalization';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi';
@@ -16,21 +15,21 @@ export function usePauseRuleInGroup() {
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
+ const rulePausedMessage = t('alerting.rules.pause-rule.success', 'Rule evaluation paused');
+ const ruleResumedMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed');
+
return useAsync(async (ruleGroup: RuleGroupIdentifier, uid: string, pause: boolean) => {
const { namespaceName } = ruleGroup;
const action = pauseRuleAction({ uid, pause });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
- const rulePauseMessage = t('alerting.rules.pause-rule.success', 'Rule evaluation paused');
- const ruleResumeMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed');
-
return upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
- requestOptions: {
- successMessage: pause ? rulePauseMessage : ruleResumeMessage,
+ notificationOptions: {
+ successMessage: pause ? rulePausedMessage : ruleResumedMessage,
},
}).unwrap();
});
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts
index 4fc62d1e031..f7c54c1121b 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts
@@ -1,11 +1,19 @@
import { Action } from '@reduxjs/toolkit';
-import { dispatch, getState } from 'app/store/store';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
+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 { fetchRulesSourceBuildInfoAction, getDataSourceRulerConfig } from '../../state/actions';
+import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
+
+const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi;
+const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi;
+
+export const RulerNotSupportedError = (name: string) =>
+ new Error(`DataSource ${name} does not support ruler API or does not have the ruler API enabled.`);
/**
* Hook for reuse that handles freshly fetching a rule group's definition, applying an action to it,
@@ -18,10 +26,11 @@ import { fetchRulesSourceBuildInfoAction, getDataSourceRulerConfig } from '../..
* @throws
*/
export function useProduceNewRuleGroup() {
- const [fetchRuleGroup, requestState] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery();
+ const [fetchRuleGroup, requestState] = useLazyGetRuleGroupForNamespaceQuery();
+ const [discoverDataSourceFeatures] = useLazyDiscoverDsFeaturesQuery();
/**
- * This function will fetch the latest configuration we have for the rule group, apply a diff to it via a reducer and sends
+ * This function will fetch the latest configuration we have for the rule group, apply a diff to it via a reducer and
* returns the result.
*
* The API does not allow operations on a single rule and will always overwrite the existing rule group with the payload.
@@ -33,20 +42,34 @@ export function useProduceNewRuleGroup() {
const produceNewRuleGroup = async (ruleGroup: RuleGroupIdentifier, action: Action) => {
const { dataSourceName, groupName, namespaceName } = ruleGroup;
- // @TODO we should really not work with the redux state (getState) here
- await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: dataSourceName }));
- const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName);
+ const { rulerConfig } = await discoverDataSourceFeatures({ rulesSourceName: dataSourceName }).unwrap();
+ if (!rulerConfig) {
+ throw RulerNotSupportedError(dataSourceName);
+ }
const latestRuleGroupDefinition = await fetchRuleGroup({
rulerConfig,
namespace: namespaceName,
group: groupName,
- }).unwrap();
+ // @TODO maybe only supress if 404?
+ notificationOptions: { showErrorAlert: false },
+ })
+ .unwrap()
+ .catch(notFoundToNullOrThrow);
- const newRuleGroupDefinition = ruleGroupReducer(latestRuleGroupDefinition, action);
+ const newRuleGroupDefinition = ruleGroupReducer(
+ latestRuleGroupDefinition ?? createBlankRuleGroup(ruleGroup.groupName),
+ action
+ );
return { newRuleGroupDefinition, rulerConfig };
};
return [produceNewRuleGroup, requestState] as const;
}
+
+const createBlankRuleGroup = (name: string): PostableRulerRuleGroupDTO => ({
+ name,
+ interval: DEFAULT_GROUP_EVALUATION_INTERVAL,
+ rules: [],
+});
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.test.tsx
index 8785f249115..fa106766b22 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.test.tsx
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.test.tsx
@@ -1,4 +1,3 @@
-import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector';
@@ -12,10 +11,16 @@ import { NAMESPACE_2, namespace2, GROUP_1, NAMESPACE_1 } from '../../mocks/mimir
import { mimirDataSource } from '../../mocks/server/configure';
import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants';
import { captureRequests, serializeRequests } from '../../mocks/server/events';
+import { SwapOperation } from '../../reducers/ruler/ruleGroups';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { SerializeState } from '../useAsync';
-import { useMoveRuleGroup, useRenameRuleGroup, useUpdateRuleGroupConfiguration } from './useUpdateRuleGroup';
+import {
+ useMoveRuleGroup,
+ useRenameRuleGroup,
+ useReorderRuleForRuleGroup,
+ useUpdateRuleGroupConfiguration,
+} from './useUpdateRuleGroup';
setupMswServer();
@@ -27,8 +32,8 @@ describe('useUpdateRuleGroupConfiguration', () => {
it('should update a rule group interval', async () => {
const capture = captureRequests();
- render( );
- await userEvent.click(byRole('button').get());
+ const { user } = render( );
+ await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture;
@@ -39,8 +44,8 @@ describe('useUpdateRuleGroupConfiguration', () => {
it('should rename a rule group', async () => {
const capture = captureRequests();
- render( );
- await userEvent.click(byRole('button').get());
+ const { user } = render( );
+ await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture;
@@ -49,14 +54,14 @@ describe('useUpdateRuleGroupConfiguration', () => {
});
it('should throw if we are trying to merge rule groups', async () => {
- render( );
- await userEvent.click(byRole('button').get());
+ const { user } = render( );
+ await user.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
});
it('should not be able to move a Grafana managed rule group', async () => {
- render( );
- await userEvent.click(byRole('button').get());
+ const { user } = render( );
+ await user.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
});
@@ -64,8 +69,10 @@ describe('useUpdateRuleGroupConfiguration', () => {
mimirDataSource();
const capture = captureRequests();
- render( );
- await userEvent.click(byRole('button').get());
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture;
@@ -76,14 +83,33 @@ describe('useUpdateRuleGroupConfiguration', () => {
it('should not move a Data Source managed rule group to namespace with existing target group name', async () => {
mimirDataSource();
- render(
+ const { user } = render(
);
- await userEvent.click(byRole('button').get());
+ await user.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
});
});
+describe('reorder rules for rule group', () => {
+ it('should correctly reorder rules', async () => {
+ mimirDataSource();
+ const capture = captureRequests();
+
+ const swaps: SwapOperation[] = [[1, 0]];
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+});
+
const UpdateRuleGroupComponent = () => {
const [updateRuleGroup, requestState] = useUpdateRuleGroupConfiguration();
@@ -161,3 +187,26 @@ const MoveDataSourceManagedRuleGroupComponent = ({
>
);
};
+
+type ReorderRuleGroupComponentProps = {
+ namespace: string;
+ group: string;
+ swaps: SwapOperation[];
+};
+
+const ReorderRuleGroupComponent = ({ namespace, group, swaps }: ReorderRuleGroupComponentProps) => {
+ const [reorderRules, requestState] = useReorderRuleForRuleGroup();
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: group,
+ namespaceName: namespace,
+ };
+
+ return (
+ <>
+ reorderRules.execute(ruleGroupID, swaps)} />
+
+ >
+ );
+};
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.ts
index e10c9604048..9c740c4b289 100644
--- a/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.ts
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.ts
@@ -1,15 +1,21 @@
-import { t } from 'i18next';
-
+import { t } from 'app/core/internationalization';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi';
import { notFoundToNullOrThrow } from '../../api/util';
-import { updateRuleGroupAction, moveRuleGroupAction, renameRuleGroupAction } from '../../reducers/ruler/ruleGroups';
+import {
+ updateRuleGroupAction,
+ moveRuleGroupAction,
+ renameRuleGroupAction,
+ reorderRulesInRuleGroupAction,
+} from '../../reducers/ruler/ruleGroups';
import { isGrafanaRulesSource } from '../../utils/datasource';
import { useAsync } from '../useAsync';
import { useProduceNewRuleGroup } from './useProduceNewRuleGroup';
+const ruleUpdateSuccessMessage = () => t('alerting.rule-groups.update.success', 'Successfully updated rule group');
+
/**
* Update an existing rule group, currently only supports updating the interval.
* Use "useRenameRuleGroup" or "useMoveRuleGroup" for updating the namespace or group name.
@@ -24,13 +30,11 @@ export function useUpdateRuleGroupConfiguration() {
const action = updateRuleGroupAction({ interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
- const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group');
-
return upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
- requestOptions: { successMessage },
+ notificationOptions: { successMessage: ruleUpdateSuccessMessage() },
}).unwrap();
});
}
@@ -56,11 +60,11 @@ export function useMoveRuleGroup() {
throw new Error('Moving a Grafana-managed rule group to another folder is currently not supported.');
}
- const action = moveRuleGroupAction({ namespaceName, groupName, interval });
+ const action = moveRuleGroupAction({ newNamespaceName: namespaceName, groupName, interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const oldNamespace = ruleGroup.namespaceName;
- const targetNamespace = action.payload.namespaceName;
+ const targetNamespace = action.payload.newNamespaceName;
const oldGroupName = ruleGroup.groupName;
const targetGroupName = action.payload.groupName;
@@ -74,7 +78,7 @@ export function useMoveRuleGroup() {
namespace: targetNamespace,
group: targetGroupName,
// since this could throw 404
- requestOptions: { showErrorAlert: false },
+ notificationOptions: { showErrorAlert: false },
})
.unwrap()
.catch(notFoundToNullOrThrow);
@@ -90,7 +94,7 @@ export function useMoveRuleGroup() {
rulerConfig,
namespace: targetNamespace,
payload: newRuleGroupDefinition,
- requestOptions: { successMessage },
+ notificationOptions: { successMessage },
}).unwrap();
// now remove the old one
@@ -98,7 +102,7 @@ export function useMoveRuleGroup() {
rulerConfig,
namespace: oldNamespace,
group: oldGroupName,
- requestOptions: { showSuccessAlert: false },
+ notificationOptions: { showSuccessAlert: false },
}).unwrap();
return result;
@@ -132,7 +136,7 @@ export function useRenameRuleGroup() {
namespace: namespaceName,
group: newGroupName,
// since this could throw 404
- requestOptions: { showErrorAlert: false },
+ notificationOptions: { showErrorAlert: false },
})
.unwrap()
.catch(notFoundToNullOrThrow);
@@ -147,7 +151,7 @@ export function useRenameRuleGroup() {
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
- requestOptions: { successMessage },
+ notificationOptions: { successMessage },
}).unwrap();
// now delete the group we renamed
@@ -155,9 +159,32 @@ export function useRenameRuleGroup() {
rulerConfig,
namespace: namespaceName,
group: oldGroupName,
- requestOptions: { showSuccessAlert: false },
+ notificationOptions: { showSuccessAlert: false },
}).unwrap();
return result;
});
}
+
+/**
+ * Reorder rules within an existing rule group. Pass in an array of swap operations Array<[oldIndex, newIndex]>.
+ * This prevents rules from accidentally being updated and only allows indices to be moved around.
+ */
+export function useReorderRuleForRuleGroup() {
+ const [produceNewRuleGroup] = useProduceNewRuleGroup();
+ const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
+
+ return useAsync(async (ruleGroup: RuleGroupIdentifier, swaps: Array<[number, number]>) => {
+ const { namespaceName } = ruleGroup;
+
+ const action = reorderRulesInRuleGroupAction({ swaps });
+ const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
+
+ return upsertRuleGroup({
+ rulerConfig,
+ namespace: namespaceName,
+ payload: newRuleGroupDefinition,
+ notificationOptions: { successMessage: ruleUpdateSuccessMessage() },
+ }).unwrap();
+ });
+}
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleInRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleInRuleGroup.test.tsx
new file mode 100644
index 00000000000..2324ed363db
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleInRuleGroup.test.tsx
@@ -0,0 +1,308 @@
+import { produce } from 'immer';
+import { render } from 'test/test-utils';
+import { byRole, byText } from 'testing-library-selector';
+
+import { AccessControlAction } from 'app/types/accessControl';
+import { EditableRuleIdentifier, GrafanaRuleIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting';
+import { PostableRuleDTO } from 'app/types/unified-alerting-dto';
+
+import { setupMswServer } from '../../mockApi';
+import { grantUserPermissions } from '../../mocks';
+import {
+ grafanaRulerGroupName,
+ grafanaRulerGroupName2,
+ grafanaRulerNamespace,
+ grafanaRulerRule,
+} from '../../mocks/grafanaRulerApi';
+import { NAMESPACE_1, group1 } from '../../mocks/mimirRulerApi';
+import { mimirDataSource } from '../../mocks/server/configure';
+import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants';
+import { captureRequests, serializeRequests } from '../../mocks/server/events';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
+import { SerializeState } from '../useAsync';
+
+import { useUpdateRuleInRuleGroup } from './useUpsertRuleFromRuleGroup';
+
+setupMswServer();
+
+beforeAll(() => {
+ grantUserPermissions([
+ AccessControlAction.AlertingRuleExternalRead,
+ AccessControlAction.AlertingRuleExternalWrite,
+ AccessControlAction.AlertingRuleRead,
+ AccessControlAction.AlertingRuleCreate,
+ ]);
+});
+
+describe('Updating a Grafana managed rule', () => {
+ it('should update a rule in an existing group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const ruleID: GrafanaRuleIdentifier = {
+ ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
+ uid: grafanaRulerRule.grafana_alert.uid,
+ };
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should move a rule in to another group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const targetRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName2,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const ruleID: GrafanaRuleIdentifier = {
+ ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
+ uid: grafanaRulerRule.grafana_alert.uid,
+ };
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should fail if the rule does not exist in the group', async () => {
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: grafanaRulerGroupName,
+ namespaceName: grafanaRulerNamespace.uid,
+ };
+
+ const ruleID: GrafanaRuleIdentifier = {
+ ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
+ uid: 'does-not-exist',
+ };
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error: no rule matching identifier found/i).find()).toBeInTheDocument();
+ });
+
+ it('should fail if the rule group does not exist', async () => {
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groupName: 'does-not-exist',
+ namespaceName: 'does-not-exist',
+ };
+
+ const ruleID: GrafanaRuleIdentifier = {
+ ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
+ uid: 'does-not-exist',
+ };
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error/i).find()).toBeInTheDocument();
+ });
+});
+
+describe('Updating a Data source managed rule', () => {
+ beforeEach(() => {
+ mimirDataSource();
+ });
+
+ it('should update a rule in an existing group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST');
+
+ const groupToUpdate = group1;
+ const ruleToUpdate = groupToUpdate.rules[0];
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: groupToUpdate.name,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(ruleGroupID, ruleToUpdate);
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should be able to move a rule if target group is different from current group', async () => {
+ const capture = captureRequests((r) => r.method === 'POST' || r.method === 'DELETE');
+
+ const groupToUpdate = group1;
+ const ruleToUpdate = groupToUpdate.rules[0];
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: groupToUpdate.name,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const targetRuleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: 'a new group',
+ namespaceName: NAMESPACE_1,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(ruleGroupID, ruleToUpdate);
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/success/i).find()).toBeInTheDocument();
+
+ const requests = await capture;
+ const serializedRequests = await serializeRequests(requests);
+ expect(serializedRequests).toMatchSnapshot();
+ });
+
+ it('should fail if the rule does not exist in the group', async () => {
+ const groupToUpdate = group1;
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: groupToUpdate.name,
+ namespaceName: NAMESPACE_1,
+ };
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(ruleGroupID, newRule);
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error: no rule matching identifier found/i).find()).toBeInTheDocument();
+ });
+
+ it('should fail if the rule group does not exist', async () => {
+ const groupToUpdate = group1;
+ const ruleToUpdate = groupToUpdate.rules[0];
+
+ const ruleGroupID: RuleGroupIdentifier = {
+ dataSourceName: MIMIR_DATASOURCE_UID,
+ groupName: 'does-not-exist',
+ namespaceName: NAMESPACE_1,
+ };
+
+ const ruleID = fromRulerRuleAndRuleGroupIdentifier(ruleGroupID, ruleToUpdate);
+
+ const newRule = produce(grafanaRulerRule, (draft) => {
+ draft.grafana_alert.title = 'updated rule title';
+ });
+
+ const { user } = render(
+
+ );
+ await user.click(byRole('button').get());
+
+ expect(await byText(/error/i).find()).toBeInTheDocument();
+ });
+});
+
+type UpdateRuleTestComponentProps = {
+ ruleGroupIdentifier: RuleGroupIdentifier;
+ targetRuleGroupIdentifier?: RuleGroupIdentifier;
+ ruleID: EditableRuleIdentifier;
+ rule: PostableRuleDTO;
+};
+
+const UpdateRuleTestComponent = ({
+ ruleGroupIdentifier,
+ targetRuleGroupIdentifier,
+ ruleID,
+ rule,
+}: UpdateRuleTestComponentProps) => {
+ const [updateRule, requestState] = useUpdateRuleInRuleGroup();
+
+ const onClick = () => {
+ updateRule.execute(ruleGroupIdentifier, ruleID, rule, targetRuleGroupIdentifier);
+ };
+
+ return (
+ <>
+ onClick()} />
+
+ >
+ );
+};
diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts
new file mode 100644
index 00000000000..adc79753748
--- /dev/null
+++ b/public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts
@@ -0,0 +1,144 @@
+import { produce } from 'immer';
+import { isEqual } from 'lodash';
+
+import { t } from 'app/core/internationalization';
+import { dispatch } from 'app/store/store';
+import { RuleGroupIdentifier, EditableRuleIdentifier } from 'app/types/unified-alerting';
+import { PostableRuleDTO } from 'app/types/unified-alerting-dto';
+
+import { alertRuleApi } from '../../api/alertRuleApi';
+import { addRuleAction, updateRuleAction } from '../../reducers/ruler/ruleGroups';
+import { fetchRulerRulesAction } from '../../state/actions';
+import { isGrafanaRuleIdentifier, isGrafanaRulerRule } from '../../utils/rules';
+import { useAsync } from '../useAsync';
+
+import { useDeleteRuleFromGroup } from './useDeleteRuleFromGroup';
+import { useProduceNewRuleGroup } from './useProduceNewRuleGroup';
+
+/**
+ * This hook will add a single rule to a rule group – a new rule group will be created if it does not already exist.
+ */
+export function useAddRuleToRuleGroup() {
+ const [produceNewRuleGroup] = useProduceNewRuleGroup();
+ const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
+
+ const successMessage = t('alerting.rules.add-rule.success', 'Rule added successfully');
+
+ return useAsync(async (ruleGroup: RuleGroupIdentifier, rule: PostableRuleDTO, interval?: string) => {
+ const { namespaceName, dataSourceName } = ruleGroup;
+
+ // the new rule might have to be created in a new group, pass name and interval (optional) to the action
+ const action = addRuleAction({ rule, interval, groupName: ruleGroup.groupName });
+ const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
+
+ const result = upsertRuleGroup({
+ rulerConfig,
+ namespace: namespaceName,
+ payload: newRuleGroupDefinition,
+ notificationOptions: { successMessage },
+ }).unwrap();
+
+ // @TODO remove
+ await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName }));
+
+ return result;
+ });
+}
+
+/**
+ * This hook will update an existing rule within a rule group, does not support moving the rule to another namespace / group
+ */
+export function useUpdateRuleInRuleGroup() {
+ const [produceNewRuleGroup] = useProduceNewRuleGroup();
+ const [moveRuleToGroup] = useMoveRuleToRuleGroup();
+ const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
+
+ const successMessage = t('alerting.rules.update-rule.success', 'Rule updated successfully');
+
+ return useAsync(
+ async (
+ ruleGroup: RuleGroupIdentifier,
+ ruleIdentifier: EditableRuleIdentifier,
+ ruleDefinition: PostableRuleDTO,
+ targetRuleGroup?: RuleGroupIdentifier
+ ) => {
+ const { namespaceName } = ruleGroup;
+ const finalRuleDefinition = copyGrafanaUID(ruleIdentifier, ruleDefinition);
+
+ // check if the existing rule and the form values have the same rule group identifier
+ const sameTargetRuleGroup = isEqual(ruleGroup, targetRuleGroup);
+ if (targetRuleGroup && !sameTargetRuleGroup) {
+ const result = moveRuleToGroup.execute(ruleGroup, targetRuleGroup, ruleIdentifier, ruleDefinition);
+ return result;
+ }
+
+ const action = updateRuleAction({ identifier: ruleIdentifier, rule: finalRuleDefinition });
+ const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
+
+ return upsertRuleGroup({
+ rulerConfig,
+ namespace: namespaceName,
+ payload: newRuleGroupDefinition,
+ notificationOptions: { successMessage },
+ }).unwrap();
+ }
+ );
+}
+
+/**
+ * This hook will move an existing rule to another namespace or group. The rule definition can also be modified.
+ * For Grafana-managed rules we can perform a single atomic move operation by copying the rule UID from the previous rule definition.
+ */
+export function useMoveRuleToRuleGroup() {
+ const [produceNewRuleGroup] = useProduceNewRuleGroup();
+ const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
+ const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
+
+ const successMessage = t('alerting.rules.update-rule.success', 'Rule updated successfully');
+
+ return useAsync(
+ async (
+ currentRuleGroup: RuleGroupIdentifier,
+ targetRuleGroup: RuleGroupIdentifier,
+ ruleIdentifier: EditableRuleIdentifier,
+ ruleDefinition: PostableRuleDTO
+ ) => {
+ const finalRuleDefinition = copyGrafanaUID(ruleIdentifier, ruleDefinition);
+
+ // 1. add the rule to the new namespace / group / ruler target
+ const addRuleToGroup = addRuleAction({ rule: finalRuleDefinition });
+ const { newRuleGroupDefinition: newTargetGroup, rulerConfig: targetGroupRulerConfig } = await produceNewRuleGroup(
+ targetRuleGroup,
+ addRuleToGroup
+ );
+
+ const result = await upsertRuleGroup({
+ rulerConfig: targetGroupRulerConfig,
+ namespace: currentRuleGroup.namespaceName,
+ payload: newTargetGroup,
+ notificationOptions: { successMessage },
+ }).unwrap();
+
+ // 2. if not Grafana-managed: remove the rule from the existing namespace / group / ruler
+ if (!isGrafanaRuleIdentifier(ruleIdentifier)) {
+ await deleteRuleFromGroup.execute(currentRuleGroup, ruleIdentifier);
+ }
+
+ return result;
+ }
+ );
+}
+
+function copyGrafanaUID(ruleIdentifier: EditableRuleIdentifier, ruleDefinition: PostableRuleDTO) {
+ const isGrafanaManagedRuleIdentifier = isGrafanaRuleIdentifier(ruleIdentifier);
+
+ // by copying over the rule UID the backend will perform an atomic move operation
+ // so there is no need for us to manually remove it from the previous group
+ return produce(ruleDefinition, (draft) => {
+ const isGrafanaManagedRuleDefinition = isGrafanaRulerRule(draft);
+
+ if (isGrafanaManagedRuleIdentifier && isGrafanaManagedRuleDefinition) {
+ draft.grafana_alert.uid = ruleIdentifier.uid;
+ }
+ });
+}
diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts
index 60782c18315..8e1e0dd4987 100644
--- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts
+++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts
@@ -1,65 +1,16 @@
import { useEffect, useMemo } from 'react';
import { useAsync } from 'react-use';
-import { useDispatch } from 'app/types';
-import {
- CombinedRule,
- RuleIdentifier,
- RuleNamespace,
- RulerDataSourceConfig,
- RulesSource,
- RuleWithLocation,
-} from 'app/types/unified-alerting';
-import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
+import { CombinedRule, RuleIdentifier, RulesSource, RuleWithLocation } from 'app/types/unified-alerting';
+import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
-import { fetchPromAndRulerRulesAction } from '../state/actions';
-import { getDataSourceByName, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
-import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux';
+import { getDataSourceByName } from '../utils/datasource';
import * as ruleId from '../utils/rule-id';
-import {
- isCloudRuleIdentifier,
- isGrafanaRuleIdentifier,
- isPrometheusRuleIdentifier,
- isRulerNotSupportedResponse,
-} from '../utils/rules';
+import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
-import { attachRulerRulesToCombinedRules, useCombinedRuleNamespaces } from './useCombinedRuleNamespaces';
-import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
-
-export function useCombinedRulesMatching(
- ruleName: string | undefined,
- ruleSourceName: string | undefined
-): AsyncRequestState {
- const requestState = useCombinedRulesLoader(ruleSourceName);
- const combinedRules = useCombinedRuleNamespaces(ruleSourceName);
-
- const rules = useMemo(() => {
- if (!ruleName || !ruleSourceName || combinedRules.length === 0) {
- return [];
- }
-
- const rules: CombinedRule[] = [];
-
- for (const namespace of combinedRules) {
- for (const group of namespace.groups) {
- for (const rule of group.rules) {
- if (rule.name === ruleName) {
- rules.push(rule);
- }
- }
- }
- }
-
- return rules;
- }, [ruleName, ruleSourceName, combinedRules]);
-
- return {
- ...requestState,
- result: rules,
- };
-}
+import { attachRulerRulesToCombinedRules } from './useCombinedRuleNamespaces';
export function useCloudCombinedRulesMatching(
ruleName: string,
@@ -123,49 +74,6 @@ export function useCloudCombinedRulesMatching(
return { loading: isLoadingDsFeatures || loading, error: error, rules: value };
}
-function useCombinedRulesLoader(
- rulesSourceName: string | undefined,
- identifier?: RuleIdentifier
-): AsyncRequestState {
- const dispatch = useDispatch();
- const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules);
- const promRuleRequest = getRequestState(rulesSourceName, promRuleRequests);
- const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
- const rulerRuleRequest = getRequestState(rulesSourceName, rulerRuleRequests);
-
- const { loading } = useAsync(async () => {
- if (!rulesSourceName) {
- return;
- }
-
- await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName, identifier }));
- }, [dispatch, rulesSourceName]);
-
- return {
- loading,
- error:
- (promRuleRequest.error ?? isRulerNotSupportedResponse(rulerRuleRequest)) ? undefined : rulerRuleRequest.error,
- dispatched: promRuleRequest.dispatched && rulerRuleRequest.dispatched,
- };
-}
-
-function getRequestState(
- ruleSourceName: string | undefined,
- slice: AsyncRequestMapSlice
-): AsyncRequestState {
- if (!ruleSourceName) {
- return initialAsyncRequestState;
- }
-
- const state = slice[ruleSourceName];
-
- if (!state) {
- return initialAsyncRequestState;
- }
-
- return state;
-}
-
interface RequestState {
result?: T;
loading: boolean;
@@ -396,29 +304,9 @@ export function useRuleWithLocation({
};
}
-export const grafanaRulerConfig: RulerDataSourceConfig = {
- dataSourceName: GRAFANA_RULES_SOURCE_NAME,
- apiVersion: 'legacy',
-};
-
-const grafanaDsFeatures = {
- rulerConfig: grafanaRulerConfig,
-};
-
export function useDataSourceFeatures(dataSourceName: string) {
- const isGrafanaDs = isGrafanaRulesSource(dataSourceName);
-
const { currentData: dsFeatures, isLoading: isLoadingDsFeatures } =
- featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery(
- {
- rulesSourceName: dataSourceName,
- },
- { skip: isGrafanaDs }
- );
-
- if (isGrafanaDs) {
- return { isLoadingDsFeatures: false, dsFeatures: grafanaDsFeatures };
- }
+ featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({ rulesSourceName: dataSourceName });
return { isLoadingDsFeatures, dsFeatures };
}
diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
index 7085c0ee0b9..4ec95bf413d 100644
--- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
+++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
@@ -22,6 +22,7 @@ import {
} from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
+import { GRAFANA_RULER_CONFIG } from '../api/featureDiscoveryApi';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import {
getAllRulesSources,
@@ -38,7 +39,6 @@ import {
isRecordingRulerRule,
} from '../utils/rules';
-import { grafanaRulerConfig } from './useCombinedRule';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export interface CacheValue {
@@ -499,7 +499,7 @@ export function useCombinedRules(
error: rulerRulesError,
} = alertRuleApi.endpoints.rulerRules.useQuery(
{
- rulerConfig: grafanaRulerConfig,
+ rulerConfig: GRAFANA_RULER_CONFIG,
filter: { dashboardUID: dashboardUID ?? undefined, panelId },
},
{
diff --git a/public/app/features/alerting/unified/mocks/mimirRulerApi.ts b/public/app/features/alerting/unified/mocks/mimirRulerApi.ts
index 95ad6d78b2a..c254f8f62c7 100644
--- a/public/app/features/alerting/unified/mocks/mimirRulerApi.ts
+++ b/public/app/features/alerting/unified/mocks/mimirRulerApi.ts
@@ -25,7 +25,7 @@ export const group2: RulerRuleGroupDTO = {
export const group3: RulerRuleGroupDTO = {
name: GROUP_3,
interval: '1m',
- rules: [mockRulerAlertingRule()],
+ rules: [mockRulerAlertingRule({ alert: 'rule 3' }), mockRulerAlertingRule({ alert: 'rule 4' })],
};
export const group4: RulerRuleGroupDTO = {
diff --git a/public/app/features/alerting/unified/mocks/server/events.ts b/public/app/features/alerting/unified/mocks/server/events.ts
index 6cc27da9d8e..a3729a14c61 100644
--- a/public/app/features/alerting/unified/mocks/server/events.ts
+++ b/public/app/features/alerting/unified/mocks/server/events.ts
@@ -3,6 +3,8 @@ import { JsonValue } from 'type-fest';
import server from 'app/features/alerting/unified/mockApi';
+type PredicateFn = (request: Request) => boolean;
+
/**
* Wait for the mock server to receive a request for the given method + url combination,
* and resolve with information about the request that was made
@@ -40,11 +42,14 @@ interface SerializedRequest {
*
* @deprecated Try not to use this 🙏 instead aim to assert against UI side effects
*/
-export async function captureRequests(): Promise {
+
+export async function captureRequests(predicateFn: PredicateFn = () => true): Promise {
const requests: Request[] = [];
server.events.on('request:start', ({ request }) => {
- requests.push(request);
+ if (predicateFn(request)) {
+ requests.push(request);
+ }
});
return requests;
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts
index a472ec19393..001e11290ee 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts
@@ -3,6 +3,7 @@ import { delay, http, HttpResponse } from 'msw';
export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert';
import {
+ PromRulesResponse,
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
@@ -28,6 +29,12 @@ export const rulerRulesHandler = () => {
});
};
+export const prometheusRulesHandler = () => {
+ return http.get('/api/prometheus/grafana/api/v1/rules', () => {
+ return HttpResponse.json({ status: 'success', data: { groups: [] } });
+ });
+};
+
export const getRulerRuleNamespaceHandler = () =>
http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => {
// This mimic API response as closely as possible - Invalid folderUid returns 403
@@ -92,10 +99,14 @@ export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
);
};
-export const deleteRulerRuleGroupHandler = () =>
+export const deleteRulerRuleGroupHandler = (options?: HandlerOptions) =>
http.delete<{ folderUid: string; groupName: string }>(
`/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`,
({ params: { folderUid } }) => {
+ if (options?.response) {
+ return options.response;
+ }
+
const namespace = namespaces[folderUid];
if (!namespace) {
return new HttpResponse(null, { status: 403 });
@@ -132,6 +143,7 @@ export const historyHandler = () => {
const handlers = [
rulerRulesHandler(),
+ prometheusRulesHandler(),
getRulerRuleNamespaceHandler(),
rulerRuleGroupHandler(),
rulerRuleHandler(),
diff --git a/public/app/features/alerting/unified/mocks/server/handlers/mimirRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/mimirRuler.ts
index b479cfa8316..076fdbdcbe3 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers/mimirRuler.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers/mimirRuler.ts
@@ -1,9 +1,25 @@
import { delay, http, HttpResponse } from 'msw';
-import { RulerRuleGroupDTO } from '../../../../../../types/unified-alerting-dto';
+import {
+ PromRulesResponse,
+ RulerRuleGroupDTO,
+ RulerRulesConfigDTO,
+} from '../../../../../../types/unified-alerting-dto';
import { namespaces } from '../../mimirRulerApi';
import { HandlerOptions } from '../configure';
+export const getRulerRulesHandler = () => {
+ return http.get(`/api/ruler/:dataSourceUID/api/v1/rules`, async () => {
+ return HttpResponse.json(namespaces);
+ });
+};
+
+export const prometheusRulesHandler = () => {
+ return http.get('/api/prometheus/:dataSourceUID/api/v1/rules', () => {
+ return HttpResponse.json({ status: 'success', data: { groups: [] } });
+ });
+};
+
export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) => {
return http.post<{ namespaceName: string }>(`/api/ruler/:dataSourceUID/api/v1/rules/:namespaceName`, async () => {
if (options?.delay !== undefined) {
@@ -65,6 +81,12 @@ export const deleteRulerRuleGroupHandler = () => {
);
};
-const handlers = [updateRulerRuleNamespaceHandler(), rulerRuleGroupHandler(), deleteRulerRuleGroupHandler()];
+const handlers = [
+ getRulerRulesHandler(),
+ prometheusRulesHandler(),
+ updateRulerRuleNamespaceHandler(),
+ rulerRuleGroupHandler(),
+ deleteRulerRuleGroupHandler(),
+];
export default handlers;
diff --git a/public/app/features/alerting/unified/reducers/ruler/ruleGroups.test.ts b/public/app/features/alerting/unified/reducers/ruler/ruleGroups.test.ts
index 5bfefa2e7ba..09719ab49b3 100644
--- a/public/app/features/alerting/unified/reducers/ruler/ruleGroups.test.ts
+++ b/public/app/features/alerting/unified/reducers/ruler/ruleGroups.test.ts
@@ -1,8 +1,26 @@
+import { createAction } from '@reduxjs/toolkit';
+import { omit } from 'lodash';
+
+import { GrafanaRuleIdentifier } from 'app/types/unified-alerting';
import { PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
-import { mockRulerAlertingRule, mockRulerGrafanaRule, mockRulerRecordingRule } from '../../mocks';
+import { mockGrafanaRulerRule, mockRulerAlertingRule, mockRulerGrafanaRule, mockRulerRecordingRule } from '../../mocks';
+import { fromRulerRule } from '../../utils/rule-id';
-import { deleteRuleAction, pauseRuleAction, ruleGroupReducer } from './ruleGroups';
+import {
+ addRuleAction,
+ deleteRuleAction,
+ moveRuleGroupAction,
+ pauseRuleAction,
+ renameRuleGroupAction,
+ reorder,
+ reorderRulesInRuleGroupAction,
+ ruleGroupReducer,
+ swapItems,
+ SwapOperation,
+ updateRuleAction,
+ updateRuleGroupAction,
+} from './ruleGroups';
describe('pausing rules', () => {
// pausing only works for Grafana managed rules
@@ -55,8 +73,9 @@ describe('removing a rule', () => {
interval: '5m',
rules: [mockRulerGrafanaRule({}, { uid: '1' }), ruleToDelete, mockRulerGrafanaRule({}, { uid: '3' })],
};
+ const ruleIdentifier = fromRulerRule('my-datasource', 'my-namespace', 'group-1', ruleToDelete);
- const action = deleteRuleAction({ rule: ruleToDelete });
+ const action = deleteRuleAction({ identifier: ruleIdentifier });
const output = ruleGroupReducer(initialGroup, action);
expect(output).toHaveProperty('rules');
@@ -83,8 +102,9 @@ describe('removing a rule', () => {
}),
],
};
+ const ruleIdentifier = fromRulerRule('my-datasource', 'my-namespace', 'group-1', ruleToDelete);
- const action = deleteRuleAction({ rule: ruleToDelete });
+ const action = deleteRuleAction({ identifier: ruleIdentifier });
const output = ruleGroupReducer(initialGroup, action);
expect(output).toHaveProperty('rules');
@@ -96,3 +116,208 @@ describe('removing a rule', () => {
expect(output).toMatchSnapshot();
});
});
+
+describe('add rule', () => {
+ it('should add a single rule to a rule group', () => {
+ const ruleToAdd = mockGrafanaRulerRule({ uid: '3' });
+
+ const rule1 = mockRulerGrafanaRule({}, { uid: '1' });
+ const rule2 = mockRulerGrafanaRule({}, { uid: '2' });
+
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [rule1, rule2],
+ };
+
+ const action = addRuleAction({ rule: ruleToAdd });
+ const output = ruleGroupReducer(initialGroup, action);
+
+ expect(output).toHaveProperty('name', 'group-1');
+ expect(output).toHaveProperty('interval', '5m');
+
+ expect(output.rules).toHaveLength(3);
+ expect(output.rules[0]).toBe(rule1);
+ expect(output.rules[1]).toBe(rule2);
+ expect(output.rules[2]).toBe(ruleToAdd);
+ });
+
+ it('should allow adding the rule to a new group with custom name and interval', () => {
+ const ruleToAdd = mockGrafanaRulerRule({ uid: '1' });
+
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'default',
+ interval: '1m',
+ rules: [],
+ };
+
+ const action = addRuleAction({ rule: ruleToAdd, groupName: 'new group', interval: '10m' });
+ const output = ruleGroupReducer(initialGroup, action);
+
+ expect(output).toHaveProperty('name', 'new group');
+ expect(output).toHaveProperty('interval', '10m');
+
+ expect(output.rules).toHaveLength(1);
+ expect(output.rules[0]).toBe(ruleToAdd);
+ });
+});
+
+describe('update rule', () => {
+ it('should update a single rule in a rule group', () => {
+ const ruleToUpdate = mockGrafanaRulerRule({ uid: '1' });
+ const ruleIdentifier = fromRulerRule('datasource', 'namespace', 'group', ruleToUpdate);
+ const updatedRule: typeof ruleToUpdate = { ...ruleToUpdate, labels: { foo: 'bar' } };
+
+ const otherRule = mockRulerGrafanaRule({}, { uid: '2' });
+
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [ruleToUpdate, otherRule],
+ };
+
+ const action = updateRuleAction({ identifier: ruleIdentifier, rule: updatedRule });
+ const output = ruleGroupReducer(initialGroup, action);
+
+ expect(output.rules).toHaveLength(2);
+ expect(output.rules[0]).toStrictEqual(updatedRule);
+ expect(output.rules[1]).toBe(otherRule);
+ });
+
+ it('should throw when rule is not found', () => {
+ const rule = mockGrafanaRulerRule({ uid: '1' });
+ const ruleIdentifier: GrafanaRuleIdentifier = {
+ uid: 'wrong one',
+ ruleSourceName: 'grafana',
+ };
+
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [rule],
+ };
+
+ const action = updateRuleAction({ identifier: ruleIdentifier, rule });
+ expect(() => {
+ ruleGroupReducer(initialGroup, action);
+ }).toThrow('no rule matching identifier found');
+ });
+});
+
+describe('re-order rules', () => {
+ const r1 = mockGrafanaRulerRule({ uid: 'r1' });
+ const r2 = mockGrafanaRulerRule({ uid: 'r2' });
+ const r3 = mockGrafanaRulerRule({ uid: 'r3' });
+
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [r1, r2, r3],
+ };
+
+ const swaps: SwapOperation[] = [
+ [0, 1],
+ [2, 1],
+ ];
+ const action = reorderRulesInRuleGroupAction({ swaps });
+ const output = ruleGroupReducer(initialGroup, action);
+
+ expect(output.rules).toHaveLength(3);
+});
+
+describe('rename rule group', () => {
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [],
+ };
+
+ it('should allow updating the group name and interval', () => {
+ const output = ruleGroupReducer(initialGroup, renameRuleGroupAction({ groupName: 'group-2', interval: '999m' }));
+ expect(output).toHaveProperty('name', 'group-2');
+ expect(output).toHaveProperty('interval', '999m');
+
+ expect(omit(output, ['name', 'interval'])).toEqual(omit(initialGroup, ['name', 'interval']));
+ });
+});
+
+describe('move rule group', () => {
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [],
+ };
+
+ it('should allow updating the group name and interval', () => {
+ const output = ruleGroupReducer(
+ initialGroup,
+ moveRuleGroupAction({ newNamespaceName: 'doesnt-really-matter', groupName: 'group-2', interval: '999m' })
+ );
+ expect(output).toHaveProperty('name', 'group-2');
+ expect(output).toHaveProperty('interval', '999m');
+
+ expect(omit(output, ['name', 'interval'])).toEqual(omit(initialGroup, ['name', 'interval']));
+ });
+});
+
+describe('update rule group', () => {
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [],
+ };
+
+ it('should allow updating the interval', () => {
+ const output = ruleGroupReducer(initialGroup, updateRuleGroupAction({ interval: '999m' }));
+ expect(output).toHaveProperty('interval', '999m');
+
+ expect(omit(output, 'interval')).toEqual(omit(initialGroup, 'interval'));
+ });
+});
+
+describe('unknown actions', () => {
+ it('should throw for unknown actions', () => {
+ expect(() => {
+ const initialGroup: PostableRulerRuleGroupDTO = {
+ name: 'group-1',
+ interval: '5m',
+ rules: [],
+ };
+
+ const unknownAction = createAction('unkown');
+
+ // @ts-expect-error
+ ruleGroupReducer(initialGroup, unknownAction);
+ }).toThrow('Unknown action');
+ });
+});
+
+describe('reorder and swap', () => {
+ it('should reorder arrays', () => {
+ const original = [1, 2, 3];
+ const operations = [
+ [1, 2],
+ [0, 2],
+ ] satisfies SwapOperation[];
+ const expected = [3, 2, 1];
+
+ expect(reorder(original, operations)).toEqual(expected);
+ expect(original).toEqual(expected); // make sure it mutates so we can use it in produce functions
+ });
+
+ it('inverse swaps should cancel out', () => {
+ const original = [1, 2, 3];
+ const operations = [
+ [1, 2],
+ [2, 1],
+ ] satisfies SwapOperation[];
+
+ expect(reorder(original, operations)).toEqual(original);
+ });
+
+ it('should throw when swapping out of bounds', () => {
+ expect(() => {
+ swapItems([], [-1, 3]);
+ }).toThrow('out of bounds');
+ });
+});
diff --git a/public/app/features/alerting/unified/reducers/ruler/ruleGroups.ts b/public/app/features/alerting/unified/reducers/ruler/ruleGroups.ts
index efe5182be75..41d7c42c889 100644
--- a/public/app/features/alerting/unified/reducers/ruler/ruleGroups.ts
+++ b/public/app/features/alerting/unified/reducers/ruler/ruleGroups.ts
@@ -1,27 +1,35 @@
import { createAction, createReducer, isAnyOf } from '@reduxjs/toolkit';
-import { remove } from 'lodash';
+import { inRange } from 'lodash';
-import { RuleIdentifier } from 'app/types/unified-alerting';
-import { PostableRuleDTO, PostableRulerRuleGroupDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
+import { EditableRuleIdentifier, GrafanaRuleIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
+import { PostableRuleDTO, PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { hashRulerRule } from '../../utils/rule-id';
-import { isCloudRulerRule, isGrafanaRulerRule } from '../../utils/rules';
+import {
+ isCloudRuleIdentifier,
+ isCloudRulerRule,
+ isGrafanaRuleIdentifier,
+ isGrafanaRulerRule,
+} from '../../utils/rules';
// rule-scoped actions
-export const addRuleAction = createAction<{ rule: PostableRuleDTO }>('ruleGroup/rules/add');
-export const updateRuleAction = createAction<{ identifier: RuleIdentifier; rule: PostableRuleDTO }>(
+export const addRuleAction = createAction<{ rule: PostableRuleDTO; groupName?: string; interval?: string }>(
+ 'ruleGroup/rules/add'
+);
+export const updateRuleAction = createAction<{ identifier: EditableRuleIdentifier; rule: PostableRuleDTO }>(
'ruleGroup/rules/update'
);
export const pauseRuleAction = createAction<{ uid: string; pause: boolean }>('ruleGroup/rules/pause');
-export const deleteRuleAction = createAction<{ rule: RulerRuleDTO }>('ruleGroup/rules/delete');
+export const deleteRuleAction = createAction<{ identifier: EditableRuleIdentifier }>('ruleGroup/rules/delete');
// group-scoped actions
export const updateRuleGroupAction = createAction<{ interval?: string }>('ruleGroup/update');
-export const moveRuleGroupAction = createAction<{ namespaceName: string; groupName?: string; interval?: string }>(
+export const moveRuleGroupAction = createAction<{ newNamespaceName: string; groupName?: string; interval?: string }>(
'ruleGroup/move'
);
export const renameRuleGroupAction = createAction<{ groupName: string; interval?: string }>('ruleGroup/rename');
-export const reorderRulesInRuleGroupAction = createAction('ruleGroup/rules/reorder');
+export const reorderRulesInRuleGroupAction = createAction<{ swaps: SwapOperation[] }>('ruleGroup/rules/reorder');
const initialState: PostableRulerRuleGroupDTO = {
name: 'initial',
@@ -30,62 +38,114 @@ const initialState: PostableRulerRuleGroupDTO = {
export const ruleGroupReducer = createReducer(initialState, (builder) => {
builder
- .addCase(addRuleAction, () => {
- throw new Error('not yet implemented');
+ .addCase(addRuleAction, (draft, { payload }) => {
+ const { rule } = payload;
+ draft.rules.push(rule);
})
- .addCase(updateRuleAction, () => {
- throw new Error('not yet implemented');
+ .addCase(updateRuleAction, (draft, { payload }) => {
+ const { identifier, rule } = payload;
+
+ const index = findRuleIndex(draft.rules, identifier);
+ draft.rules[index] = rule;
})
.addCase(deleteRuleAction, (draft, { payload }) => {
- const { rule } = payload;
+ const { identifier } = payload;
- // deleting a Grafana managed rule is by using the UID
- if (isGrafanaRulerRule(rule)) {
- const ruleUID = rule.grafana_alert.uid;
- remove(draft.rules, (rule) => isGrafanaRulerRule(rule) && rule.grafana_alert.uid === ruleUID);
- }
-
- // deleting a Data-source managed rule is by computing the rule hash
- if (isCloudRulerRule(rule)) {
- const ruleHash = hashRulerRule(rule);
- remove(draft.rules, (rule) => isCloudRulerRule(rule) && hashRulerRule(rule) === ruleHash);
- }
+ const index = findRuleIndex(draft.rules, identifier);
+ draft.rules.splice(index, 1);
})
.addCase(pauseRuleAction, (draft, { payload }) => {
const { uid, pause } = payload;
- let match = false;
+ const identifier: GrafanaRuleIdentifier = { ruleSourceName: GRAFANA_RULES_SOURCE_NAME, uid };
+ const index = findRuleIndex(draft.rules, identifier);
+ const matchingRule = draft.rules[index];
- for (const rule of draft.rules) {
- if (isGrafanaRulerRule(rule) && rule.grafana_alert.uid === uid) {
- match = true;
- rule.grafana_alert.is_paused = pause;
- break;
- }
- }
-
- if (!match) {
- throw new Error(`No rule with UID ${uid} found in group ${draft.name}`);
+ if (isGrafanaRulerRule(matchingRule)) {
+ matchingRule.grafana_alert.is_paused = pause;
+ } else {
+ throw new Error('Matching rule is not a Grafana-managed rule');
}
})
- .addCase(reorderRulesInRuleGroupAction, () => {
- throw new Error('not yet implemented');
+ .addCase(reorderRulesInRuleGroupAction, (draft, { payload }) => {
+ const { swaps } = payload;
+ reorder(draft.rules, swaps);
})
// rename and move should allow updating the group's name
- .addMatcher(isAnyOf(renameRuleGroupAction, moveRuleGroupAction), (draft, { payload }) => {
+ .addMatcher(isAnyOf(renameRuleGroupAction, moveRuleGroupAction, addRuleAction), (draft, { payload }) => {
const { groupName } = payload;
- if (groupName) {
- draft.name = groupName;
- }
+ draft.name = groupName ?? draft.name;
})
// update, rename and move should all allow updating the interval of the group
- .addMatcher(isAnyOf(updateRuleGroupAction, renameRuleGroupAction, moveRuleGroupAction), (draft, { payload }) => {
- const { interval } = payload;
- if (interval) {
- draft.interval = interval;
+ .addMatcher(
+ isAnyOf(updateRuleGroupAction, renameRuleGroupAction, moveRuleGroupAction, addRuleAction),
+ (draft, { payload }) => {
+ const { interval } = payload;
+ draft.interval = interval ?? draft.interval;
}
- })
+ )
.addDefaultCase((_draft, action) => {
throw new Error(`Unknown action for rule group reducer: ${action.type}`);
});
});
+
+/**
+ * Utility function for finding rules matching a identifier, pass this into .find, .findIndex, .remove
+ * or any other function with matching predicate.
+ */
+const ruleFinder = (identifier: RuleIdentifier) => {
+ const grafanaManagedIdentifier = isGrafanaRuleIdentifier(identifier);
+ const dataSourceManagedIdentifier = isCloudRuleIdentifier(identifier);
+
+ return (rule: PostableRuleDTO) => {
+ const isGrafanaManagedRule = isGrafanaRulerRule(rule);
+ const isDataSourceManagedRule = isCloudRulerRule(rule);
+
+ if (grafanaManagedIdentifier && isGrafanaManagedRule) {
+ return rule.grafana_alert.uid === identifier.uid;
+ }
+
+ if (isDataSourceManagedRule && dataSourceManagedIdentifier) {
+ return hashRulerRule(rule) === identifier.rulerRuleHash;
+ }
+
+ return;
+ };
+};
+
+// [oldIndex, newIndex]
+export type SwapOperation = [number, number];
+
+/**
+ * ⚠️ This function mutates the input array
+ * reorder several items in a list, given a set of swap
+ */
+export function reorder(items: T[], swaps: Array<[number, number]>) {
+ for (const swap of swaps) {
+ swapItems(items, swap);
+ }
+ return items;
+}
+
+/**
+ * ⚠️ This function mutates the input array
+ * swaps two items in an array of items
+ */
+export function swapItems(items: T[], [oldIndex, newIndex]: SwapOperation): void {
+ const inBounds = inRange(oldIndex, items.length) && inRange(newIndex, items.length);
+ if (!inBounds) {
+ throw new Error('Invalid operation: index out of bounds');
+ }
+
+ const [movedItem] = items.splice(oldIndex, 1);
+ items.splice(newIndex, 0, movedItem);
+}
+
+function findRuleIndex(rules: PostableRuleDTO[], identifier: EditableRuleIdentifier) {
+ const index = rules.findIndex(ruleFinder(identifier));
+ if (index === -1) {
+ throw new Error('no rule matching identifier found');
+ }
+
+ return index;
+}
diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts
index 8f9b4b9fcb0..276c43a0bcc 100644
--- a/public/app/features/alerting/unified/state/actions.ts
+++ b/public/app/features/alerting/unified/state/actions.ts
@@ -12,34 +12,16 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO, StoreState, ThunkResult } from 'app/types';
import {
- CombinedRuleGroup,
- CombinedRuleNamespace,
PromBasedDataSource,
RuleIdentifier,
RuleNamespace,
- RuleWithLocation,
RulerDataSourceConfig,
StateHistoryItem,
} from 'app/types/unified-alerting';
-import {
- PostableRulerRuleGroupDTO,
- PromApplication,
- RulerRuleDTO,
- RulerRulesConfigDTO,
-} from 'app/types/unified-alerting-dto';
+import { PromApplication, RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { backendSrv } from '../../../../core/services/backend_srv';
-import {
- LogMessages,
- logError,
- logInfo,
- trackSwitchToPoliciesRouting,
- trackSwitchToSimplifiedRouting,
- withPerformanceLogging,
- withPromRulesMetadataLogging,
- withRulerRulesMetadataLogging,
-} from '../Analytics';
-import { alertRuleApi } from '../api/alertRuleApi';
+import { withPerformanceLogging, withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
import {
deleteAlertManagerConfig,
fetchAlertGroups,
@@ -50,26 +32,12 @@ import { alertmanagerApi } from '../api/alertmanagerApi';
import { fetchAnnotations } from '../api/annotations';
import { discoverFeatures } from '../api/buildInfo';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
-import { FetchRulerRulesFilter, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
-import { RuleFormValues } from '../types/rule-form';
+import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
-import {
- GRAFANA_RULES_SOURCE_NAME,
- getAllRulesSourceNames,
- getRulesDataSource,
- getRulesSourceName,
-} from '../utils/datasource';
+import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames, getRulesDataSource } from '../utils/datasource';
import { makeAMLink } from '../utils/misc';
import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux';
-import * as ruleId from '../utils/rule-id';
-import { getRulerClient } from '../utils/rulerClient';
-import {
- getAlertInfo,
- isDataSourceManagedRuleByType,
- isGrafanaManagedRuleByType,
- isGrafanaRulerRule,
- isRulerNotSupportedResponse,
-} from '../utils/rules';
+import { getAlertInfo, isRulerNotSupportedResponse } from '../utils/rules';
import { safeParsePrometheusDuration } from '../utils/time';
function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) {
@@ -323,119 +291,6 @@ export function fetchAllPromRulesAction(force = false): ThunkResult {
};
}
-export function deleteRulesGroupAction(
- namespace: CombinedRuleNamespace,
- ruleGroup: CombinedRuleGroup
-): ThunkResult {
- return async (dispatch, getState) => {
- withAppEvents(
- (async () => {
- const sourceName = getRulesSourceName(namespace.rulesSource);
- const rulerConfig = getDataSourceRulerConfig(getState, sourceName);
-
- await deleteRulerRulesGroup(rulerConfig, namespace.name, ruleGroup.name);
- await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: sourceName }));
- })(),
- { successMessage: 'Group deleted' }
- );
- };
-}
-
-export const saveRuleFormAction = createAsyncThunk(
- 'unifiedalerting/saveRuleForm',
- (
- {
- values,
- existing,
- redirectOnSave,
- evaluateEvery,
- }: {
- values: RuleFormValues;
- existing?: RuleWithLocation;
- redirectOnSave?: string;
- initialAlertRuleName?: string;
- evaluateEvery: string;
- },
- thunkAPI
- ): Promise =>
- withAppEvents(
- withSerializedError(
- (async () => {
- const { type } = values;
- if (!type) {
- return;
- }
-
- // TODO getRulerConfig should be smart enough to provide proper rulerClient implementation
- // For the dataSourceName specified
- // in case of system (cortex/loki)
- let identifier: RuleIdentifier;
-
- if (isDataSourceManagedRuleByType(type)) {
- if (!values.dataSourceName) {
- throw new Error('The Data source has not been defined.');
- }
-
- const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName);
- const rulerClient = getRulerClient(rulerConfig);
- identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing);
- await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName }));
- // in case of grafana managed rules or grafana-managed recording rules
- } else if (isGrafanaManagedRuleByType(type)) {
- const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
- const rulerClient = getRulerClient(rulerConfig);
- identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing);
- reportSwitchingRoutingType(values, existing);
- // when using a Granfa-managed alert rule we can invalidate a single rule
- thunkAPI.dispatch(alertRuleApi.util.invalidateTags([{ type: 'GrafanaRulerRule', id: identifier.uid }]));
- } else {
- throw new Error('Unexpected rule form type');
- }
-
- logInfo(LogMessages.successSavingAlertRule, { type, isNew: (!existing).toString() });
-
- if (redirectOnSave) {
- locationService.push(redirectOnSave);
- } else {
- // if the identifier comes up empty (this happens when Grafana managed rule moves to another namespace or group)
- const stringifiedIdentifier = ruleId.stringifyIdentifier(identifier);
- if (!stringifiedIdentifier) {
- locationService.push('/alerting/list');
- return;
- }
- // redirect to edit page
- const newLocation = `/alerting/${encodeURIComponent(stringifiedIdentifier)}/edit`;
- if (locationService.getLocation().pathname !== newLocation) {
- locationService.replace(newLocation);
- }
- }
- })()
- ),
- {
- successMessage: existing ? `Rule "${values.name}" updated.` : `Rule "${values.name}" saved.`,
- errorMessage: 'Failed to save rule',
- }
- )
-);
-
-function reportSwitchingRoutingType(values: RuleFormValues, existingRule: RuleWithLocation | undefined) {
- // track if the user switched from simplified routing to policies routing or vice versa
- if (isGrafanaRulerRule(existingRule?.rule)) {
- const ga = existingRule?.rule.grafana_alert;
- const existingWasUsingSimplifiedRouting = Boolean(ga?.notification_settings?.receiver);
- const newValuesUsesSimplifiedRouting = values.manualRouting;
- const shouldTrackSwitchToSimplifiedRouting = !existingWasUsingSimplifiedRouting && newValuesUsesSimplifiedRouting;
- const shouldTrackSwitchToPoliciesRouting = existingWasUsingSimplifiedRouting && !newValuesUsesSimplifiedRouting;
-
- if (shouldTrackSwitchToSimplifiedRouting) {
- trackSwitchToSimplifiedRouting();
- }
- if (shouldTrackSwitchToPoliciesRouting) {
- trackSwitchToPoliciesRouting();
- }
- }
-}
-
export const fetchGrafanaAnnotationsAction = createAsyncThunk(
'unifiedalerting/fetchGrafanaAnnotations',
(alertId: string): Promise => withSerializedError(fetchAnnotations(alertId))
@@ -582,56 +437,3 @@ export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDurat
return forNumber ? forNumber !== 0 && forNumber < everyNumber : false;
});
};
-
-interface UpdateRulesOrderOptions {
- rulesSourceName: string;
- namespaceName: string;
- groupName: string;
- newRules: RulerRuleDTO[];
- folderUid: string;
-}
-
-export const updateRulesOrder = createAsyncThunk(
- 'unifiedalerting/updateRulesOrderForGroup',
- async (options: UpdateRulesOrderOptions, thunkAPI): Promise => {
- return withAppEvents(
- withSerializedError(
- (async () => {
- const { rulesSourceName, namespaceName, groupName, newRules, folderUid } = options;
-
- const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
- const rulesResult = await fetchRulerRules(rulerConfig);
-
- const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName);
- if (!existingGroup) {
- throw new Error(`Group "${groupName}" not found.`);
- }
-
- // We're unlikely to have this happen, as any user of this action should have already ensured
- // that the entire group was fetched before sending a new order.
- // But as a final safeguard we should fail if we somehow ended up here with a mismatched rules count
- // This would indicate an accidental deletion of rules following a frontend bug
- if (existingGroup.rules.length !== newRules.length) {
- const err = new Error('Rules count mismatch. Please refresh the page and try again.');
- logError(err, { namespaceName, groupName });
- throw err;
- }
-
- const payload: PostableRulerRuleGroupDTO = {
- name: existingGroup.name,
- interval: existingGroup.interval,
- rules: newRules,
- };
-
- await setRulerRuleGroup(rulerConfig, folderUid ?? namespaceName, payload);
-
- await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName }));
- })()
- ),
- {
- errorMessage: 'Failed to update namespace / group',
- successMessage: 'Update successful',
- }
- );
- }
-);
diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts
index 02217feac8e..d12154135d1 100644
--- a/public/app/features/alerting/unified/state/reducers.ts
+++ b/public/app/features/alerting/unified/state/reducers.ts
@@ -10,7 +10,6 @@ import {
fetchPromRulesAction,
fetchRulerRulesAction,
fetchRulesSourceBuildInfoAction,
- saveRuleFormAction,
testReceiversAction,
updateAlertManagerConfigAction,
} from './actions';
@@ -24,9 +23,6 @@ export const reducer = combineReducers({
promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer,
rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName)
.reducer,
- ruleForm: combineReducers({
- saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
- }),
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer,
folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer,
diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap
index 746e34f15e6..70899d7e938 100644
--- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap
+++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap
@@ -2,11 +2,7 @@
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana alerting rule 1`] = `
{
- "annotations": {
- "description": "",
- "runbook_url": "",
- "summary": "",
- },
+ "annotations": {},
"for": "1m",
"grafana_alert": {
"condition": "A",
@@ -17,19 +13,13 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
"notification_settings": undefined,
"title": "",
},
- "labels": {
- "": "",
- },
+ "labels": {},
}
`;
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana recording rule 1`] = `
{
- "annotations": {
- "description": "",
- "runbook_url": "",
- "summary": "",
- },
+ "annotations": {},
"grafana_alert": {
"condition": "A",
"data": [],
@@ -40,19 +30,13 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
},
"title": "",
},
- "labels": {
- "": "",
- },
+ "labels": {},
}
`;
exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = `
{
- "annotations": {
- "description": "",
- "runbook_url": "",
- "summary": "",
- },
+ "annotations": {},
"for": "1m",
"grafana_alert": {
"condition": "A",
@@ -79,26 +63,18 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
"notification_settings": undefined,
"title": "",
},
- "labels": {
- "": "",
- },
+ "labels": {},
}
`;
exports[`formValuesToRulerGrafanaRuleDTO should not set keep_firing_for if values are undefined 1`] = `
{
"alert": "",
- "annotations": {
- "description": "",
- "runbook_url": "",
- "summary": "",
- },
+ "annotations": {},
"expr": "",
"for": "1m",
"keep_firing_for": undefined,
- "labels": {
- "": "",
- },
+ "labels": {},
}
`;
@@ -123,17 +99,11 @@ exports[`formValuesToRulerGrafanaRuleDTO should parse keep_firing_for 1`] = `
exports[`formValuesToRulerGrafanaRuleDTO should set keep_firing_for if values are populated 1`] = `
{
"alert": "",
- "annotations": {
- "description": "",
- "runbook_url": "",
- "summary": "",
- },
+ "annotations": {},
"expr": "",
"for": "1m",
"keep_firing_for": "1m",
- "labels": {
- "": "",
- },
+ "labels": {},
}
`;
diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts
index 0b7bfa3d09d..bfc7f86ea5c 100644
--- a/public/app/features/alerting/unified/utils/rule-form.test.ts
+++ b/public/app/features/alerting/unified/utils/rule-form.test.ts
@@ -8,6 +8,8 @@ import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import {
MANUAL_ROUTING_KEY,
alertingRulerRuleToRuleForm,
+ cleanAnnotations,
+ cleanLabels,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
getContactPointsFromDTO,
@@ -254,3 +256,32 @@ describe('getDefautManualRouting', () => {
expect(getDefautManualRouting()).toBe(true);
});
});
+
+describe('cleanAnnotations', () => {
+ it('should remove falsy KVs', () => {
+ const output = cleanAnnotations([{ key: '', value: '' }]);
+ expect(output).toStrictEqual([]);
+ });
+
+ it('should trim keys and values', () => {
+ const output = cleanAnnotations([{ key: ' spaces ', value: ' spaces too ' }]);
+ expect(output).toStrictEqual([{ key: 'spaces', value: 'spaces too' }]);
+ });
+});
+
+describe('cleanLabels', () => {
+ it('should remove falsy KVs', () => {
+ const output = cleanLabels([{ key: '', value: '' }]);
+ expect(output).toStrictEqual([]);
+ });
+
+ it('should trim keys and values', () => {
+ const output = cleanLabels([{ key: ' spaces ', value: ' spaces too ' }]);
+ expect(output).toStrictEqual([{ key: 'spaces', value: 'spaces too' }]);
+ });
+
+ it('should leave empty values', () => {
+ const output = cleanLabels([{ key: 'key', value: '' }]);
+ expect(output).toStrictEqual([{ key: 'key', value: '' }]);
+ });
+});
diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts
index 960e24e07da..547e3031aea 100644
--- a/public/app/features/alerting/unified/utils/rule-form.ts
+++ b/public/app/features/alerting/unified/utils/rule-form.ts
@@ -40,6 +40,8 @@ import {
RulerRuleDTO,
} from 'app/types/unified-alerting-dto';
+type KVObject = { key: string; value: string };
+
import { EvalFunction } from '../../state/alertDef';
import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
@@ -51,6 +53,7 @@ import {
isAlertingRulerRule,
isGrafanaAlertingRuleByType,
isGrafanaRecordingRule,
+ isGrafanaRecordingRuleByType,
isGrafanaRulerRule,
isRecordingRulerRule,
} from './rules';
@@ -77,7 +80,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
uid: '',
labels: [{ key: '', value: '' }],
annotations: defaultAnnotations,
- dataSourceName: null,
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME, // let's use Grafana-managed alert rule by default
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
group: '',
@@ -118,6 +121,10 @@ export const getDefautManualRouting = () => {
export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values;
+
+ const annotations = arrayToRecord(cleanAnnotations(values.annotations));
+ const labels = arrayToRecord(cleanLabels(values.labels));
+
if (type === RuleFormType.cloudAlerting) {
let keepFiringFor: string | undefined;
if (keepFiringForTime && keepFiringForTimeUnit) {
@@ -128,24 +135,21 @@ export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
alert: name,
for: `${forTime}${forTimeUnit}`,
keep_firing_for: keepFiringFor,
- annotations: arrayToRecord(values.annotations || []),
- labels: arrayToRecord(values.labels || []),
+ annotations,
+ labels,
expr: expression,
};
} else if (type === RuleFormType.cloudRecording) {
return {
record: name,
- labels: arrayToRecord(values.labels || []),
+ labels,
expr: expression,
};
}
throw new Error(`unexpected rule type: ${type}`);
}
-export function listifyLabelsOrAnnotations(
- item: Labels | Annotations | undefined,
- addEmpty: boolean
-): Array<{ key: string; value: string }> {
+export function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined, addEmpty: boolean): KVObject[] {
const list = [...recordToArray(item || {})];
if (addEmpty) {
list.push({ key: '', value: '' });
@@ -154,7 +158,7 @@ export function listifyLabelsOrAnnotations(
}
//make sure default annotations are always shown in order even if empty
-export function normalizeDefaultAnnotations(annotations: Array<{ key: string; value: string }>) {
+export function normalizeDefaultAnnotations(annotations: KVObject[]) {
const orderedAnnotations = [...annotations];
const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key);
@@ -213,52 +217,70 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
type,
metric,
} = values;
- if (condition) {
- const notificationSettings: GrafanaNotificationSettings | undefined = getNotificationSettingsForDTO(
- manualRouting,
- contactPoints
- );
- if (isGrafanaAlertingRuleByType(type)) {
- return {
- grafana_alert: {
- title: name,
- condition,
- data: queries.map(fixBothInstantAndRangeQuery),
- is_paused: Boolean(isPaused),
+ if (!condition) {
+ throw new Error('You cannot create an alert rule without specifying the alert condition');
+ }
- // Alerting rule specific
- no_data_state: noDataState,
- exec_err_state: execErrState,
- notification_settings: notificationSettings,
- },
- annotations: arrayToRecord(values.annotations || []),
- labels: arrayToRecord(values.labels || []),
+ const notificationSettings = getNotificationSettingsForDTO(manualRouting, contactPoints);
+
+ const annotations = arrayToRecord(cleanAnnotations(values.annotations));
+ const labels = arrayToRecord(cleanLabels(values.labels));
+
+ const wantsAlertingRule = isGrafanaAlertingRuleByType(type);
+ const wantsRecordingRule = isGrafanaRecordingRuleByType(type!);
+
+ if (wantsAlertingRule) {
+ return {
+ grafana_alert: {
+ title: name,
+ condition,
+ data: queries.map(fixBothInstantAndRangeQuery),
+ is_paused: Boolean(isPaused),
// Alerting rule specific
- for: evaluateFor,
- };
- } else {
- return {
- grafana_alert: {
- title: name,
- condition,
- data: queries.map(fixBothInstantAndRangeQuery),
- is_paused: Boolean(isPaused),
+ no_data_state: noDataState,
+ exec_err_state: execErrState,
+ notification_settings: notificationSettings,
+ },
+ annotations,
+ labels,
- // Recording rule specific
- record: {
- metric: metric ?? name,
- from: condition,
- },
+ // Alerting rule specific
+ for: evaluateFor,
+ };
+ } else if (wantsRecordingRule) {
+ return {
+ grafana_alert: {
+ title: name,
+ condition,
+ data: queries.map(fixBothInstantAndRangeQuery),
+ is_paused: Boolean(isPaused),
+
+ // Recording rule specific
+ record: {
+ metric: metric ?? name,
+ from: condition,
},
- annotations: arrayToRecord(values.annotations || []),
- labels: arrayToRecord(values.labels || []),
- };
- }
+ },
+ annotations,
+ labels,
+ };
}
- throw new Error('Cannot create rule without specifying alert condition');
+
+ throw new Error(`Failed to convert form values to Grafana rule: unknown type ${type}`);
}
+export const cleanAnnotations = (kvs: KVObject[]) =>
+ kvs.map(trimKeyAndValue).filter(({ key, value }: KVObject): Boolean => Boolean(key) && Boolean(value));
+
+export const cleanLabels = (kvs: KVObject[]) =>
+ kvs.map(trimKeyAndValue).filter(({ key }: KVObject): Boolean => Boolean(key));
+
+const trimKeyAndValue = ({ key, value }: KVObject): KVObject => ({
+ key: key.trim(),
+ value: value.trim(),
+});
+
export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManagerManualRouting | undefined {
const contactPoint: ContactPoint | undefined = ga.notification_settings
? {
diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts
index b3785ffcaf5..6a8f73ad39c 100644
--- a/public/app/features/alerting/unified/utils/rule-id.ts
+++ b/public/app/features/alerting/unified/utils/rule-id.ts
@@ -1,7 +1,15 @@
import { nth } from 'lodash';
import { locationService } from '@grafana/runtime';
-import { CombinedRule, Rule, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
+import {
+ CloudRuleIdentifier,
+ CombinedRule,
+ EditableRuleIdentifier,
+ Rule,
+ RuleGroupIdentifier,
+ RuleIdentifier,
+ RuleWithLocation,
+} from 'app/types/unified-alerting';
import { Annotations, Labels, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
@@ -21,7 +29,7 @@ export function fromRulerRule(
namespace: string,
groupName: string,
rule: RulerRuleDTO
-): RuleIdentifier {
+): EditableRuleIdentifier {
if (isGrafanaRulerRule(rule)) {
return { uid: rule.grafana_alert.uid!, ruleSourceName: 'grafana' };
}
@@ -31,7 +39,15 @@ export function fromRulerRule(
groupName,
ruleName: isAlertingRulerRule(rule) ? rule.alert : rule.record,
rulerRuleHash: hashRulerRule(rule),
- };
+ } satisfies CloudRuleIdentifier;
+}
+
+export function fromRulerRuleAndRuleGroupIdentifier(
+ ruleGroup: RuleGroupIdentifier,
+ rule: RulerRuleDTO
+): EditableRuleIdentifier {
+ const { dataSourceName, namespaceName, groupName } = ruleGroup;
+ return fromRulerRule(dataSourceName, namespaceName, groupName, rule);
}
export function fromRule(ruleSourceName: string, namespace: string, groupName: string, rule: Rule): RuleIdentifier {
diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts
deleted file mode 100644
index 1ea7804223f..00000000000
--- a/public/app/features/alerting/unified/utils/rulerClient.ts
+++ /dev/null
@@ -1,289 +0,0 @@
-import {
- GrafanaRuleIdentifier,
- RuleIdentifier,
- RulerDataSourceConfig,
- RuleWithLocation,
-} from 'app/types/unified-alerting';
-import {
- PostableRuleGrafanaRuleDTO,
- PostableRulerRuleGroupDTO,
- RulerGrafanaRuleDTO,
- RulerRuleGroupDTO,
-} from 'app/types/unified-alerting-dto';
-
-import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesGroup, setRulerRuleGroup } from '../api/ruler';
-import { RuleFormValues } from '../types/rule-form';
-import * as ruleId from '../utils/rule-id';
-
-import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
-import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO } from './rule-form';
-import {
- isCloudRuleIdentifier,
- isGrafanaRuleIdentifier,
- isGrafanaRulerRule,
- isPrometheusRuleIdentifier,
-} from './rules';
-
-export interface RulerClient {
- findEditableRule(ruleIdentifier: RuleIdentifier): Promise;
-
- deleteRule(ruleWithLocation: RuleWithLocation): Promise;
-
- saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise;
-
- saveGrafanaRule(
- values: RuleFormValues,
- evaluateEvery: string,
- existing?: RuleWithLocation
- ): Promise;
-}
-
-export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
- const findEditableRule = async (ruleIdentifier: RuleIdentifier): Promise => {
- if (isGrafanaRuleIdentifier(ruleIdentifier)) {
- const namespaces = await fetchRulerRules(rulerConfig);
- // find namespace and group that contains the uid for the rule
- for (const [namespace, groups] of Object.entries(namespaces)) {
- for (const group of groups) {
- const rule = group.rules.find(
- (rule) => isGrafanaRulerRule(rule) && rule.grafana_alert?.uid === ruleIdentifier.uid
- );
- if (rule) {
- return {
- group,
- ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
- namespace: namespace,
- namespace_uid: (isGrafanaRulerRule(rule) && rule.grafana_alert.namespace_uid) || undefined,
- rule,
- };
- }
- }
- }
- }
-
- if (isCloudRuleIdentifier(ruleIdentifier)) {
- const { ruleSourceName, namespace, groupName } = ruleIdentifier;
- const group = await fetchRulerRulesGroup(rulerConfig, namespace, groupName);
-
- if (!group) {
- return null;
- }
-
- const rule = group.rules.find((rule) => {
- const identifier = ruleId.fromRulerRule(ruleSourceName, namespace, group.name, rule);
- return ruleId.equal(identifier, ruleIdentifier);
- });
-
- if (!rule) {
- return null;
- }
-
- return {
- group,
- ruleSourceName,
- namespace,
- rule,
- };
- }
-
- if (isPrometheusRuleIdentifier(ruleIdentifier)) {
- throw new Error('Native prometheus rules can not be edited in grafana.');
- }
-
- return null;
- };
-
- const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise => {
- const { namespace, group, rule, namespace_uid } = ruleWithLocation;
-
- // it was the last rule, delete the entire group
- if (group.rules.length === 1) {
- await deleteRulerRulesGroup(rulerConfig, namespace_uid || namespace, group.name);
- return;
- }
- // post the group with rule removed
- await setRulerRuleGroup(rulerConfig, namespace_uid || namespace, {
- ...group,
- rules: group.rules.filter((r) => r !== rule),
- });
- };
-
- const saveLotexRule = async (
- values: RuleFormValues,
- evaluateEvery: string,
- existing?: RuleWithLocation
- ): Promise => {
- const { dataSourceName, group, namespace } = values;
- const formRule = formValuesToRulerRuleDTO(values);
- if (dataSourceName && group && namespace) {
- // if we're updating a rule...
- if (existing) {
- // refetch it so we always have the latest greatest
- const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existing));
- if (!freshExisting) {
- throw new Error('Rule not found.');
- }
- // if namespace or group was changed, delete the old rule
- if (freshExisting.namespace !== namespace || freshExisting.group.name !== group) {
- await deleteRule(freshExisting);
- } else {
- // if same namespace or group, update the group replacing the old rule with new
- const payload = {
- ...freshExisting.group,
- rules: freshExisting.group.rules.map((existingRule) =>
- existingRule === freshExisting.rule ? formRule : existingRule
- ),
- evaluateEvery: evaluateEvery,
- };
- await setRulerRuleGroup(rulerConfig, namespace, payload);
- return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
- }
- }
-
- // if creating new rule or existing rule was in a different namespace/group, create new rule in target group
-
- const targetGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group);
-
- const payload: RulerRuleGroupDTO = targetGroup
- ? {
- ...targetGroup,
- rules: [...targetGroup.rules, formRule],
- }
- : {
- name: group,
- rules: [formRule],
- };
-
- await setRulerRuleGroup(rulerConfig, namespace, payload);
- return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
- } else {
- throw new Error('Data source and location must be specified');
- }
- };
-
- const saveGrafanaRule = async (
- values: RuleFormValues,
- evaluateEvery: string,
- existingRule?: RuleWithLocation
- ): Promise => {
- const { folder, group } = values;
- if (!folder) {
- throw new Error('Folder must be specified');
- }
-
- const newRule = formValuesToRulerGrafanaRuleDTO(values);
- const namespaceUID = folder.uid;
- const groupSpec = { name: group, interval: evaluateEvery };
-
- if (!existingRule) {
- return addRuleToNamespaceAndGroup(namespaceUID, groupSpec, newRule);
- }
-
- // we'll fetch the existing group again, someone might have updated it while we were editing a rule
- const freshExisting = await findEditableRule(ruleId.fromRuleWithLocation(existingRule));
- if (!freshExisting) {
- throw new Error('Rule not found.');
- }
-
- const sameNamespace = freshExisting.namespace_uid === namespaceUID;
- const sameGroup = freshExisting.group.name === values.group;
- const sameLocation = sameNamespace && sameGroup;
-
- if (sameLocation) {
- // we're update a rule in the same namespace and group
- return updateGrafanaRule(freshExisting, newRule, evaluateEvery);
- } else {
- // we're moving a rule to either a different group or namespace
- return moveGrafanaRule(namespaceUID, groupSpec, freshExisting, newRule);
- }
- };
-
- const addRuleToNamespaceAndGroup = async (
- namespaceUID: string,
- group: { name: string; interval: string },
- newRule: PostableRuleGrafanaRuleDTO
- ): Promise => {
- const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name);
- if (!existingGroup) {
- throw new Error(`No group found with name "${group.name}"`);
- }
-
- const payload: PostableRulerRuleGroupDTO = {
- name: group.name,
- interval: group.interval,
- rules: (existingGroup.rules ?? []).concat(newRule as RulerGrafanaRuleDTO),
- };
-
- await setRulerRuleGroup(rulerConfig, namespaceUID, payload);
-
- return { uid: newRule.grafana_alert.uid ?? '', ruleSourceName: GRAFANA_RULES_SOURCE_NAME };
- };
-
- // move the rule to another namespace / groupname
- const moveGrafanaRule = async (
- namespace: string,
- group: { name: string; interval: string },
- existingRule: RuleWithLocation,
- newRule: PostableRuleGrafanaRuleDTO
- ): Promise => {
- // make sure our updated alert has the same UID as before
- // that way the rule is automatically moved to the new namespace / group name
- copyGrafanaUID(existingRule, newRule);
-
- // add the new rule to the requested namespace and group
- const identifier = await addRuleToNamespaceAndGroup(namespace, group, newRule);
-
- return identifier;
- };
-
- const updateGrafanaRule = async (
- existingRule: RuleWithLocation,
- newRule: PostableRuleGrafanaRuleDTO,
- interval: string
- ): Promise => {
- // make sure our updated alert has the same UID as before
- copyGrafanaUID(existingRule, newRule);
-
- // create the new array of rules we want to send to the group. Keep the order of alerts in the group.
- const newRules = existingRule.group.rules.map((rule) => {
- if (!isGrafanaRulerRule(rule)) {
- return rule;
- }
- if (rule.grafana_alert.uid === existingRule.rule.grafana_alert.uid) {
- return newRule;
- }
- return rule;
- });
-
- await setRulerRuleGroup(rulerConfig, existingRule.namespace_uid ?? '', {
- name: existingRule.group.name,
- interval: interval,
- rules: newRules,
- });
-
- return { uid: existingRule.rule.grafana_alert.uid, ruleSourceName: GRAFANA_RULES_SOURCE_NAME };
- };
-
- // Would be nice to somehow align checking of ruler type between different methods
- // Maybe each datasource should have its own ruler client implementation
- return {
- findEditableRule,
- deleteRule,
- saveLotexRule,
- saveGrafanaRule,
- };
-}
-
-//copy the Grafana rule UID from the old rule to the new rule
-function copyGrafanaUID(
- oldRule: RuleWithLocation,
- newRule: PostableRuleGrafanaRuleDTO
-): asserts oldRule is RuleWithLocation {
- // type guard to make sure we're working with a Grafana managed rule
- if (!isGrafanaRulerRule(oldRule.rule)) {
- throw new Error('The rule is not a Grafana managed rule');
- }
-
- const uid = oldRule.rule.grafana_alert.uid;
- newRule.grafana_alert.uid = uid;
-}
diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts
index c0830d19dc9..fe4aa16b4b4 100644
--- a/public/app/features/alerting/unified/utils/rules.ts
+++ b/public/app/features/alerting/unified/utils/rules.ts
@@ -18,6 +18,8 @@ import {
RuleIdentifier,
RuleNamespace,
RuleWithLocation,
+ RulesSource,
+ EditableRuleIdentifier,
} from 'app/types/unified-alerting';
import {
GrafanaAlertState,
@@ -36,7 +38,7 @@ import {
import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
import { State } from '../components/StateTag';
import { RuleHealth } from '../search/rulesSearchParser';
-import { RuleFormType } from '../types/rule-form';
+import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName, isGrafanaRulesSource } from './datasource';
@@ -115,6 +117,10 @@ export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifi
return 'ruleHash' in identifier;
}
+export function isEditableRuleIdentifier(identifier: RuleIdentifier): identifier is EditableRuleIdentifier {
+ return isGrafanaRuleIdentifier(identifier) || isCloudRuleIdentifier(identifier);
+}
+
export function getRuleHealth(health: string): RuleHealth | undefined {
switch (health) {
case 'ok':
@@ -350,6 +356,26 @@ export function getRuleGroupLocationFromRuleWithLocation(rule: RuleWithLocation)
};
}
+export function getRuleGroupLocationFromFormValues(values: RuleFormValues): RuleGroupIdentifier {
+ const dataSourceName = values.dataSourceName;
+ const namespaceName = values.folder?.uid ?? values.namespace;
+ const groupName = values.group;
+
+ if (!dataSourceName) {
+ throw new Error('no datasource name in form values');
+ }
+
+ return {
+ dataSourceName,
+ namespaceName,
+ groupName,
+ };
+}
+
+export function rulesSourceToDataSourceName(rulesSource: RulesSource): string {
+ return isGrafanaRulesSource(rulesSource) ? rulesSource : rulesSource.name;
+}
+
export function isGrafanaAlertingRuleByType(type?: RuleFormType) {
return type === RuleFormType.grafana;
}
diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts
index 64082751f1b..338fa189fc9 100644
--- a/public/app/types/unified-alerting.ts
+++ b/public/app/types/unified-alerting.ts
@@ -182,7 +182,15 @@ export interface PrometheusRuleIdentifier {
ruleHash: string;
}
-export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier | PrometheusRuleIdentifier;
+export type RuleIdentifier = EditableRuleIdentifier | PrometheusRuleIdentifier;
+
+/**
+ * This type is a union of all rule identifiers that should have a ruler API
+ *
+ * We do not support PrometheusRuleIdentifier because vanilla Prometheus has no ruler API
+ */
+export type EditableRuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier;
+
export interface FilterState {
queryString?: string;
dataSource?: string;
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 45aff726dd2..410922a0ddb 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -203,6 +203,9 @@
"recording-rule": "Recording rule"
},
"rules": {
+ "add-rule": {
+ "success": "Rule added successfully"
+ },
"delete-rule": {
"success": "Rule successfully deleted"
},
@@ -211,6 +214,9 @@
},
"resume-rule": {
"success": "Rule evaluation resumed"
+ },
+ "update-rule": {
+ "success": "Rule updated successfully"
}
}
},
@@ -345,6 +351,7 @@
}
},
"common": {
+ "cancel": "Cancel",
"locale": {
"default": "Default"
},
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index fe72cd0a0b5..f10487ec526 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -203,6 +203,9 @@
"recording-rule": "Ŗęčőřđįʼnģ řūľę"
},
"rules": {
+ "add-rule": {
+ "success": "Ŗūľę äđđęđ şūččęşşƒūľľy"
+ },
"delete-rule": {
"success": "Ŗūľę şūččęşşƒūľľy đęľęŧęđ"
},
@@ -211,6 +214,9 @@
},
"resume-rule": {
"success": "Ŗūľę ęväľūäŧįőʼn řęşūmęđ"
+ },
+ "update-rule": {
+ "success": "Ŗūľę ūpđäŧęđ şūččęşşƒūľľy"
}
}
},
@@ -345,6 +351,7 @@
}
},
"common": {
+ "cancel": "Cäʼnčęľ",
"locale": {
"default": "Đęƒäūľŧ"
},