Alerting: useProduceNewRuleGroup for creating / updating alert rules. (#90497)

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Gilles De Mey 2024-08-22 14:57:23 +02:00 committed by GitHub
parent c315c2719d
commit 00381711a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 3192 additions and 1448 deletions

View File

@ -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.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"] [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": [ "public/app/features/alerting/unified/utils/rules.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "1"],

View File

@ -240,14 +240,6 @@ export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter)
export function trackRulesListViewChange(payload: { view: string }) { export function trackRulesListViewChange(payload: { view: string }) {
reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); 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() { export function trackEditInputWithTemplate() {
reportInteraction('grafana_alerting_contact_point_form_edit_input_with_template'); reportInteraction('grafana_alerting_contact_point_form_edit_input_with_template');
} }

View File

@ -10,7 +10,7 @@ import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto
import { searchFolders } from '../../manage-dashboards/state/actions'; import { searchFolders } from '../../manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo'; 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 { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
@ -114,7 +114,6 @@ const mocks = {
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeatures: jest.mocked(discoverFeatures),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules), fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),

View File

@ -1,23 +1,18 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest'; 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 { selectors } from '@grafana/e2e-selectors';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types'; 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 { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi'; import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource } from './mocks'; import { grantUserPermissions } from './mocks';
import { emptyExternalAlertmanagersResponse, mockAlertmanagersResponse } from './mocks/alertmanagerApi'; import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import { mimirDataSource } from './mocks/server/configure';
import { setupDataSources } from './testSetup/datasources'; import { MIMIR_DATASOURCE_UID } from './mocks/server/constants';
import { buildInfoResponse } from './testSetup/featureDiscovery'; import { captureRequests, serializeRequests } from './mocks/server/events';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name // 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('../../../../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', () => ({ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>, AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
})); }));
setupDataSources(dataSources.default); setupMswServer();
mimirDataSource();
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),
},
};
describe('RuleEditor cloud', () => { describe('RuleEditor cloud', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
contextSrv.isEditor = true;
contextSrv.hasEditPermissionInFolders = true;
grantUserPermissions([ grantUserPermissions([
AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleUpdate,
@ -88,28 +46,6 @@ describe('RuleEditor cloud', () => {
}); });
it('can create a new cloud alert', async () => { 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(); const user = userEvent.setup();
renderRuleEditor(); renderRuleEditor();
@ -134,14 +70,13 @@ describe('RuleEditor cloud', () => {
const dataSourceSelect = await ui.inputs.dataSource.find(); const dataSourceSelect = await ui.inputs.dataSource.find();
await user.click(dataSourceSelect); await user.click(dataSourceSelect);
await user.click(screen.getByText('Prom')); await user.click(screen.getByText(MIMIR_DATASOURCE_UID));
await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
await user.type(await ui.inputs.expr.find(), 'up == 1'); await user.type(await ui.inputs.expr.find(), 'up == 1');
await user.type(ui.inputs.name.get(), 'my great new rule'); await user.type(ui.inputs.name.get(), 'my great new rule');
await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2);
await clickSelectOption(ui.inputs.group.get(), 'group2'); await clickSelectOption(ui.inputs.group.get(), GROUP_3);
await user.type(ui.inputs.annotationValue(0).get(), 'some summary'); await user.type(ui.inputs.annotationValue(0).get(), 'some summary');
await user.type(ui.inputs.annotationValue(1).get(), 'some description'); await user.type(ui.inputs.annotationValue(1).get(), 'some description');
@ -150,24 +85,11 @@ describe('RuleEditor cloud', () => {
await user.click(ui.buttons.addLabel.get()); await user.click(ui.buttons.addLabel.get());
// save and check what was sent to backend // save and check what was sent to backend
const capture = captureRequests();
await user.click(ui.buttons.saveAndExit.get()); await user.click(ui.buttons.saveAndExit.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); const requests = await capture;
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: 'Prom', apiVersion: 'config' }, const serializedRequests = await serializeRequests(requests);
'namespace2', expect(serializedRequests).toMatchSnapshot();
{
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,
},
],
}
);
}); });
}); });

View File

@ -1,7 +1,6 @@
import * as React from 'react';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { ui } from 'test/helpers/alertingRuleEditor'; 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 { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
@ -11,14 +10,12 @@ import { backendSrv } from '../../../core/services/backend_srv';
import { AccessControlAction } from '../../../types'; import { AccessControlAction } from '../../../types';
import RuleEditor from './RuleEditor'; import RuleEditor from './RuleEditor';
import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { setupMswServer } from './mockApi'; import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource, mockFolder } from './mocks'; import { grantUserPermissions, mockDataSource, mockFolder } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; import { grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources'; import { setupDataSources } from './testSetup/datasources';
import { Annotation } from './utils/constants'; import { Annotation } from './utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
@ -42,9 +39,6 @@ jest.setTimeout(60 * 1000);
const mocks = { const mocks = {
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
api: {
setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
},
}; };
setupMswServer(); setupMswServer();
@ -110,7 +104,6 @@ describe('RuleEditor grafana managed rules', () => {
setupDataSources(dataSources.default); setupDataSources(dataSources.default);
mocks.api.setRulerRuleGroup.mockResolvedValue();
// mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); // mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]);
@ -143,25 +136,8 @@ describe('RuleEditor grafana managed rules', () => {
// save and check what was sent to backend // save and check what was sent to backend
await user.click(ui.buttons.save.get()); await user.click(ui.buttons.save.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]);
expect(screen.getByText('New folder')).toBeInTheDocument(); 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 },
},
],
}
);
}); });
}); });

View File

@ -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 userEvent from '@testing-library/user-event';
import * as React from 'react'; import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; 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 { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo'; import { discoverFeatures } from './api/buildInfo';
import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource } from './mocks'; import { grantUserPermissions, mockDataSource } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources'; import { setupDataSources } from './testSetup/datasources';
import * as config from './utils/config'; 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', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
@ -50,7 +46,6 @@ const mocks = {
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeatures: jest.mocked(discoverFeatures),
setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
}, },
}; };
@ -90,7 +85,6 @@ describe('RuleEditor grafana managed rules', () => {
setupDataSources(dataSources.default); setupDataSources(dataSources.default);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.searchFolders.mockResolvedValue([ mocks.searchFolders.mockResolvedValue([
{ {
title: 'Folder A', title: 'Folder A',
@ -126,33 +120,5 @@ describe('RuleEditor grafana managed rules', () => {
// save and check what was sent to backend // save and check what was sent to backend
await userEvent.click(ui.buttons.saveAndExit.get()); 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,
},
},
],
}
);
}); });
}); });

View File

@ -1,24 +1,18 @@
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byText } from 'testing-library-selector'; 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 { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AccessControlAction } from 'app/types'; 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 { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks'; import { grantUserPermissions } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi';
import * as config from './utils/config'; 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', () => ({ jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({
RecordingRuleEditor: ({ queries, onChangeQuery }: Pick<RecordingRuleEditorProps, 'queries' | 'onChangeQuery'>) => { RecordingRuleEditor: ({ queries, onChangeQuery }: Pick<RecordingRuleEditorProps, 'queries' | 'onChangeQuery'>) => {
@ -45,9 +39,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>, AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
})); }));
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. // there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it // lets just skip it
jest.mock('app/features/query/components/QueryEditorRow', () => ({ jest.mock('app/features/query/components/QueryEditorRow', () => ({
@ -55,50 +46,11 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => <p>hi</p>, QueryEditorRow: () => <p>hi</p>,
})); }));
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(); setupMswServer();
mimirDataSource();
describe('RuleEditor recording rules', () => { describe('RuleEditor recording rules', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
contextSrv.isEditor = true;
contextSrv.hasEditPermissionInFolders = true;
grantUserPermissions([ grantUserPermissions([
AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleUpdate,
@ -115,47 +67,17 @@ describe('RuleEditor recording rules', () => {
}); });
it('can create a new cloud recording rule', async () => { 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); renderRuleEditor(undefined, true);
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule');
const dataSourceSelect = ui.inputs.dataSource.get(); const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(dataSourceSelect); await userEvent.click(dataSourceSelect);
await userEvent.click(screen.getByText('Prom')); await userEvent.click(screen.getByText(MIMIR_DATASOURCE_UID));
await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2);
await clickSelectOption(ui.inputs.group.get(), 'group2'); await clickSelectOption(ui.inputs.group.get(), GROUP_3);
await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
@ -168,28 +90,17 @@ describe('RuleEditor recording rules', () => {
).get() ).get()
).toBeInTheDocument() ).toBeInTheDocument()
); );
expect(mocks.api.setRulerRuleGroup).not.toBeCalled();
// fix name and re-submit // fix name and re-submit
await userEvent.clear(await ui.inputs.name.find()); await userEvent.clear(await ui.inputs.name.find());
await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule'); await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule');
// save and check what was sent to backend // save and check what was sent to backend
const capture = captureRequests();
await userEvent.click(ui.buttons.saveAndExit.get()); await userEvent.click(ui.buttons.saveAndExit.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); const requests = await capture;
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: 'Prom', apiVersion: 'legacy' }, const serializedRequests = await serializeRequests(requests);
'namespace2', expect(serializedRequests).toMatchSnapshot();
{
name: 'group2',
rules: [
{
record: 'my:great:new:recording:rule',
labels: {},
expr: 'up == 1',
},
],
}
);
}); });
}); });

View File

@ -30,7 +30,7 @@ import RuleList from './RuleList';
import { discoverFeatures } from './api/buildInfo'; import { discoverFeatures } from './api/buildInfo';
import { fetchRules } from './api/prometheus'; import { fetchRules } from './api/prometheus';
import * as apiRuler from './api/ruler'; import * as apiRuler from './api/ruler';
import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from './api/ruler'; import { fetchRulerRules } from './api/ruler';
import { import {
MockDataSourceSrv, MockDataSourceSrv,
getPotentiallyPausedRulerRules, getPotentiallyPausedRulerRules,
@ -79,9 +79,6 @@ const mocks = {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeatures: jest.mocked(discoverFeatures),
fetchRules: jest.mocked(fetchRules), fetchRules: jest.mocked(fetchRules),
fetchRulerRules: jest.mocked(fetchRulerRules), fetchRulerRules: jest.mocked(fetchRulerRules),
deleteGroup: jest.mocked(deleteRulerRulesGroup),
deleteNamespace: jest.mocked(deleteNamespace),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder), rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder),
}, },
}; };
@ -582,7 +579,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); 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(); const user = userEvent.setup();
mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]);
@ -713,8 +710,6 @@ describe('RuleList', () => {
mocks.api.fetchRulerRules.mockImplementation(({ dataSourceName }) => mocks.api.fetchRulerRules.mockImplementation(({ dataSourceName }) =>
Promise.resolve(dataSourceName === testDatasources.prom.name ? someRulerRules : {}) Promise.resolve(dataSourceName === testDatasources.prom.name ? someRulerRules : {})
); );
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.deleteNamespace.mockResolvedValue();
await renderRuleList(); await renderRuleList();
@ -752,30 +747,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); 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.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 () => { testCase('rename just the lotex group', async () => {
@ -791,25 +763,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); 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.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 () => { testCase('edit lotex group eval interval, no renaming', async () => {
@ -822,19 +776,7 @@ describe('RuleList', () => {
await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); 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.fetchRulerRules).toHaveBeenCalledTimes(4);
expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(
1,
{ dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' },
'namespace1',
{
...someRulerRules.namespace1[0],
interval: '5m',
}
);
}); });
}); });

View File

@ -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",
},
]
`;

View File

@ -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",
},
]
`;

View File

@ -22,7 +22,7 @@ import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource }
import { arrayKeyValuesToObject } from '../utils/labels'; import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
import { alertingApi, withRequestOptions, WithRequestOptions } from './alertingApi'; import { alertingApi, WithNotificationOptions } from './alertingApi';
import { import {
FetchPromRulesFilter, FetchPromRulesFilter,
groupRulesByFileName, groupRulesByFileName,
@ -227,11 +227,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({
// TODO This should be probably a separate ruler API file // TODO This should be probably a separate ruler API file
getRuleGroupForNamespace: build.query< getRuleGroupForNamespace: build.query<
RulerRuleGroupDTO, 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); const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
return withRequestOptions({ url: path, params }, requestOptions); return {
url: path,
params,
notificationOptions,
};
}, },
providesTags: (_result, _error, { namespace, group }) => [ providesTags: (_result, _error, { namespace, group }) => [
{ {
@ -244,13 +248,21 @@ export const alertRuleApi = alertingApi.injectEndpoints({
deleteRuleGroupFromNamespace: build.mutation< deleteRuleGroupFromNamespace: build.mutation<
RulerRuleGroupDTO, 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 successMessage = t('alerting.rule-groups.delete.success', 'Successfully deleted rule group');
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, 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 }) => [ invalidatesTags: (_result, _error, { namespace, group }) => [
{ {
@ -263,29 +275,35 @@ export const alertRuleApi = alertingApi.injectEndpoints({
upsertRuleGroupForNamespace: build.mutation< upsertRuleGroupForNamespace: build.mutation<
AlertGroupUpdated, AlertGroupUpdated,
WithRequestOptions<{ WithNotificationOptions<{
rulerConfig: RulerDataSourceConfig; rulerConfig: RulerDataSourceConfig;
namespace: string; namespace: string;
payload: PostableRulerRuleGroupDTO; payload: PostableRulerRuleGroupDTO;
}> }>
>({ >({
query: ({ payload, namespace, rulerConfig, requestOptions }) => { query: ({ payload, namespace, rulerConfig, notificationOptions }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group'); const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group');
return withRequestOptions( return {
{ url: path,
url: path, params,
params, data: payload,
data: payload, method: 'POST',
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<RulerGrafanaRuleDTO, { uid: string }>({ getAlertRule: build.query<RulerGrafanaRuleDTO, { uid: string }>({

View File

@ -1,5 +1,5 @@
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; import { BaseQueryFn, createApi, defaultSerializeQueryArgs } from '@reduxjs/toolkit/query/react';
import { defaultsDeep } from 'lodash'; import { omit } from 'lodash';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { AppEvents } from '@grafana/data'; import { AppEvents } from '@grafana/data';
@ -9,6 +9,16 @@ import appEvents from 'app/core/app_events';
import { logMeasurement } from '../Analytics'; import { logMeasurement } from '../Analytics';
export type ExtendedBackendSrvRequest = BackendSrvRequest & { 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. * Custom success message to show after completion of the request.
* *
@ -23,34 +33,23 @@ export type ExtendedBackendSrvRequest = BackendSrvRequest & {
* will not be shown * will not be shown
*/ */
errorMessage?: string; errorMessage?: string;
/** } & Pick<BackendSrvRequest, 'showSuccessAlert' | 'showErrorAlert'>;
* 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'];
};
// utility type for passing request options to endpoints // utility type for passing request options to endpoints
export type WithRequestOptions<T> = T & { export type WithNotificationOptions<T> = T & {
requestOptions?: Partial<ExtendedBackendSrvRequest>; notificationOptions?: NotificationOptions;
}; };
export function withRequestOptions( // we'll use this type to prevent any consumer of the API from passing "showSuccessAlert" or "showErrorAlert" to the request options
options: BackendSrvRequest, export type BaseQueryFnArgs = WithNotificationOptions<
requestOptions: Partial<ExtendedBackendSrvRequest> = {}, Omit<ExtendedBackendSrvRequest, 'showSuccessAlert' | 'showErrorAlert'>
defaults: Partial<ExtendedBackendSrvRequest> = {} >;
): ExtendedBackendSrvRequest {
return {
...options,
...defaultsDeep(requestOptions, defaults),
};
}
export const backendSrvBaseQuery = export const backendSrvBaseQuery =
(): BaseQueryFn<ExtendedBackendSrvRequest> => (): BaseQueryFn<BaseQueryFnArgs> =>
async ({ successMessage, errorMessage, body, ...requestOptions }) => { async ({ body, notificationOptions = {}, ...requestOptions }) => {
const { errorMessage, showErrorAlert, successMessage, showSuccessAlert } = notificationOptions;
try { try {
const modifiedRequestOptions: BackendSrvRequest = { const modifiedRequestOptions: BackendSrvRequest = {
...requestOptions, ...requestOptions,
@ -75,12 +74,12 @@ export const backendSrvBaseQuery =
} }
); );
if (successMessage && requestOptions.showSuccessAlert !== false) { if (successMessage && showSuccessAlert !== false) {
appEvents.emit(AppEvents.alertSuccess, [successMessage]); appEvents.emit(AppEvents.alertSuccess, [successMessage]);
} }
return { data, meta }; return { data, meta };
} catch (error) { } catch (error) {
if (errorMessage && requestOptions.showErrorAlert !== false) { if (errorMessage && showErrorAlert !== false) {
appEvents.emit(AppEvents.alertError, [errorMessage]); appEvents.emit(AppEvents.alertError, [errorMessage]);
} }
return { error }; return { error };
@ -90,6 +89,16 @@ export const backendSrvBaseQuery =
export const alertingApi = createApi({ export const alertingApi = createApi({
reducerPath: 'alertingApi', reducerPath: 'alertingApi',
baseQuery: backendSrvBaseQuery(), 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: [ tagTypes: [
'AlertingConfiguration', 'AlertingConfiguration',
'AlertmanagerConfiguration', 'AlertmanagerConfiguration',

View File

@ -2,11 +2,16 @@ import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto'; import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto';
import { withPerformanceLogging } from '../Analytics'; import { withPerformanceLogging } from '../Analytics';
import { getRulesDataSource } from '../utils/datasource'; import { getRulesDataSource, isGrafanaRulesSource } from '../utils/datasource';
import { alertingApi } from './alertingApi'; import { alertingApi } from './alertingApi';
import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo'; import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo';
export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = {
dataSourceName: 'grafana',
apiVersion: 'legacy',
};
export const featureDiscoveryApi = alertingApi.injectEndpoints({ export const featureDiscoveryApi = alertingApi.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
discoverAmFeatures: build.query<AlertmanagerApiFeatures, { amSourceName: string }>({ discoverAmFeatures: build.query<AlertmanagerApiFeatures, { amSourceName: string }>({
@ -22,6 +27,10 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({ discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({
queryFn: async ({ rulesSourceName }) => { queryFn: async ({ rulesSourceName }) => {
if (isGrafanaRulesSource(rulesSourceName)) {
return { data: { rulerConfig: GRAFANA_RULER_CONFIG } };
}
const dsSettings = getRulesDataSource(rulesSourceName); const dsSettings = getRulesDataSource(rulesSourceName);
if (!dsSettings) { if (!dsSettings) {
return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) }; return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) };

View File

@ -3,7 +3,7 @@ import { lastValueFrom } from 'rxjs';
import { isObject } from '@grafana/data'; import { isObject } from '@grafana/data';
import { FetchResponse, getBackendSrv } from '@grafana/runtime'; import { FetchResponse, getBackendSrv } from '@grafana/runtime';
import { RulerDataSourceConfig } from 'app/types/unified-alerting'; 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 { containsPathSeparator } from '../components/rule-editor/util';
import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants';
@ -111,25 +111,6 @@ function getRulerPath(rulerConfig: RulerDataSourceConfig) {
return `${grafanaServerPath}/api/v1/rules`; 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<void> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespaceIdentifier);
await lastValueFrom(
getBackendSrv().fetch<unknown>({
method: 'POST',
url: path,
data: group,
showErrorAlert: false,
showSuccessAlert: false,
params,
})
);
}
export interface FetchRulerRulesFilter { export interface FetchRulerRulesFilter {
dashboardUID?: string; dashboardUID?: string;
panelId?: number; panelId?: number;
@ -172,19 +153,6 @@ export async function fetchRulerRulesGroup(
return rulerGetRequest<RulerRuleGroupDTO | null>(path, null, params); return rulerGetRequest<RulerRuleGroupDTO | null>(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 // false in case ruler is not supported. this is weird, but we'll work on it
async function rulerGetRequest<T>(url: string, empty: T, params?: Record<string, string>): Promise<T> { async function rulerGetRequest<T>(url: string, empty: T, params?: Record<string, string>): Promise<T> {
try { try {
@ -243,16 +211,3 @@ function isCortexErrorResponse(error: FetchResponse<ErrorResponseMessage>) {
(error.data.message?.includes('group does not exist') || error.data.message?.includes('no rule groups found')) (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<void> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
await lastValueFrom(
getBackendSrv().fetch<unknown>({
method: 'DELETE',
url: path,
showErrorAlert: false,
showSuccessAlert: false,
params,
})
);
}

View File

@ -14,7 +14,7 @@ import { AccessControlAction } from 'app/types';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
import { grafanaRulerConfig } from '../../hooks/useCombinedRule'; import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
@ -33,7 +33,7 @@ export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups
alertRuleApi.endpoints.rulerNamespace.useQuery( alertRuleApi.endpoints.rulerNamespace.useQuery(
{ {
namespace: folderUid, namespace: folderUid,
rulerConfig: grafanaRulerConfig, rulerConfig: GRAFANA_RULER_CONFIG,
}, },
{ {
skip: !folderUid, skip: !folderUid,

View File

@ -9,17 +9,16 @@ import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } fro
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
import { import {
getRuleGroupLocationFromFormValues,
getRuleGroupLocationFromRuleWithLocation, getRuleGroupLocationFromRuleWithLocation,
isGrafanaManagedRuleByType, isGrafanaManagedRuleByType,
isGrafanaRulerRule, isGrafanaRulerRule,
isGrafanaRulerRulePaused, isGrafanaRulerRulePaused,
isRecordingRuleByType, isRecordingRuleByType,
} from 'app/features/alerting/unified/utils/rules'; } from 'app/features/alerting/unified/utils/rules';
import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting'; import { RuleWithLocation } from 'app/types/unified-alerting';
import { import {
@ -30,10 +29,8 @@ import {
trackAlertRuleFormSaved, trackAlertRuleFormSaved,
} from '../../../Analytics'; } from '../../../Analytics';
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
import { saveRuleFormAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { initialAsyncRequestState } from '../../../utils/redux';
import { import {
DEFAULT_GROUP_EVALUATION_INTERVAL, DEFAULT_GROUP_EVALUATION_INTERVAL,
MANUAL_ROUTING_KEY, MANUAL_ROUTING_KEY,
@ -42,7 +39,10 @@ import {
getDefaultQueries, getDefaultQueries,
ignoreHiddenQueries, ignoreHiddenQueries,
normalizeDefaultAnnotations, normalizeDefaultAnnotations,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
} from '../../../utils/rule-form'; } from '../../../utils/rule-form';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep'; import AnnotationsStep from '../AnnotationsStep';
@ -61,15 +61,18 @@ type Props = {
export const AlertRuleForm = ({ existing, prefill }: Props) => { export const AlertRuleForm = ({ existing, prefill }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
const [showEditYaml, setShowEditYaml] = useState(false); const [showEditYaml, setShowEditYaml] = useState(false);
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
const routeParams = useParams<{ type: string; id: string }>(); const routeParams = useParams<{ type: string; id: string }>();
const ruleType = translateRouteParamToRuleType(routeParams.type); const ruleType = translateRouteParamToRuleType(routeParams.type);
const uidFromParams = routeParams.id; const uidFromParams = routeParams.id;
const returnTo = !queryParams.returnTo ? '/alerting/list' : String(queryParams.returnTo); const returnTo = !queryParams.returnTo ? '/alerting/list' : String(queryParams.returnTo);
@ -103,23 +106,27 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
shouldFocusError: true, shouldFocusError: true,
}); });
const { handleSubmit, watch } = formAPI; const {
handleSubmit,
watch,
formState: { isSubmitting },
} = formAPI;
const type = watch('type'); const type = watch('type');
const grafanaTypeRule = isGrafanaManagedRuleByType(type ?? RuleFormType.grafana);
const dataSourceName = watch('dataSourceName'); const dataSourceName = watch('dataSourceName');
const showDataSourceDependantStep = Boolean(type && (isGrafanaManagedRuleByType(type) || !!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 [conditionErrorMsg, setConditionErrorMsg] = useState('');
const checkAlertCondition = (msg = '') => { const checkAlertCondition = (msg = '') => {
setConditionErrorMsg(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 !== '') { if (conditionErrorMsg !== '') {
notifyApp.error(conditionErrorMsg); notifyApp.error(conditionErrorMsg);
return; return;
@ -136,33 +143,39 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
} }
} }
dispatch( const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values);
saveRuleFormAction({
values: { const ruleGroupIdentifier = existing
...defaultValues, ? getRuleGroupLocationFromRuleWithLocation(existing)
...values, : getRuleGroupLocationFromFormValues(values);
annotations:
values.annotations // @TODO what is "evaluateEvery" being used for?
?.map(({ key, value }) => ({ key: key.trim(), value: value.trim() })) // @TODO move this to a hook too to make sure the logic here is tested for regressions?
.filter(({ key, value }) => !!key && !!value) ?? [], if (!existing) {
labels: await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, values.evaluateEvery);
values.labels } else {
?.map(({ key, value }) => ({ key: key.trim(), value: value.trim() })) const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
.filter(({ key }) => !!key) ?? [], const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values);
},
existing, await updateRuleInRuleGroup.execute(
redirectOnSave: exitOnSave ? returnTo : undefined, ruleGroupIdentifier,
initialAlertRuleName: defaultValues.name, ruleIdentifier,
evaluateEvery: evaluateEvery, ruleDefinition,
}) targetRuleGroupIdentifier
); );
}
if (exitOnSave && returnTo) {
locationService.push(returnTo);
}
}; };
const deleteRule = async () => { const deleteRule = async () => {
if (existing) { if (existing) {
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(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); locationService.replace(returnTo);
} }
}; };
@ -194,9 +207,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
type="button" type="button"
size="sm" size="sm"
onClick={handleSubmit((values) => submit(values, false), onInvalid)} onClick={handleSubmit((values) => submit(values, false), onInvalid)}
disabled={submitState.loading} disabled={isSubmitting}
> >
{submitState.loading && <Spinner className={styles.buttonSpinner} inline={true} />} {isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule Save rule
</Button> </Button>
)} )}
@ -205,13 +218,13 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
type="button" type="button"
size="sm" size="sm"
onClick={handleSubmit((values) => submit(values, true), onInvalid)} onClick={handleSubmit((values) => submit(values, true), onInvalid)}
disabled={submitState.loading} disabled={isSubmitting}
> >
{submitState.loading && <Spinner className={styles.buttonSpinner} inline={true} />} {isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
Save rule and exit Save rule and exit
</Button> </Button>
<Link to={returnTo}> <Link to={returnTo}>
<Button variant="secondary" disabled={submitState.loading} type="button" onClick={cancelRuleCreation} size="sm"> <Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
Cancel Cancel
</Button> </Button>
</Link> </Link>
@ -225,7 +238,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
variant="secondary" variant="secondary"
type="button" type="button"
onClick={() => setShowEditYaml(true)} onClick={() => setShowEditYaml(true)}
disabled={submitState.loading} disabled={isSubmitting}
size="sm" size="sm"
> >
Edit YAML Edit YAML

View File

@ -2,29 +2,23 @@ import { ReactNode } from 'react';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { ui } from 'test/helpers/alertingRuleEditor'; import { ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest'; 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 { byRole } from 'testing-library-selector';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import RuleEditor from 'app/features/alerting/unified/RuleEditor'; 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 { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure'; 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 { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
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 { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/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'; import { setupDataSources } from '../../../../testSetup/datasources';
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
@ -33,12 +27,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
jest.setTimeout(60 * 1000); jest.setTimeout(60 * 1000);
const mocks = {
api: {
setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
},
};
setupMswServer(); setupMswServer();
const dataSources = { 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 () => { it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
renderSimplifiedRuleEditor(); renderSimplifiedRuleEditor();
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); 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 // save and check that call to backend was not made
await user.click(ui.buttons.saveAndExit.get()); await user.click(ui.buttons.saveAndExit.get());
expect(await screen.findByText('Contact point is required.')).toBeInTheDocument(); 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 () => { 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 () => { it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const contactPointName = 'lotsa-emails'; const contactPointName = 'lotsa-emails';
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
renderSimplifiedRuleEditor(); renderSimplifiedRuleEditor();
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); 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 // save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get()); await user.click(ui.buttons.saveAndExit.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); const requests = await capture;
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, const serializedRequests = await serializeRequests(requests);
grafanaRulerNamespace2.uid, expect(serializedRequests).toMatchSnapshot();
{
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,
},
},
},
],
}
);
}); });
describe('alertingApiServer enabled', () => { describe('alertingApiServer enabled', () => {

View File

@ -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",
},
]
`;

View File

@ -7,6 +7,7 @@ import { CombinedRule } from 'app/types/unified-alerting';
import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { fetchPromAndRulerRulesAction } from '../../state/actions'; import { fetchPromAndRulerRulesAction } from '../../state/actions';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules'; import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules';
type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void];
@ -29,12 +30,14 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
return; return;
} }
const location = getRuleGroupLocationFromCombinedRule(rule); const ruleGroupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
await deleteRuleFromGroup.execute(location, rule.rulerRule); const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, rule.rulerRule);
await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier);
// refetch rules for this rules source // refetch rules for this rules source
// @TODO remove this when we moved everything to RTKQ then the endpoint will simply invalidate the tags // @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(); dismissModal();

View File

@ -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
});
});

View File

@ -8,22 +8,30 @@ import {
DropResult, DropResult,
} from '@hello-pangea/dnd'; } from '@hello-pangea/dnd';
import cx from 'classnames'; import cx from 'classnames';
import { compact } from 'lodash'; import { produce } from 'immer';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import * as React from 'react'; import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui';
import { useCombinedRuleNamespaces } from 'app/features/alerting/unified/hooks/useCombinedRuleNamespaces'; import { Trans } from 'app/core/internationalization';
import { dispatch } from 'app/store/store'; import { dispatch, getState } from 'app/store/store';
import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { updateRulesOrder } from '../../state/actions'; import { alertRuleApi } from '../../api/alertRuleApi';
import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; 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 { hashRulerRule } from '../../utils/rule-id';
import { isAlertingRule, isRecordingRule } from '../../utils/rules'; import {
isAlertingRulerRule,
import { AlertStateTag } from './AlertStateTag'; isGrafanaRulerRule,
isRecordingRulerRule,
rulesSourceToDataSourceName,
} from '../../utils/rules';
interface ModalProps { interface ModalProps {
namespace: CombinedRuleNamespace; namespace: CombinedRuleNamespace;
@ -32,24 +40,36 @@ interface ModalProps {
folderUid?: string; folderUid?: string;
} }
type CombinedRuleWithUID = { uid: string } & CombinedRule; type RulerRuleWithUID = { uid: string } & RulerRuleDTO;
export const ReorderCloudGroupModal = (props: ModalProps) => { export const ReorderCloudGroupModal = (props: ModalProps) => {
const styles = useStyles2(getStyles);
const { group, namespace, onClose, folderUid } = props; const { group, namespace, onClose, folderUid } = props;
const [operations, setOperations] = useState<Array<[number, number]>>([]);
const [reorderRulesInGroup, reorderState] = useReorderRuleForRuleGroup();
const isUpdating = isLoading(reorderState);
// The list of rules might have been filtered before we get to this reordering modal // 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 // We need to grab the full (unfiltered) list
// deleting any rules (as they otherwise would have been omitted from the payload) const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource);
const unfilteredNamespaces = useCombinedRuleNamespaces(); const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName);
const matchedNamespace = unfilteredNamespaces.find( const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery(
(ns) => ns.rulesSource === namespace.rulesSource && ns.name === namespace.name {
rulerConfig,
namespace: folderUid ?? namespace.name,
group: group.name,
},
{ refetchOnMountOrArgChange: true }
); );
const matchedGroup = matchedNamespace?.groups.find((g) => g.name === group.name);
const [pending, setPending] = useState<boolean>(false); const [rulesList, setRulesList] = useState<RulerRuleDTO[]>([]);
const [rulesList, setRulesList] = useState<CombinedRule[]>(matchedGroup?.rules || []);
const styles = useStyles2(getStyles); useEffect(() => {
if (ruleGroup) {
setRulesList(ruleGroup?.rules);
}
}, [ruleGroup]);
const onDragEnd = useCallback( const onDragEnd = useCallback(
(result: DropResult) => { (result: DropResult) => {
@ -58,39 +78,50 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
return; return;
} }
const sameIndex = result.destination.index === result.source.index; const swapOperation: SwapOperation = [result.source.index, result.destination.index];
if (sameIndex) {
return;
}
const newOrderedRules = reorder(rulesList, result.source.index, result.destination.index); // add old index and new index to the modifications object
setRulesList(newOrderedRules); // optimistically update the new rules list setOperations(
produce(operations, (draft) => {
const rulesSourceName = getRulesSourceName(namespace.rulesSource); draft.push(swapOperation);
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,
}) })
) );
.unwrap()
.finally(() => { // re-order the rules list for the UI rendering
setPending(false); 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 // assign unique but stable identifiers to each (alerting / recording) rule
const rulesWithUID: CombinedRuleWithUID[] = rulesList.map((rule) => ({ const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({
...rule, ...rulerRule,
uid: String(hashRulerRule(rule.rulerRule!)), // TODO fix this coercion? uid: hashRulerRule(rulerRule),
})); }));
return ( return (
@ -101,37 +132,50 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
onDismiss={onClose} onDismiss={onClose}
onClickBackdrop={onClose} onClickBackdrop={onClose}
> >
<DragDropContext onDragEnd={onDragEnd}> {loadingRules && 'Loading...'}
<Droppable {rulesWithUID.length > 0 && (
droppableId="alert-list" <>
mode="standard" <DragDropContext onDragEnd={onDragEnd}>
renderClone={(provided, _snapshot, rubric) => ( <Droppable
<ListItem provided={provided} rule={rulesWithUID[rubric.source.index]} isClone /> droppableId="alert-list"
)} mode="standard"
> renderClone={(provided, _snapshot, rubric) => (
{(droppableProvided: DroppableProvided) => ( <ListItem provided={provided} rule={rulesWithUID[rubric.source.index]} isClone />
<div )}
ref={droppableProvided.innerRef}
className={cx(styles.listContainer, pending && styles.disabled)}
{...droppableProvided.droppableProps}
> >
{rulesWithUID.map((rule, index) => ( {(droppableProvided: DroppableProvided) => (
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={pending}> <div
{(provided: DraggableProvided) => <ListItem key={rule.uid} provided={provided} rule={rule} />} ref={droppableProvided.innerRef}
</Draggable> className={cx(styles.listContainer, isUpdating && styles.disabled)}
))} {...droppableProvided.droppableProps}
{droppableProvided.placeholder} >
</div> {rulesWithUID.map((rule, index) => (
)} <Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={isUpdating}>
</Droppable> {(provided: DraggableProvided) => <ListItem key={rule.uid} provided={provided} rule={rule} />}
</DragDropContext> </Draggable>
))}
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<Modal.ButtonRow>
<Button variant="secondary" fill="outline" onClick={onClose}>
<Trans i18nKey={'common.cancel'}>Cancel</Trans>
</Button>
<Button onClick={() => updateRulesOrder()} disabled={isUpdating}>
<Trans i18nKey={'common.save'}>Save</Trans>
</Button>
</Modal.ButtonRow>
</>
)}
</Modal> </Modal>
); );
}; };
interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> { interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> {
provided: DraggableProvided; provided: DraggableProvided;
rule: CombinedRule; rule: RulerRuleDTO;
isClone?: boolean; isClone?: boolean;
isDragging?: boolean; isDragging?: boolean;
} }
@ -139,6 +183,7 @@ interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> {
const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => { const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
// @TODO does this work with Grafana-managed recording rules too? Double check that.
return ( return (
<div <div
data-testid="reorder-alert-rule" data-testid="reorder-alert-rule"
@ -147,10 +192,15 @@ const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListI
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
> >
{isAlertingRule(rule.promRule) && <AlertStateTag state={rule.promRule.state} />} {isGrafanaRulerRule(rule) && <div className={styles.listItemName}>{rule.grafana_alert.title}</div>}
{isRecordingRule(rule.promRule) && <Badge text={'Recording'} color={'blue'} />} {isRecordingRulerRule(rule) && (
<div className={styles.listItemName}>{rule.name}</div> <>
<Icon name={'draggabledots'} /> <div className={styles.listItemName}>{rule.record}</div>
<Badge text="Recording" color="purple" />
</>
)}
{isAlertingRulerRule(rule) && <div className={styles.listItemName}>{rule.alert}</div>}
<Icon name="draggabledots" />
</div> </div>
); );
}; };
@ -235,11 +285,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(2), height: theme.spacing(2),
}), }),
}); });
export function reorder<T>(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;
}

View File

@ -6,17 +6,16 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics'; import { LogMessages, logInfo } from '../../Analytics';
import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup';
import { useFolder } from '../../hooks/useFolder'; import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler'; import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions';
import { useRulesAccess } from '../../utils/accessControlHooks'; import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc'; import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { isFederatedRuleGroup, isGrafanaRulerRule, rulesSourceToDataSourceName } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle'; import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation'; import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
@ -40,8 +39,8 @@ interface Props {
export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => {
const { rulesSource } = namespace; const { rulesSource } = namespace;
const dispatch = useDispatch();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [deleteRuleGroup] = useDeleteRuleGroup();
const [isEditingGroup, setIsEditingGroup] = useState(false); const [isEditingGroup, setIsEditingGroup] = useState(false);
const [isDeletingGroup, setIsDeletingGroup] = 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 isListView = viewMode === 'list';
const isGroupView = viewMode === 'grouped'; const isGroupView = viewMode === 'grouped';
const deleteGroup = () => { const deleteGroup = async () => {
dispatch(deleteRulesGroupAction(namespace, group)); 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); setIsDeletingGroup(false);
}; };

View File

@ -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",
},
]
`;

View File

@ -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",
},
]
`;

View File

@ -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",
},
]
`;

View File

@ -1,5 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // 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`] = ` exports[`useUpdateRuleGroupConfiguration should be able to move a Data Source managed rule group 1`] = `
[ [
{ {

View File

@ -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",
},
]
`;

View File

@ -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(<AddRuleTestComponent ruleGroupIdentifier={ruleGroupID} rule={rule} />);
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(<AddRuleTestComponent ruleGroupIdentifier={ruleGroupID} rule={rule} interval="15m" />);
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(<AddRuleTestComponent ruleGroupIdentifier={ruleGroupID} rule={rule} />);
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(<AddRuleTestComponent ruleGroupIdentifier={ruleGroupID} rule={rule} />);
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(<AddRuleTestComponent ruleGroupIdentifier={ruleGroupID} rule={rule} interval="15m" />);
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 (
<>
<button onClick={() => onClick()} />
<SerializeState state={requestState} />
</>
);
};

View File

@ -1,4 +1,3 @@
import userEvent from '@testing-library/user-event';
import { HttpResponse } from 'msw'; import { HttpResponse } from 'msw';
import { render } from 'test/test-utils'; import { render } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector'; import { byRole, byText } from 'testing-library-selector';
@ -19,6 +18,7 @@ import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { setUpdateRulerRuleNamespaceHandler, setRulerRuleGroupHandler } from '../../mocks/server/configure'; import { setUpdateRulerRuleNamespaceHandler, setRulerRuleGroupHandler } from '../../mocks/server/configure';
import { captureRequests, serializeRequests } from '../../mocks/server/events'; import { captureRequests, serializeRequests } from '../../mocks/server/events';
import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../../mocks/server/handlers/mimirRuler'; import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../../mocks/server/handlers/mimirRuler';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules'; import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules';
import { SerializeState } from '../useAsync'; import { SerializeState } from '../useAsync';
@ -60,9 +60,9 @@ describe('delete rule', () => {
const capture = captureRequests(); const capture = captureRequests();
render(<DeleteTestComponent rule={rules[1]} />); const { user } = render(<DeleteTestComponent rule={rules[1]} />);
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument(); expect(await byText(/success/i).find()).toBeInTheDocument();
@ -99,9 +99,9 @@ describe('delete rule', () => {
const capture = captureRequests(); const capture = captureRequests();
render(<DeleteTestComponent rule={rules[1]} />); const { user } = render(<DeleteTestComponent rule={rules[1]} />);
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument(); 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 () => { it('should delete the entire group if no more rules are left', async () => {
const capture = captureRequests(); const rule = mockCombinedRule({
const combined = mockCombinedRule({
rulerRule: grafanaRulerRule, rulerRule: grafanaRulerRule,
}); });
render(<DeleteTestComponent rule={combined} />); const group = mockRulerRuleGroup({
await userEvent.click(byRole('button').get()); name: 'group-1',
rules: [rule.rulerRule!],
});
setRulerRuleGroupHandler({
response: HttpResponse.json(group),
});
const capture = captureRequests();
const { user } = render(<DeleteTestComponent rule={rule} />);
await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument(); expect(await byText(/success/i).find()).toBeInTheDocument();
@ -136,8 +145,9 @@ const DeleteTestComponent = ({ rule }: DeleteTestComponentProps) => {
// always handle your errors! // always handle your errors!
const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule); const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule);
const ruleID = fromRulerRuleAndRuleGroupIdentifier(ruleGroupID, rule.rulerRule!);
const onClick = () => { const onClick = () => {
deleteRuleFromGroup.execute(ruleGroupID, rule.rulerRule!); deleteRuleFromGroup.execute(ruleGroupID, ruleID);
}; };
return ( return (

View File

@ -1,7 +1,5 @@
import { t } from 'i18next'; import { t } from 'app/core/internationalization';
import { EditableRuleIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
import { deleteRuleAction } from '../../reducers/ruler/ruleGroups'; import { deleteRuleAction } from '../../reducers/ruler/ruleGroups';
@ -20,10 +18,10 @@ export function useDeleteRuleFromGroup() {
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation(); const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.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 { groupName, namespaceName } = ruleGroup;
const action = deleteRuleAction({ rule }); const action = deleteRuleAction({ identifier: ruleIdentifier });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action); const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const successMessage = t('alerting.rules.delete-rule.success', 'Rule successfully deleted'); const successMessage = t('alerting.rules.delete-rule.success', 'Rule successfully deleted');
@ -34,7 +32,7 @@ export function useDeleteRuleFromGroup() {
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
group: groupName, group: groupName,
requestOptions: { successMessage }, notificationOptions: { successMessage },
}).unwrap(); }).unwrap();
} }
@ -43,7 +41,7 @@ export function useDeleteRuleFromGroup() {
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
payload: newRuleGroupDefinition, payload: newRuleGroupDefinition,
requestOptions: { successMessage }, notificationOptions: { successMessage },
}).unwrap(); }).unwrap();
}); });
} }

View File

@ -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(<DeleteTestComponent ruleGroupIdentifier={ruleGroupID} />);
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(<DeleteTestComponent ruleGroupIdentifier={ruleGroupID} />);
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 (
<>
<button onClick={() => onClick()} />
<SerializeState state={requestState} />
</>
);
};

View File

@ -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;
});
}

View File

@ -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(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
ruleID={ruleID}
rule={ruleToMove}
/>
);
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(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={currentRuleGroupID}
ruleID={ruleID}
rule={grafanaRulerRule}
/>
);
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(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
ruleID={ruleID}
rule={newRule}
/>
);
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(
<MoveRuleTestComponent
currentRuleGroupIdentifier={currentRuleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
ruleID={ruleID}
rule={ruleToMove}
/>
);
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(
<MoveRuleTestComponent
currentRuleGroupIdentifier={curentRuleGroupID}
targetRuleGroupIdentifier={curentRuleGroupID}
ruleID={ruleID}
rule={newRule}
/>
);
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 (
<>
<button onClick={() => onClick()} />
<SerializeState state={requestState} />
</>
);
};

View File

@ -54,7 +54,7 @@ describe('pause rule', () => {
expect(byText(/uninitialized/i).get()).toBeInTheDocument(); expect(byText(/uninitialized/i).get()).toBeInTheDocument();
await userEvent.click(byRole('button').get()); 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 () => { it('should be able to handle error', async () => {

View File

@ -1,5 +1,4 @@
import { t } from 'i18next'; import { t } from 'app/core/internationalization';
import { RuleGroupIdentifier } from 'app/types/unified-alerting'; import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
@ -16,21 +15,21 @@ export function usePauseRuleInGroup() {
const [produceNewRuleGroup] = useProduceNewRuleGroup(); const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation(); 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) => { return useAsync(async (ruleGroup: RuleGroupIdentifier, uid: string, pause: boolean) => {
const { namespaceName } = ruleGroup; const { namespaceName } = ruleGroup;
const action = pauseRuleAction({ uid, pause }); const action = pauseRuleAction({ uid, pause });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action); 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({ return upsertRuleGroup({
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
payload: newRuleGroupDefinition, payload: newRuleGroupDefinition,
requestOptions: { notificationOptions: {
successMessage: pause ? rulePauseMessage : ruleResumeMessage, successMessage: pause ? rulePausedMessage : ruleResumedMessage,
}, },
}).unwrap(); }).unwrap();
}); });

View File

@ -1,11 +1,19 @@
import { Action } from '@reduxjs/toolkit'; import { Action } from '@reduxjs/toolkit';
import { dispatch, getState } from 'app/store/store';
import { RuleGroupIdentifier } from 'app/types/unified-alerting'; import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { notFoundToNullOrThrow } from '../../api/util';
import { ruleGroupReducer } from '../../reducers/ruler/ruleGroups'; 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, * 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 * @throws
*/ */
export function useProduceNewRuleGroup() { 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. * returns the result.
* *
* The API does not allow operations on a single rule and will always overwrite the existing rule group with the payload. * 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 produceNewRuleGroup = async (ruleGroup: RuleGroupIdentifier, action: Action) => {
const { dataSourceName, groupName, namespaceName } = ruleGroup; const { dataSourceName, groupName, namespaceName } = ruleGroup;
// @TODO we should really not work with the redux state (getState) here const { rulerConfig } = await discoverDataSourceFeatures({ rulesSourceName: dataSourceName }).unwrap();
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: dataSourceName })); if (!rulerConfig) {
const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName); throw RulerNotSupportedError(dataSourceName);
}
const latestRuleGroupDefinition = await fetchRuleGroup({ const latestRuleGroupDefinition = await fetchRuleGroup({
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
group: groupName, 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 { newRuleGroupDefinition, rulerConfig };
}; };
return [produceNewRuleGroup, requestState] as const; return [produceNewRuleGroup, requestState] as const;
} }
const createBlankRuleGroup = (name: string): PostableRulerRuleGroupDTO => ({
name,
interval: DEFAULT_GROUP_EVALUATION_INTERVAL,
rules: [],
});

View File

@ -1,4 +1,3 @@
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils'; import { render } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector'; 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 { mimirDataSource } from '../../mocks/server/configure';
import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants'; import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants';
import { captureRequests, serializeRequests } from '../../mocks/server/events'; import { captureRequests, serializeRequests } from '../../mocks/server/events';
import { SwapOperation } from '../../reducers/ruler/ruleGroups';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { SerializeState } from '../useAsync'; import { SerializeState } from '../useAsync';
import { useMoveRuleGroup, useRenameRuleGroup, useUpdateRuleGroupConfiguration } from './useUpdateRuleGroup'; import {
useMoveRuleGroup,
useRenameRuleGroup,
useReorderRuleForRuleGroup,
useUpdateRuleGroupConfiguration,
} from './useUpdateRuleGroup';
setupMswServer(); setupMswServer();
@ -27,8 +32,8 @@ describe('useUpdateRuleGroupConfiguration', () => {
it('should update a rule group interval', async () => { it('should update a rule group interval', async () => {
const capture = captureRequests(); const capture = captureRequests();
render(<UpdateRuleGroupComponent />); const { user } = render(<UpdateRuleGroupComponent />);
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument(); expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture; const requests = await capture;
@ -39,8 +44,8 @@ describe('useUpdateRuleGroupConfiguration', () => {
it('should rename a rule group', async () => { it('should rename a rule group', async () => {
const capture = captureRequests(); const capture = captureRequests();
render(<RenameRuleGroupComponent />); const { user } = render(<RenameRuleGroupComponent />);
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument(); expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture; const requests = await capture;
@ -49,14 +54,14 @@ describe('useUpdateRuleGroupConfiguration', () => {
}); });
it('should throw if we are trying to merge rule groups', async () => { it('should throw if we are trying to merge rule groups', async () => {
render(<RenameRuleGroupComponent group={grafanaRulerGroupName2} />); const { user } = render(<RenameRuleGroupComponent group={grafanaRulerGroupName2} />);
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument(); expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
}); });
it('should not be able to move a Grafana managed rule group', async () => { it('should not be able to move a Grafana managed rule group', async () => {
render(<MoveGrafanaManagedRuleGroupComponent />); const { user } = render(<MoveGrafanaManagedRuleGroupComponent />);
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument(); expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
}); });
@ -64,8 +69,10 @@ describe('useUpdateRuleGroupConfiguration', () => {
mimirDataSource(); mimirDataSource();
const capture = captureRequests(); const capture = captureRequests();
render(<MoveDataSourceManagedRuleGroupComponent namespace={NAMESPACE_2} group={'a-new-group'} interval={'2m'} />); const { user } = render(
await userEvent.click(byRole('button').get()); <MoveDataSourceManagedRuleGroupComponent namespace={NAMESPACE_2} group={'a-new-group'} interval={'2m'} />
);
await user.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument(); expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture; 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 () => { it('should not move a Data Source managed rule group to namespace with existing target group name', async () => {
mimirDataSource(); mimirDataSource();
render( const { user } = render(
<MoveDataSourceManagedRuleGroupComponent namespace={NAMESPACE_2} group={namespace2[0].name} interval={'2m'} /> <MoveDataSourceManagedRuleGroupComponent namespace={NAMESPACE_2} group={namespace2[0].name} interval={'2m'} />
); );
await userEvent.click(byRole('button').get()); await user.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument(); 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(
<ReorderRuleGroupComponent namespace={NAMESPACE_2} group={namespace2[0].name} swaps={swaps} />
);
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 UpdateRuleGroupComponent = () => {
const [updateRuleGroup, requestState] = useUpdateRuleGroupConfiguration(); 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 (
<>
<button onClick={() => reorderRules.execute(ruleGroupID, swaps)} />
<SerializeState state={requestState} />
</>
);
};

View File

@ -1,15 +1,21 @@
import { t } from 'i18next'; import { t } from 'app/core/internationalization';
import { RuleGroupIdentifier } from 'app/types/unified-alerting'; import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi'; import { alertRuleApi } from '../../api/alertRuleApi';
import { notFoundToNullOrThrow } from '../../api/util'; 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 { isGrafanaRulesSource } from '../../utils/datasource';
import { useAsync } from '../useAsync'; import { useAsync } from '../useAsync';
import { useProduceNewRuleGroup } from './useProduceNewRuleGroup'; 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. * Update an existing rule group, currently only supports updating the interval.
* Use "useRenameRuleGroup" or "useMoveRuleGroup" for updating the namespace or group name. * Use "useRenameRuleGroup" or "useMoveRuleGroup" for updating the namespace or group name.
@ -24,13 +30,11 @@ export function useUpdateRuleGroupConfiguration() {
const action = updateRuleGroupAction({ interval }); const action = updateRuleGroupAction({ interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action); const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group');
return upsertRuleGroup({ return upsertRuleGroup({
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
payload: newRuleGroupDefinition, payload: newRuleGroupDefinition,
requestOptions: { successMessage }, notificationOptions: { successMessage: ruleUpdateSuccessMessage() },
}).unwrap(); }).unwrap();
}); });
} }
@ -56,11 +60,11 @@ export function useMoveRuleGroup() {
throw new Error('Moving a Grafana-managed rule group to another folder is currently not supported.'); 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 { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const oldNamespace = ruleGroup.namespaceName; const oldNamespace = ruleGroup.namespaceName;
const targetNamespace = action.payload.namespaceName; const targetNamespace = action.payload.newNamespaceName;
const oldGroupName = ruleGroup.groupName; const oldGroupName = ruleGroup.groupName;
const targetGroupName = action.payload.groupName; const targetGroupName = action.payload.groupName;
@ -74,7 +78,7 @@ export function useMoveRuleGroup() {
namespace: targetNamespace, namespace: targetNamespace,
group: targetGroupName, group: targetGroupName,
// since this could throw 404 // since this could throw 404
requestOptions: { showErrorAlert: false }, notificationOptions: { showErrorAlert: false },
}) })
.unwrap() .unwrap()
.catch(notFoundToNullOrThrow); .catch(notFoundToNullOrThrow);
@ -90,7 +94,7 @@ export function useMoveRuleGroup() {
rulerConfig, rulerConfig,
namespace: targetNamespace, namespace: targetNamespace,
payload: newRuleGroupDefinition, payload: newRuleGroupDefinition,
requestOptions: { successMessage }, notificationOptions: { successMessage },
}).unwrap(); }).unwrap();
// now remove the old one // now remove the old one
@ -98,7 +102,7 @@ export function useMoveRuleGroup() {
rulerConfig, rulerConfig,
namespace: oldNamespace, namespace: oldNamespace,
group: oldGroupName, group: oldGroupName,
requestOptions: { showSuccessAlert: false }, notificationOptions: { showSuccessAlert: false },
}).unwrap(); }).unwrap();
return result; return result;
@ -132,7 +136,7 @@ export function useRenameRuleGroup() {
namespace: namespaceName, namespace: namespaceName,
group: newGroupName, group: newGroupName,
// since this could throw 404 // since this could throw 404
requestOptions: { showErrorAlert: false }, notificationOptions: { showErrorAlert: false },
}) })
.unwrap() .unwrap()
.catch(notFoundToNullOrThrow); .catch(notFoundToNullOrThrow);
@ -147,7 +151,7 @@ export function useRenameRuleGroup() {
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
payload: newRuleGroupDefinition, payload: newRuleGroupDefinition,
requestOptions: { successMessage }, notificationOptions: { successMessage },
}).unwrap(); }).unwrap();
// now delete the group we renamed // now delete the group we renamed
@ -155,9 +159,32 @@ export function useRenameRuleGroup() {
rulerConfig, rulerConfig,
namespace: namespaceName, namespace: namespaceName,
group: oldGroupName, group: oldGroupName,
requestOptions: { showSuccessAlert: false }, notificationOptions: { showSuccessAlert: false },
}).unwrap(); }).unwrap();
return result; 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();
});
}

View File

@ -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(
<UpdateRuleTestComponent ruleGroupIdentifier={ruleGroupID} ruleID={ruleID} rule={newRule} />
);
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(
<UpdateRuleTestComponent
ruleGroupIdentifier={ruleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
ruleID={ruleID}
rule={newRule}
/>
);
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(
<UpdateRuleTestComponent ruleGroupIdentifier={ruleGroupID} ruleID={ruleID} rule={newRule} />
);
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(
<UpdateRuleTestComponent ruleGroupIdentifier={ruleGroupID} ruleID={ruleID} rule={newRule} />
);
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(
<UpdateRuleTestComponent ruleGroupIdentifier={ruleGroupID} ruleID={ruleID} rule={newRule} />
);
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(
<UpdateRuleTestComponent
ruleGroupIdentifier={ruleGroupID}
targetRuleGroupIdentifier={targetRuleGroupID}
ruleID={ruleID}
rule={newRule}
/>
);
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(
<UpdateRuleTestComponent ruleGroupIdentifier={ruleGroupID} ruleID={ruleID} rule={newRule} />
);
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(
<UpdateRuleTestComponent ruleGroupIdentifier={ruleGroupID} ruleID={ruleID} rule={newRule} />
);
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 (
<>
<button onClick={() => onClick()} />
<SerializeState state={requestState} />
</>
);
};

View File

@ -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;
}
});
}

View File

@ -1,65 +1,16 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { useDispatch } from 'app/types'; import { CombinedRule, RuleIdentifier, RulesSource, RuleWithLocation } from 'app/types/unified-alerting';
import { import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
CombinedRule,
RuleIdentifier,
RuleNamespace,
RulerDataSourceConfig,
RulesSource,
RuleWithLocation,
} from 'app/types/unified-alerting';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi'; import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { fetchPromAndRulerRulesAction } from '../state/actions'; import { getDataSourceByName } from '../utils/datasource';
import { getDataSourceByName, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux';
import * as ruleId from '../utils/rule-id'; import * as ruleId from '../utils/rule-id';
import { import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
isCloudRuleIdentifier,
isGrafanaRuleIdentifier,
isPrometheusRuleIdentifier,
isRulerNotSupportedResponse,
} from '../utils/rules';
import { attachRulerRulesToCombinedRules, useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; import { attachRulerRulesToCombinedRules } from './useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export function useCombinedRulesMatching(
ruleName: string | undefined,
ruleSourceName: string | undefined
): AsyncRequestState<CombinedRule[]> {
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,
};
}
export function useCloudCombinedRulesMatching( export function useCloudCombinedRulesMatching(
ruleName: string, ruleName: string,
@ -123,49 +74,6 @@ export function useCloudCombinedRulesMatching(
return { loading: isLoadingDsFeatures || loading, error: error, rules: value }; return { loading: isLoadingDsFeatures || loading, error: error, rules: value };
} }
function useCombinedRulesLoader(
rulesSourceName: string | undefined,
identifier?: RuleIdentifier
): AsyncRequestState<void> {
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<RulerRulesConfigDTO | RuleNamespace[] | null>
): AsyncRequestState<RulerRulesConfigDTO | RuleNamespace[] | null> {
if (!ruleSourceName) {
return initialAsyncRequestState;
}
const state = slice[ruleSourceName];
if (!state) {
return initialAsyncRequestState;
}
return state;
}
interface RequestState<T> { interface RequestState<T> {
result?: T; result?: T;
loading: boolean; 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) { export function useDataSourceFeatures(dataSourceName: string) {
const isGrafanaDs = isGrafanaRulesSource(dataSourceName);
const { currentData: dsFeatures, isLoading: isLoadingDsFeatures } = const { currentData: dsFeatures, isLoading: isLoadingDsFeatures } =
featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery( featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({ rulesSourceName: dataSourceName });
{
rulesSourceName: dataSourceName,
},
{ skip: isGrafanaDs }
);
if (isGrafanaDs) {
return { isLoadingDsFeatures: false, dsFeatures: grafanaDsFeatures };
}
return { isLoadingDsFeatures, dsFeatures }; return { isLoadingDsFeatures, dsFeatures };
} }

View File

@ -22,6 +22,7 @@ import {
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi'; import { alertRuleApi } from '../api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../api/featureDiscoveryApi';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { import {
getAllRulesSources, getAllRulesSources,
@ -38,7 +39,6 @@ import {
isRecordingRulerRule, isRecordingRulerRule,
} from '../utils/rules'; } from '../utils/rules';
import { grafanaRulerConfig } from './useCombinedRule';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export interface CacheValue { export interface CacheValue {
@ -499,7 +499,7 @@ export function useCombinedRules(
error: rulerRulesError, error: rulerRulesError,
} = alertRuleApi.endpoints.rulerRules.useQuery( } = alertRuleApi.endpoints.rulerRules.useQuery(
{ {
rulerConfig: grafanaRulerConfig, rulerConfig: GRAFANA_RULER_CONFIG,
filter: { dashboardUID: dashboardUID ?? undefined, panelId }, filter: { dashboardUID: dashboardUID ?? undefined, panelId },
}, },
{ {

View File

@ -25,7 +25,7 @@ export const group2: RulerRuleGroupDTO = {
export const group3: RulerRuleGroupDTO = { export const group3: RulerRuleGroupDTO = {
name: GROUP_3, name: GROUP_3,
interval: '1m', interval: '1m',
rules: [mockRulerAlertingRule()], rules: [mockRulerAlertingRule({ alert: 'rule 3' }), mockRulerAlertingRule({ alert: 'rule 4' })],
}; };
export const group4: RulerRuleGroupDTO = { export const group4: RulerRuleGroupDTO = {

View File

@ -3,6 +3,8 @@ import { JsonValue } from 'type-fest';
import server from 'app/features/alerting/unified/mockApi'; 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, * 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 * 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 * @deprecated Try not to use this 🙏 instead aim to assert against UI side effects
*/ */
export async function captureRequests(): Promise<Request[]> {
export async function captureRequests(predicateFn: PredicateFn = () => true): Promise<Request[]> {
const requests: Request[] = []; const requests: Request[] = [];
server.events.on('request:start', ({ request }) => { server.events.on('request:start', ({ request }) => {
requests.push(request); if (predicateFn(request)) {
requests.push(request);
}
}); });
return requests; return requests;

View File

@ -3,6 +3,7 @@ import { delay, http, HttpResponse } from 'msw';
export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert'; export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert';
import { import {
PromRulesResponse,
RulerGrafanaRuleDTO, RulerGrafanaRuleDTO,
RulerRuleGroupDTO, RulerRuleGroupDTO,
RulerRulesConfigDTO, RulerRulesConfigDTO,
@ -28,6 +29,12 @@ export const rulerRulesHandler = () => {
}); });
}; };
export const prometheusRulesHandler = () => {
return http.get('/api/prometheus/grafana/api/v1/rules', () => {
return HttpResponse.json<PromRulesResponse>({ status: 'success', data: { groups: [] } });
});
};
export const getRulerRuleNamespaceHandler = () => export const getRulerRuleNamespaceHandler = () =>
http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => { 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 // 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 }>( http.delete<{ folderUid: string; groupName: string }>(
`/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`, `/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`,
({ params: { folderUid } }) => { ({ params: { folderUid } }) => {
if (options?.response) {
return options.response;
}
const namespace = namespaces[folderUid]; const namespace = namespaces[folderUid];
if (!namespace) { if (!namespace) {
return new HttpResponse(null, { status: 403 }); return new HttpResponse(null, { status: 403 });
@ -132,6 +143,7 @@ export const historyHandler = () => {
const handlers = [ const handlers = [
rulerRulesHandler(), rulerRulesHandler(),
prometheusRulesHandler(),
getRulerRuleNamespaceHandler(), getRulerRuleNamespaceHandler(),
rulerRuleGroupHandler(), rulerRuleGroupHandler(),
rulerRuleHandler(), rulerRuleHandler(),

View File

@ -1,9 +1,25 @@
import { delay, http, HttpResponse } from 'msw'; 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 { namespaces } from '../../mimirRulerApi';
import { HandlerOptions } from '../configure'; import { HandlerOptions } from '../configure';
export const getRulerRulesHandler = () => {
return http.get(`/api/ruler/:dataSourceUID/api/v1/rules`, async () => {
return HttpResponse.json<RulerRulesConfigDTO>(namespaces);
});
};
export const prometheusRulesHandler = () => {
return http.get('/api/prometheus/:dataSourceUID/api/v1/rules', () => {
return HttpResponse.json<PromRulesResponse>({ status: 'success', data: { groups: [] } });
});
};
export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) => { export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) => {
return http.post<{ namespaceName: string }>(`/api/ruler/:dataSourceUID/api/v1/rules/:namespaceName`, async () => { return http.post<{ namespaceName: string }>(`/api/ruler/:dataSourceUID/api/v1/rules/:namespaceName`, async () => {
if (options?.delay !== undefined) { 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; export default handlers;

View File

@ -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 { 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', () => { describe('pausing rules', () => {
// pausing only works for Grafana managed rules // pausing only works for Grafana managed rules
@ -55,8 +73,9 @@ describe('removing a rule', () => {
interval: '5m', interval: '5m',
rules: [mockRulerGrafanaRule({}, { uid: '1' }), ruleToDelete, mockRulerGrafanaRule({}, { uid: '3' })], 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); const output = ruleGroupReducer(initialGroup, action);
expect(output).toHaveProperty('rules'); 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); const output = ruleGroupReducer(initialGroup, action);
expect(output).toHaveProperty('rules'); expect(output).toHaveProperty('rules');
@ -96,3 +116,208 @@ describe('removing a rule', () => {
expect(output).toMatchSnapshot(); 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');
});
});

View File

@ -1,27 +1,35 @@
import { createAction, createReducer, isAnyOf } from '@reduxjs/toolkit'; import { createAction, createReducer, isAnyOf } from '@reduxjs/toolkit';
import { remove } from 'lodash'; import { inRange } from 'lodash';
import { RuleIdentifier } from 'app/types/unified-alerting'; import { EditableRuleIdentifier, GrafanaRuleIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
import { PostableRuleDTO, PostableRulerRuleGroupDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { PostableRuleDTO, PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { hashRulerRule } from '../../utils/rule-id'; import { hashRulerRule } from '../../utils/rule-id';
import { isCloudRulerRule, isGrafanaRulerRule } from '../../utils/rules'; import {
isCloudRuleIdentifier,
isCloudRulerRule,
isGrafanaRuleIdentifier,
isGrafanaRulerRule,
} from '../../utils/rules';
// rule-scoped actions // rule-scoped actions
export const addRuleAction = createAction<{ rule: PostableRuleDTO }>('ruleGroup/rules/add'); export const addRuleAction = createAction<{ rule: PostableRuleDTO; groupName?: string; interval?: string }>(
export const updateRuleAction = createAction<{ identifier: RuleIdentifier; rule: PostableRuleDTO }>( 'ruleGroup/rules/add'
);
export const updateRuleAction = createAction<{ identifier: EditableRuleIdentifier; rule: PostableRuleDTO }>(
'ruleGroup/rules/update' 'ruleGroup/rules/update'
); );
export const pauseRuleAction = createAction<{ uid: string; pause: boolean }>('ruleGroup/rules/pause'); 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 // group-scoped actions
export const updateRuleGroupAction = createAction<{ interval?: string }>('ruleGroup/update'); 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' 'ruleGroup/move'
); );
export const renameRuleGroupAction = createAction<{ groupName: string; interval?: string }>('ruleGroup/rename'); 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 = { const initialState: PostableRulerRuleGroupDTO = {
name: 'initial', name: 'initial',
@ -30,62 +38,114 @@ const initialState: PostableRulerRuleGroupDTO = {
export const ruleGroupReducer = createReducer(initialState, (builder) => { export const ruleGroupReducer = createReducer(initialState, (builder) => {
builder builder
.addCase(addRuleAction, () => { .addCase(addRuleAction, (draft, { payload }) => {
throw new Error('not yet implemented'); const { rule } = payload;
draft.rules.push(rule);
}) })
.addCase(updateRuleAction, () => { .addCase(updateRuleAction, (draft, { payload }) => {
throw new Error('not yet implemented'); const { identifier, rule } = payload;
const index = findRuleIndex(draft.rules, identifier);
draft.rules[index] = rule;
}) })
.addCase(deleteRuleAction, (draft, { payload }) => { .addCase(deleteRuleAction, (draft, { payload }) => {
const { rule } = payload; const { identifier } = payload;
// deleting a Grafana managed rule is by using the UID const index = findRuleIndex(draft.rules, identifier);
if (isGrafanaRulerRule(rule)) { draft.rules.splice(index, 1);
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);
}
}) })
.addCase(pauseRuleAction, (draft, { payload }) => { .addCase(pauseRuleAction, (draft, { payload }) => {
const { uid, pause } = 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(matchingRule)) {
if (isGrafanaRulerRule(rule) && rule.grafana_alert.uid === uid) { matchingRule.grafana_alert.is_paused = pause;
match = true; } else {
rule.grafana_alert.is_paused = pause; throw new Error('Matching rule is not a Grafana-managed rule');
break;
}
}
if (!match) {
throw new Error(`No rule with UID ${uid} found in group ${draft.name}`);
} }
}) })
.addCase(reorderRulesInRuleGroupAction, () => { .addCase(reorderRulesInRuleGroupAction, (draft, { payload }) => {
throw new Error('not yet implemented'); const { swaps } = payload;
reorder(draft.rules, swaps);
}) })
// rename and move should allow updating the group's name // 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; const { groupName } = payload;
if (groupName) { draft.name = groupName ?? draft.name;
draft.name = groupName;
}
}) })
// update, rename and move should all allow updating the interval of the group // update, rename and move should all allow updating the interval of the group
.addMatcher(isAnyOf(updateRuleGroupAction, renameRuleGroupAction, moveRuleGroupAction), (draft, { payload }) => { .addMatcher(
const { interval } = payload; isAnyOf(updateRuleGroupAction, renameRuleGroupAction, moveRuleGroupAction, addRuleAction),
if (interval) { (draft, { payload }) => {
draft.interval = interval; const { interval } = payload;
draft.interval = interval ?? draft.interval;
} }
}) )
.addDefaultCase((_draft, action) => { .addDefaultCase((_draft, action) => {
throw new Error(`Unknown action for rule group reducer: ${action.type}`); 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<T>(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<T>(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;
}

View File

@ -12,34 +12,16 @@ import {
} from 'app/plugins/datasource/alertmanager/types'; } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO, StoreState, ThunkResult } from 'app/types'; import { FolderDTO, StoreState, ThunkResult } from 'app/types';
import { import {
CombinedRuleGroup,
CombinedRuleNamespace,
PromBasedDataSource, PromBasedDataSource,
RuleIdentifier, RuleIdentifier,
RuleNamespace, RuleNamespace,
RuleWithLocation,
RulerDataSourceConfig, RulerDataSourceConfig,
StateHistoryItem, StateHistoryItem,
} from 'app/types/unified-alerting'; } from 'app/types/unified-alerting';
import { import { PromApplication, RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
PostableRulerRuleGroupDTO,
PromApplication,
RulerRuleDTO,
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { backendSrv } from '../../../../core/services/backend_srv'; import { backendSrv } from '../../../../core/services/backend_srv';
import { import { withPerformanceLogging, withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
LogMessages,
logError,
logInfo,
trackSwitchToPoliciesRouting,
trackSwitchToSimplifiedRouting,
withPerformanceLogging,
withPromRulesMetadataLogging,
withRulerRulesMetadataLogging,
} from '../Analytics';
import { alertRuleApi } from '../api/alertRuleApi';
import { import {
deleteAlertManagerConfig, deleteAlertManagerConfig,
fetchAlertGroups, fetchAlertGroups,
@ -50,26 +32,12 @@ import { alertmanagerApi } from '../api/alertmanagerApi';
import { fetchAnnotations } from '../api/annotations'; import { fetchAnnotations } from '../api/annotations';
import { discoverFeatures } from '../api/buildInfo'; import { discoverFeatures } from '../api/buildInfo';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
import { FetchRulerRulesFilter, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler'; import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler';
import { RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
import { import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames, getRulesDataSource } from '../utils/datasource';
GRAFANA_RULES_SOURCE_NAME,
getAllRulesSourceNames,
getRulesDataSource,
getRulesSourceName,
} from '../utils/datasource';
import { makeAMLink } from '../utils/misc'; import { makeAMLink } from '../utils/misc';
import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux';
import * as ruleId from '../utils/rule-id'; import { getAlertInfo, isRulerNotSupportedResponse } from '../utils/rules';
import { getRulerClient } from '../utils/rulerClient';
import {
getAlertInfo,
isDataSourceManagedRuleByType,
isGrafanaManagedRuleByType,
isGrafanaRulerRule,
isRulerNotSupportedResponse,
} from '../utils/rules';
import { safeParsePrometheusDuration } from '../utils/time'; import { safeParsePrometheusDuration } from '../utils/time';
function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) {
@ -323,119 +291,6 @@ export function fetchAllPromRulesAction(force = false): ThunkResult<void> {
}; };
} }
export function deleteRulesGroupAction(
namespace: CombinedRuleNamespace,
ruleGroup: CombinedRuleGroup
): ThunkResult<void> {
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<void> =>
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<RulerRuleDTO> | 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( export const fetchGrafanaAnnotationsAction = createAsyncThunk(
'unifiedalerting/fetchGrafanaAnnotations', 'unifiedalerting/fetchGrafanaAnnotations',
(alertId: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(alertId)) (alertId: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(alertId))
@ -582,56 +437,3 @@ export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDurat
return forNumber ? forNumber !== 0 && forNumber < everyNumber : false; 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<void> => {
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',
}
);
}
);

View File

@ -10,7 +10,6 @@ import {
fetchPromRulesAction, fetchPromRulesAction,
fetchRulerRulesAction, fetchRulerRulesAction,
fetchRulesSourceBuildInfoAction, fetchRulesSourceBuildInfoAction,
saveRuleFormAction,
testReceiversAction, testReceiversAction,
updateAlertManagerConfigAction, updateAlertManagerConfigAction,
} from './actions'; } from './actions';
@ -24,9 +23,6 @@ export const reducer = combineReducers({
promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer,
rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName)
.reducer, .reducer,
ruleForm: combineReducers({
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
}),
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer, saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,
deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer, deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer,
folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer, folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer,

View File

@ -2,11 +2,7 @@
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana alerting rule 1`] = ` exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana alerting rule 1`] = `
{ {
"annotations": { "annotations": {},
"description": "",
"runbook_url": "",
"summary": "",
},
"for": "1m", "for": "1m",
"grafana_alert": { "grafana_alert": {
"condition": "A", "condition": "A",
@ -17,19 +13,13 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
"notification_settings": undefined, "notification_settings": undefined,
"title": "", "title": "",
}, },
"labels": { "labels": {},
"": "",
},
} }
`; `;
exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana recording rule 1`] = ` exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form values for grafana recording rule 1`] = `
{ {
"annotations": { "annotations": {},
"description": "",
"runbook_url": "",
"summary": "",
},
"grafana_alert": { "grafana_alert": {
"condition": "A", "condition": "A",
"data": [], "data": [],
@ -40,19 +30,13 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
}, },
"title": "", "title": "",
}, },
"labels": { "labels": {},
"": "",
},
} }
`; `;
exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = ` exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range type queries 1`] = `
{ {
"annotations": { "annotations": {},
"description": "",
"runbook_url": "",
"summary": "",
},
"for": "1m", "for": "1m",
"grafana_alert": { "grafana_alert": {
"condition": "A", "condition": "A",
@ -79,26 +63,18 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
"notification_settings": undefined, "notification_settings": undefined,
"title": "", "title": "",
}, },
"labels": { "labels": {},
"": "",
},
} }
`; `;
exports[`formValuesToRulerGrafanaRuleDTO should not set keep_firing_for if values are undefined 1`] = ` exports[`formValuesToRulerGrafanaRuleDTO should not set keep_firing_for if values are undefined 1`] = `
{ {
"alert": "", "alert": "",
"annotations": { "annotations": {},
"description": "",
"runbook_url": "",
"summary": "",
},
"expr": "", "expr": "",
"for": "1m", "for": "1m",
"keep_firing_for": undefined, "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`] = ` exports[`formValuesToRulerGrafanaRuleDTO should set keep_firing_for if values are populated 1`] = `
{ {
"alert": "", "alert": "",
"annotations": { "annotations": {},
"description": "",
"runbook_url": "",
"summary": "",
},
"expr": "", "expr": "",
"for": "1m", "for": "1m",
"keep_firing_for": "1m", "keep_firing_for": "1m",
"labels": { "labels": {},
"": "",
},
} }
`; `;

View File

@ -8,6 +8,8 @@ import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { import {
MANUAL_ROUTING_KEY, MANUAL_ROUTING_KEY,
alertingRulerRuleToRuleForm, alertingRulerRuleToRuleForm,
cleanAnnotations,
cleanLabels,
formValuesToRulerGrafanaRuleDTO, formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO, formValuesToRulerRuleDTO,
getContactPointsFromDTO, getContactPointsFromDTO,
@ -254,3 +256,32 @@ describe('getDefautManualRouting', () => {
expect(getDefautManualRouting()).toBe(true); 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: '' }]);
});
});

View File

@ -40,6 +40,8 @@ import {
RulerRuleDTO, RulerRuleDTO,
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
type KVObject = { key: string; value: string };
import { EvalFunction } from '../../state/alertDef'; import { EvalFunction } from '../../state/alertDef';
import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form'; import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
@ -51,6 +53,7 @@ import {
isAlertingRulerRule, isAlertingRulerRule,
isGrafanaAlertingRuleByType, isGrafanaAlertingRuleByType,
isGrafanaRecordingRule, isGrafanaRecordingRule,
isGrafanaRecordingRuleByType,
isGrafanaRulerRule, isGrafanaRulerRule,
isRecordingRulerRule, isRecordingRulerRule,
} from './rules'; } from './rules';
@ -77,7 +80,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
uid: '', uid: '',
labels: [{ key: '', value: '' }], labels: [{ key: '', value: '' }],
annotations: defaultAnnotations, 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 type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
group: '', group: '',
@ -118,6 +121,10 @@ export const getDefautManualRouting = () => {
export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO { export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values; 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) { if (type === RuleFormType.cloudAlerting) {
let keepFiringFor: string | undefined; let keepFiringFor: string | undefined;
if (keepFiringForTime && keepFiringForTimeUnit) { if (keepFiringForTime && keepFiringForTimeUnit) {
@ -128,24 +135,21 @@ export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
alert: name, alert: name,
for: `${forTime}${forTimeUnit}`, for: `${forTime}${forTimeUnit}`,
keep_firing_for: keepFiringFor, keep_firing_for: keepFiringFor,
annotations: arrayToRecord(values.annotations || []), annotations,
labels: arrayToRecord(values.labels || []), labels,
expr: expression, expr: expression,
}; };
} else if (type === RuleFormType.cloudRecording) { } else if (type === RuleFormType.cloudRecording) {
return { return {
record: name, record: name,
labels: arrayToRecord(values.labels || []), labels,
expr: expression, expr: expression,
}; };
} }
throw new Error(`unexpected rule type: ${type}`); throw new Error(`unexpected rule type: ${type}`);
} }
export function listifyLabelsOrAnnotations( export function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined, addEmpty: boolean): KVObject[] {
item: Labels | Annotations | undefined,
addEmpty: boolean
): Array<{ key: string; value: string }> {
const list = [...recordToArray(item || {})]; const list = [...recordToArray(item || {})];
if (addEmpty) { if (addEmpty) {
list.push({ key: '', value: '' }); list.push({ key: '', value: '' });
@ -154,7 +158,7 @@ export function listifyLabelsOrAnnotations(
} }
//make sure default annotations are always shown in order even if empty //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 orderedAnnotations = [...annotations];
const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key); const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key);
@ -213,52 +217,70 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
type, type,
metric, metric,
} = values; } = values;
if (condition) { if (!condition) {
const notificationSettings: GrafanaNotificationSettings | undefined = getNotificationSettingsForDTO( throw new Error('You cannot create an alert rule without specifying the alert condition');
manualRouting, }
contactPoints
);
if (isGrafanaAlertingRuleByType(type)) {
return {
grafana_alert: {
title: name,
condition,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
// Alerting rule specific const notificationSettings = getNotificationSettingsForDTO(manualRouting, contactPoints);
no_data_state: noDataState,
exec_err_state: execErrState, const annotations = arrayToRecord(cleanAnnotations(values.annotations));
notification_settings: notificationSettings, const labels = arrayToRecord(cleanLabels(values.labels));
},
annotations: arrayToRecord(values.annotations || []), const wantsAlertingRule = isGrafanaAlertingRuleByType(type);
labels: arrayToRecord(values.labels || []), const wantsRecordingRule = isGrafanaRecordingRuleByType(type!);
if (wantsAlertingRule) {
return {
grafana_alert: {
title: name,
condition,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
// Alerting rule specific // Alerting rule specific
for: evaluateFor, no_data_state: noDataState,
}; exec_err_state: execErrState,
} else { notification_settings: notificationSettings,
return { },
grafana_alert: { annotations,
title: name, labels,
condition,
data: queries.map(fixBothInstantAndRangeQuery),
is_paused: Boolean(isPaused),
// Recording rule specific // Alerting rule specific
record: { for: evaluateFor,
metric: metric ?? name, };
from: condition, } 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 { export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManagerManualRouting | undefined {
const contactPoint: ContactPoint | undefined = ga.notification_settings const contactPoint: ContactPoint | undefined = ga.notification_settings
? { ? {

View File

@ -1,7 +1,15 @@
import { nth } from 'lodash'; import { nth } from 'lodash';
import { locationService } from '@grafana/runtime'; 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 { Annotations, Labels, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
@ -21,7 +29,7 @@ export function fromRulerRule(
namespace: string, namespace: string,
groupName: string, groupName: string,
rule: RulerRuleDTO rule: RulerRuleDTO
): RuleIdentifier { ): EditableRuleIdentifier {
if (isGrafanaRulerRule(rule)) { if (isGrafanaRulerRule(rule)) {
return { uid: rule.grafana_alert.uid!, ruleSourceName: 'grafana' }; return { uid: rule.grafana_alert.uid!, ruleSourceName: 'grafana' };
} }
@ -31,7 +39,15 @@ export function fromRulerRule(
groupName, groupName,
ruleName: isAlertingRulerRule(rule) ? rule.alert : rule.record, ruleName: isAlertingRulerRule(rule) ? rule.alert : rule.record,
rulerRuleHash: hashRulerRule(rule), 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 { export function fromRule(ruleSourceName: string, namespace: string, groupName: string, rule: Rule): RuleIdentifier {

View File

@ -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<RuleWithLocation | null>;
deleteRule(ruleWithLocation: RuleWithLocation): Promise<void>;
saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveGrafanaRule(
values: RuleFormValues,
evaluateEvery: string,
existing?: RuleWithLocation
): Promise<GrafanaRuleIdentifier>;
}
export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
const findEditableRule = async (ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null> => {
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<void> => {
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<RuleIdentifier> => {
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<GrafanaRuleIdentifier> => {
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<GrafanaRuleIdentifier> => {
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<GrafanaRuleIdentifier> => {
// 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<GrafanaRuleIdentifier> => {
// 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<RulerGrafanaRuleDTO> {
// 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;
}

View File

@ -18,6 +18,8 @@ import {
RuleIdentifier, RuleIdentifier,
RuleNamespace, RuleNamespace,
RuleWithLocation, RuleWithLocation,
RulesSource,
EditableRuleIdentifier,
} from 'app/types/unified-alerting'; } from 'app/types/unified-alerting';
import { import {
GrafanaAlertState, GrafanaAlertState,
@ -36,7 +38,7 @@ import {
import { CombinedRuleNamespace } from '../../../../types/unified-alerting'; import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
import { State } from '../components/StateTag'; import { State } from '../components/StateTag';
import { RuleHealth } from '../search/rulesSearchParser'; 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 { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName, isGrafanaRulesSource } from './datasource'; import { getRulesSourceName, isGrafanaRulesSource } from './datasource';
@ -115,6 +117,10 @@ export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifi
return 'ruleHash' in identifier; return 'ruleHash' in identifier;
} }
export function isEditableRuleIdentifier(identifier: RuleIdentifier): identifier is EditableRuleIdentifier {
return isGrafanaRuleIdentifier(identifier) || isCloudRuleIdentifier(identifier);
}
export function getRuleHealth(health: string): RuleHealth | undefined { export function getRuleHealth(health: string): RuleHealth | undefined {
switch (health) { switch (health) {
case 'ok': 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) { export function isGrafanaAlertingRuleByType(type?: RuleFormType) {
return type === RuleFormType.grafana; return type === RuleFormType.grafana;
} }

View File

@ -182,7 +182,15 @@ export interface PrometheusRuleIdentifier {
ruleHash: string; 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 { export interface FilterState {
queryString?: string; queryString?: string;
dataSource?: string; dataSource?: string;

View File

@ -203,6 +203,9 @@
"recording-rule": "Recording rule" "recording-rule": "Recording rule"
}, },
"rules": { "rules": {
"add-rule": {
"success": "Rule added successfully"
},
"delete-rule": { "delete-rule": {
"success": "Rule successfully deleted" "success": "Rule successfully deleted"
}, },
@ -211,6 +214,9 @@
}, },
"resume-rule": { "resume-rule": {
"success": "Rule evaluation resumed" "success": "Rule evaluation resumed"
},
"update-rule": {
"success": "Rule updated successfully"
} }
} }
}, },
@ -345,6 +351,7 @@
} }
}, },
"common": { "common": {
"cancel": "Cancel",
"locale": { "locale": {
"default": "Default" "default": "Default"
}, },

View File

@ -203,6 +203,9 @@
"recording-rule": "Ŗęčőřđįʼnģ řūľę" "recording-rule": "Ŗęčőřđįʼnģ řūľę"
}, },
"rules": { "rules": {
"add-rule": {
"success": "Ŗūľę äđđęđ şūččęşşƒūľľy"
},
"delete-rule": { "delete-rule": {
"success": "Ŗūľę şūččęşşƒūľľy đęľęŧęđ" "success": "Ŗūľę şūččęşşƒūľľy đęľęŧęđ"
}, },
@ -211,6 +214,9 @@
}, },
"resume-rule": { "resume-rule": {
"success": "Ŗūľę ęväľūäŧįőʼn řęşūmęđ" "success": "Ŗūľę ęväľūäŧįőʼn řęşūmęđ"
},
"update-rule": {
"success": "Ŗūľę ūpđäŧęđ şūččęşşƒūľľy"
} }
} }
}, },
@ -345,6 +351,7 @@
} }
}, },
"common": { "common": {
"cancel": "Cäʼnčęľ",
"locale": { "locale": {
"default": "Đęƒäūľŧ" "default": "Đęƒäūľŧ"
}, },