From 93870c1cd8b159eaa5edbbf08aa4e8b1b4e0deda Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 30 May 2024 12:55:06 +0200 Subject: [PATCH] Alerting: Use new endpoints to fetch single GMA rule on view and edit pages (#87625) --- .../alerting/unified/CloneRuleEditor.test.tsx | 121 +++------ .../alerting/unified/CloneRuleEditor.tsx | 17 +- .../alerting/unified/ExistingRuleEditor.tsx | 38 +-- .../unified/RuleEditorExisting.test.tsx | 92 ++----- .../unified/RuleEditorGrafanaRules.test.tsx | 88 +----- .../alerting/unified/api/alertRuleApi.ts | 8 + .../alerting/unified/api/alertingApi.ts | 1 + .../export/GrafanaModifyExport.test.tsx | 46 +--- .../components/export/GrafanaModifyExport.tsx | 143 +++++----- .../components/rule-editor/FolderAndGroup.tsx | 76 +++--- .../SimplifiedRuleEditor.test.tsx | 195 ++------------ .../rule-viewer/RuleViewer.test.tsx | 15 +- .../alerting/unified/hooks/useCombinedRule.ts | 255 +++++++++++++----- public/app/features/alerting/unified/mocks.ts | 2 + .../alerting/unified/mocks/alertRuleApi.ts | 70 ++++- .../unified/mocks/server/all-handlers.ts | 1 + .../mocks/server/handlers/alertRule.ts | 75 ++++++ .../mocks/server/handlers/alertRules.ts | 73 ++++- .../alerting/unified/state/actions.ts | 25 +- .../alerting/unified/state/reducers.ts | 2 - .../alerting/unified/utils/query.test.ts | 1 + .../alerting/unified/utils/rule-form.test.ts | 2 + .../alerting/unified/utils/rule-id.test.ts | 1 + .../alerting/unified/utils/rulerClient.ts | 24 +- .../fixtures/alertRules.fixture.ts | 1 + public/app/types/unified-alerting-dto.ts | 1 + public/app/types/unified-alerting.ts | 2 +- public/test/helpers/alertingRuleEditor.tsx | 1 + 28 files changed, 668 insertions(+), 708 deletions(-) create mode 100644 public/app/features/alerting/unified/mocks/server/handlers/alertRule.ts diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 61fef735f65..06d5354d0bd 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -1,17 +1,16 @@ import { render, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; -import { setupServer } from 'msw/node'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { TestProvider } from 'test/helpers/TestProvider'; import { byRole, byTestId, byText } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors/src'; -import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; -import { backendSrv } from 'app/core/services/backend_srv'; +import { setDataSourceSrv } from '@grafana/runtime'; import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; +import { AccessControlAction } from '../../../types'; import { RulerAlertingRuleDTO, RulerGrafanaRuleDTO, @@ -21,19 +20,21 @@ import { import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { mockApi, mockSearchApi } from './mockApi'; +import { mockFeatureDiscoveryApi, mockSearchApi, setupMswServer } from './mockApi'; import { - labelsPluginMetaMock, + grantUserPermissions, mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockRulerRuleGroup, - mockStore, } from './mocks'; +import { grafanaRulerRule } from './mocks/alertRuleApi'; import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi'; import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi'; import { AlertingQueryRunner } from './state/AlertingQueryRunner'; +import { setupDataSources } from './testSetup/datasources'; +import { buildInfoResponse } from './testSetup/featureDiscovery'; import { RuleFormValues } from './types/rule-form'; import { Annotation } from './utils/constants'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; @@ -55,20 +56,7 @@ jest.mock('./components/rule-editor/notificaton-preview/NotificationPreview', () jest.spyOn(AlertingQueryRunner.prototype, 'run').mockImplementation(() => Promise.resolve()); -const server = setupServer(); - -beforeAll(() => { - setBackendSrv(backendSrv); - server.listen({ onUnhandledRequest: 'error' }); -}); - -beforeEach(() => { - server.resetHandlers(); -}); - -afterAll(() => { - server.close(); -}); +const server = setupMswServer(); const ui = { inputs: { @@ -80,46 +68,16 @@ const ui = { annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), labelValue: (idx: number) => byTestId(`label-value-${idx}`), }, - loadingIndicator: byText('Loading the rule'), + loadingIndicator: byText('Loading the rule...'), }; -function getProvidersWrapper() { - return function Wrapper({ children }: React.PropsWithChildren<{}>) { - const store = mockStore((store) => { - store.unifiedAlerting.dataSources['grafana'] = { - loading: false, - dispatched: true, - result: { - id: 'grafana', - name: 'grafana', - rulerConfig: { - dataSourceName: 'grafana', - apiVersion: 'legacy', - }, - }, - }; - store.unifiedAlerting.dataSources['my-prom-ds'] = { - loading: false, - dispatched: true, - result: { - id: 'my-prom-ds', - name: 'my-prom-ds', - rulerConfig: { - dataSourceName: 'my-prom-ds', - apiVersion: 'config', - }, - }, - }; - }); - - const formApi = useForm({ defaultValues: getDefaultFormValues() }); - - return ( - - {children} - - ); - }; +function Wrapper({ children }: React.PropsWithChildren<{}>) { + const formApi = useForm({ defaultValues: getDefaultFormValues() }); + return ( + + {children} + + ); } const amConfig: AlertManagerCortexConfig = { @@ -139,33 +97,26 @@ const amConfig: AlertManagerCortexConfig = { template_files: {}, }; -mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false }); describe('CloneRuleEditor', function () { + grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]); + describe('Grafana-managed rules', function () { it('should populate form values from the existing alert rule', async function () { setDataSourceSrv(new MockDataSourceSrv({})); - const originRule: RulerGrafanaRuleDTO = mockRulerGrafanaRule( - { - for: '1m', - labels: { severity: 'critical', region: 'nasa' }, - annotations: { [Annotation.summary]: 'This is a very important alert rule' }, - }, - { uid: 'grafana-rule-1', title: 'First Grafana Rule', data: [] } - ); - - mockRulerRulesApiResponse(server, 'grafana', { - 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }], - }); - mockSearchApi(server).search([ - mockDashboardSearchItem({ title: 'folder-one', uid: '123', type: DashboardSearchItemType.DashDB }), + mockDashboardSearchItem({ + title: 'folder-one', + uid: grafanaRulerRule.grafana_alert.namespace_uid, + type: DashboardSearchItemType.DashDB, + }), ]); mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); - render(, { - wrapper: getProvidersWrapper(), - }); + render( + , + { wrapper: Wrapper } + ); await waitForElementToBeRemoved(ui.loadingIndicator.query()); await waitFor(() => { @@ -173,9 +124,9 @@ describe('CloneRuleEditor', function () { }); await waitFor(() => { - expect(ui.inputs.name.get()).toHaveValue('First Grafana Rule (copy)'); + expect(ui.inputs.name.get()).toHaveValue(`${grafanaRulerRule.grafana_alert.title} (copy)`); expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one'); - expect(ui.inputs.group.get()).toHaveTextContent('group1'); + expect(ui.inputs.group.get()).toHaveTextContent(grafanaRulerRule.grafana_alert.rule_group); expect( byRole('listitem', { name: 'severity: critical', @@ -186,7 +137,7 @@ describe('CloneRuleEditor', function () { name: 'region: nasa', }).get() ).toBeInTheDocument(); - expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule'); + expect(ui.inputs.annotationValue(0).get()).toHaveTextContent(grafanaRulerRule.annotations[Annotation.summary]); }); }); }); @@ -197,12 +148,8 @@ describe('CloneRuleEditor', function () { name: 'my-prom-ds', uid: 'my-prom-ds', }); - config.datasources = { - 'my-prom-ds': dsSettings, - }; - - setDataSourceSrv(new MockDataSourceSrv({ 'my-prom-ds': dsSettings })); - + setupDataSources(dsSettings); + mockFeatureDiscoveryApi(server).discoverDsFeatures(dsSettings, buildInfoResponse.mimir); const originRule = mockRulerAlertingRule({ for: '1m', alert: 'First Ruler Rule', @@ -242,9 +189,7 @@ describe('CloneRuleEditor', function () { rulerRuleHash: hashRulerRule(originRule), }} />, - { - wrapper: getProvidersWrapper(), - } + { wrapper: Wrapper } ); await waitForElementToBeRemoved(ui.loadingIndicator.query()); diff --git a/public/app/features/alerting/unified/CloneRuleEditor.tsx b/public/app/features/alerting/unified/CloneRuleEditor.tsx index 76586428e1e..1a70ee3348b 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.tsx @@ -1,32 +1,25 @@ import { cloneDeep } from 'lodash'; import React from 'react'; -import { useAsync } from 'react-use'; import { locationService } from '@grafana/runtime/src'; import { Alert, LoadingPlaceholder } from '@grafana/ui/src'; -import { useDispatch } from '../../../types'; import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting'; import { RulerRuleDTO } from '../../../types/unified-alerting-dto'; import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; -import { fetchEditableRuleAction } from './state/actions'; +import { useRuleWithLocation } from './hooks/useCombinedRule'; import { generateCopiedName } from './utils/duplicate'; +import { stringifyErrorLike } from './utils/misc'; import { rulerRuleToFormValues } from './utils/rule-form'; import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules'; import { createUrl } from './utils/url'; export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) { - const dispatch = useDispatch(); - - const { - loading, - value: rule, - error, - } = useAsync(() => dispatch(fetchEditableRuleAction(sourceRuleId)).unwrap(), [sourceRuleId]); + const { loading, result: rule, error } = useRuleWithLocation({ ruleIdentifier: sourceRuleId }); if (loading) { - return ; + return ; } if (rule) { @@ -39,7 +32,7 @@ export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier if (error) { return ( - {error.message} + {stringifyErrorLike(error)} ); } diff --git a/public/app/features/alerting/unified/ExistingRuleEditor.tsx b/public/app/features/alerting/unified/ExistingRuleEditor.tsx index d5b10acb2af..df864f05287 100644 --- a/public/app/features/alerting/unified/ExistingRuleEditor.tsx +++ b/public/app/features/alerting/unified/ExistingRuleEditor.tsx @@ -1,16 +1,13 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Alert, LoadingPlaceholder } from '@grafana/ui'; -import { useCleanup } from 'app/core/hooks/useCleanup'; -import { useDispatch } from 'app/types'; import { RuleIdentifier } from 'app/types/unified-alerting'; import { AlertWarning } from './AlertWarning'; import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; +import { useRuleWithLocation } from './hooks/useCombinedRule'; import { useIsRuleEditable } from './hooks/useIsRuleEditable'; -import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchEditableRuleAction } from './state/actions'; -import { initialAsyncRequestState } from './utils/redux'; +import { stringifyErrorLike } from './utils/misc'; import * as ruleId from './utils/rule-id'; interface ExistingRuleEditorProps { @@ -19,42 +16,31 @@ interface ExistingRuleEditorProps { } export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) { - useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState)); - const { loading: loadingAlertRule, - result, + result: ruleWithLocation, error, - dispatched, - } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); + } = useRuleWithLocation({ ruleIdentifier: identifier }); - const dispatch = useDispatch(); - const { isEditable, loading: loadingEditable } = useIsRuleEditable( - ruleId.ruleIdentifierToRuleSourceName(identifier), - result?.rule - ); + const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier); + + const { isEditable, loading: loadingEditable } = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule); const loading = loadingAlertRule || loadingEditable; - useEffect(() => { - if (!dispatched) { - dispatch(fetchEditableRuleAction(identifier)); - } - }, [dispatched, dispatch, identifier]); - - if (loading || isEditable === undefined) { + if (loading) { return ; } if (error) { return ( - {error.message} + {stringifyErrorLike(error)} ); } - if (!result) { + if (!ruleWithLocation) { return Sorry! This rule does not exist.; } @@ -62,5 +48,5 @@ export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) return Sorry! You do not have permission to edit this rule.; } - return ; + return ; } diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx index ec49c212dfa..9fe2b5e35fc 100644 --- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -5,27 +5,25 @@ import { Route } from 'react-router-dom'; import { TestProvider } from 'test/helpers/TestProvider'; import { ui } from 'test/helpers/alertingRuleEditor'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; -import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; import { backendSrv } from '../../../core/services/backend_srv'; import { AccessControlAction } from '../../../types'; import RuleEditor from './RuleEditor'; -import { discoverFeatures } from './api/buildInfo'; -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import * as ruler from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { MockDataSourceSrv, grantUserPermissions, mockDataSource, mockFolder } from './mocks'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; -import * as config from './utils/config'; +import { setupMswServer } from './mockApi'; +import { grantUserPermissions, mockDataSource, mockFolder } from './mocks'; +import { grafanaRulerGroup, grafanaRulerRule } from './mocks/alertRuleApi'; +import { setupDataSources } from './testSetup/datasources'; +import { Annotation } from './utils/constants'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; -import { getDefaultQueries } from './utils/rule-form'; jest.mock('./components/rule-editor/ExpressionEditor', () => ({ - // eslint-disable-next-line react/display-name ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( onChange(e.target.value)} /> ), @@ -35,34 +33,25 @@ 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', () => ({ - // eslint-disable-next-line react/display-name QueryEditorRow: () =>

hi

, })); -jest.spyOn(config, 'getAllDataSources'); - 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), + setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'), }, }; +setupMswServer(); + function renderRuleEditor(identifier?: string) { locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); @@ -95,10 +84,9 @@ describe('RuleEditor grafana managed rules', () => { }); it('can edit grafana managed rule', async () => { - const uid = 'FOOBAR123'; const folder = { title: 'Folder A', - uid: 'abcd', + uid: grafanaRulerRule.grafana_alert.namespace_uid, id: 1, type: DashboardSearchItemType.DashDB, }; @@ -127,46 +115,20 @@ describe('RuleEditor grafana managed rules', () => { }, }); - setDataSourceSrv(new MockDataSourceSrv(dataSources)); + setupDataSources(dataSources.default); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRules.mockResolvedValue({ - [folder.title]: [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid, - namespace_uid: 'abcd', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - }); + // mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); - renderRuleEditor(uid); + renderRuleEditor(grafanaRulerRule.grafana_alert.uid); // check that it's filled in const nameInput = await ui.inputs.name.find(); - expect(nameInput).toHaveValue('my great new rule'); + expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title); //check that folder is in the list expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); - expect(ui.inputs.annotationValue(0).get()).toHaveValue('some summary'); - expect(ui.inputs.annotationValue(1).get()).toHaveValue('some description'); + expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]); //check that slashed folders are not in the list expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); @@ -195,25 +157,15 @@ describe('RuleEditor grafana managed rules', () => { expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'abcd', + grafanaRulerRule.grafana_alert.namespace_uid, { - interval: '1m', - name: 'group1', + interval: grafanaRulerGroup.interval, + name: grafanaRulerGroup.name, rules: [ { - annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - notification_settings: undefined, - is_paused: false, - no_data_state: 'NoData', - title: 'my great new rule', - }, + ...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 f630d4abbc6..524d6be99cb 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -5,33 +5,30 @@ import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { byRole } 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 { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; -import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; +import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; import { discoverFeatures } from './api/buildInfo'; -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import * as ruler from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import { grantUserPermissions, mockDataSource } from './mocks'; +import { grafanaRulerGroup, grafanaRulerRule } from './mocks/alertRuleApi'; +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', () => ({ - // eslint-disable-next-line react/display-name ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( onChange(e.target.value)} /> ), })); -jest.mock('./api/buildInfo'); -jest.mock('./api/ruler'); jest.mock('../../../../app/features/manage-dashboards/state/actions'); jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ @@ -41,7 +38,6 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ // 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', () => ({ - // eslint-disable-next-line react/display-name QueryEditorRow: () =>

hi

, })); @@ -54,11 +50,7 @@ const mocks = { 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), + setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'), }, }; @@ -96,64 +88,13 @@ describe('RuleEditor grafana managed rules', () => { ), }; - setDataSourceSrv(new MockDataSourceSrv(dataSources)); + setupDataSources(dataSources.default); 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({ - 'Folder A': [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid: '23', - namespace_uid: 'abcd', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - namespace2: [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid: '23', - namespace_uid: 'b', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - }); mocks.searchFolders.mockResolvedValue([ { title: 'Folder A', - uid: 'abcd', + uid: grafanaRulerRule.grafana_alert.namespace_uid, id: 1, type: DashboardSearchItemType.DashDB, }, @@ -171,12 +112,6 @@ describe('RuleEditor grafana managed rules', () => { }, ] as DashboardSearchHit[]); - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Prometheus, - features: { - rulerApiEnabled: false, - }, - }); renderRuleEditor(); await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); @@ -186,7 +121,7 @@ describe('RuleEditor grafana managed rules', () => { await clickSelectOption(folderInput, 'Folder A'); const groupInput = await ui.inputs.group.find(); await userEvent.click(byRole('combobox').get(groupInput)); - await clickSelectOption(groupInput, 'group1'); + await clickSelectOption(groupInput, grafanaRulerGroup.name); await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); // save and check what was sent to backend @@ -196,11 +131,12 @@ describe('RuleEditor grafana managed rules', () => { // 9seg expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'abcd', + grafanaRulerRule.grafana_alert.namespace_uid, { interval: '1m', - name: 'group1', + name: grafanaRulerGroup.name, rules: [ + grafanaRulerRule, { annotations: { description: 'some description' }, labels: {}, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index b849de1e9a6..cf9f443202a 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -203,6 +203,13 @@ export const alertRuleApi = alertingApi.injectEndpoints({ providesTags: ['CombinedAlertRule'], }), + rulerNamespace: build.query({ + query: ({ rulerConfig, namespace }) => { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); + return { url: path, params }; + }, + }), + // TODO This should be probably a separate ruler API file rulerRuleGroup: build.query< RulerRuleGroupDTO, @@ -219,6 +226,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ // TODO: In future, if supported in other rulers, parametrize ruler source name // For now, to make the consumption of this hook clearer, only support Grafana ruler query: ({ uid }) => ({ url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rule/${uid}` }), + providesTags: (_result, _error, { uid }) => [{ type: 'GrafanaRulerRule', id: uid }], }), exportRules: build.query({ diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index e90fc21aa6d..906a9b12084 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -43,6 +43,7 @@ export const alertingApi = createApi({ 'DataSourceSettings', 'GrafanaLabels', 'CombinedAlertRule', + 'GrafanaRulerRule', ], endpoints: () => ({}), }); diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx index 3f2e0dd3a15..5a2bae664b2 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx @@ -5,10 +5,10 @@ import { render, waitFor, waitForElementToBeRemoved, userEvent } from 'test/test import { byRole, byTestId, byText } from 'testing-library-selector'; import { DashboardSearchItemType } from '../../../../search/types'; -import { mockAlertRuleApi, mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi'; -import { getGrafanaRule, mockDashboardSearchItem, mockDataSource } from '../../mocks'; +import { mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi'; +import { mockDashboardSearchItem, mockDataSource } from '../../mocks'; +import { grafanaRulerRule } from '../../mocks/alertRuleApi'; import { setupDataSources } from '../../testSetup/datasources'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import GrafanaModifyExport from './GrafanaModifyExport'; @@ -31,7 +31,7 @@ jest.mock('@grafana/ui', () => ({ })); const ui = { - loading: byText('Loading the rule'), + loading: byText('Loading the rule...'), form: { nameInput: byRole('textbox', { name: 'name' }), folder: byTestId('folder-picker'), @@ -66,55 +66,27 @@ const server = setupMswServer(); describe('GrafanaModifyExport', () => { setupDataSources(dataSources.default); - const grafanaRule = getGrafanaRule(undefined, { - uid: 'test-rule-uid', - title: 'cpu-usage', - namespace_uid: 'folderUID1', - data: [ - { - refId: 'A', - datasourceUid: dataSources.default.uid, - queryType: 'alerting', - relativeTimeRange: { from: 1000, to: 2000 }, - model: { - refId: 'A', - expression: 'vector(1)', - queryType: 'alerting', - datasource: { uid: dataSources.default.uid, type: 'prometheus' }, - }, - }, - ], - }); - it('Should render edit form for the specified rule', async () => { mockSearchApi(server).search([ mockDashboardSearchItem({ - title: grafanaRule.namespace.name, - uid: 'folderUID1', + title: grafanaRulerRule.grafana_alert.title, + uid: grafanaRulerRule.grafana_alert.namespace_uid, url: '', tags: [], type: DashboardSearchItemType.DashFolder, }), ]); - mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, { - [grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }], - }); - mockAlertRuleApi(server).rulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, 'folderUID1', grafanaRule.group.name, { - name: grafanaRule.group.name, - interval: '1m', - rules: [grafanaRule.rulerRule!], - }); - mockExportApi(server).modifiedExport('folderUID1', { + mockExportApi(server).modifiedExport(grafanaRulerRule.grafana_alert.namespace_uid, { yaml: 'Yaml Export Content', json: 'Json Export Content', }); const user = userEvent.setup(); - renderModifyExport('test-rule-uid'); + renderModifyExport(grafanaRulerRule.grafana_alert.uid); await waitForElementToBeRemoved(() => ui.loading.get()); - expect(await ui.form.nameInput.find()).toHaveValue('cpu-usage'); + expect(await ui.form.nameInput.find()).toHaveValue('Grafana-rule'); await user.click(ui.exportButton.get()); diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx index 3e6a3b62c76..e7426dc9a1b 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx @@ -1,14 +1,13 @@ import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useAsync } from 'react-use'; +import { useMemo } from 'react'; import { locationService } from '@grafana/runtime'; import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { GrafanaRouteComponentProps } from '../../../../../core/navigation/types'; -import { useDispatch } from '../../../../../types'; import { RuleIdentifier } from '../../../../../types/unified-alerting'; -import { fetchEditableRuleAction, fetchRulesSourceBuildInfoAction } from '../../state/actions'; +import { useRuleWithLocation } from '../../hooks/useCombinedRule'; +import { stringifyErrorLike } from '../../utils/misc'; import { formValuesFromExistingRule } from '../../utils/rule-form'; import * as ruleId from '../../utils/rule-id'; import { isGrafanaRulerRule } from '../../utils/rules'; @@ -19,79 +18,34 @@ import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExpor interface GrafanaModifyExportProps extends GrafanaRouteComponentProps<{ id?: string }> {} export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) { - const dispatch = useDispatch(); - - // Get rule source build info - const [ruleIdentifier, setRuleIdentifier] = useState(undefined); - - useEffect(() => { - const identifier = ruleId.tryParse(match.params.id, true); - setRuleIdentifier(identifier); + const ruleIdentifier = useMemo(() => { + return ruleId.tryParse(match.params.id, true); }, [match.params.id]); - const { loading: loadingBuildInfo = true } = useAsync(async () => { - if (ruleIdentifier) { - await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: ruleIdentifier.ruleSourceName })); - } - }, [dispatch, ruleIdentifier]); - - // Get rule - const { - loading, - value: alertRule, - error, - } = useAsync(async () => { - if (!ruleIdentifier) { - return; - } - return await dispatch(fetchEditableRuleAction(ruleIdentifier)).unwrap(); - }, [ruleIdentifier, loadingBuildInfo]); - if (!ruleIdentifier) { - return
Rule not found
; - } - - if (loading) { - return ; - } - - if (error) { return ( - - {error.message} - - ); - } - - if (!alertRule && !loading && !loadingBuildInfo) { - // alert rule does not exist - return ( - - locationService.replace(createUrl('/alerting/list'))} - /> - - ); - } - - if (alertRule && !isGrafanaRulerRule(alertRule.rule)) { - // alert rule exists but is not a grafana-managed rule - return ( - - locationService.replace(createUrl('/alerting/list'))} - /> - + + + The rule UID in the page URL is invalid. Please check the URL and try again. + + ); } + return ( + + + + ); +} + +interface ModifyExportWrapperProps { + children: React.ReactNode; +} + +function ModifyExportWrapper({ children }: ModifyExportWrapperProps) { return ( - {alertRule && ( - - )} + {children} ); } + +function RuleModifyExport({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }) { + const { loading, error, result: rulerRule } = useRuleWithLocation({ ruleIdentifier: ruleIdentifier }); + + if (loading) { + return ; + } + + if (error) { + return ( + + {stringifyErrorLike(error)} + + ); + } + + if (!rulerRule && !loading) { + // alert rule does not exist + return ( + locationService.replace(createUrl('/alerting/list'))} + /> + ); + } + + if (rulerRule && !isGrafanaRulerRule(rulerRule.rule)) { + // alert rule exists but is not a grafana-managed rule + return ( + locationService.replace(createUrl('/alerting/list'))} + /> + ); + } + + if (rulerRule && isGrafanaRulerRule(rulerRule.rule)) { + return ( + + ); + } + + return ; +} 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 bb8f7f28611..d21b697117c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { debounce, take, uniqueId } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { FormProvider, useForm, useFormContext, Controller } from 'react-hook-form'; import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; @@ -9,15 +9,12 @@ import { AsyncSelect, Box, Button, Field, Input, Label, Modal, Stack, Text, useS import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/services/context_srv'; import { createFolder } from 'app/features/manage-dashboards/state/actions'; -import { AccessControlAction, useDispatch } from 'app/types'; -import { CombinedRuleGroup } from 'app/types/unified-alerting'; -import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { AccessControlAction } from 'app/types'; +import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; -import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; -import { fetchRulerRulesAction } from '../../state/actions'; +import { alertRuleApi } from '../../api/alertRuleApi'; +import { grafanaRulerConfig } from '../../hooks/useCombinedRule'; import { RuleFormValues } from '../../types/rule-form'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { isGrafanaRulerRule } from '../../utils/rules'; import { ProvisioningBadge } from '../Provisioning'; @@ -30,42 +27,51 @@ import { checkForPathSeparator } from './util'; export const MAX_GROUP_RESULTS = 1000; export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => { - const dispatch = useDispatch(); - // fetch the ruler rules from the database so we can figure out what other "groups" are already defined // for our folders - useEffect(() => { - dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); - }, [dispatch]); + const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } = + alertRuleApi.endpoints.rulerNamespace.useQuery( + { + namespace: folderUid, + rulerConfig: grafanaRulerConfig, + }, + { + skip: !folderUid, + refetchOnMountOrArgChange: true, + } + ); - const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; + // There should be only one entry in the rulerNamespace object + // However it uses folder name as key, so to avoid fetching folder name, we use Object.values + const groupOptions = useMemo(() => { + if (!rulerNamespace) { + // still waiting for namespace information to be fetched + return []; + } - const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); - const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? []; + const folderGroups = Object.values(rulerNamespace).flat() ?? []; - const groupOptions = folderGroups - .map>((group) => { - const isProvisioned = isProvisionedGroup(group); - return { - label: group.name, - value: group.name, - description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, - // we include provisioned folders, but disable the option to select them - isDisabled: !enableProvisionedGroups ? isProvisioned : false, - isProvisioned: isProvisioned, - }; - }) + return folderGroups + .map>((group) => { + const isProvisioned = isProvisionedGroup(group); + return { + label: group.name, + value: group.name, + description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, + // we include provisioned folders, but disable the option to select them + isDisabled: !enableProvisionedGroups ? isProvisioned : false, + isProvisioned: isProvisioned, + }; + }) - .sort(sortByLabel); + .sort(sortByLabel); + }, [rulerNamespace, enableProvisionedGroups]); - return { groupOptions, loading: groupfoldersForGrafana?.loading }; + return { groupOptions, loading: isLoadingRulerNamespace }; }; -const isProvisionedGroup = (group: CombinedRuleGroup) => { - return group.rules.some( - (rule) => isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance) === true - ); +const isProvisionedGroup = (group: RulerRuleGroupDTO) => { + return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true); }; const sortByLabel = (a: SelectableValue, b: SelectableValue) => { 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 036efb074f1..0cae6f92809 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 @@ -7,50 +7,39 @@ import { ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { byRole } from 'testing-library-selector'; -import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import RuleEditor from 'app/features/alerting/unified/RuleEditor'; -import { discoverFeatures } from 'app/features/alerting/unified/api/buildInfo'; -import { - fetchRulerRules, - fetchRulerRulesGroup, - fetchRulerRulesNamespace, - setRulerRuleGroup, -} from 'app/features/alerting/unified/api/ruler'; +import * as ruler from 'app/features/alerting/unified/api/ruler'; import * as useContactPoints from 'app/features/alerting/unified/components/contact-points/useContactPoints'; -import * as dsByPermission from 'app/features/alerting/unified/hooks/useAlertManagerSources'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; -import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; +import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; -import { fetchRulerRulesIfNotFetchedYet } from 'app/features/alerting/unified/state/actions'; import * as utils_config from 'app/features/alerting/unified/utils/config'; import { - AlertManagerDataSource, DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME, - getAlertManagerDataSourcesByPermission, useGetAlertManagerDataSourcesByPermissionAndConfig, } from 'app/features/alerting/unified/utils/datasource'; import { getDefaultQueries } from 'app/features/alerting/unified/utils/rule-form'; import { searchFolders } from 'app/features/manage-dashboards/state/actions'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; -import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; +import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; +import { grafanaRulerEmptyGroup, grafanaRulerNamespace2, grafanaRulerRule } from '../../../../mocks/alertRuleApi'; +import { setupDataSources } from '../../../../testSetup/datasources'; import { RECEIVER_META_KEY } from '../../../contact-points/useContactPoints'; import { ContactPointWithMetadata } from '../../../contact-points/utils'; import { ExpressionEditorProps } from '../../ExpressionEditor'; jest.mock('app/features/alerting/unified/components/rule-editor/ExpressionEditor', () => ({ - // eslint-disable-next-line react/display-name ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( onChange(e.target.value)} /> ), })); -jest.mock('app/features/alerting/unified/api/buildInfo'); -jest.mock('app/features/alerting/unified/api/ruler'); jest.mock('app/features/manage-dashboards/state/actions'); jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ @@ -60,29 +49,13 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ // 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', () => ({ - // eslint-disable-next-line react/display-name QueryEditorRow: () =>

hi

, })); -// simplified routing mocks -const grafanaAlertManagerDataSource: AlertManagerDataSource = { - name: GRAFANA_RULES_SOURCE_NAME, - imgUrl: 'public/img/grafana_icon.svg', - hasConfigurationAPI: true, -}; -jest.mock('app/features/alerting/unified/utils/datasource', () => { - return { - ...jest.requireActual('app/features/alerting/unified/utils/datasource'), - getAlertManagerDataSourcesByPermission: jest.fn(), - useGetAlertManagerDataSourcesByPermissionAndConfig: jest.fn(), - getAlertmanagerDataSourceByName: jest.fn(), - }; -}); - const user = userEvent.setup(); -jest.spyOn(utils_config, 'getAllDataSources'); -jest.spyOn(dsByPermission, 'useAlertManagersByPermission'); +// jest.spyOn(utils_config, 'getAllDataSources'); +// jest.spyOn(dsByPermission, 'useAlertManagersByPermission'); jest.spyOn(useContactPoints, 'useContactPointsWithStatus'); jest.setTimeout(60 * 1000); @@ -92,14 +65,8 @@ const mocks = { searchFolders: jest.mocked(searchFolders), useContactPointsWithStatus: jest.mocked(useContactPoints.useContactPointsWithStatus), useGetAlertManagerDataSourcesByPermissionAndConfig: jest.mocked(useGetAlertManagerDataSourcesByPermissionAndConfig), - getAlertManagerDataSourcesByPermission: jest.mocked(getAlertManagerDataSourcesByPermission), 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), + setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'), }, }; @@ -125,17 +92,6 @@ describe('Can create a new grafana managed alert unsing simplified routing', () AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite, ]); - mocks.getAlertManagerDataSourcesByPermission.mockReturnValue({ - availableInternalDataSources: [grafanaAlertManagerDataSource], - availableExternalDataSources: [], - }); - - mocks.useGetAlertManagerDataSourcesByPermissionAndConfig.mockReturnValue([grafanaAlertManagerDataSource]); - - jest.mocked(dsByPermission.useAlertManagersByPermission).mockReturnValue({ - availableInternalDataSources: [grafanaAlertManagerDataSource], - availableExternalDataSources: [], - }); }); const dataSources = { @@ -152,6 +108,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', () type: DataSourceType.Alertmanager, }), }; + setupDataSources(dataSources.default, dataSources.am); it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => { // no contact points found @@ -162,64 +119,11 @@ describe('Can create a new grafana managed alert unsing simplified routing', () refetchReceivers: jest.fn(), }); - 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({ - 'Folder A': [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid: '23', - namespace_uid: 'abcd', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - namespace2: [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid: '23', - namespace_uid: 'b', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - }); mocks.searchFolders.mockResolvedValue([ { title: 'Folder A', - uid: 'abcd', + uid: grafanaRulerRule.grafana_alert.namespace_uid, id: 1, type: DashboardSearchItemType.DashDB, }, @@ -235,12 +139,6 @@ describe('Can create a new grafana managed alert unsing simplified routing', () }, ] as DashboardSearchHit[]); - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Prometheus, - features: { - rulerApiEnabled: false, - }, - }); config.featureToggles.alertingSimplifiedRouting = true; renderSimplifiedRuleEditor(); await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); @@ -251,7 +149,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', () await clickSelectOption(folderInput, 'Folder A'); const groupInput = await ui.inputs.group.find(); await user.click(byRole('combobox').get(groupInput)); - await clickSelectOption(groupInput, 'group1'); + await clickSelectOption(groupInput, grafanaRulerRule.grafana_alert.rule_group); //select contact point routing await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); // do not select a contact point @@ -289,64 +187,11 @@ describe('Can create a new grafana managed alert unsing simplified routing', () refetchReceivers: jest.fn(), }); - 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({ - 'Folder A': [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid: '23', - namespace_uid: 'abcd', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - namespace2: [ - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '1m', - grafana_alert: { - uid: '23', - namespace_uid: 'b', - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - }); mocks.searchFolders.mockResolvedValue([ { title: 'Folder A', - uid: 'abcd', + uid: grafanaRulerNamespace2.uid, id: 1, type: DashboardSearchItemType.DashDB, }, @@ -364,12 +209,6 @@ describe('Can create a new grafana managed alert unsing simplified routing', () }, ] as DashboardSearchHit[]); - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Prometheus, - features: { - rulerApiEnabled: false, - }, - }); config.featureToggles.alertingSimplifiedRouting = true; renderSimplifiedRuleEditor(); await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); @@ -380,7 +219,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', () await clickSelectOption(folderInput, 'Folder A'); const groupInput = await ui.inputs.group.find(); await user.click(byRole('combobox').get(groupInput)); - await clickSelectOption(groupInput, 'group1'); + await clickSelectOption(groupInput, grafanaRulerEmptyGroup.name); //select contact point routing await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); @@ -392,10 +231,10 @@ describe('Can create a new grafana managed alert unsing simplified routing', () await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'abcd', + grafanaRulerNamespace2.uid, { - interval: '1m', - name: 'group1', + interval: grafanaRulerEmptyGroup.interval, + name: grafanaRulerEmptyGroup.name, rules: [ { annotations: {}, diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 1cc6153512c..db772c4ff33 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -1,24 +1,24 @@ +import { within } from '@testing-library/react'; import React from 'react'; import { render, waitFor, screen, userEvent } from 'test/test-utils'; import { byText, byRole } from 'testing-library-selector'; -import { setBackendSrv, setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime'; +import { setBackendSrv, setPluginExtensionsHook } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; -import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules'; import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { - MockDataSourceSrv, getCloudRule, getGrafanaRule, grantUserPermissions, mockDataSource, mockPluginLinkExtension, } from '../../mocks'; +import { grafanaRulerRule } from '../../mocks/alertRuleApi'; import { setupDataSources } from '../../testSetup/datasources'; import { Annotation } from '../../utils/constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -104,7 +104,7 @@ describe('RuleViewer', () => { totals: { alerting: 1 }, }, }, - { uid: 'test1' } + { uid: grafanaRulerRule.grafana_alert.uid } ); const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule); @@ -114,6 +114,7 @@ describe('RuleViewer', () => { AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstanceCreate, ]); setBackendSrv(backendSrv); @@ -181,7 +182,6 @@ describe('RuleViewer', () => { }), }; setupDataSources(dataSources.grafana, dataSources.am); - setDataSourceSrv(new MockDataSourceSrv(dataSources)); await renderRuleViewer(mockRule, mockRuleIdentifier); @@ -189,7 +189,10 @@ describe('RuleViewer', () => { await user.click(ELEMENTS.actions.more.button.get()); await user.click(ELEMENTS.actions.more.actions.silence.get()); - expect(await screen.findByLabelText(/^alert rule/i)).toHaveValue(MOCK_GRAFANA_ALERT_RULE_TITLE); + const silenceDrawer = await screen.findByRole('dialog', { name: 'Drawer title Silence alert rule' }); + expect(await within(silenceDrawer).findByLabelText(/^alert rule/i)).toHaveValue( + grafanaRulerRule.grafana_alert.title + ); }); }); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 62c1d5da811..705ee589e62 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -2,7 +2,14 @@ import { useEffect, useMemo } from 'react'; import { useAsync } from 'react-use'; import { useDispatch } from 'app/types'; -import { CombinedRule, RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting'; +import { + CombinedRule, + RuleIdentifier, + RuleNamespace, + RulerDataSourceConfig, + RulesSource, + RuleWithLocation, +} from 'app/types/unified-alerting'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../api/alertRuleApi'; @@ -18,11 +25,7 @@ import { isRulerNotSupportedResponse, } from '../utils/rules'; -import { - attachRulerRulesToCombinedRules, - combineRulesNamespaces, - useCombinedRuleNamespaces, -} from './useCombinedRuleNamespaces'; +import { attachRulerRulesToCombinedRules, useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; export function useCombinedRulesMatching( @@ -162,15 +165,25 @@ function getRequestState( return state; } -export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): { +interface RequestState { + result?: T; loading: boolean; - result?: CombinedRule; error?: unknown; -} { +} + +// Many places still use the old way of fetching code so synchronizing cache expiration is difficult +// Hence, this hook fetches a fresh version of a rule most of the time +// Due to enabled filtering for Prometheus and Ruler rules it shouldn't be a problem +export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): RequestState { const { ruleSourceName } = ruleIdentifier; - const dsSettings = getDataSourceByName(ruleSourceName); + const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier); const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName); + const { + loading: isLoadingRuleLocation, + error: ruleLocationError, + result: ruleLocation, + } = useRuleLocation(ruleIdentifier); const { currentData: promRuleNs, @@ -178,85 +191,47 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti error: promRuleNsError, } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery( { - // TODO Refactor parameters ruleSourceName: ruleIdentifier.ruleSourceName, - namespace: - isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier) - ? ruleIdentifier.namespace - : undefined, - groupName: - isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier) - ? ruleIdentifier.groupName - : undefined, - ruleName: - isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier) - ? ruleIdentifier.ruleName - : undefined, + namespace: ruleLocation?.namespace, + groupName: ruleLocation?.group, + ruleName: ruleLocation?.ruleName, + }, + { + skip: !ruleLocation || isLoadingRuleLocation, + refetchOnMountOrArgChange: true, } - // TODO – experiment with enabling these now that we request a single alert rule more efficiently. - // Requires a recent version of Prometheus with support for query params on /api/v1/rules - // { - // refetchOnFocus: true, - // refetchOnReconnect: true, - // } ); const [ fetchRulerRuleGroup, - { currentData: rulerRuleGroup, isLoading: isLoadingRulerGroup, error: rulerRuleGroupError }, + { + currentData: rulerRuleGroup, + isLoading: isLoadingRulerGroup, + error: rulerRuleGroupError, + isUninitialized: rulerRuleGroupUninitialized, + }, ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); - const [fetchRulerRules, { currentData: rulerRules, isLoading: isLoadingRulerRules, error: rulerRulesError }] = - alertRuleApi.endpoints.rulerRules.useLazyQuery(); - useEffect(() => { - if (!dsFeatures?.rulerConfig) { + if (!dsFeatures?.rulerConfig || !ruleLocation) { return; } - if (dsFeatures.rulerConfig && isCloudRuleIdentifier(ruleIdentifier)) { - fetchRulerRuleGroup({ - rulerConfig: dsFeatures.rulerConfig, - namespace: ruleIdentifier.namespace, - group: ruleIdentifier.groupName, - }); - } else if (isGrafanaRuleIdentifier(ruleIdentifier)) { - // TODO Fetch a single group for Grafana managed rules, we're currently still fetching all rules for Grafana managed - fetchRulerRules({ rulerConfig: dsFeatures.rulerConfig }); - } - }, [dsFeatures, fetchRulerRuleGroup, fetchRulerRules, ruleIdentifier]); + fetchRulerRuleGroup({ + rulerConfig: dsFeatures.rulerConfig, + namespace: ruleLocation.namespace, + group: ruleLocation.group, + }); + }, [dsFeatures, fetchRulerRuleGroup, ruleLocation]); const rule = useMemo(() => { - if (!promRuleNs) { + if (!promRuleNs || !ruleSource) { return; } - if (isGrafanaRuleIdentifier(ruleIdentifier)) { - const combinedNamespaces = combineRulesNamespaces('grafana', promRuleNs, rulerRules); - - for (const namespace of combinedNamespaces) { - for (const group of namespace.groups) { - for (const rule of group.rules) { - const id = ruleId.fromCombinedRule(ruleSourceName, rule); - - if (ruleId.equal(id, ruleIdentifier)) { - return rule; - } - } - } - } - } - - if (!dsSettings) { - return; - } - - if ( - promRuleNs.length > 0 && - (isCloudRuleIdentifier(ruleIdentifier) || isPrometheusRuleIdentifier(ruleIdentifier)) - ) { + if (promRuleNs.length > 0) { const namespaces = promRuleNs.map((ns) => - attachRulerRulesToCombinedRules(dsSettings, ns, rulerRuleGroup ? [rulerRuleGroup] : []) + attachRulerRulesToCombinedRules(ruleSource, ns, rulerRuleGroup ? [rulerRuleGroup] : []) ); for (const namespace of namespaces) { @@ -273,15 +248,147 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti } return; - }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, rulerRules, dsSettings]); + }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource]); return { - loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || isLoadingRulerRules, - error: promRuleNsError ?? rulerRuleGroupError ?? rulerRulesError, + loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || rulerRuleGroupUninitialized, + error: ruleLocationError ?? promRuleNsError ?? rulerRuleGroupError, result: rule, }; } +interface RuleLocation { + namespace: string; + group: string; + ruleName: string; +} + +function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState { + const { isLoading, currentData, error, isUninitialized } = alertRuleApi.endpoints.getAlertRule.useQuery( + { uid: isGrafanaRuleIdentifier(ruleIdentifier) ? ruleIdentifier.uid : '' }, + { skip: !isGrafanaRuleIdentifier(ruleIdentifier), refetchOnMountOrArgChange: true } + ); + + return useMemo(() => { + if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) { + return { + result: { + namespace: ruleIdentifier.namespace, + group: ruleIdentifier.groupName, + ruleName: ruleIdentifier.ruleName, + }, + loading: false, + }; + } + + if (isGrafanaRuleIdentifier(ruleIdentifier)) { + if (isLoading || isUninitialized) { + return { loading: true }; + } + + if (error) { + return { loading: false, error }; + } + if (currentData) { + return { + result: { + namespace: currentData.grafana_alert.namespace_uid, + group: currentData.grafana_alert.rule_group, + ruleName: currentData.grafana_alert.title, + }, + loading: false, + }; + } + + // In theory, this should never happen + return { + loading: false, + error: new Error(`Unable to obtain rule location for rule ${ruleIdentifier.uid}`), + }; + } + + return { + loading: false, + error: new Error('Unsupported rule identifier'), + }; + }, [ruleIdentifier, isLoading, isUninitialized, error, currentData]); +} + +function getRulesSourceFromIdentifier(ruleIdentifier: RuleIdentifier): RulesSource | undefined { + if (isGrafanaRuleIdentifier(ruleIdentifier)) { + return 'grafana'; + } + + return getDataSourceByName(ruleIdentifier.ruleSourceName); +} + +// This Hook fetches rule definition from the Ruler API only +export function useRuleWithLocation({ + ruleIdentifier, +}: { + ruleIdentifier: RuleIdentifier; +}): RequestState { + const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier); + + const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleIdentifier.ruleSourceName); + const { + loading: isLoadingRuleLocation, + error: ruleLocationError, + result: ruleLocation, + } = useRuleLocation(ruleIdentifier); + + const [ + fetchRulerRuleGroup, + { + currentData: rulerRuleGroup, + isLoading: isLoadingRulerGroup, + isUninitialized: isUninitializedRulerGroup, + error: rulerRuleGroupError, + }, + ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + + useEffect(() => { + if (!dsFeatures?.rulerConfig || !ruleLocation) { + return; + } + + fetchRulerRuleGroup({ + rulerConfig: dsFeatures.rulerConfig, + namespace: ruleLocation.namespace, + group: ruleLocation.group, + }); + }, [dsFeatures, fetchRulerRuleGroup, ruleLocation]); + + const ruleWithLocation = useMemo(() => { + const { ruleSourceName } = ruleIdentifier; + if (!rulerRuleGroup || !ruleSource || !ruleLocation) { + return; + } + + const rule = rulerRuleGroup.rules.find((rule) => { + const id = ruleId.fromRulerRule(ruleSourceName, ruleLocation.namespace, ruleLocation.group, rule); + return ruleId.equal(id, ruleIdentifier); + }); + + if (!rule) { + return; + } + + return { + ruleSourceName: ruleSourceName, + group: rulerRuleGroup, + namespace: ruleLocation.namespace, + rule: rule, + }; + }, [ruleIdentifier, rulerRuleGroup, ruleSource, ruleLocation]); + + return { + loading: isLoadingRuleLocation || isLoadingDsFeatures || isLoadingRulerGroup || isUninitializedRulerGroup, + error: ruleLocationError ?? rulerRuleGroupError, + result: ruleWithLocation, + }; +} + export const grafanaRulerConfig: RulerDataSourceConfig = { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy', diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 985edb8f45d..30728ef3762 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -117,6 +117,7 @@ export const mockRulerGrafanaRule = ( uid: '123', title: 'myalert', namespace_uid: '123', + rule_group: 'my-group', condition: 'A', no_data_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting, @@ -214,6 +215,7 @@ export const mockGrafanaRulerRule = (partial: Partial = { uid: '', title: 'my rule', namespace_uid: 'NAMESPACE_UID', + rule_group: 'my-group', condition: '', no_data_state: GrafanaAlertStateDecision.NoData, exec_err_state: GrafanaAlertStateDecision.Error, diff --git a/public/app/features/alerting/unified/mocks/alertRuleApi.ts b/public/app/features/alerting/unified/mocks/alertRuleApi.ts index 6f8d7858bd6..26e393b8463 100644 --- a/public/app/features/alerting/unified/mocks/alertRuleApi.ts +++ b/public/app/features/alerting/unified/mocks/alertRuleApi.ts @@ -1,9 +1,15 @@ import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; -import { PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { + GrafanaAlertStateDecision, + PromRulesResponse, + RulerGrafanaRuleDTO, + RulerRuleGroupDTO, +} from 'app/types/unified-alerting-dto'; import { PreviewResponse, PREVIEW_URL, PROM_RULES_URL } from '../api/alertRuleApi'; +import { Annotation } from '../utils/constants'; export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) { server.use(http.post(PREVIEW_URL, () => HttpResponse.json(result))); @@ -12,3 +18,65 @@ export function mockPreviewApiResponse(server: SetupServer, result: PreviewRespo export function mockPromRulesApiResponse(server: SetupServer, result: PromRulesResponse) { server.use(http.get(PROM_RULES_URL, () => HttpResponse.json(result))); } + +const grafanaRulerGroupName = 'grafana-group-1'; +export const grafanaRulerNamespace = { name: 'test-folder-1', uid: 'uuid020c61ef' }; +export const grafanaRulerNamespace2 = { name: 'test-folder-2', uid: '6abdb25bc1eb' }; + +export const grafanaRulerRule: RulerGrafanaRuleDTO = { + for: '5m', + labels: { + severity: 'critical', + region: 'nasa', + }, + annotations: { + [Annotation.summary]: 'Test alert', + }, + grafana_alert: { + uid: '4d7125fee983', + title: 'Grafana-rule', + namespace_uid: 'uuid020c61ef', + rule_group: grafanaRulerGroupName, + data: [ + { + refId: 'A', + datasourceUid: 'datasource-uid', + queryType: 'alerting', + relativeTimeRange: { from: 1000, to: 2000 }, + model: { + refId: 'A', + expression: 'vector(1)', + queryType: 'alerting', + datasource: { uid: 'datasource-uid', type: 'prometheus' }, + }, + }, + ], + condition: 'A', + no_data_state: GrafanaAlertStateDecision.NoData, + exec_err_state: GrafanaAlertStateDecision.Error, + is_paused: false, + notification_settings: undefined, + }, +}; + +export const grafanaRulerGroup: RulerRuleGroupDTO = { + name: grafanaRulerGroupName, + interval: '1m', + rules: [grafanaRulerRule], +}; + +export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = { + name: 'empty-group', + interval: '1m', + rules: [], +}; + +export const namespaceByUid: Record = { + [grafanaRulerNamespace.uid]: grafanaRulerNamespace, + [grafanaRulerNamespace2.uid]: grafanaRulerNamespace2, +}; + +export const namespaces: Record = { + [grafanaRulerNamespace.uid]: [grafanaRulerGroup], + [grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup], +}; diff --git a/public/app/features/alerting/unified/mocks/server/all-handlers.ts b/public/app/features/alerting/unified/mocks/server/all-handlers.ts index 908f4d77acc..8aa4831116d 100644 --- a/public/app/features/alerting/unified/mocks/server/all-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/all-handlers.ts @@ -21,6 +21,7 @@ const allHandlers = [ ...folderHandlers, ...pluginsHandlers, ...silenceHandlers, + ...alertRuleHandlers, ]; export default allHandlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/alertRule.ts b/public/app/features/alerting/unified/mocks/server/handlers/alertRule.ts new file mode 100644 index 00000000000..bfa2afd5536 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/alertRule.ts @@ -0,0 +1,75 @@ +import { http, HttpResponse } from 'msw'; + +import { + RulerGrafanaRuleDTO, + RulerRuleGroupDTO, + RulerRulesConfigDTO, +} from '../../../../../../types/unified-alerting-dto'; +import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi'; + +export const rulerRulesHandler = () => { + return http.get(`/api/ruler/grafana/api/v1/rules`, () => { + const response = Object.entries(namespaces).reduce((acc, [namespaceUid, groups]) => { + acc[namespaceByUid[namespaceUid].name] = groups; + return acc; + }, {}); + + return HttpResponse.json(response); + }); +}; + +export const rulerRuleNamespaceHandler = () => { + return http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => { + // This mimic API response as closely as possible - Invalid folderUid returns 403 + const namespace = namespaces[folderUid]; + if (!namespace) { + return new HttpResponse(null, { status: 403 }); + } + + return HttpResponse.json({ + [namespaceByUid[folderUid].name]: namespaces[folderUid], + }); + }); +}; + +export const rulerRuleGroupHandler = () => { + return http.get<{ folderUid: string; groupName: string }>( + `/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`, + ({ params: { folderUid, groupName } }) => { + // This mimic API response as closely as possible. + // Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules + const namespace = namespaces[folderUid]; + if (!namespace) { + return new HttpResponse(null, { status: 403 }); + } + + const matchingGroup = namespace.find((group) => group.name === groupName); + return HttpResponse.json({ + name: groupName, + interval: matchingGroup?.interval, + rules: matchingGroup?.rules ?? [], + }); + } + ); +}; + +export const getAlertRuleHandler = () => { + const grafanaRules = new Map( + [grafanaRulerRule].map((rule) => [rule.grafana_alert.uid, rule]) + ); + + return http.get<{ uid: string }>(`/api/ruler/grafana/api/v1/rule/:uid`, ({ params: { uid } }) => { + const rule = grafanaRules.get(uid); + if (!rule) { + return new HttpResponse(null, { status: 404 }); + } + return HttpResponse.json(rule); + }); +}; + +export const alertRuleHandlers = [ + rulerRulesHandler(), + rulerRuleNamespaceHandler(), + rulerRuleGroupHandler(), + getAlertRuleHandler(), +]; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts b/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts index 970e0ff184f..2b46d9e578e 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts @@ -2,15 +2,72 @@ import { http, HttpResponse } from 'msw'; export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert'; -const alertRuleDetailsHandler = () => - http.get<{ folderUid: string }>(`/api/ruler/:ruler/api/v1/rule/:uid`, () => { - // TODO: Scaffold out alert rule response logic as this endpoint is used more in tests - return HttpResponse.json({ - grafana_alert: { - title: MOCK_GRAFANA_ALERT_RULE_TITLE, - }, +import { + RulerGrafanaRuleDTO, + RulerRuleGroupDTO, + RulerRulesConfigDTO, +} from '../../../../../../types/unified-alerting-dto'; +import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi'; + +export const rulerRulesHandler = () => { + return http.get(`/api/ruler/grafana/api/v1/rules`, () => { + const response = Object.entries(namespaces).reduce((acc, [namespaceUid, groups]) => { + acc[namespaceByUid[namespaceUid].name] = groups; + return acc; + }, {}); + + return HttpResponse.json(response); + }); +}; + +export const rulerRuleNamespaceHandler = () => { + return http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => { + // This mimic API response as closely as possible - Invalid folderUid returns 403 + const namespace = namespaces[folderUid]; + if (!namespace) { + return new HttpResponse(null, { status: 403 }); + } + + return HttpResponse.json({ + [namespaceByUid[folderUid].name]: namespaces[folderUid], }); }); +}; -const handlers = [alertRuleDetailsHandler()]; +export const rulerRuleGroupHandler = () => { + return http.get<{ folderUid: string; groupName: string }>( + `/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`, + ({ params: { folderUid, groupName } }) => { + // This mimic API response as closely as possible. + // Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules + const namespace = namespaces[folderUid]; + if (!namespace) { + return new HttpResponse(null, { status: 403 }); + } + + const matchingGroup = namespace.find((group) => group.name === groupName); + return HttpResponse.json({ + name: groupName, + interval: matchingGroup?.interval, + rules: matchingGroup?.rules ?? [], + }); + } + ); +}; + +export const rulerRuleHandler = () => { + const grafanaRules = new Map( + [grafanaRulerRule].map((rule) => [rule.grafana_alert.uid, rule]) + ); + + return http.get<{ uid: string }>(`/api/ruler/grafana/api/v1/rule/:uid`, ({ params: { uid } }) => { + const rule = grafanaRules.get(uid); + if (!rule) { + return new HttpResponse(null, { status: 404 }); + } + return HttpResponse.json(rule); + }); +}; + +const handlers = [rulerRulesHandler(), rulerRuleNamespaceHandler(), rulerRuleGroupHandler(), rulerRuleHandler()]; export default handlers; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 394bc225dec..5724e3e1341 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -17,8 +17,8 @@ import { PromBasedDataSource, RuleIdentifier, RuleNamespace, - RulerDataSourceConfig, RuleWithLocation, + RulerDataSourceConfig, StateHistoryItem, } from 'app/types/unified-alerting'; import { @@ -30,15 +30,16 @@ import { import { backendSrv } from '../../../../core/services/backend_srv'; import { + LogMessages, logError, logInfo, - LogMessages, trackSwitchToPoliciesRouting, trackSwitchToSimplifiedRouting, withPerformanceLogging, withPromRulesMetadataLogging, withRulerRulesMetadataLogging, } from '../Analytics'; +import { alertRuleApi } from '../api/alertRuleApi'; import { deleteAlertManagerConfig, fetchAlertGroups, @@ -51,20 +52,20 @@ import { discoverFeatures } from '../api/buildInfo'; import { fetchNotifiers } from '../api/grafana'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { + FetchRulerRulesFilter, deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, - FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; import { encodeGrafanaNamespace } from '../components/expressions/util'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { + GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames, getRulesDataSource, getRulesSourceName, - GRAFANA_RULES_SOURCE_NAME, } from '../utils/datasource'; import { makeAMLink } from '../utils/misc'; import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; @@ -316,14 +317,6 @@ export function fetchAllPromRulesAction(force = false): ThunkResult { }; } -export const fetchEditableRuleAction = createAsyncThunk( - 'unifiedalerting/fetchEditableRule', - (ruleIdentifier: RuleIdentifier, thunkAPI): Promise => { - const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, ruleIdentifier.ruleSourceName); - return withSerializedError(getRulerClient(rulerConfig).findEditableRule(ruleIdentifier)); - } -); - export function deleteRulesGroupAction( namespace: CombinedRuleNamespace, ruleGroup: CombinedRuleGroup @@ -403,6 +396,7 @@ export const saveRuleFormAction = createAsyncThunk( // For the dataSourceName specified // in case of system (cortex/loki) let identifier: RuleIdentifier; + if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) { if (!values.dataSourceName) { throw new Error('The Data source has not been defined.'); @@ -412,14 +406,14 @@ export const saveRuleFormAction = createAsyncThunk( const rulerClient = getRulerClient(rulerConfig); identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing); await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName })); - // in case of grafana managed } else if (type === RuleFormType.grafana) { const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); const rulerClient = getRulerClient(rulerConfig); identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing); reportSwitchingRoutingType(values, existing); - await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); + // when using a Granfa-managed alert rule we can invalidate a single rule + thunkAPI.dispatch(alertRuleApi.util.invalidateTags([{ type: 'GrafanaRulerRule', id: identifier.uid }])); } else { throw new Error('Unexpected rule form type'); } @@ -439,9 +433,6 @@ export const saveRuleFormAction = createAsyncThunk( const newLocation = `/alerting/${encodeURIComponent(stringifiedIdentifier)}/edit`; if (locationService.getLocation().pathname !== newLocation) { locationService.replace(newLocation); - } else { - // refresh the details of the current editable rule after saving - thunkAPI.dispatch(fetchEditableRuleAction(identifier)); } } })() diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 47d08ec1f1b..36e1daa647f 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -5,7 +5,6 @@ import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux'; import { deleteAlertManagerConfigAction, fetchAlertGroupsAction, - fetchEditableRuleAction, fetchFolderAction, fetchGrafanaAnnotationsAction, fetchGrafanaNotifiersAction, @@ -29,7 +28,6 @@ export const reducer = combineReducers({ .reducer, ruleForm: combineReducers({ saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer, - existingRule: createAsyncSlice('existingRule', fetchEditableRuleAction).reducer, }), grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer, saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer, diff --git a/public/app/features/alerting/unified/utils/query.test.ts b/public/app/features/alerting/unified/utils/query.test.ts index 6632f291f6c..9b02aac176b 100644 --- a/public/app/features/alerting/unified/utils/query.test.ts +++ b/public/app/features/alerting/unified/utils/query.test.ts @@ -89,6 +89,7 @@ const grafanaAlert = { condition: 'B', exec_err_state: GrafanaAlertStateDecision.Alerting, namespace_uid: 'namespaceuid123', + rule_group: 'my-group', no_data_state: GrafanaAlertStateDecision.NoData, title: 'Test alert', uid: 'asdf23', diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index 164720b2f4a..72a708d8581 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -97,6 +97,7 @@ describe('getContactPointsFromDTO', () => { uid: '123', title: 'myalert', namespace_uid: '123', + rule_group: 'my-group', condition: 'A', no_data_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting, @@ -120,6 +121,7 @@ describe('getContactPointsFromDTO', () => { uid: '123', title: 'myalert', namespace_uid: '123', + rule_group: 'my-group', condition: 'A', no_data_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting, diff --git a/public/app/features/alerting/unified/utils/rule-id.test.ts b/public/app/features/alerting/unified/utils/rule-id.test.ts index 508199de2a2..854f093bd5c 100644 --- a/public/app/features/alerting/unified/utils/rule-id.test.ts +++ b/public/app/features/alerting/unified/utils/rule-id.test.ts @@ -46,6 +46,7 @@ describe('hashRulerRule', () => { const grafanaAlertDefinition: GrafanaRuleDefinition = { uid: RULE_UID, namespace_uid: 'namespace', + rule_group: 'my-group', title: 'my rule', condition: '', data: [], diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts index 1e6405ef0c2..1ea7804223f 100644 --- a/public/app/features/alerting/unified/utils/rulerClient.ts +++ b/public/app/features/alerting/unified/utils/rulerClient.ts @@ -1,4 +1,9 @@ -import { RuleIdentifier, RulerDataSourceConfig, RuleWithLocation } from 'app/types/unified-alerting'; +import { + GrafanaRuleIdentifier, + RuleIdentifier, + RulerDataSourceConfig, + RuleWithLocation, +} from 'app/types/unified-alerting'; import { PostableRuleGrafanaRuleDTO, PostableRulerRuleGroupDTO, @@ -21,9 +26,16 @@ import { export interface RulerClient { findEditableRule(ruleIdentifier: RuleIdentifier): Promise; + deleteRule(ruleWithLocation: RuleWithLocation): Promise; + saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise; - saveGrafanaRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise; + + saveGrafanaRule( + values: RuleFormValues, + evaluateEvery: string, + existing?: RuleWithLocation + ): Promise; } export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient { @@ -153,7 +165,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient values: RuleFormValues, evaluateEvery: string, existingRule?: RuleWithLocation - ): Promise => { + ): Promise => { const { folder, group } = values; if (!folder) { throw new Error('Folder must be specified'); @@ -190,7 +202,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient namespaceUID: string, group: { name: string; interval: string }, newRule: PostableRuleGrafanaRuleDTO - ): Promise => { + ): Promise => { const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name); if (!existingGroup) { throw new Error(`No group found with name "${group.name}"`); @@ -213,7 +225,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient group: { name: string; interval: string }, existingRule: RuleWithLocation, newRule: PostableRuleGrafanaRuleDTO - ): Promise => { + ): Promise => { // make sure our updated alert has the same UID as before // that way the rule is automatically moved to the new namespace / group name copyGrafanaUID(existingRule, newRule); @@ -228,7 +240,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient existingRule: RuleWithLocation, newRule: PostableRuleGrafanaRuleDTO, interval: string - ): Promise => { + ): Promise => { // make sure our updated alert has the same UID as before copyGrafanaUID(existingRule, newRule); diff --git a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts index ab84c47a59f..e1eec38e844 100644 --- a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts @@ -44,6 +44,7 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, see ], uid: random.guid(), namespace_uid: folderUid, + rule_group: 'my-group', no_data_state: GrafanaAlertStateDecision.NoData, exec_err_state: GrafanaAlertStateDecision.Error, is_paused: false, diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 5094b9daa06..b226871d30b 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -221,6 +221,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition { id?: string; uid: string; namespace_uid: string; + rule_group: string; provenance?: string; } diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index a381b21e546..88a3ea75581 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -139,9 +139,9 @@ export interface CombinedRuleNamespace { export interface RuleWithLocation { ruleSourceName: string; namespace: string; + namespace_uid?: string; // Grafana folder UID group: RulerRuleGroupDTO; rule: T; - namespace_uid?: string; } export interface CombinedRuleWithLocation extends CombinedRule { diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index feca69b1f62..272e3fb4868 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -10,6 +10,7 @@ import RuleEditor from 'app/features/alerting/unified/RuleEditor'; import { TestProvider } from './TestProvider'; export const ui = { + loadingIndicator: byText('Loading rule...'), inputs: { name: byRole('textbox', { name: 'name' }), alertType: byTestId('alert-type-picker'),