mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: useProduceNewRuleGroup for creating / updating alert rules. (#90497)
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
parent
c315c2719d
commit
00381711a4
@ -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"],
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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 }>({
|
||||||
|
@ -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',
|
||||||
|
@ -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}`) };
|
||||||
|
@ -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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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();
|
||||||
|
|
||||||
|
@ -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
|
|
||||||
});
|
|
||||||
});
|
|
@ -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(() => {
|
|
||||||
setPending(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[group.name, namespace.name, namespace.rulesSource, rulesList, folderUid]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// re-order the rules list for the UI rendering
|
||||||
|
const newOrderedRules = produce(rulesList, (draft) => {
|
||||||
|
swapItems(draft, swapOperation);
|
||||||
|
});
|
||||||
|
setRulesList(newOrderedRules);
|
||||||
|
},
|
||||||
|
[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,6 +132,9 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
|
|||||||
onDismiss={onClose}
|
onDismiss={onClose}
|
||||||
onClickBackdrop={onClose}
|
onClickBackdrop={onClose}
|
||||||
>
|
>
|
||||||
|
{loadingRules && 'Loading...'}
|
||||||
|
{rulesWithUID.length > 0 && (
|
||||||
|
<>
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable
|
<Droppable
|
||||||
droppableId="alert-list"
|
droppableId="alert-list"
|
||||||
@ -112,11 +146,11 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
|
|||||||
{(droppableProvided: DroppableProvided) => (
|
{(droppableProvided: DroppableProvided) => (
|
||||||
<div
|
<div
|
||||||
ref={droppableProvided.innerRef}
|
ref={droppableProvided.innerRef}
|
||||||
className={cx(styles.listContainer, pending && styles.disabled)}
|
className={cx(styles.listContainer, isUpdating && styles.disabled)}
|
||||||
{...droppableProvided.droppableProps}
|
{...droppableProvided.droppableProps}
|
||||||
>
|
>
|
||||||
{rulesWithUID.map((rule, index) => (
|
{rulesWithUID.map((rule, index) => (
|
||||||
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={pending}>
|
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={isUpdating}>
|
||||||
{(provided: DraggableProvided) => <ListItem key={rule.uid} provided={provided} rule={rule} />}
|
{(provided: DraggableProvided) => <ListItem key={rule.uid} provided={provided} rule={rule} />}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
))}
|
))}
|
||||||
@ -125,13 +159,23 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
|
|||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
</DragDropContext>
|
</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;
|
|
||||||
}
|
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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`] = `
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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 (
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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 () => {
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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: [],
|
||||||
|
});
|
||||||
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
@ -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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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 = {
|
||||||
|
@ -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 }) => {
|
||||||
|
if (predicateFn(request)) {
|
||||||
requests.push(request);
|
requests.push(request);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return requests;
|
return requests;
|
||||||
|
@ -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(),
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -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(
|
||||||
|
isAnyOf(updateRuleGroupAction, renameRuleGroupAction, moveRuleGroupAction, addRuleAction),
|
||||||
|
(draft, { payload }) => {
|
||||||
const { interval } = payload;
|
const { interval } = payload;
|
||||||
if (interval) {
|
draft.interval = interval ?? draft.interval;
|
||||||
draft.interval = 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;
|
||||||
|
}
|
||||||
|
@ -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',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
@ -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,
|
||||||
|
@ -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": {},
|
||||||
"": "",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -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: '' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -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,12 +217,19 @@ 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
|
|
||||||
);
|
const notificationSettings = getNotificationSettingsForDTO(manualRouting, contactPoints);
|
||||||
if (isGrafanaAlertingRuleByType(type)) {
|
|
||||||
|
const annotations = arrayToRecord(cleanAnnotations(values.annotations));
|
||||||
|
const labels = arrayToRecord(cleanLabels(values.labels));
|
||||||
|
|
||||||
|
const wantsAlertingRule = isGrafanaAlertingRuleByType(type);
|
||||||
|
const wantsRecordingRule = isGrafanaRecordingRuleByType(type!);
|
||||||
|
|
||||||
|
if (wantsAlertingRule) {
|
||||||
return {
|
return {
|
||||||
grafana_alert: {
|
grafana_alert: {
|
||||||
title: name,
|
title: name,
|
||||||
@ -231,13 +242,13 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
|
|||||||
exec_err_state: execErrState,
|
exec_err_state: execErrState,
|
||||||
notification_settings: notificationSettings,
|
notification_settings: notificationSettings,
|
||||||
},
|
},
|
||||||
annotations: arrayToRecord(values.annotations || []),
|
annotations,
|
||||||
labels: arrayToRecord(values.labels || []),
|
labels,
|
||||||
|
|
||||||
// Alerting rule specific
|
// Alerting rule specific
|
||||||
for: evaluateFor,
|
for: evaluateFor,
|
||||||
};
|
};
|
||||||
} else {
|
} else if (wantsRecordingRule) {
|
||||||
return {
|
return {
|
||||||
grafana_alert: {
|
grafana_alert: {
|
||||||
title: name,
|
title: name,
|
||||||
@ -251,14 +262,25 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
|
|||||||
from: condition,
|
from: condition,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
annotations: arrayToRecord(values.annotations || []),
|
annotations,
|
||||||
labels: arrayToRecord(values.labels || []),
|
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
|
||||||
? {
|
? {
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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": "Đęƒäūľŧ"
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user