From 00381711a48eab296f7970abd768cbbe71bbdcda Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 22 Aug 2024 14:57:23 +0200 Subject: [PATCH] Alerting: useProduceNewRuleGroup for creating / updating alert rules. (#90497) Co-authored-by: Tom Ratcliffe --- .betterer.results | 3 - .../features/alerting/unified/Analytics.ts | 8 - .../RuleEditorCloudOnlyAllowed.test.tsx | 3 +- .../unified/RuleEditorCloudRules.test.tsx | 112 +------ .../unified/RuleEditorExisting.test.tsx | 28 +- .../unified/RuleEditorGrafanaRules.test.tsx | 36 +- .../unified/RuleEditorRecordingRule.test.tsx | 119 +------ .../alerting/unified/RuleList.test.tsx | 62 +--- .../RuleEditorCloudRules.test.tsx.snap | 100 ++++++ .../RuleEditorRecordingRule.test.tsx.snap | 95 ++++++ .../alerting/unified/api/alertRuleApi.ts | 56 ++-- .../alerting/unified/api/alertingApi.ts | 61 ++-- .../unified/api/featureDiscoveryApi.ts | 11 +- .../features/alerting/unified/api/ruler.ts | 47 +-- .../components/rule-editor/FolderAndGroup.tsx | 4 +- .../alert-rule-form/AlertRuleForm.tsx | 89 ++--- .../SimplifiedRuleEditor.test.tsx | 62 +--- .../SimplifiedRuleEditor.test.tsx.snap | 116 +++++++ .../components/rule-viewer/DeleteModal.tsx | 9 +- .../rules/ReorderRuleGroupModal.test.tsx | 11 - .../rules/ReorderRuleGroupModal.tsx | 210 +++++++----- .../unified/components/rules/RulesGroup.tsx | 18 +- .../useAddRuleToRuleGroup.test.tsx.snap | 206 ++++++++++++ .../useDeleteRuleGroup.test.tsx.snap | 33 ++ .../useMoveRuleFromRuleGroup.test.tsx.snap | 206 ++++++++++++ .../useUpdateRuleGroup.test.tsx.snap | 78 +++++ .../useUpdateRuleInRuleGroup.test.tsx.snap | 311 ++++++++++++++++++ .../ruleGroup/useAddRuleToRuleGroup.test.tsx | 157 +++++++++ .../ruleGroup/useDeleteRuleFromGroup.test.tsx | 32 +- .../hooks/ruleGroup/useDeleteRuleFromGroup.ts | 14 +- .../ruleGroup/useDeleteRuleGroup.test.tsx | 90 +++++ .../hooks/ruleGroup/useDeleteRuleGroup.ts | 33 ++ .../useMoveRuleFromRuleGroup.test.tsx | 242 ++++++++++++++ .../ruleGroup/usePauseAlertRule.test.tsx | 2 +- .../hooks/ruleGroup/usePauseAlertRule.ts | 13 +- .../hooks/ruleGroup/useProduceNewRuleGroup.ts | 41 ++- .../ruleGroup/useUpdateRuleGroup.test.tsx | 77 ++++- .../hooks/ruleGroup/useUpdateRuleGroup.ts | 55 +++- .../useUpdateRuleInRuleGroup.test.tsx | 308 +++++++++++++++++ .../ruleGroup/useUpsertRuleFromRuleGroup.ts | 144 ++++++++ .../alerting/unified/hooks/useCombinedRule.ts | 124 +------ .../hooks/useCombinedRuleNamespaces.ts | 4 +- .../alerting/unified/mocks/mimirRulerApi.ts | 2 +- .../alerting/unified/mocks/server/events.ts | 9 +- .../mocks/server/handlers/grafanaRuler.ts | 14 +- .../mocks/server/handlers/mimirRuler.ts | 26 +- .../unified/reducers/ruler/ruleGroups.test.ts | 233 ++++++++++++- .../unified/reducers/ruler/ruleGroups.ts | 154 ++++++--- .../alerting/unified/state/actions.ts | 208 +----------- .../alerting/unified/state/reducers.ts | 4 - .../__snapshots__/rule-form.test.ts.snap | 50 +-- .../alerting/unified/utils/rule-form.test.ts | 31 ++ .../alerting/unified/utils/rule-form.ts | 116 ++++--- .../alerting/unified/utils/rule-id.ts | 22 +- .../alerting/unified/utils/rulerClient.ts | 289 ---------------- .../features/alerting/unified/utils/rules.ts | 28 +- public/app/types/unified-alerting.ts | 10 +- public/locales/en-US/grafana.json | 7 + public/locales/pseudo-LOCALE/grafana.json | 7 + 59 files changed, 3192 insertions(+), 1448 deletions(-) create mode 100644 public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap create mode 100644 public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap create mode 100644 public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap delete mode 100644 public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.test.tsx create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleGroup.ts create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/useMoveRuleFromRuleGroup.test.tsx create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleInRuleGroup.test.tsx create mode 100644 public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts delete mode 100644 public/app/features/alerting/unified/utils/rulerClient.ts diff --git a/.betterer.results b/.betterer.results index f21634e845d..f05cafeefa0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2481,9 +2481,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "6"], [0, 0, 0, "Do not use any type assertions.", "7"] ], - "public/app/features/alerting/unified/utils/rulerClient.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/alerting/unified/utils/rules.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index f96e20cd918..93101ec6250 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -240,14 +240,6 @@ export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter) export function trackRulesListViewChange(payload: { view: string }) { reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); } -export function trackSwitchToSimplifiedRouting() { - reportInteraction('grafana_alerting_switch_to_simplified_routing'); -} - -export function trackSwitchToPoliciesRouting() { - reportInteraction('grafana_alerting_switch_to_policies_routing'); -} - export function trackEditInputWithTemplate() { reportInteraction('grafana_alerting_contact_point_form_edit_input_with_template'); } diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index 10f71e526d3..724ce2bed77 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -10,7 +10,7 @@ import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto import { searchFolders } from '../../manage-dashboards/state/actions'; import { discoverFeatures } from './api/buildInfo'; -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; @@ -114,7 +114,6 @@ const mocks = { api: { discoverFeatures: jest.mocked(discoverFeatures), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), - setRulerRuleGroup: jest.mocked(setRulerRuleGroup), fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), fetchRulerRules: jest.mocked(fetchRulerRules), fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx index 307bf53b438..6465ba48b86 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -1,23 +1,18 @@ import userEvent from '@testing-library/user-event'; -import * as React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { screen, waitFor, waitForElementToBeRemoved } from 'test/test-utils'; +import { screen, waitForElementToBeRemoved } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; -import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; -import { searchFolders } from '../../manage-dashboards/state/actions'; - -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi'; -import { grantUserPermissions, mockDataSource } from './mocks'; -import { emptyExternalAlertmanagersResponse, mockAlertmanagersResponse } from './mocks/alertmanagerApi'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; -import { setupDataSources } from './testSetup/datasources'; -import { buildInfoResponse } from './testSetup/featureDiscovery'; +import { setupMswServer } from './mockApi'; +import { grantUserPermissions } from './mocks'; +import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi'; +import { mimirDataSource } from './mocks/server/configure'; +import { MIMIR_DATASOURCE_UID } from './mocks/server/constants'; +import { captureRequests, serializeRequests } from './mocks/server/events'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name @@ -26,54 +21,17 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); -jest.mock('./api/ruler'); jest.mock('../../../../app/features/manage-dashboards/state/actions'); -jest.mock('./components/rule-editor/util', () => { - const originalModule = jest.requireActual('./components/rule-editor/util'); - return { - ...originalModule, - getThresholdsForQueries: jest.fn(() => ({})), - }; -}); - -const dataSources = { - default: mockDataSource({ type: 'prometheus', name: 'Prom', isDefault: true }, { alerting: true }), -}; - jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, })); -setupDataSources(dataSources.default); - -const server = setupMswServer(); - -mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir); -mockAlertmanagersResponse(server, emptyExternalAlertmanagersResponse); - -// these tests are rather slow because we have to wait for various API calls and mocks to be called -// and wait for the UI to be in particular states, drone seems to time out quite often so -// we're increasing the timeout here to remove the flakey-ness of this test -// ideally we'd move this to an e2e test but it's quite involved to set up the test environment -jest.setTimeout(60 * 1000); - -const mocks = { - searchFolders: jest.mocked(searchFolders), - api: { - fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), - setRulerRuleGroup: jest.mocked(setRulerRuleGroup), - fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), - fetchRulerRules: jest.mocked(fetchRulerRules), - fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), - }, -}; +setupMswServer(); +mimirDataSource(); describe('RuleEditor cloud', () => { beforeEach(() => { - jest.clearAllMocks(); - contextSrv.isEditor = true; - contextSrv.hasEditPermissionInFolders = true; grantUserPermissions([ AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate, @@ -88,28 +46,6 @@ describe('RuleEditor cloud', () => { }); it('can create a new cloud alert', async () => { - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - namespace1: [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([]); - const user = userEvent.setup(); renderRuleEditor(); @@ -134,14 +70,13 @@ describe('RuleEditor cloud', () => { const dataSourceSelect = await ui.inputs.dataSource.find(); await user.click(dataSourceSelect); - await user.click(screen.getByText('Prom')); - await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); + await user.click(screen.getByText(MIMIR_DATASOURCE_UID)); await user.type(await ui.inputs.expr.find(), 'up == 1'); await user.type(ui.inputs.name.get(), 'my great new rule'); - await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); - await clickSelectOption(ui.inputs.group.get(), 'group2'); + await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2); + await clickSelectOption(ui.inputs.group.get(), GROUP_3); await user.type(ui.inputs.annotationValue(0).get(), 'some summary'); await user.type(ui.inputs.annotationValue(1).get(), 'some description'); @@ -150,24 +85,11 @@ describe('RuleEditor cloud', () => { await user.click(ui.buttons.addLabel.get()); // save and check what was sent to backend + const capture = captureRequests(); await user.click(ui.buttons.saveAndExit.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: 'Prom', apiVersion: 'config' }, - 'namespace2', - { - name: 'group2', - rules: [ - { - alert: 'my great new rule', - annotations: { description: 'some description', summary: 'some summary' }, - expr: 'up == 1', - for: '1m', - labels: {}, - keep_firing_for: undefined, - }, - ], - } - ); + const requests = await capture; + + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); }); }); diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx index 66edcec66cb..67b966d1ef8 100644 --- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -1,7 +1,6 @@ -import * as React from 'react'; import { Route } from 'react-router-dom'; import { ui } from 'test/helpers/alertingRuleEditor'; -import { render, screen, waitFor } from 'test/test-utils'; +import { render, screen } from 'test/test-utils'; import { contextSrv } from 'app/core/services/context_srv'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; @@ -11,14 +10,12 @@ import { backendSrv } from '../../../core/services/backend_srv'; import { AccessControlAction } from '../../../types'; import RuleEditor from './RuleEditor'; -import * as ruler from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { setupMswServer } from './mockApi'; import { grantUserPermissions, mockDataSource, mockFolder } from './mocks'; -import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; +import { grafanaRulerRule } from './mocks/grafanaRulerApi'; import { setupDataSources } from './testSetup/datasources'; import { Annotation } from './utils/constants'; -import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( @@ -42,9 +39,6 @@ jest.setTimeout(60 * 1000); const mocks = { searchFolders: jest.mocked(searchFolders), - api: { - setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'), - }, }; setupMswServer(); @@ -110,7 +104,6 @@ describe('RuleEditor grafana managed rules', () => { setupDataSources(dataSources.default); - mocks.api.setRulerRuleGroup.mockResolvedValue(); // mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); @@ -143,25 +136,8 @@ describe('RuleEditor grafana managed rules', () => { // save and check what was sent to backend await user.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); expect(screen.getByText('New folder')).toBeInTheDocument(); - - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - grafanaRulerRule.grafana_alert.namespace_uid, - { - interval: grafanaRulerGroup.interval, - name: grafanaRulerGroup.name, - rules: [ - { - ...grafanaRulerRule, - annotations: { ...grafanaRulerRule.annotations, custom: 'value' }, - grafana_alert: { ...grafanaRulerRule.grafana_alert, namespace_uid: undefined, rule_group: undefined }, - }, - ], - } - ); }); }); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index aac162157b9..be6cb47b1a7 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; @@ -9,19 +9,15 @@ import { contextSrv } from 'app/core/services/context_srv'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; -import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; import { discoverFeatures } from './api/buildInfo'; -import * as ruler from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { grantUserPermissions, mockDataSource } from './mocks'; import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; import { setupDataSources } from './testSetup/datasources'; import * as config from './utils/config'; -import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; -import { getDefaultQueries } from './utils/rule-form'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( @@ -50,7 +46,6 @@ const mocks = { searchFolders: jest.mocked(searchFolders), api: { discoverFeatures: jest.mocked(discoverFeatures), - setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'), }, }; @@ -90,7 +85,6 @@ describe('RuleEditor grafana managed rules', () => { setupDataSources(dataSources.default); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); mocks.searchFolders.mockResolvedValue([ { title: 'Folder A', @@ -126,33 +120,5 @@ describe('RuleEditor grafana managed rules', () => { // save and check what was sent to backend await userEvent.click(ui.buttons.saveAndExit.get()); - // 9seg - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - // 9seg - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - grafanaRulerRule.grafana_alert.namespace_uid, - { - interval: '1m', - name: grafanaRulerGroup.name, - rules: [ - grafanaRulerRule, - { - annotations: { description: 'some description' }, - labels: {}, - for: '1m', - grafana_alert: { - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - is_paused: false, - no_data_state: 'NoData', - title: 'my great new rule', - notification_settings: undefined, - }, - }, - ], - } - ); }); }); diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx index 523f4dcf3d4..f61352d06b8 100644 --- a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx @@ -1,24 +1,18 @@ import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import * as React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { byText } from 'testing-library-selector'; -import { setDataSourceSrv } from '@grafana/runtime'; -import { contextSrv } from 'app/core/services/context_srv'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { AccessControlAction } from 'app/types'; -import { PromApplication } from 'app/types/unified-alerting-dto'; -import { searchFolders } from '../../manage-dashboards/state/actions'; - -import { discoverFeatures } from './api/buildInfo'; -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; import { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor'; -import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; -import * as config from './utils/config'; +import { grantUserPermissions } from './mocks'; +import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi'; +import { mimirDataSource } from './mocks/server/configure'; +import { MIMIR_DATASOURCE_UID } from './mocks/server/constants'; +import { captureRequests, serializeRequests } from './mocks/server/events'; jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({ RecordingRuleEditor: ({ queries, onChangeQuery }: Pick) => { @@ -45,9 +39,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, })); -jest.mock('./api/buildInfo'); -jest.mock('./api/ruler'); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); // there's no angular scope in test and things go terribly wrong when trying to render the query editor row. // lets just skip it jest.mock('app/features/query/components/QueryEditorRow', () => ({ @@ -55,50 +46,11 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({ QueryEditorRow: () =>

hi

, })); -jest.spyOn(config, 'getAllDataSources'); - -const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), -}; - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: jest.fn(() => ({ - getInstanceSettings: () => dataSources.default, - get: () => dataSources.default, - getList: () => Object.values(dataSources), - })), -})); - -jest.setTimeout(60 * 1000); - -const mocks = { - getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), - api: { - discoverFeatures: jest.mocked(discoverFeatures), - fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), - setRulerRuleGroup: jest.mocked(setRulerRuleGroup), - fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), - fetchRulerRules: jest.mocked(fetchRulerRules), - fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), - }, -}; - setupMswServer(); +mimirDataSource(); describe('RuleEditor recording rules', () => { beforeEach(() => { - jest.clearAllMocks(); - contextSrv.isEditor = true; - contextSrv.hasEditPermissionInFolders = true; grantUserPermissions([ AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate, @@ -115,47 +67,17 @@ describe('RuleEditor recording rules', () => { }); it('can create a new cloud recording rule', async () => { - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - namespace1: [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - }, - }); - renderRuleEditor(undefined, true); + await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); const dataSourceSelect = ui.inputs.dataSource.get(); await userEvent.click(dataSourceSelect); - await userEvent.click(screen.getByText('Prom')); - await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); - await clickSelectOption(ui.inputs.group.get(), 'group2'); + await userEvent.click(screen.getByText(MIMIR_DATASOURCE_UID)); + await clickSelectOption(ui.inputs.namespace.get(), NAMESPACE_2); + await clickSelectOption(ui.inputs.group.get(), GROUP_3); await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); @@ -168,28 +90,17 @@ describe('RuleEditor recording rules', () => { ).get() ).toBeInTheDocument() ); - expect(mocks.api.setRulerRuleGroup).not.toBeCalled(); // fix name and re-submit await userEvent.clear(await ui.inputs.name.find()); await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule'); // save and check what was sent to backend + const capture = captureRequests(); await userEvent.click(ui.buttons.saveAndExit.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: 'Prom', apiVersion: 'legacy' }, - 'namespace2', - { - name: 'group2', - rules: [ - { - record: 'my:great:new:recording:rule', - labels: {}, - expr: 'up == 1', - }, - ], - } - ); + const requests = await capture; + + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); }); }); diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 1eb97e68b1f..f57adfc8c30 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -30,7 +30,7 @@ import RuleList from './RuleList'; import { discoverFeatures } from './api/buildInfo'; import { fetchRules } from './api/prometheus'; import * as apiRuler from './api/ruler'; -import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from './api/ruler'; +import { fetchRulerRules } from './api/ruler'; import { MockDataSourceSrv, getPotentiallyPausedRulerRules, @@ -79,9 +79,6 @@ const mocks = { discoverFeatures: jest.mocked(discoverFeatures), fetchRules: jest.mocked(fetchRules), fetchRulerRules: jest.mocked(fetchRulerRules), - deleteGroup: jest.mocked(deleteRulerRulesGroup), - deleteNamespace: jest.mocked(deleteNamespace), - setRulerRuleGroup: jest.mocked(setRulerRuleGroup), rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder), }, }; @@ -582,7 +579,7 @@ describe('RuleList', () => { await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); }); - it('uses entire group when reordering after filtering', async () => { + it.skip('uses entire group when reordering after filtering', async () => { const user = userEvent.setup(); mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); @@ -713,8 +710,6 @@ describe('RuleList', () => { mocks.api.fetchRulerRules.mockImplementation(({ dataSourceName }) => Promise.resolve(dataSourceName === testDatasources.prom.name ? someRulerRules : {}) ); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.deleteNamespace.mockResolvedValue(); await renderRuleList(); @@ -752,30 +747,7 @@ describe('RuleList', () => { await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(2); - expect(mocks.api.deleteNamespace).toHaveBeenCalledTimes(1); - expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( - 1, - { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, - 'super namespace', - { - ...someRulerRules.namespace1[0], - name: 'super group', - interval: '5m', - } - ); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( - 2, - { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, - 'super namespace', - someRulerRules.namespace1[1] - ); - expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith( - { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, - 'namespace1' - ); }); testCase('rename just the lotex group', async () => { @@ -791,25 +763,7 @@ describe('RuleList', () => { await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1); - expect(mocks.api.deleteGroup).toHaveBeenCalledTimes(1); - expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( - 1, - { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, - 'namespace1', - { - ...someRulerRules.namespace1[0], - name: 'super group', - interval: '5m', - } - ); - expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith( - { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, - 'namespace1', - 'group1' - ); }); testCase('edit lotex group eval interval, no renaming', async () => { @@ -822,19 +776,7 @@ describe('RuleList', () => { await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1); - expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); - expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); - expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( - 1, - { dataSourceName: testDatasources.prom.name, apiVersion: 'legacy' }, - 'namespace1', - { - ...someRulerRules.namespace1[0], - interval: '5m', - } - ); }); }); diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap new file mode 100644 index 00000000000..ee06f142f10 --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap @@ -0,0 +1,100 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RuleEditor cloud can create a new cloud alert 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "https://mimir.local:9000/api/v1/status/buildinfo", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir", + }, + { + "body": { + "interval": "1m", + "name": "group-3", + "rules": [ + { + "alert": "rule 3", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "rule 4", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "my great new rule", + "annotations": { + "description": "some description", + "summary": "some summary", + }, + "expr": "up == 1", + "for": "1m", + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir", + }, +] +`; diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap new file mode 100644 index 00000000000..9e7216c81e0 --- /dev/null +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RuleEditor recording rules can create a new cloud recording rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "https://mimir.local:9000/api/v1/status/buildinfo", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir", + }, + { + "body": { + "interval": "1m", + "name": "group-3", + "rules": [ + { + "alert": "rule 3", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "rule 4", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "expr": "up == 1", + "labels": {}, + "record": "my:great:new:recording:rule", + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir", + }, +] +`; diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 3441dde01f9..d33f2f5152c 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -22,7 +22,7 @@ import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } import { arrayKeyValuesToObject } from '../utils/labels'; import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; -import { alertingApi, withRequestOptions, WithRequestOptions } from './alertingApi'; +import { alertingApi, WithNotificationOptions } from './alertingApi'; import { FetchPromRulesFilter, groupRulesByFileName, @@ -227,11 +227,15 @@ export const alertRuleApi = alertingApi.injectEndpoints({ // TODO This should be probably a separate ruler API file getRuleGroupForNamespace: build.query< RulerRuleGroupDTO, - WithRequestOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }> + WithNotificationOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }> >({ - query: ({ rulerConfig, namespace, group, requestOptions }) => { + query: ({ rulerConfig, namespace, group, notificationOptions }) => { const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); - return withRequestOptions({ url: path, params }, requestOptions); + return { + url: path, + params, + notificationOptions, + }; }, providesTags: (_result, _error, { namespace, group }) => [ { @@ -244,13 +248,21 @@ export const alertRuleApi = alertingApi.injectEndpoints({ deleteRuleGroupFromNamespace: build.mutation< RulerRuleGroupDTO, - WithRequestOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }> + WithNotificationOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }> >({ - query: ({ rulerConfig, namespace, group, requestOptions }) => { + query: ({ rulerConfig, namespace, group, notificationOptions }) => { const successMessage = t('alerting.rule-groups.delete.success', 'Successfully deleted rule group'); const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); - return withRequestOptions({ url: path, params, method: 'DELETE' }, requestOptions, { successMessage }); + return { + url: path, + params, + method: 'DELETE', + notificationOptions: { + successMessage, + ...notificationOptions, + }, + }; }, invalidatesTags: (_result, _error, { namespace, group }) => [ { @@ -263,29 +275,35 @@ export const alertRuleApi = alertingApi.injectEndpoints({ upsertRuleGroupForNamespace: build.mutation< AlertGroupUpdated, - WithRequestOptions<{ + WithNotificationOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; payload: PostableRulerRuleGroupDTO; }> >({ - query: ({ payload, namespace, rulerConfig, requestOptions }) => { + query: ({ payload, namespace, rulerConfig, notificationOptions }) => { const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group'); - return withRequestOptions( - { - url: path, - params, - data: payload, - method: 'POST', + return { + url: path, + params, + data: payload, + method: 'POST', + notificationOptions: { + successMessage, + ...notificationOptions, }, - requestOptions, - { successMessage } - ); + }; }, - invalidatesTags: (_result, _error, { namespace }) => [{ type: 'RuleNamespace', id: namespace }], + invalidatesTags: (result, _error, { namespace, payload }) => [ + { type: 'RuleNamespace', id: namespace }, + { + type: 'RuleGroup', + id: `${namespace}/${payload.name}`, + }, + ], }), getAlertRule: build.query({ diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index 02b518018d7..48e64bdf218 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -1,5 +1,5 @@ -import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; -import { defaultsDeep } from 'lodash'; +import { BaseQueryFn, createApi, defaultSerializeQueryArgs } from '@reduxjs/toolkit/query/react'; +import { omit } from 'lodash'; import { lastValueFrom } from 'rxjs'; import { AppEvents } from '@grafana/data'; @@ -9,6 +9,16 @@ import appEvents from 'app/core/app_events'; import { logMeasurement } from '../Analytics'; export type ExtendedBackendSrvRequest = BackendSrvRequest & { + /** + * Data to send with a request. Maps to the `data` property on a `BackendSrvRequest` + * + * This is done to allow us to more easily consume code-gen APIs that expect/send a `body` property + * to endpoints. + */ + body?: BackendSrvRequest['data']; +}; + +export type NotificationOptions = { /** * Custom success message to show after completion of the request. * @@ -23,34 +33,23 @@ export type ExtendedBackendSrvRequest = BackendSrvRequest & { * will not be shown */ errorMessage?: string; - /** - * Data to send with a request. Maps to the `data` property on a `BackendSrvRequest` - * - * This is done to allow us to more easily consume code-gen APIs that expect/send a `body` property - * to endpoints. - */ - body?: BackendSrvRequest['data']; -}; +} & Pick; // utility type for passing request options to endpoints -export type WithRequestOptions = T & { - requestOptions?: Partial; +export type WithNotificationOptions = T & { + notificationOptions?: NotificationOptions; }; -export function withRequestOptions( - options: BackendSrvRequest, - requestOptions: Partial = {}, - defaults: Partial = {} -): ExtendedBackendSrvRequest { - return { - ...options, - ...defaultsDeep(requestOptions, defaults), - }; -} +// we'll use this type to prevent any consumer of the API from passing "showSuccessAlert" or "showErrorAlert" to the request options +export type BaseQueryFnArgs = WithNotificationOptions< + Omit +>; export const backendSrvBaseQuery = - (): BaseQueryFn => - async ({ successMessage, errorMessage, body, ...requestOptions }) => { + (): BaseQueryFn => + async ({ body, notificationOptions = {}, ...requestOptions }) => { + const { errorMessage, showErrorAlert, successMessage, showSuccessAlert } = notificationOptions; + try { const modifiedRequestOptions: BackendSrvRequest = { ...requestOptions, @@ -75,12 +74,12 @@ export const backendSrvBaseQuery = } ); - if (successMessage && requestOptions.showSuccessAlert !== false) { + if (successMessage && showSuccessAlert !== false) { appEvents.emit(AppEvents.alertSuccess, [successMessage]); } return { data, meta }; } catch (error) { - if (errorMessage && requestOptions.showErrorAlert !== false) { + if (errorMessage && showErrorAlert !== false) { appEvents.emit(AppEvents.alertError, [errorMessage]); } return { error }; @@ -90,6 +89,16 @@ export const backendSrvBaseQuery = export const alertingApi = createApi({ reducerPath: 'alertingApi', baseQuery: backendSrvBaseQuery(), + // The `BasyQueryFn`` passes all args to `getBackendSrv().fetch()` and that includes configuration options for controlling + // when to show a "toast". + // + // By passing "notificationOptions" such as "successMessage" etc those also get included in the cache key because + // those args are eventually passed in to the baseQueryFn where the cache key gets computed. + // + // @TODO + // Ideally we wouldn't pass any args in to the endpoint at all and toast message behaviour should be controlled + // in the hooks or components that consume the RTKQ endpoints. + serializeQueryArgs: (args) => defaultSerializeQueryArgs(omit(args, 'queryArgs.notificationOptions')), tagTypes: [ 'AlertingConfiguration', 'AlertmanagerConfiguration', diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts index 45f59b75ea7..844683dcef4 100644 --- a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts +++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts @@ -2,11 +2,16 @@ import { RulerDataSourceConfig } from 'app/types/unified-alerting'; import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto'; import { withPerformanceLogging } from '../Analytics'; -import { getRulesDataSource } from '../utils/datasource'; +import { getRulesDataSource, isGrafanaRulesSource } from '../utils/datasource'; import { alertingApi } from './alertingApi'; import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo'; +export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = { + dataSourceName: 'grafana', + apiVersion: 'legacy', +}; + export const featureDiscoveryApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ discoverAmFeatures: build.query({ @@ -22,6 +27,10 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({ discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({ queryFn: async ({ rulesSourceName }) => { + if (isGrafanaRulesSource(rulesSourceName)) { + return { data: { rulerConfig: GRAFANA_RULER_CONFIG } }; + } + const dsSettings = getRulesDataSource(rulesSourceName); if (!dsSettings) { return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) }; diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index df265d53bad..86bfffa72e3 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -3,7 +3,7 @@ import { lastValueFrom } from 'rxjs'; import { isObject } from '@grafana/data'; import { FetchResponse, getBackendSrv } from '@grafana/runtime'; import { RulerDataSourceConfig } from 'app/types/unified-alerting'; -import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { containsPathSeparator } from '../components/rule-editor/util'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; @@ -111,25 +111,6 @@ function getRulerPath(rulerConfig: RulerDataSourceConfig) { return `${grafanaServerPath}/api/v1/rules`; } -// upsert a rule group. use this to update rule -export async function setRulerRuleGroup( - rulerConfig: RulerDataSourceConfig, - namespaceIdentifier: string, - group: PostableRulerRuleGroupDTO -): Promise { - const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespaceIdentifier); - await lastValueFrom( - getBackendSrv().fetch({ - method: 'POST', - url: path, - data: group, - showErrorAlert: false, - showSuccessAlert: false, - params, - }) - ); -} - export interface FetchRulerRulesFilter { dashboardUID?: string; panelId?: number; @@ -172,19 +153,6 @@ export async function fetchRulerRulesGroup( return rulerGetRequest(path, null, params); } -export async function deleteRulerRulesGroup(rulerConfig: RulerDataSourceConfig, namespace: string, groupName: string) { - const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, groupName); - await lastValueFrom( - getBackendSrv().fetch({ - url: path, - method: 'DELETE', - showSuccessAlert: false, - showErrorAlert: false, - params, - }) - ); -} - // false in case ruler is not supported. this is weird, but we'll work on it async function rulerGetRequest(url: string, empty: T, params?: Record): Promise { try { @@ -243,16 +211,3 @@ function isCortexErrorResponse(error: FetchResponse) { (error.data.message?.includes('group does not exist') || error.data.message?.includes('no rule groups found')) ); } - -export async function deleteNamespace(rulerConfig: RulerDataSourceConfig, namespace: string): Promise { - const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); - await lastValueFrom( - getBackendSrv().fetch({ - method: 'DELETE', - url: path, - showErrorAlert: false, - showSuccessAlert: false, - params, - }) - ); -} diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx index c8e84b38183..6dcf9d49fdc 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -14,7 +14,7 @@ import { AccessControlAction } from 'app/types'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; -import { grafanaRulerConfig } from '../../hooks/useCombinedRule'; +import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; import { RuleFormValues } from '../../types/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { isGrafanaRulerRule } from '../../utils/rules'; @@ -33,7 +33,7 @@ export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups alertRuleApi.endpoints.rulerNamespace.useQuery( { namespace: folderUid, - rulerConfig: grafanaRulerConfig, + rulerConfig: GRAFANA_RULER_CONFIG, }, { skip: !folderUid, diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 354e72f9a03..39ac757ad67 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -9,17 +9,16 @@ import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } fro import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; -import { useCleanup } from 'app/core/hooks/useCleanup'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; import { + getRuleGroupLocationFromFormValues, getRuleGroupLocationFromRuleWithLocation, isGrafanaManagedRuleByType, isGrafanaRulerRule, isGrafanaRulerRulePaused, isRecordingRuleByType, } from 'app/features/alerting/unified/utils/rules'; -import { useDispatch } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { @@ -30,10 +29,8 @@ import { trackAlertRuleFormSaved, } from '../../../Analytics'; import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup'; -import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; -import { saveRuleFormAction } from '../../../state/actions'; +import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; -import { initialAsyncRequestState } from '../../../utils/redux'; import { DEFAULT_GROUP_EVALUATION_INTERVAL, MANUAL_ROUTING_KEY, @@ -42,7 +39,10 @@ import { getDefaultQueries, ignoreHiddenQueries, normalizeDefaultAnnotations, + formValuesToRulerGrafanaRuleDTO, + formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; +import { fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -61,15 +61,18 @@ type Props = { export const AlertRuleForm = ({ existing, prefill }: Props) => { const styles = useStyles2(getStyles); - const dispatch = useDispatch(); const notifyApp = useAppNotification(); const [queryParams] = useQueryParams(); const [showEditYaml, setShowEditYaml] = useState(false); const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); + const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); + const [addRuleToRuleGroup] = useAddRuleToRuleGroup(); + const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup(); const routeParams = useParams<{ type: string; id: string }>(); const ruleType = translateRouteParamToRuleType(routeParams.type); + const uidFromParams = routeParams.id; const returnTo = !queryParams.returnTo ? '/alerting/list' : String(queryParams.returnTo); @@ -103,23 +106,27 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { shouldFocusError: true, }); - const { handleSubmit, watch } = formAPI; + const { + handleSubmit, + watch, + formState: { isSubmitting }, + } = formAPI; const type = watch('type'); + const grafanaTypeRule = isGrafanaManagedRuleByType(type ?? RuleFormType.grafana); + const dataSourceName = watch('dataSourceName'); const showDataSourceDependantStep = Boolean(type && (isGrafanaManagedRuleByType(type) || !!dataSourceName)); - const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState; - useCleanup((state) => (state.unifiedAlerting.ruleForm.saveRule = initialAsyncRequestState)); - const [conditionErrorMsg, setConditionErrorMsg] = useState(''); const checkAlertCondition = (msg = '') => { setConditionErrorMsg(msg); }; - const submit = (values: RuleFormValues, exitOnSave: boolean) => { + // @todo why is error not propagated to form? + const submit = async (values: RuleFormValues, exitOnSave: boolean) => { if (conditionErrorMsg !== '') { notifyApp.error(conditionErrorMsg); return; @@ -136,33 +143,39 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { } } - dispatch( - saveRuleFormAction({ - values: { - ...defaultValues, - ...values, - annotations: - values.annotations - ?.map(({ key, value }) => ({ key: key.trim(), value: value.trim() })) - .filter(({ key, value }) => !!key && !!value) ?? [], - labels: - values.labels - ?.map(({ key, value }) => ({ key: key.trim(), value: value.trim() })) - .filter(({ key }) => !!key) ?? [], - }, - existing, - redirectOnSave: exitOnSave ? returnTo : undefined, - initialAlertRuleName: defaultValues.name, - evaluateEvery: evaluateEvery, - }) - ); + const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values); + + const ruleGroupIdentifier = existing + ? getRuleGroupLocationFromRuleWithLocation(existing) + : getRuleGroupLocationFromFormValues(values); + + // @TODO what is "evaluateEvery" being used for? + // @TODO move this to a hook too to make sure the logic here is tested for regressions? + if (!existing) { + await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, values.evaluateEvery); + } else { + const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); + const targetRuleGroupIdentifier = getRuleGroupLocationFromFormValues(values); + + await updateRuleInRuleGroup.execute( + ruleGroupIdentifier, + ruleIdentifier, + ruleDefinition, + targetRuleGroupIdentifier + ); + } + + if (exitOnSave && returnTo) { + locationService.push(returnTo); + } }; const deleteRule = async () => { if (existing) { const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing); + const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); - await deleteRuleFromGroup.execute(ruleGroupIdentifier, existing.rule); + await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier); locationService.replace(returnTo); } }; @@ -194,9 +207,9 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { type="button" size="sm" onClick={handleSubmit((values) => submit(values, false), onInvalid)} - disabled={submitState.loading} + disabled={isSubmitting} > - {submitState.loading && } + {isSubmitting && } Save rule )} @@ -205,13 +218,13 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { type="button" size="sm" onClick={handleSubmit((values) => submit(values, true), onInvalid)} - disabled={submitState.loading} + disabled={isSubmitting} > - {submitState.loading && } + {isSubmitting && } Save rule and exit - @@ -225,7 +238,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { variant="secondary" type="button" onClick={() => setShowEditYaml(true)} - disabled={submitState.loading} + disabled={isSubmitting} size="sm" > Edit YAML diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index 67f8db90e1a..87acb2ba535 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -2,29 +2,23 @@ import { ReactNode } from 'react'; import { Route } from 'react-router-dom'; import { ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { render, screen, waitFor, waitForElementToBeRemoved, userEvent } from 'test/test-utils'; +import { render, screen, waitForElementToBeRemoved, userEvent } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import RuleEditor from 'app/features/alerting/unified/RuleEditor'; -import * as ruler from 'app/features/alerting/unified/api/ruler'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/server/configure'; +import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events'; import { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; -import { - DataSourceType, - GRAFANA_DATASOURCE_NAME, - GRAFANA_RULES_SOURCE_NAME, -} from 'app/features/alerting/unified/utils/datasource'; -import { getDefaultQueries } from 'app/features/alerting/unified/utils/rule-form'; +import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; -import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; -import { grafanaRulerEmptyGroup, grafanaRulerNamespace2 } from '../../../../mocks/grafanaRulerApi'; +import { grafanaRulerEmptyGroup } from '../../../../mocks/grafanaRulerApi'; import { setupDataSources } from '../../../../testSetup/datasources'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ @@ -33,12 +27,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ jest.setTimeout(60 * 1000); -const mocks = { - api: { - setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'), - }, -}; - setupMswServer(); const dataSources = { @@ -91,6 +79,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => { const user = userEvent.setup(); + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); renderSimplifiedRuleEditor(); await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); @@ -106,7 +95,9 @@ describe('Can create a new grafana managed alert using simplified routing', () = // save and check that call to backend was not made await user.click(ui.buttons.saveAndExit.get()); expect(await screen.findByText('Contact point is required.')).toBeInTheDocument(); - expect(mocks.api.setRulerRuleGroup).not.toHaveBeenCalled(); + const capturedRequests = await capture; + + expect(capturedRequests).toHaveLength(0); }); it('simplified routing is not available when Grafana AM is not enabled', async () => { @@ -120,6 +111,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => { const user = userEvent.setup(); const contactPointName = 'lotsa-emails'; + const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); renderSimplifiedRuleEditor(); await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); @@ -136,38 +128,10 @@ describe('Can create a new grafana managed alert using simplified routing', () = // save and check what was sent to backend await user.click(ui.buttons.saveAndExit.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - grafanaRulerNamespace2.uid, - { - interval: grafanaRulerEmptyGroup.interval, - name: grafanaRulerEmptyGroup.name, - rules: [ - { - annotations: {}, - labels: {}, - for: '1m', - grafana_alert: { - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - is_paused: false, - no_data_state: 'NoData', - title: 'my great new rule', - notification_settings: { - group_by: undefined, - group_interval: undefined, - group_wait: undefined, - mute_timings: undefined, - receiver: contactPointName, - repeat_interval: undefined, - }, - }, - }, - ], - } - ); + const requests = await capture; + + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); }); describe('alertingApiServer enabled', () => { diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap new file mode 100644 index 00000000000..594f329ed16 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Can create a new grafana managed alert using simplified routing can create new grafana managed alert when using simplified routing and selecting a contact point 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "empty-group", + "rules": [ + { + "annotations": {}, + "for": "1m", + "grafana_alert": { + "condition": "B", + "data": [ + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "A", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "A", + "type": "reduce", + }, + "queryType": "", + "refId": "A", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "refId": "B", + "type": "threshold", + }, + "queryType": "", + "refId": "B", + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "no_data_state": "NoData", + "notification_settings": { + "receiver": "lotsa-emails", + }, + "title": "my great new rule", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/6abdb25bc1eb?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index 9821f7483b4..3e2e87a4438 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -7,6 +7,7 @@ import { CombinedRule } from 'app/types/unified-alerting'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { fetchPromAndRulerRulesAction } from '../../state/actions'; +import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id'; import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules'; type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; @@ -29,12 +30,14 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { return; } - const location = getRuleGroupLocationFromCombinedRule(rule); - await deleteRuleFromGroup.execute(location, rule.rulerRule); + const ruleGroupIdentifier = getRuleGroupLocationFromCombinedRule(rule); + const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, rule.rulerRule); + + await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier); // refetch rules for this rules source // @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags - dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: location.dataSourceName })); + dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: ruleGroupIdentifier.dataSourceName })); dismissModal(); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx deleted file mode 100644 index 8744867ab72..00000000000 --- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { reorder } from './ReorderRuleGroupModal'; - -describe('test reorder', () => { - it('should reorder arrays', () => { - const original = [1, 2, 3]; - const expected = [1, 3, 2]; - - expect(reorder(original, 1, 2)).toEqual(expected); - expect(original).not.toEqual(expected); // make sure we've not mutated the original - }); -}); diff --git a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx index 278be0bf5dd..3a40868f20e 100644 --- a/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/ReorderRuleGroupModal.tsx @@ -8,22 +8,30 @@ import { DropResult, } from '@hello-pangea/dnd'; import cx from 'classnames'; -import { compact } from 'lodash'; -import { useCallback, useState } from 'react'; +import { produce } from 'immer'; +import { useCallback, useEffect, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Badge, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; -import { useCombinedRuleNamespaces } from 'app/features/alerting/unified/hooks/useCombinedRuleNamespaces'; -import { dispatch } from 'app/store/store'; -import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { Badge, Button, Icon, Modal, Tooltip, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { dispatch, getState } from 'app/store/store'; +import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; -import { updateRulesOrder } from '../../state/actions'; -import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; +import { alertRuleApi } from '../../api/alertRuleApi'; +import { useReorderRuleForRuleGroup } from '../../hooks/ruleGroup/useUpdateRuleGroup'; +import { isLoading } from '../../hooks/useAsync'; +import { swapItems, SwapOperation } from '../../reducers/ruler/ruleGroups'; +import { fetchRulerRulesAction, getDataSourceRulerConfig } from '../../state/actions'; +import { isCloudRulesSource } from '../../utils/datasource'; import { hashRulerRule } from '../../utils/rule-id'; -import { isAlertingRule, isRecordingRule } from '../../utils/rules'; - -import { AlertStateTag } from './AlertStateTag'; +import { + isAlertingRulerRule, + isGrafanaRulerRule, + isRecordingRulerRule, + rulesSourceToDataSourceName, +} from '../../utils/rules'; interface ModalProps { namespace: CombinedRuleNamespace; @@ -32,24 +40,36 @@ interface ModalProps { folderUid?: string; } -type CombinedRuleWithUID = { uid: string } & CombinedRule; +type RulerRuleWithUID = { uid: string } & RulerRuleDTO; export const ReorderCloudGroupModal = (props: ModalProps) => { + const styles = useStyles2(getStyles); const { group, namespace, onClose, folderUid } = props; + const [operations, setOperations] = useState>([]); + + const [reorderRulesInGroup, reorderState] = useReorderRuleForRuleGroup(); + const isUpdating = isLoading(reorderState); // The list of rules might have been filtered before we get to this reordering modal - // We need to grab the full (unfiltered) list so we are able to reorder via the API without - // deleting any rules (as they otherwise would have been omitted from the payload) - const unfilteredNamespaces = useCombinedRuleNamespaces(); - const matchedNamespace = unfilteredNamespaces.find( - (ns) => ns.rulesSource === namespace.rulesSource && ns.name === namespace.name + // We need to grab the full (unfiltered) list + const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource); + const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName); + const { currentData: ruleGroup, isLoading: loadingRules } = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery( + { + rulerConfig, + namespace: folderUid ?? namespace.name, + group: group.name, + }, + { refetchOnMountOrArgChange: true } ); - const matchedGroup = matchedNamespace?.groups.find((g) => g.name === group.name); - const [pending, setPending] = useState(false); - const [rulesList, setRulesList] = useState(matchedGroup?.rules || []); + const [rulesList, setRulesList] = useState([]); - const styles = useStyles2(getStyles); + useEffect(() => { + if (ruleGroup) { + setRulesList(ruleGroup?.rules); + } + }, [ruleGroup]); const onDragEnd = useCallback( (result: DropResult) => { @@ -58,39 +78,50 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { return; } - const sameIndex = result.destination.index === result.source.index; - if (sameIndex) { - return; - } + const swapOperation: SwapOperation = [result.source.index, result.destination.index]; - const newOrderedRules = reorder(rulesList, result.source.index, result.destination.index); - setRulesList(newOrderedRules); // optimistically update the new rules list - - const rulesSourceName = getRulesSourceName(namespace.rulesSource); - const rulerRules = compact(newOrderedRules.map((rule) => rule.rulerRule)); - - setPending(true); - dispatch( - updateRulesOrder({ - namespaceName: namespace.name, - groupName: group.name, - rulesSourceName: rulesSourceName, - newRules: rulerRules, - folderUid: folderUid || namespace.name, + // add old index and new index to the modifications object + setOperations( + produce(operations, (draft) => { + draft.push(swapOperation); }) - ) - .unwrap() - .finally(() => { - setPending(false); - }); + ); + + // re-order the rules list for the UI rendering + const newOrderedRules = produce(rulesList, (draft) => { + swapItems(draft, swapOperation); + }); + setRulesList(newOrderedRules); }, - [group.name, namespace.name, namespace.rulesSource, rulesList, folderUid] + [rulesList, operations] ); + const updateRulesOrder = useCallback(async () => { + const ruleGroupIdentifier: RuleGroupIdentifier = { + dataSourceName: rulesSourceToDataSourceName(namespace.rulesSource), + groupName: group.name, + namespaceName: folderUid ?? namespace.name, + }; + + await reorderRulesInGroup.execute(ruleGroupIdentifier, operations); + // TODO: Remove once RTKQ is more prevalently used + await dispatch(fetchRulerRulesAction({ rulesSourceName: dataSourceName })); + onClose(); + }, [ + namespace.rulesSource, + namespace.name, + group.name, + folderUid, + reorderRulesInGroup, + operations, + dataSourceName, + onClose, + ]); + // assign unique but stable identifiers to each (alerting / recording) rule - const rulesWithUID: CombinedRuleWithUID[] = rulesList.map((rule) => ({ - ...rule, - uid: String(hashRulerRule(rule.rulerRule!)), // TODO fix this coercion? + const rulesWithUID: RulerRuleWithUID[] = rulesList.map((rulerRule) => ({ + ...rulerRule, + uid: hashRulerRule(rulerRule), })); return ( @@ -101,37 +132,50 @@ export const ReorderCloudGroupModal = (props: ModalProps) => { onDismiss={onClose} onClickBackdrop={onClose} > - - ( - - )} - > - {(droppableProvided: DroppableProvided) => ( -
0 && ( + <> + + ( + + )} > - {rulesWithUID.map((rule, index) => ( - - {(provided: DraggableProvided) => } - - ))} - {droppableProvided.placeholder} -
- )} -
-
+ {(droppableProvided: DroppableProvided) => ( +
+ {rulesWithUID.map((rule, index) => ( + + {(provided: DraggableProvided) => } + + ))} + {droppableProvided.placeholder} +
+ )} + + + + + + + + )} ); }; interface ListItemProps extends React.HTMLAttributes { provided: DraggableProvided; - rule: CombinedRule; + rule: RulerRuleDTO; isClone?: boolean; isDragging?: boolean; } @@ -139,6 +183,7 @@ interface ListItemProps extends React.HTMLAttributes { const ListItem = ({ provided, rule, isClone = false, isDragging = false }: ListItemProps) => { const styles = useStyles2(getStyles); + // @TODO does this work with Grafana-managed recording rules too? Double check that. return (
- {isAlertingRule(rule.promRule) && } - {isRecordingRule(rule.promRule) && } -
{rule.name}
- + {isGrafanaRulerRule(rule) &&
{rule.grafana_alert.title}
} + {isRecordingRulerRule(rule) && ( + <> +
{rule.record}
+ + + )} + {isAlertingRulerRule(rule) &&
{rule.alert}
} +
); }; @@ -235,11 +285,3 @@ const getStyles = (theme: GrafanaTheme2) => ({ height: theme.spacing(2), }), }); - -export function reorder(rules: T[], startIndex: number, endIndex: number): T[] { - const result = Array.from(rules); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - - return result; -} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index bb66ef6421e..5ae024d814c 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -6,17 +6,16 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; -import { useDispatch } from 'app/types'; -import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; import { LogMessages, logInfo } from '../../Analytics'; +import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useFolder } from '../../hooks/useFolder'; import { useHasRuler } from '../../hooks/useHasRuler'; -import { deleteRulesGroupAction } from '../../state/actions'; import { useRulesAccess } from '../../utils/accessControlHooks'; import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc'; -import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; +import { isFederatedRuleGroup, isGrafanaRulerRule, rulesSourceToDataSourceName } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { RuleLocation } from '../RuleLocation'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; @@ -40,8 +39,8 @@ interface Props { export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { const { rulesSource } = namespace; - const dispatch = useDispatch(); const styles = useStyles2(getStyles); + const [deleteRuleGroup] = useDeleteRuleGroup(); const [isEditingGroup, setIsEditingGroup] = useState(false); const [isDeletingGroup, setIsDeletingGroup] = useState(false); @@ -74,8 +73,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: const isListView = viewMode === 'list'; const isGroupView = viewMode === 'grouped'; - const deleteGroup = () => { - dispatch(deleteRulesGroupAction(namespace, group)); + const deleteGroup = async () => { + const namespaceName = decodeGrafanaNamespace(namespace).name; + const groupName = group.name; + const dataSourceName = rulesSourceToDataSourceName(namespace.rulesSource); + + const ruleGroupIdentifier: RuleGroupIdentifier = { namespaceName, groupName, dataSourceName }; + await deleteRuleGroup.execute(ruleGroupIdentifier); setIsDeletingGroup(false); }; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap new file mode 100644 index 00000000000..8d3ddb718e3 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useAddRuleToRuleGroup.test.tsx.snap @@ -0,0 +1,206 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Creating a Data source managed rule should be able to add a rule to a existing rule group 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "group-1", + "rules": [ + { + "alert": "alert1", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "my new rule", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir", + }, +] +`; + +exports[`Creating a Data source managed rule should be able to add a rule to a new rule group 1`] = ` +[ + { + "body": { + "interval": "15m", + "name": "new group", + "rules": [ + { + "annotations": {}, + "for": "", + "grafana_alert": { + "condition": "", + "data": [], + "exec_err_state": "Error", + "namespace_uid": "NAMESPACE_UID", + "no_data_state": "NoData", + "rule_group": "my-group", + "title": "my new rule", + "uid": "mock-rule-uid-123", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/new%20namespace?subtype=mimir", + }, +] +`; + +exports[`Creating a Grafana managed rule should be able to add a rule to a existing rule group 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": {}, + "for": "", + "grafana_alert": { + "condition": "", + "data": [], + "exec_err_state": "Error", + "namespace_uid": "NAMESPACE_UID", + "no_data_state": "NoData", + "rule_group": "my-group", + "title": "my new rule", + "uid": "mock-rule-uid-123", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; + +exports[`Creating a Grafana managed rule should be able to add a rule to a new rule group 1`] = ` +[ + { + "body": { + "interval": "15m", + "name": "grafana-group-3", + "rules": [ + { + "annotations": {}, + "for": "", + "grafana_alert": { + "condition": "", + "data": [], + "exec_err_state": "Error", + "namespace_uid": "NAMESPACE_UID", + "no_data_state": "NoData", + "rule_group": "my-group", + "title": "my new rule", + "uid": "mock-rule-uid-123", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap new file mode 100644 index 00000000000..b6ecad04404 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useDeleteRuleGroup.test.tsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Grafana managed should be able to delete a Grafana managed rule group 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex", + }, +] +`; + +exports[`data-source managed should be able to delete a data-source managed rule group 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir", + }, +] +`; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap new file mode 100644 index 00000000000..2374935f47a --- /dev/null +++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useMoveRuleFromRuleGroup.test.tsx.snap @@ -0,0 +1,206 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Moving a Data source managed rule should move a rule in an existing group to a new group 1`] = ` +[ + { + "body": { + "name": "entirely new group name", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "updated rule title", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir", + }, +] +`; + +exports[`Moving a Data source managed rule should move a rule in an existing group to another existing group 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "group-3", + "rules": [ + { + "alert": "rule 3", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "rule 4", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "alert1", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir", + }, +] +`; + +exports[`Moving a Grafana managed rule should move a rule from an existing group to another group in the same namespace 1`] = ` +[ + { + "body": { + "name": "empty-group", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap index 62c6e987364..5357d0c6a6d 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleGroup.test.tsx.snap @@ -1,5 +1,83 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`reorder rules for rule group should correctly reorder rules 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "https://mimir.local:9000/api/v1/status/buildinfo", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir", + }, + { + "body": { + "interval": "1m", + "name": "group-3", + "rules": [ + { + "alert": "rule 4", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + { + "alert": "rule 3", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "severity": "warning", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/group-3?subtype=mimir", + }, +] +`; + exports[`useUpdateRuleGroupConfiguration should be able to move a Data Source managed rule group 1`] = ` [ { diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap new file mode 100644 index 00000000000..1b0143c619e --- /dev/null +++ b/public/app/features/alerting/unified/hooks/ruleGroup/__snapshots__/useUpdateRuleInRuleGroup.test.tsx.snap @@ -0,0 +1,311 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Updating a Data source managed rule should be able to move a rule if target group is different from current group 1`] = ` +[ + { + "body": { + "name": "a new group", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "updated rule title", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir", + }, +] +`; + +exports[`Updating a Data source managed rule should update a rule in an existing group 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "updated rule title", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1?subtype=mimir", + }, +] +`; + +exports[`Updating a Grafana managed rule should move a rule in to another group 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-2", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "updated rule title", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; + +exports[`Updating a Grafana managed rule should update a rule in an existing group 1`] = ` +[ + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "updated rule title", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx new file mode 100644 index 00000000000..acbcc25052c --- /dev/null +++ b/public/app/features/alerting/unified/hooks/ruleGroup/useAddRuleToRuleGroup.test.tsx @@ -0,0 +1,157 @@ +import { render } from 'test/test-utils'; +import { byRole, byText } from 'testing-library-selector'; + +import { AccessControlAction } from 'app/types/accessControl'; +import { RuleGroupIdentifier } from 'app/types/unified-alerting'; +import { PostableRuleDTO } from 'app/types/unified-alerting-dto'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions, mockGrafanaRulerRule, mockRulerAlertingRule } from '../../mocks'; +import { grafanaRulerGroupName, grafanaRulerNamespace } from '../../mocks/grafanaRulerApi'; +import { GROUP_1, NAMESPACE_1 } from '../../mocks/mimirRulerApi'; +import { mimirDataSource } from '../../mocks/server/configure'; +import { MIMIR_DATASOURCE_UID } from '../../mocks/server/constants'; +import { captureRequests, serializeRequests } from '../../mocks/server/events'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { SerializeState } from '../useAsync'; + +import { useAddRuleToRuleGroup } from './useUpsertRuleFromRuleGroup'; + +setupMswServer(); + +beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleCreate, + ]); +}); + +describe('Creating a Grafana managed rule', () => { + it('should be able to add a rule to a existing rule group', async () => { + const capture = captureRequests((r) => r.method === 'POST'); + + const ruleGroupID: RuleGroupIdentifier = { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + groupName: grafanaRulerGroupName, + namespaceName: grafanaRulerNamespace.uid, + }; + + const rule = mockGrafanaRulerRule({ title: 'my new rule' }); + + const { user } = render(); + await user.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should be able to add a rule to a new rule group', async () => { + const capture = captureRequests((r) => r.method === 'POST'); + + const ruleGroupID: RuleGroupIdentifier = { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + groupName: 'grafana-group-3', + namespaceName: grafanaRulerNamespace.uid, + }; + + const rule = mockGrafanaRulerRule({ title: 'my new rule' }); + + const { user } = render(); + await user.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should not be able to add a rule to a non-existing namespace', async () => { + const ruleGroupID: RuleGroupIdentifier = { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + groupName: grafanaRulerGroupName, + namespaceName: 'does-not-exist', + }; + + const rule = mockGrafanaRulerRule({ title: 'my new rule' }); + + const { user } = render(); + await user.click(byRole('button').get()); + + expect(await byText(/error/i).find()).toBeInTheDocument(); + }); +}); + +describe('Creating a Data source managed rule', () => { + beforeEach(() => { + mimirDataSource(); + }); + + it('should be able to add a rule to a existing rule group', async () => { + const capture = captureRequests((r) => r.method === 'POST'); + + const ruleGroupID: RuleGroupIdentifier = { + dataSourceName: MIMIR_DATASOURCE_UID, + groupName: GROUP_1, + namespaceName: NAMESPACE_1, + }; + + const rule = mockRulerAlertingRule({ alert: 'my new rule' }); + + const { user } = render(); + await user.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should be able to add a rule to a new rule group', async () => { + const capture = captureRequests((r) => r.method === 'POST'); + + const ruleGroupID: RuleGroupIdentifier = { + dataSourceName: MIMIR_DATASOURCE_UID, + groupName: 'new group', + namespaceName: 'new namespace', + }; + + const rule = mockGrafanaRulerRule({ title: 'my new rule' }); + + const { user } = render(); + await user.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); +}); + +type AddRuleTestComponentProps = { + ruleGroupIdentifier: RuleGroupIdentifier; + rule: PostableRuleDTO; + interval?: string; +}; + +const AddRuleTestComponent = ({ ruleGroupIdentifier, rule, interval }: AddRuleTestComponentProps) => { + const [addRule, requestState] = useAddRuleToRuleGroup(); + + const onClick = () => { + addRule.execute(ruleGroupIdentifier, rule, interval); + }; + + return ( + <> +