Alerting: Use new endpoints to fetch single GMA rule on view and edit pages (#87625)

This commit is contained in:
Konrad Lalik 2024-05-30 12:55:06 +02:00 committed by GitHub
parent b07b6771e8
commit 93870c1cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 668 additions and 708 deletions

View File

@ -1,17 +1,16 @@
import { render, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; import { render, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import { setupServer } from 'msw/node';
import React from 'react'; import React from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { TestProvider } from 'test/helpers/TestProvider'; import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byTestId, byText } from 'testing-library-selector'; import { byRole, byTestId, byText } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors/src'; import { selectors } from '@grafana/e2e-selectors/src';
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; import { setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types'; import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { RuleWithLocation } from 'app/types/unified-alerting'; import { RuleWithLocation } from 'app/types/unified-alerting';
import { AccessControlAction } from '../../../types';
import { import {
RulerAlertingRuleDTO, RulerAlertingRuleDTO,
RulerGrafanaRuleDTO, RulerGrafanaRuleDTO,
@ -21,19 +20,21 @@ import {
import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor'; import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockApi, mockSearchApi } from './mockApi'; import { mockFeatureDiscoveryApi, mockSearchApi, setupMswServer } from './mockApi';
import { import {
labelsPluginMetaMock, grantUserPermissions,
mockDataSource, mockDataSource,
MockDataSourceSrv, MockDataSourceSrv,
mockRulerAlertingRule, mockRulerAlertingRule,
mockRulerGrafanaRule, mockRulerGrafanaRule,
mockRulerRuleGroup, mockRulerRuleGroup,
mockStore,
} from './mocks'; } from './mocks';
import { grafanaRulerRule } from './mocks/alertRuleApi';
import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi'; import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi';
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi'; import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
import { AlertingQueryRunner } from './state/AlertingQueryRunner'; import { AlertingQueryRunner } from './state/AlertingQueryRunner';
import { setupDataSources } from './testSetup/datasources';
import { buildInfoResponse } from './testSetup/featureDiscovery';
import { RuleFormValues } from './types/rule-form'; import { RuleFormValues } from './types/rule-form';
import { Annotation } from './utils/constants'; import { Annotation } from './utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; 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()); jest.spyOn(AlertingQueryRunner.prototype, 'run').mockImplementation(() => Promise.resolve());
const server = setupServer(); const server = setupMswServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});
beforeEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
const ui = { const ui = {
inputs: { inputs: {
@ -80,46 +68,16 @@ const ui = {
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
labelValue: (idx: number) => byTestId(`label-value-${idx}`), labelValue: (idx: number) => byTestId(`label-value-${idx}`),
}, },
loadingIndicator: byText('Loading the rule'), loadingIndicator: byText('Loading the rule...'),
}; };
function getProvidersWrapper() { function Wrapper({ children }: React.PropsWithChildren<{}>) {
return function Wrapper({ children }: React.PropsWithChildren<{}>) { const formApi = useForm<RuleFormValues>({ defaultValues: getDefaultFormValues() });
const store = mockStore((store) => { return (
store.unifiedAlerting.dataSources['grafana'] = { <TestProvider>
loading: false, <FormProvider {...formApi}>{children}</FormProvider>
dispatched: true, </TestProvider>
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<RuleFormValues>({ defaultValues: getDefaultFormValues() });
return (
<TestProvider store={store}>
<FormProvider {...formApi}>{children}</FormProvider>
</TestProvider>
);
};
} }
const amConfig: AlertManagerCortexConfig = { const amConfig: AlertManagerCortexConfig = {
@ -139,33 +97,26 @@ const amConfig: AlertManagerCortexConfig = {
template_files: {}, template_files: {},
}; };
mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false });
describe('CloneRuleEditor', function () { describe('CloneRuleEditor', function () {
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
describe('Grafana-managed rules', function () { describe('Grafana-managed rules', function () {
it('should populate form values from the existing alert rule', async function () { it('should populate form values from the existing alert rule', async function () {
setDataSourceSrv(new MockDataSourceSrv({})); 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([ 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); mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, { render(
wrapper: getProvidersWrapper(), <CloneRuleEditor sourceRuleId={{ uid: grafanaRulerRule.grafana_alert.uid, ruleSourceName: 'grafana' }} />,
}); { wrapper: Wrapper }
);
await waitForElementToBeRemoved(ui.loadingIndicator.query()); await waitForElementToBeRemoved(ui.loadingIndicator.query());
await waitFor(() => { await waitFor(() => {
@ -173,9 +124,9 @@ describe('CloneRuleEditor', function () {
}); });
await waitFor(() => { 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.folderContainer.get()).toHaveTextContent('folder-one');
expect(ui.inputs.group.get()).toHaveTextContent('group1'); expect(ui.inputs.group.get()).toHaveTextContent(grafanaRulerRule.grafana_alert.rule_group);
expect( expect(
byRole('listitem', { byRole('listitem', {
name: 'severity: critical', name: 'severity: critical',
@ -186,7 +137,7 @@ describe('CloneRuleEditor', function () {
name: 'region: nasa', name: 'region: nasa',
}).get() }).get()
).toBeInTheDocument(); ).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', name: 'my-prom-ds',
uid: 'my-prom-ds', uid: 'my-prom-ds',
}); });
config.datasources = { setupDataSources(dsSettings);
'my-prom-ds': dsSettings, mockFeatureDiscoveryApi(server).discoverDsFeatures(dsSettings, buildInfoResponse.mimir);
};
setDataSourceSrv(new MockDataSourceSrv({ 'my-prom-ds': dsSettings }));
const originRule = mockRulerAlertingRule({ const originRule = mockRulerAlertingRule({
for: '1m', for: '1m',
alert: 'First Ruler Rule', alert: 'First Ruler Rule',
@ -242,9 +189,7 @@ describe('CloneRuleEditor', function () {
rulerRuleHash: hashRulerRule(originRule), rulerRuleHash: hashRulerRule(originRule),
}} }}
/>, />,
{ { wrapper: Wrapper }
wrapper: getProvidersWrapper(),
}
); );
await waitForElementToBeRemoved(ui.loadingIndicator.query()); await waitForElementToBeRemoved(ui.loadingIndicator.query());

View File

@ -1,32 +1,25 @@
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import React from 'react'; import React from 'react';
import { useAsync } from 'react-use';
import { locationService } from '@grafana/runtime/src'; import { locationService } from '@grafana/runtime/src';
import { Alert, LoadingPlaceholder } from '@grafana/ui/src'; import { Alert, LoadingPlaceholder } from '@grafana/ui/src';
import { useDispatch } from '../../../types';
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting'; import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting';
import { RulerRuleDTO } from '../../../types/unified-alerting-dto'; import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; 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 { generateCopiedName } from './utils/duplicate';
import { stringifyErrorLike } from './utils/misc';
import { rulerRuleToFormValues } from './utils/rule-form'; import { rulerRuleToFormValues } from './utils/rule-form';
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules'; import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules';
import { createUrl } from './utils/url'; import { createUrl } from './utils/url';
export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) { export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) {
const dispatch = useDispatch(); const { loading, result: rule, error } = useRuleWithLocation({ ruleIdentifier: sourceRuleId });
const {
loading,
value: rule,
error,
} = useAsync(() => dispatch(fetchEditableRuleAction(sourceRuleId)).unwrap(), [sourceRuleId]);
if (loading) { if (loading) {
return <LoadingPlaceholder text="Loading the rule" />; return <LoadingPlaceholder text="Loading the rule..." />;
} }
if (rule) { if (rule) {
@ -39,7 +32,7 @@ export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier
if (error) { if (error) {
return ( return (
<Alert title="Error" severity="error"> <Alert title="Error" severity="error">
{error.message} {stringifyErrorLike(error)}
</Alert> </Alert>
); );
} }

View File

@ -1,16 +1,13 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Alert, LoadingPlaceholder } from '@grafana/ui'; 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 { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from './AlertWarning'; import { AlertWarning } from './AlertWarning';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
import { useRuleWithLocation } from './hooks/useCombinedRule';
import { useIsRuleEditable } from './hooks/useIsRuleEditable'; import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { stringifyErrorLike } from './utils/misc';
import { fetchEditableRuleAction } from './state/actions';
import { initialAsyncRequestState } from './utils/redux';
import * as ruleId from './utils/rule-id'; import * as ruleId from './utils/rule-id';
interface ExistingRuleEditorProps { interface ExistingRuleEditorProps {
@ -19,42 +16,31 @@ interface ExistingRuleEditorProps {
} }
export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) { export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) {
useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState));
const { const {
loading: loadingAlertRule, loading: loadingAlertRule,
result, result: ruleWithLocation,
error, error,
dispatched, } = useRuleWithLocation({ ruleIdentifier: identifier });
} = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
const dispatch = useDispatch(); const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
const { isEditable, loading: loadingEditable } = useIsRuleEditable(
ruleId.ruleIdentifierToRuleSourceName(identifier), const { isEditable, loading: loadingEditable } = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule);
result?.rule
);
const loading = loadingAlertRule || loadingEditable; const loading = loadingAlertRule || loadingEditable;
useEffect(() => { if (loading) {
if (!dispatched) {
dispatch(fetchEditableRuleAction(identifier));
}
}, [dispatched, dispatch, identifier]);
if (loading || isEditable === undefined) {
return <LoadingPlaceholder text="Loading rule..." />; return <LoadingPlaceholder text="Loading rule..." />;
} }
if (error) { if (error) {
return ( return (
<Alert severity="error" title="Failed to load rule"> <Alert severity="error" title="Failed to load rule">
{error.message} {stringifyErrorLike(error)}
</Alert> </Alert>
); );
} }
if (!result) { if (!ruleWithLocation) {
return <AlertWarning title="Rule not found">Sorry! This rule does not exist.</AlertWarning>; return <AlertWarning title="Rule not found">Sorry! This rule does not exist.</AlertWarning>;
} }
@ -62,5 +48,5 @@ export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps)
return <AlertWarning title="Cannot edit rule">Sorry! You do not have permission to edit this rule.</AlertWarning>; return <AlertWarning title="Cannot edit rule">Sorry! You do not have permission to edit this rule.</AlertWarning>;
} }
return <AlertRuleForm existing={result} />; return <AlertRuleForm existing={ruleWithLocation} />;
} }

View File

@ -5,27 +5,25 @@ import { Route } from 'react-router-dom';
import { TestProvider } from 'test/helpers/TestProvider'; import { TestProvider } from 'test/helpers/TestProvider';
import { ui } from 'test/helpers/alertingRuleEditor'; 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 { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; 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 { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
import { backendSrv } from '../../../core/services/backend_srv'; import { backendSrv } from '../../../core/services/backend_srv';
import { AccessControlAction } from '../../../types'; import { AccessControlAction } from '../../../types';
import RuleEditor from './RuleEditor'; import RuleEditor from './RuleEditor';
import { discoverFeatures } from './api/buildInfo'; import * as ruler from './api/ruler';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource, mockFolder } from './mocks'; import { setupMswServer } from './mockApi';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import { grantUserPermissions, mockDataSource, mockFolder } from './mocks';
import * as config from './utils/config'; 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 { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} /> <input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
), ),
@ -35,34 +33,25 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>, AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
})); }));
jest.mock('./api/buildInfo');
jest.mock('./api/ruler');
jest.mock('../../../../app/features/manage-dashboards/state/actions'); jest.mock('../../../../app/features/manage-dashboards/state/actions');
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. // there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it // lets just skip it
jest.mock('app/features/query/components/QueryEditorRow', () => ({ jest.mock('app/features/query/components/QueryEditorRow', () => ({
// eslint-disable-next-line react/display-name
QueryEditorRow: () => <p>hi</p>, QueryEditorRow: () => <p>hi</p>,
})); }));
jest.spyOn(config, 'getAllDataSources');
jest.setTimeout(60 * 1000); jest.setTimeout(60 * 1000);
const mocks = { const mocks = {
getAllDataSources: jest.mocked(config.getAllDataSources),
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
}, },
}; };
setupMswServer();
function renderRuleEditor(identifier?: string) { function renderRuleEditor(identifier?: string) {
locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`);
@ -95,10 +84,9 @@ describe('RuleEditor grafana managed rules', () => {
}); });
it('can edit grafana managed rule', async () => { it('can edit grafana managed rule', async () => {
const uid = 'FOOBAR123';
const folder = { const folder = {
title: 'Folder A', title: 'Folder A',
uid: 'abcd', uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1, id: 1,
type: DashboardSearchItemType.DashDB, 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.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.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.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]);
renderRuleEditor(uid); renderRuleEditor(grafanaRulerRule.grafana_alert.uid);
// check that it's filled in // check that it's filled in
const nameInput = await ui.inputs.name.find(); 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 //check that folder is in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some summary'); expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]);
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some description');
//check that slashed folders are not in the list //check that slashed folders are not in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
@ -195,25 +157,15 @@ describe('RuleEditor grafana managed rules', () => {
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'abcd', grafanaRulerRule.grafana_alert.namespace_uid,
{ {
interval: '1m', interval: grafanaRulerGroup.interval,
name: 'group1', name: grafanaRulerGroup.name,
rules: [ rules: [
{ {
annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, ...grafanaRulerRule,
labels: { severity: 'warn', team: 'the a-team' }, annotations: { ...grafanaRulerRule.annotations, custom: 'value' },
for: '1m', grafana_alert: { ...grafanaRulerRule.grafana_alert, namespace_uid: undefined, rule_group: undefined },
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',
},
}, },
], ],
} }

View File

@ -5,33 +5,30 @@ import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byRole } from 'testing-library-selector'; import { byRole } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { GrafanaAlertStateDecision, 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 { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo'; 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 { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks'; import { grantUserPermissions, mockDataSource } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import { grafanaRulerGroup, grafanaRulerRule } from './mocks/alertRuleApi';
import { setupDataSources } from './testSetup/datasources';
import * as config from './utils/config'; import * as config from './utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form'; import { getDefaultQueries } from './utils/rule-form';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} /> <input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
), ),
})); }));
jest.mock('./api/buildInfo');
jest.mock('./api/ruler');
jest.mock('../../../../app/features/manage-dashboards/state/actions'); jest.mock('../../../../app/features/manage-dashboards/state/actions');
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ 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. // there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it // lets just skip it
jest.mock('app/features/query/components/QueryEditorRow', () => ({ jest.mock('app/features/query/components/QueryEditorRow', () => ({
// eslint-disable-next-line react/display-name
QueryEditorRow: () => <p>hi</p>, QueryEditorRow: () => <p>hi</p>,
})); }));
@ -54,11 +50,7 @@ const mocks = {
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeatures: jest.mocked(discoverFeatures),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
}, },
}; };
@ -96,64 +88,13 @@ describe('RuleEditor grafana managed rules', () => {
), ),
}; };
setDataSourceSrv(new MockDataSourceSrv(dataSources)); setupDataSources(dataSources.default);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue(); 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([ mocks.searchFolders.mockResolvedValue([
{ {
title: 'Folder A', title: 'Folder A',
uid: 'abcd', uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1, id: 1,
type: DashboardSearchItemType.DashDB, type: DashboardSearchItemType.DashDB,
}, },
@ -171,12 +112,6 @@ describe('RuleEditor grafana managed rules', () => {
}, },
] as DashboardSearchHit[]); ] as DashboardSearchHit[]);
mocks.api.discoverFeatures.mockResolvedValue({
application: PromApplication.Prometheus,
features: {
rulerApiEnabled: false,
},
});
renderRuleEditor(); renderRuleEditor();
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); await waitForElementToBeRemoved(screen.getAllByTestId('Spinner'));
@ -186,7 +121,7 @@ describe('RuleEditor grafana managed rules', () => {
await clickSelectOption(folderInput, 'Folder A'); await clickSelectOption(folderInput, 'Folder A');
const groupInput = await ui.inputs.group.find(); const groupInput = await ui.inputs.group.find();
await userEvent.click(byRole('combobox').get(groupInput)); 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'); await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
// save and check what was sent to backend // save and check what was sent to backend
@ -196,11 +131,12 @@ describe('RuleEditor grafana managed rules', () => {
// 9seg // 9seg
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'abcd', grafanaRulerRule.grafana_alert.namespace_uid,
{ {
interval: '1m', interval: '1m',
name: 'group1', name: grafanaRulerGroup.name,
rules: [ rules: [
grafanaRulerRule,
{ {
annotations: { description: 'some description' }, annotations: { description: 'some description' },
labels: {}, labels: {},

View File

@ -203,6 +203,13 @@ export const alertRuleApi = alertingApi.injectEndpoints({
providesTags: ['CombinedAlertRule'], providesTags: ['CombinedAlertRule'],
}), }),
rulerNamespace: build.query<RulerRulesConfigDTO, { rulerConfig: RulerDataSourceConfig; namespace: string }>({
query: ({ rulerConfig, namespace }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
return { url: path, params };
},
}),
// TODO This should be probably a separate ruler API file // TODO This should be probably a separate ruler API file
rulerRuleGroup: build.query< rulerRuleGroup: build.query<
RulerRuleGroupDTO, RulerRuleGroupDTO,
@ -219,6 +226,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({
// TODO: In future, if supported in other rulers, parametrize ruler source name // 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 // 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}` }), query: ({ uid }) => ({ url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rule/${uid}` }),
providesTags: (_result, _error, { uid }) => [{ type: 'GrafanaRulerRule', id: uid }],
}), }),
exportRules: build.query<string, ExportRulesParams>({ exportRules: build.query<string, ExportRulesParams>({

View File

@ -43,6 +43,7 @@ export const alertingApi = createApi({
'DataSourceSettings', 'DataSourceSettings',
'GrafanaLabels', 'GrafanaLabels',
'CombinedAlertRule', 'CombinedAlertRule',
'GrafanaRulerRule',
], ],
endpoints: () => ({}), endpoints: () => ({}),
}); });

View File

@ -5,10 +5,10 @@ import { render, waitFor, waitForElementToBeRemoved, userEvent } from 'test/test
import { byRole, byTestId, byText } from 'testing-library-selector'; import { byRole, byTestId, byText } from 'testing-library-selector';
import { DashboardSearchItemType } from '../../../../search/types'; import { DashboardSearchItemType } from '../../../../search/types';
import { mockAlertRuleApi, mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi'; import { mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi';
import { getGrafanaRule, mockDashboardSearchItem, mockDataSource } from '../../mocks'; import { mockDashboardSearchItem, mockDataSource } from '../../mocks';
import { grafanaRulerRule } from '../../mocks/alertRuleApi';
import { setupDataSources } from '../../testSetup/datasources'; import { setupDataSources } from '../../testSetup/datasources';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import GrafanaModifyExport from './GrafanaModifyExport'; import GrafanaModifyExport from './GrafanaModifyExport';
@ -31,7 +31,7 @@ jest.mock('@grafana/ui', () => ({
})); }));
const ui = { const ui = {
loading: byText('Loading the rule'), loading: byText('Loading the rule...'),
form: { form: {
nameInput: byRole('textbox', { name: 'name' }), nameInput: byRole('textbox', { name: 'name' }),
folder: byTestId('folder-picker'), folder: byTestId('folder-picker'),
@ -66,55 +66,27 @@ const server = setupMswServer();
describe('GrafanaModifyExport', () => { describe('GrafanaModifyExport', () => {
setupDataSources(dataSources.default); 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 () => { it('Should render edit form for the specified rule', async () => {
mockSearchApi(server).search([ mockSearchApi(server).search([
mockDashboardSearchItem({ mockDashboardSearchItem({
title: grafanaRule.namespace.name, title: grafanaRulerRule.grafana_alert.title,
uid: 'folderUID1', uid: grafanaRulerRule.grafana_alert.namespace_uid,
url: '', url: '',
tags: [], tags: [],
type: DashboardSearchItemType.DashFolder, type: DashboardSearchItemType.DashFolder,
}), }),
]); ]);
mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, { mockExportApi(server).modifiedExport(grafanaRulerRule.grafana_alert.namespace_uid, {
[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', {
yaml: 'Yaml Export Content', yaml: 'Yaml Export Content',
json: 'Json Export Content', json: 'Json Export Content',
}); });
const user = userEvent.setup(); const user = userEvent.setup();
renderModifyExport('test-rule-uid'); renderModifyExport(grafanaRulerRule.grafana_alert.uid);
await waitForElementToBeRemoved(() => ui.loading.get()); 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()); await user.click(ui.exportButton.get());

View File

@ -1,14 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { GrafanaRouteComponentProps } from '../../../../../core/navigation/types'; import { GrafanaRouteComponentProps } from '../../../../../core/navigation/types';
import { useDispatch } from '../../../../../types';
import { RuleIdentifier } from '../../../../../types/unified-alerting'; 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 { formValuesFromExistingRule } from '../../utils/rule-form';
import * as ruleId from '../../utils/rule-id'; import * as ruleId from '../../utils/rule-id';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
@ -19,79 +18,34 @@ import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExpor
interface GrafanaModifyExportProps extends GrafanaRouteComponentProps<{ id?: string }> {} interface GrafanaModifyExportProps extends GrafanaRouteComponentProps<{ id?: string }> {}
export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) { export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) {
const dispatch = useDispatch(); const ruleIdentifier = useMemo<RuleIdentifier | undefined>(() => {
return ruleId.tryParse(match.params.id, true);
// Get rule source build info
const [ruleIdentifier, setRuleIdentifier] = useState<RuleIdentifier | undefined>(undefined);
useEffect(() => {
const identifier = ruleId.tryParse(match.params.id, true);
setRuleIdentifier(identifier);
}, [match.params.id]); }, [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) { if (!ruleIdentifier) {
return <div>Rule not found</div>;
}
if (loading) {
return <LoadingPlaceholder text="Loading the rule" />;
}
if (error) {
return ( return (
<Alert title="Cannot load modify export" severity="error"> <ModifyExportWrapper>
{error.message} <Alert title="Invalid rule ID" severity="error">
</Alert> The rule UID in the page URL is invalid. Please check the URL and try again.
); </Alert>
} </ModifyExportWrapper>
if (!alertRule && !loading && !loadingBuildInfo) {
// alert rule does not exist
return (
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={{ text: 'Modify export' }}>
<Alert
title="Cannot load the rule. The rule does not exist"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
</AlertingPageWrapper>
);
}
if (alertRule && !isGrafanaRulerRule(alertRule.rule)) {
// alert rule exists but is not a grafana-managed rule
return (
<AlertingPageWrapper isLoading={loading} navId="alert-list" pageNav={{ text: 'Modify export' }}>
<Alert
title="This rule is not a Grafana-managed alert rule"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
</AlertingPageWrapper>
); );
} }
return (
<ModifyExportWrapper>
<RuleModifyExport ruleIdentifier={ruleIdentifier} />
</ModifyExportWrapper>
);
}
interface ModifyExportWrapperProps {
children: React.ReactNode;
}
function ModifyExportWrapper({ children }: ModifyExportWrapperProps) {
return ( return (
<AlertingPageWrapper <AlertingPageWrapper
isLoading={loading}
navId="alert-list" navId="alert-list"
pageNav={{ pageNav={{
text: 'Modify export', text: 'Modify export',
@ -99,9 +53,56 @@ export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps)
'Modify the current alert rule and export the rule definition in the format of your choice. Any changes you make will not be saved.', 'Modify the current alert rule and export the rule definition in the format of your choice. Any changes you make will not be saved.',
}} }}
> >
{alertRule && ( {children}
<ModifyExportRuleForm ruleForm={formValuesFromExistingRule(alertRule)} alertUid={match.params.id ?? ''} />
)}
</AlertingPageWrapper> </AlertingPageWrapper>
); );
} }
function RuleModifyExport({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }) {
const { loading, error, result: rulerRule } = useRuleWithLocation({ ruleIdentifier: ruleIdentifier });
if (loading) {
return <LoadingPlaceholder text="Loading the rule..." />;
}
if (error) {
return (
<Alert title="Cannot load modify export" severity="error">
{stringifyErrorLike(error)}
</Alert>
);
}
if (!rulerRule && !loading) {
// alert rule does not exist
return (
<Alert
title="Cannot load the rule. The rule does not exist"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
);
}
if (rulerRule && !isGrafanaRulerRule(rulerRule.rule)) {
// alert rule exists but is not a grafana-managed rule
return (
<Alert
title="This rule is not a Grafana-managed alert rule"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
);
}
if (rulerRule && isGrafanaRulerRule(rulerRule.rule)) {
return (
<ModifyExportRuleForm
ruleForm={formValuesFromExistingRule(rulerRule)}
alertUid={rulerRule.rule.grafana_alert.uid}
/>
);
}
return <Alert title="Unknown error" />;
}

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { debounce, take, uniqueId } from 'lodash'; 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 { FormProvider, useForm, useFormContext, Controller } from 'react-hook-form';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; 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 appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { createFolder } from 'app/features/manage-dashboards/state/actions'; import { createFolder } from 'app/features/manage-dashboards/state/actions';
import { AccessControlAction, useDispatch } from 'app/types'; import { AccessControlAction } from 'app/types';
import { CombinedRuleGroup } from 'app/types/unified-alerting'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { alertRuleApi } from '../../api/alertRuleApi';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { grafanaRulerConfig } from '../../hooks/useCombinedRule';
import { fetchRulerRulesAction } from '../../state/actions';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
import { ProvisioningBadge } from '../Provisioning'; import { ProvisioningBadge } from '../Provisioning';
@ -30,42 +27,51 @@ import { checkForPathSeparator } from './util';
export const MAX_GROUP_RESULTS = 1000; export const MAX_GROUP_RESULTS = 1000;
export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => { 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 // fetch the ruler rules from the database so we can figure out what other "groups" are already defined
// for our folders // for our folders
useEffect(() => { const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } =
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); alertRuleApi.endpoints.rulerNamespace.useQuery(
}, [dispatch]); {
namespace: folderUid,
rulerConfig: grafanaRulerConfig,
},
{
skip: !folderUid,
refetchOnMountOrArgChange: true,
}
);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); // There should be only one entry in the rulerNamespace object
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; // 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 = Object.values(rulerNamespace).flat() ?? [];
const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? [];
const groupOptions = folderGroups return folderGroups
.map<SelectableValue<string>>((group) => { .map<SelectableValue<string>>((group) => {
const isProvisioned = isProvisionedGroup(group); const isProvisioned = isProvisionedGroup(group);
return { return {
label: group.name, label: group.name,
value: group.name, value: group.name,
description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
// we include provisioned folders, but disable the option to select them // we include provisioned folders, but disable the option to select them
isDisabled: !enableProvisionedGroups ? isProvisioned : false, isDisabled: !enableProvisionedGroups ? isProvisioned : false,
isProvisioned: isProvisioned, isProvisioned: isProvisioned,
}; };
}) })
.sort(sortByLabel); .sort(sortByLabel);
}, [rulerNamespace, enableProvisionedGroups]);
return { groupOptions, loading: groupfoldersForGrafana?.loading }; return { groupOptions, loading: isLoadingRulerNamespace };
}; };
const isProvisionedGroup = (group: CombinedRuleGroup) => { const isProvisionedGroup = (group: RulerRuleGroupDTO) => {
return group.rules.some( return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true);
(rule) => isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance) === true
);
}; };
const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) => { const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) => {

View File

@ -7,50 +7,39 @@ import { ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byRole } from 'testing-library-selector'; 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 { contextSrv } from 'app/core/services/context_srv';
import RuleEditor from 'app/features/alerting/unified/RuleEditor'; import RuleEditor from 'app/features/alerting/unified/RuleEditor';
import { discoverFeatures } from 'app/features/alerting/unified/api/buildInfo'; import * as ruler from 'app/features/alerting/unified/api/ruler';
import {
fetchRulerRules,
fetchRulerRulesGroup,
fetchRulerRulesNamespace,
setRulerRuleGroup,
} from 'app/features/alerting/unified/api/ruler';
import * as useContactPoints from 'app/features/alerting/unified/components/contact-points/useContactPoints'; 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 { 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 { 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 * as utils_config from 'app/features/alerting/unified/utils/config';
import { import {
AlertManagerDataSource,
DataSourceType, DataSourceType,
GRAFANA_DATASOURCE_NAME, GRAFANA_DATASOURCE_NAME,
GRAFANA_RULES_SOURCE_NAME, GRAFANA_RULES_SOURCE_NAME,
getAlertManagerDataSourcesByPermission,
useGetAlertManagerDataSourcesByPermissionAndConfig, useGetAlertManagerDataSourcesByPermissionAndConfig,
} from 'app/features/alerting/unified/utils/datasource'; } from 'app/features/alerting/unified/utils/datasource';
import { getDefaultQueries } from 'app/features/alerting/unified/utils/rule-form'; import { getDefaultQueries } from 'app/features/alerting/unified/utils/rule-form';
import { searchFolders } from 'app/features/manage-dashboards/state/actions'; import { searchFolders } from 'app/features/manage-dashboards/state/actions';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { GrafanaAlertStateDecision, 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 { RECEIVER_META_KEY } from '../../../contact-points/useContactPoints';
import { ContactPointWithMetadata } from '../../../contact-points/utils'; import { ContactPointWithMetadata } from '../../../contact-points/utils';
import { ExpressionEditorProps } from '../../ExpressionEditor'; import { ExpressionEditorProps } from '../../ExpressionEditor';
jest.mock('app/features/alerting/unified/components/rule-editor/ExpressionEditor', () => ({ jest.mock('app/features/alerting/unified/components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} /> <input value={value} data-testid="expr" onChange={(e) => 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/features/manage-dashboards/state/actions');
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ 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. // there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it // lets just skip it
jest.mock('app/features/query/components/QueryEditorRow', () => ({ jest.mock('app/features/query/components/QueryEditorRow', () => ({
// eslint-disable-next-line react/display-name
QueryEditorRow: () => <p>hi</p>, QueryEditorRow: () => <p>hi</p>,
})); }));
// 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(); const user = userEvent.setup();
jest.spyOn(utils_config, 'getAllDataSources'); // jest.spyOn(utils_config, 'getAllDataSources');
jest.spyOn(dsByPermission, 'useAlertManagersByPermission'); // jest.spyOn(dsByPermission, 'useAlertManagersByPermission');
jest.spyOn(useContactPoints, 'useContactPointsWithStatus'); jest.spyOn(useContactPoints, 'useContactPointsWithStatus');
jest.setTimeout(60 * 1000); jest.setTimeout(60 * 1000);
@ -92,14 +65,8 @@ const mocks = {
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
useContactPointsWithStatus: jest.mocked(useContactPoints.useContactPointsWithStatus), useContactPointsWithStatus: jest.mocked(useContactPoints.useContactPointsWithStatus),
useGetAlertManagerDataSourcesByPermissionAndConfig: jest.mocked(useGetAlertManagerDataSourcesByPermissionAndConfig), useGetAlertManagerDataSourcesByPermissionAndConfig: jest.mocked(useGetAlertManagerDataSourcesByPermissionAndConfig),
getAlertManagerDataSourcesByPermission: jest.mocked(getAlertManagerDataSourcesByPermission),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
}, },
}; };
@ -125,17 +92,6 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite, AccessControlAction.AlertingNotificationsWrite,
]); ]);
mocks.getAlertManagerDataSourcesByPermission.mockReturnValue({
availableInternalDataSources: [grafanaAlertManagerDataSource],
availableExternalDataSources: [],
});
mocks.useGetAlertManagerDataSourcesByPermissionAndConfig.mockReturnValue([grafanaAlertManagerDataSource]);
jest.mocked(dsByPermission.useAlertManagersByPermission).mockReturnValue({
availableInternalDataSources: [grafanaAlertManagerDataSource],
availableExternalDataSources: [],
});
}); });
const dataSources = { const dataSources = {
@ -152,6 +108,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
type: DataSourceType.Alertmanager, 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 () => { it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => {
// no contact points found // no contact points found
@ -162,64 +119,11 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
refetchReceivers: jest.fn(), refetchReceivers: jest.fn(),
}); });
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue(); 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([ mocks.searchFolders.mockResolvedValue([
{ {
title: 'Folder A', title: 'Folder A',
uid: 'abcd', uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1, id: 1,
type: DashboardSearchItemType.DashDB, type: DashboardSearchItemType.DashDB,
}, },
@ -235,12 +139,6 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
}, },
] as DashboardSearchHit[]); ] as DashboardSearchHit[]);
mocks.api.discoverFeatures.mockResolvedValue({
application: PromApplication.Prometheus,
features: {
rulerApiEnabled: false,
},
});
config.featureToggles.alertingSimplifiedRouting = true; config.featureToggles.alertingSimplifiedRouting = true;
renderSimplifiedRuleEditor(); renderSimplifiedRuleEditor();
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); 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'); await clickSelectOption(folderInput, 'Folder A');
const groupInput = await ui.inputs.group.find(); const groupInput = await ui.inputs.group.find();
await user.click(byRole('combobox').get(groupInput)); await user.click(byRole('combobox').get(groupInput));
await clickSelectOption(groupInput, 'group1'); await clickSelectOption(groupInput, grafanaRulerRule.grafana_alert.rule_group);
//select contact point routing //select contact point routing
await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get());
// do not select a contact point // do not select a contact point
@ -289,64 +187,11 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
refetchReceivers: jest.fn(), refetchReceivers: jest.fn(),
}); });
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue(); 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([ mocks.searchFolders.mockResolvedValue([
{ {
title: 'Folder A', title: 'Folder A',
uid: 'abcd', uid: grafanaRulerNamespace2.uid,
id: 1, id: 1,
type: DashboardSearchItemType.DashDB, type: DashboardSearchItemType.DashDB,
}, },
@ -364,12 +209,6 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
}, },
] as DashboardSearchHit[]); ] as DashboardSearchHit[]);
mocks.api.discoverFeatures.mockResolvedValue({
application: PromApplication.Prometheus,
features: {
rulerApiEnabled: false,
},
});
config.featureToggles.alertingSimplifiedRouting = true; config.featureToggles.alertingSimplifiedRouting = true;
renderSimplifiedRuleEditor(); renderSimplifiedRuleEditor();
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); 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'); await clickSelectOption(folderInput, 'Folder A');
const groupInput = await ui.inputs.group.find(); const groupInput = await ui.inputs.group.find();
await user.click(byRole('combobox').get(groupInput)); await user.click(byRole('combobox').get(groupInput));
await clickSelectOption(groupInput, 'group1'); await clickSelectOption(groupInput, grafanaRulerEmptyGroup.name);
//select contact point routing //select contact point routing
await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get());
const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); 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()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'abcd', grafanaRulerNamespace2.uid,
{ {
interval: '1m', interval: grafanaRulerEmptyGroup.interval,
name: 'group1', name: grafanaRulerEmptyGroup.name,
rules: [ rules: [
{ {
annotations: {}, annotations: {},

View File

@ -1,24 +1,24 @@
import { within } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { render, waitFor, screen, userEvent } from 'test/test-utils'; import { render, waitFor, screen, userEvent } from 'test/test-utils';
import { byText, byRole } from 'testing-library-selector'; 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 { backendSrv } from 'app/core/services/backend_srv';
import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; 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 { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
import { import {
MockDataSourceSrv,
getCloudRule, getCloudRule,
getGrafanaRule, getGrafanaRule,
grantUserPermissions, grantUserPermissions,
mockDataSource, mockDataSource,
mockPluginLinkExtension, mockPluginLinkExtension,
} from '../../mocks'; } from '../../mocks';
import { grafanaRulerRule } from '../../mocks/alertRuleApi';
import { setupDataSources } from '../../testSetup/datasources'; import { setupDataSources } from '../../testSetup/datasources';
import { Annotation } from '../../utils/constants'; import { Annotation } from '../../utils/constants';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@ -104,7 +104,7 @@ describe('RuleViewer', () => {
totals: { alerting: 1 }, totals: { alerting: 1 },
}, },
}, },
{ uid: 'test1' } { uid: grafanaRulerRule.grafana_alert.uid }
); );
const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule); const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule);
@ -114,6 +114,7 @@ describe('RuleViewer', () => {
AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete, AccessControlAction.AlertingRuleDelete,
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingInstanceCreate,
]); ]);
setBackendSrv(backendSrv); setBackendSrv(backendSrv);
@ -181,7 +182,6 @@ describe('RuleViewer', () => {
}), }),
}; };
setupDataSources(dataSources.grafana, dataSources.am); setupDataSources(dataSources.grafana, dataSources.am);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
await renderRuleViewer(mockRule, mockRuleIdentifier); await renderRuleViewer(mockRule, mockRuleIdentifier);
@ -189,7 +189,10 @@ describe('RuleViewer', () => {
await user.click(ELEMENTS.actions.more.button.get()); await user.click(ELEMENTS.actions.more.button.get());
await user.click(ELEMENTS.actions.more.actions.silence.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
);
}); });
}); });

View File

@ -2,7 +2,14 @@ import { useEffect, useMemo } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { useDispatch } from 'app/types'; 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 { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi'; import { alertRuleApi } from '../api/alertRuleApi';
@ -18,11 +25,7 @@ import {
isRulerNotSupportedResponse, isRulerNotSupportedResponse,
} from '../utils/rules'; } from '../utils/rules';
import { import { attachRulerRulesToCombinedRules, useCombinedRuleNamespaces } from './useCombinedRuleNamespaces';
attachRulerRulesToCombinedRules,
combineRulesNamespaces,
useCombinedRuleNamespaces,
} from './useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export function useCombinedRulesMatching( export function useCombinedRulesMatching(
@ -162,15 +165,25 @@ function getRequestState(
return state; return state;
} }
export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): { interface RequestState<T> {
result?: T;
loading: boolean; loading: boolean;
result?: CombinedRule;
error?: unknown; 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<CombinedRule> {
const { ruleSourceName } = ruleIdentifier; const { ruleSourceName } = ruleIdentifier;
const dsSettings = getDataSourceByName(ruleSourceName); const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier);
const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName); const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName);
const {
loading: isLoadingRuleLocation,
error: ruleLocationError,
result: ruleLocation,
} = useRuleLocation(ruleIdentifier);
const { const {
currentData: promRuleNs, currentData: promRuleNs,
@ -178,85 +191,47 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti
error: promRuleNsError, error: promRuleNsError,
} = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery( } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery(
{ {
// TODO Refactor parameters
ruleSourceName: ruleIdentifier.ruleSourceName, ruleSourceName: ruleIdentifier.ruleSourceName,
namespace: namespace: ruleLocation?.namespace,
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier) groupName: ruleLocation?.group,
? ruleIdentifier.namespace ruleName: ruleLocation?.ruleName,
: undefined, },
groupName: {
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier) skip: !ruleLocation || isLoadingRuleLocation,
? ruleIdentifier.groupName refetchOnMountOrArgChange: true,
: undefined,
ruleName:
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
? ruleIdentifier.ruleName
: undefined,
} }
// 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 [ const [
fetchRulerRuleGroup, fetchRulerRuleGroup,
{ currentData: rulerRuleGroup, isLoading: isLoadingRulerGroup, error: rulerRuleGroupError }, {
currentData: rulerRuleGroup,
isLoading: isLoadingRulerGroup,
error: rulerRuleGroupError,
isUninitialized: rulerRuleGroupUninitialized,
},
] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery();
const [fetchRulerRules, { currentData: rulerRules, isLoading: isLoadingRulerRules, error: rulerRulesError }] =
alertRuleApi.endpoints.rulerRules.useLazyQuery();
useEffect(() => { useEffect(() => {
if (!dsFeatures?.rulerConfig) { if (!dsFeatures?.rulerConfig || !ruleLocation) {
return; return;
} }
if (dsFeatures.rulerConfig && isCloudRuleIdentifier(ruleIdentifier)) { fetchRulerRuleGroup({
fetchRulerRuleGroup({ rulerConfig: dsFeatures.rulerConfig,
rulerConfig: dsFeatures.rulerConfig, namespace: ruleLocation.namespace,
namespace: ruleIdentifier.namespace, group: ruleLocation.group,
group: ruleIdentifier.groupName, });
}); }, [dsFeatures, fetchRulerRuleGroup, ruleLocation]);
} 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]);
const rule = useMemo(() => { const rule = useMemo(() => {
if (!promRuleNs) { if (!promRuleNs || !ruleSource) {
return; return;
} }
if (isGrafanaRuleIdentifier(ruleIdentifier)) { if (promRuleNs.length > 0) {
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))
) {
const namespaces = promRuleNs.map((ns) => const namespaces = promRuleNs.map((ns) =>
attachRulerRulesToCombinedRules(dsSettings, ns, rulerRuleGroup ? [rulerRuleGroup] : []) attachRulerRulesToCombinedRules(ruleSource, ns, rulerRuleGroup ? [rulerRuleGroup] : [])
); );
for (const namespace of namespaces) { for (const namespace of namespaces) {
@ -273,15 +248,147 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti
} }
return; return;
}, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, rulerRules, dsSettings]); }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource]);
return { return {
loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || isLoadingRulerRules, loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || rulerRuleGroupUninitialized,
error: promRuleNsError ?? rulerRuleGroupError ?? rulerRulesError, error: ruleLocationError ?? promRuleNsError ?? rulerRuleGroupError,
result: rule, result: rule,
}; };
} }
interface RuleLocation {
namespace: string;
group: string;
ruleName: string;
}
function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<RuleLocation> {
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<RuleWithLocation> {
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 = { export const grafanaRulerConfig: RulerDataSourceConfig = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME, dataSourceName: GRAFANA_RULES_SOURCE_NAME,
apiVersion: 'legacy', apiVersion: 'legacy',

View File

@ -117,6 +117,7 @@ export const mockRulerGrafanaRule = (
uid: '123', uid: '123',
title: 'myalert', title: 'myalert',
namespace_uid: '123', namespace_uid: '123',
rule_group: 'my-group',
condition: 'A', condition: 'A',
no_data_state: GrafanaAlertStateDecision.Alerting, no_data_state: GrafanaAlertStateDecision.Alerting,
exec_err_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting,
@ -214,6 +215,7 @@ export const mockGrafanaRulerRule = (partial: Partial<GrafanaRuleDefinition> = {
uid: '', uid: '',
title: 'my rule', title: 'my rule',
namespace_uid: 'NAMESPACE_UID', namespace_uid: 'NAMESPACE_UID',
rule_group: 'my-group',
condition: '', condition: '',
no_data_state: GrafanaAlertStateDecision.NoData, no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Error, exec_err_state: GrafanaAlertStateDecision.Error,

View File

@ -1,9 +1,15 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { SetupServer } from 'msw/node'; 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 { PreviewResponse, PREVIEW_URL, PROM_RULES_URL } from '../api/alertRuleApi';
import { Annotation } from '../utils/constants';
export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) { export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) {
server.use(http.post(PREVIEW_URL, () => HttpResponse.json(result))); 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) { export function mockPromRulesApiResponse(server: SetupServer, result: PromRulesResponse) {
server.use(http.get(PROM_RULES_URL, () => HttpResponse.json(result))); 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<string, { name: string; uid: string }> = {
[grafanaRulerNamespace.uid]: grafanaRulerNamespace,
[grafanaRulerNamespace2.uid]: grafanaRulerNamespace2,
};
export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
[grafanaRulerNamespace.uid]: [grafanaRulerGroup],
[grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup],
};

View File

@ -21,6 +21,7 @@ const allHandlers = [
...folderHandlers, ...folderHandlers,
...pluginsHandlers, ...pluginsHandlers,
...silenceHandlers, ...silenceHandlers,
...alertRuleHandlers,
]; ];
export default allHandlers; export default allHandlers;

View File

@ -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<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
acc[namespaceByUid[namespaceUid].name] = groups;
return acc;
}, {});
return HttpResponse.json<RulerRulesConfigDTO>(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<RulerRulesConfigDTO>({
[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<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
rules: matchingGroup?.rules ?? [],
});
}
);
};
export const getAlertRuleHandler = () => {
const grafanaRules = new Map<string, RulerGrafanaRuleDTO>(
[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(),
];

View File

@ -2,15 +2,72 @@ import { http, HttpResponse } from 'msw';
export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert'; export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert';
const alertRuleDetailsHandler = () => import {
http.get<{ folderUid: string }>(`/api/ruler/:ruler/api/v1/rule/:uid`, () => { RulerGrafanaRuleDTO,
// TODO: Scaffold out alert rule response logic as this endpoint is used more in tests RulerRuleGroupDTO,
return HttpResponse.json({ RulerRulesConfigDTO,
grafana_alert: { } from '../../../../../../types/unified-alerting-dto';
title: MOCK_GRAFANA_ALERT_RULE_TITLE, import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi';
},
export const rulerRulesHandler = () => {
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {
const response = Object.entries(namespaces).reduce<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
acc[namespaceByUid[namespaceUid].name] = groups;
return acc;
}, {});
return HttpResponse.json<RulerRulesConfigDTO>(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<RulerRulesConfigDTO>({
[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<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
rules: matchingGroup?.rules ?? [],
});
}
);
};
export const rulerRuleHandler = () => {
const grafanaRules = new Map<string, RulerGrafanaRuleDTO>(
[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; export default handlers;

View File

@ -17,8 +17,8 @@ import {
PromBasedDataSource, PromBasedDataSource,
RuleIdentifier, RuleIdentifier,
RuleNamespace, RuleNamespace,
RulerDataSourceConfig,
RuleWithLocation, RuleWithLocation,
RulerDataSourceConfig,
StateHistoryItem, StateHistoryItem,
} from 'app/types/unified-alerting'; } from 'app/types/unified-alerting';
import { import {
@ -30,15 +30,16 @@ import {
import { backendSrv } from '../../../../core/services/backend_srv'; import { backendSrv } from '../../../../core/services/backend_srv';
import { import {
LogMessages,
logError, logError,
logInfo, logInfo,
LogMessages,
trackSwitchToPoliciesRouting, trackSwitchToPoliciesRouting,
trackSwitchToSimplifiedRouting, trackSwitchToSimplifiedRouting,
withPerformanceLogging, withPerformanceLogging,
withPromRulesMetadataLogging, withPromRulesMetadataLogging,
withRulerRulesMetadataLogging, withRulerRulesMetadataLogging,
} from '../Analytics'; } from '../Analytics';
import { alertRuleApi } from '../api/alertRuleApi';
import { import {
deleteAlertManagerConfig, deleteAlertManagerConfig,
fetchAlertGroups, fetchAlertGroups,
@ -51,20 +52,20 @@ import { discoverFeatures } from '../api/buildInfo';
import { fetchNotifiers } from '../api/grafana'; import { fetchNotifiers } from '../api/grafana';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
import { import {
FetchRulerRulesFilter,
deleteNamespace, deleteNamespace,
deleteRulerRulesGroup, deleteRulerRulesGroup,
fetchRulerRules, fetchRulerRules,
FetchRulerRulesFilter,
setRulerRuleGroup, setRulerRuleGroup,
} from '../api/ruler'; } from '../api/ruler';
import { encodeGrafanaNamespace } from '../components/expressions/util'; import { encodeGrafanaNamespace } from '../components/expressions/util';
import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
import { import {
GRAFANA_RULES_SOURCE_NAME,
getAllRulesSourceNames, getAllRulesSourceNames,
getRulesDataSource, getRulesDataSource,
getRulesSourceName, getRulesSourceName,
GRAFANA_RULES_SOURCE_NAME,
} from '../utils/datasource'; } from '../utils/datasource';
import { makeAMLink } from '../utils/misc'; import { makeAMLink } from '../utils/misc';
import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux'; import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../utils/redux';
@ -316,14 +317,6 @@ export function fetchAllPromRulesAction(force = false): ThunkResult<void> {
}; };
} }
export const fetchEditableRuleAction = createAsyncThunk(
'unifiedalerting/fetchEditableRule',
(ruleIdentifier: RuleIdentifier, thunkAPI): Promise<RuleWithLocation | null> => {
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, ruleIdentifier.ruleSourceName);
return withSerializedError(getRulerClient(rulerConfig).findEditableRule(ruleIdentifier));
}
);
export function deleteRulesGroupAction( export function deleteRulesGroupAction(
namespace: CombinedRuleNamespace, namespace: CombinedRuleNamespace,
ruleGroup: CombinedRuleGroup ruleGroup: CombinedRuleGroup
@ -403,6 +396,7 @@ export const saveRuleFormAction = createAsyncThunk(
// For the dataSourceName specified // For the dataSourceName specified
// in case of system (cortex/loki) // in case of system (cortex/loki)
let identifier: RuleIdentifier; let identifier: RuleIdentifier;
if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) { if (type === RuleFormType.cloudAlerting || type === RuleFormType.cloudRecording) {
if (!values.dataSourceName) { if (!values.dataSourceName) {
throw new Error('The Data source has not been defined.'); throw new Error('The Data source has not been defined.');
@ -412,14 +406,14 @@ export const saveRuleFormAction = createAsyncThunk(
const rulerClient = getRulerClient(rulerConfig); const rulerClient = getRulerClient(rulerConfig);
identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing); identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing);
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName })); await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName }));
// in case of grafana managed // in case of grafana managed
} else if (type === RuleFormType.grafana) { } else if (type === RuleFormType.grafana) {
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
const rulerClient = getRulerClient(rulerConfig); const rulerClient = getRulerClient(rulerConfig);
identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing); identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing);
reportSwitchingRoutingType(values, 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 { } else {
throw new Error('Unexpected rule form type'); throw new Error('Unexpected rule form type');
} }
@ -439,9 +433,6 @@ export const saveRuleFormAction = createAsyncThunk(
const newLocation = `/alerting/${encodeURIComponent(stringifiedIdentifier)}/edit`; const newLocation = `/alerting/${encodeURIComponent(stringifiedIdentifier)}/edit`;
if (locationService.getLocation().pathname !== newLocation) { if (locationService.getLocation().pathname !== newLocation) {
locationService.replace(newLocation); locationService.replace(newLocation);
} else {
// refresh the details of the current editable rule after saving
thunkAPI.dispatch(fetchEditableRuleAction(identifier));
} }
} }
})() })()

View File

@ -5,7 +5,6 @@ import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux';
import { import {
deleteAlertManagerConfigAction, deleteAlertManagerConfigAction,
fetchAlertGroupsAction, fetchAlertGroupsAction,
fetchEditableRuleAction,
fetchFolderAction, fetchFolderAction,
fetchGrafanaAnnotationsAction, fetchGrafanaAnnotationsAction,
fetchGrafanaNotifiersAction, fetchGrafanaNotifiersAction,
@ -29,7 +28,6 @@ export const reducer = combineReducers({
.reducer, .reducer,
ruleForm: combineReducers({ ruleForm: combineReducers({
saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer, saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer,
existingRule: createAsyncSlice('existingRule', fetchEditableRuleAction).reducer,
}), }),
grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer, grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer,
saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer, saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer,

View File

@ -89,6 +89,7 @@ const grafanaAlert = {
condition: 'B', condition: 'B',
exec_err_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting,
namespace_uid: 'namespaceuid123', namespace_uid: 'namespaceuid123',
rule_group: 'my-group',
no_data_state: GrafanaAlertStateDecision.NoData, no_data_state: GrafanaAlertStateDecision.NoData,
title: 'Test alert', title: 'Test alert',
uid: 'asdf23', uid: 'asdf23',

View File

@ -97,6 +97,7 @@ describe('getContactPointsFromDTO', () => {
uid: '123', uid: '123',
title: 'myalert', title: 'myalert',
namespace_uid: '123', namespace_uid: '123',
rule_group: 'my-group',
condition: 'A', condition: 'A',
no_data_state: GrafanaAlertStateDecision.Alerting, no_data_state: GrafanaAlertStateDecision.Alerting,
exec_err_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting,
@ -120,6 +121,7 @@ describe('getContactPointsFromDTO', () => {
uid: '123', uid: '123',
title: 'myalert', title: 'myalert',
namespace_uid: '123', namespace_uid: '123',
rule_group: 'my-group',
condition: 'A', condition: 'A',
no_data_state: GrafanaAlertStateDecision.Alerting, no_data_state: GrafanaAlertStateDecision.Alerting,
exec_err_state: GrafanaAlertStateDecision.Alerting, exec_err_state: GrafanaAlertStateDecision.Alerting,

View File

@ -46,6 +46,7 @@ describe('hashRulerRule', () => {
const grafanaAlertDefinition: GrafanaRuleDefinition = { const grafanaAlertDefinition: GrafanaRuleDefinition = {
uid: RULE_UID, uid: RULE_UID,
namespace_uid: 'namespace', namespace_uid: 'namespace',
rule_group: 'my-group',
title: 'my rule', title: 'my rule',
condition: '', condition: '',
data: [], data: [],

View File

@ -1,4 +1,9 @@
import { RuleIdentifier, RulerDataSourceConfig, RuleWithLocation } from 'app/types/unified-alerting'; import {
GrafanaRuleIdentifier,
RuleIdentifier,
RulerDataSourceConfig,
RuleWithLocation,
} from 'app/types/unified-alerting';
import { import {
PostableRuleGrafanaRuleDTO, PostableRuleGrafanaRuleDTO,
PostableRulerRuleGroupDTO, PostableRulerRuleGroupDTO,
@ -21,9 +26,16 @@ import {
export interface RulerClient { export interface RulerClient {
findEditableRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null>; findEditableRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null>;
deleteRule(ruleWithLocation: RuleWithLocation): Promise<void>; deleteRule(ruleWithLocation: RuleWithLocation): Promise<void>;
saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>; saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveGrafanaRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveGrafanaRule(
values: RuleFormValues,
evaluateEvery: string,
existing?: RuleWithLocation
): Promise<GrafanaRuleIdentifier>;
} }
export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient { export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
@ -153,7 +165,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
values: RuleFormValues, values: RuleFormValues,
evaluateEvery: string, evaluateEvery: string,
existingRule?: RuleWithLocation existingRule?: RuleWithLocation
): Promise<RuleIdentifier> => { ): Promise<GrafanaRuleIdentifier> => {
const { folder, group } = values; const { folder, group } = values;
if (!folder) { if (!folder) {
throw new Error('Folder must be specified'); throw new Error('Folder must be specified');
@ -190,7 +202,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
namespaceUID: string, namespaceUID: string,
group: { name: string; interval: string }, group: { name: string; interval: string },
newRule: PostableRuleGrafanaRuleDTO newRule: PostableRuleGrafanaRuleDTO
): Promise<RuleIdentifier> => { ): Promise<GrafanaRuleIdentifier> => {
const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name); const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name);
if (!existingGroup) { if (!existingGroup) {
throw new Error(`No group found with name "${group.name}"`); 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 }, group: { name: string; interval: string },
existingRule: RuleWithLocation, existingRule: RuleWithLocation,
newRule: PostableRuleGrafanaRuleDTO newRule: PostableRuleGrafanaRuleDTO
): Promise<RuleIdentifier> => { ): Promise<GrafanaRuleIdentifier> => {
// make sure our updated alert has the same UID as before // make sure our updated alert has the same UID as before
// that way the rule is automatically moved to the new namespace / group name // that way the rule is automatically moved to the new namespace / group name
copyGrafanaUID(existingRule, newRule); copyGrafanaUID(existingRule, newRule);
@ -228,7 +240,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
existingRule: RuleWithLocation, existingRule: RuleWithLocation,
newRule: PostableRuleGrafanaRuleDTO, newRule: PostableRuleGrafanaRuleDTO,
interval: string interval: string
): Promise<RuleIdentifier> => { ): Promise<GrafanaRuleIdentifier> => {
// make sure our updated alert has the same UID as before // make sure our updated alert has the same UID as before
copyGrafanaUID(existingRule, newRule); copyGrafanaUID(existingRule, newRule);

View File

@ -44,6 +44,7 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, see
], ],
uid: random.guid(), uid: random.guid(),
namespace_uid: folderUid, namespace_uid: folderUid,
rule_group: 'my-group',
no_data_state: GrafanaAlertStateDecision.NoData, no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Error, exec_err_state: GrafanaAlertStateDecision.Error,
is_paused: false, is_paused: false,

View File

@ -221,6 +221,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
id?: string; id?: string;
uid: string; uid: string;
namespace_uid: string; namespace_uid: string;
rule_group: string;
provenance?: string; provenance?: string;
} }

View File

@ -139,9 +139,9 @@ export interface CombinedRuleNamespace {
export interface RuleWithLocation<T = RulerRuleDTO> { export interface RuleWithLocation<T = RulerRuleDTO> {
ruleSourceName: string; ruleSourceName: string;
namespace: string; namespace: string;
namespace_uid?: string; // Grafana folder UID
group: RulerRuleGroupDTO; group: RulerRuleGroupDTO;
rule: T; rule: T;
namespace_uid?: string;
} }
export interface CombinedRuleWithLocation extends CombinedRule { export interface CombinedRuleWithLocation extends CombinedRule {

View File

@ -10,6 +10,7 @@ import RuleEditor from 'app/features/alerting/unified/RuleEditor';
import { TestProvider } from './TestProvider'; import { TestProvider } from './TestProvider';
export const ui = { export const ui = {
loadingIndicator: byText('Loading rule...'),
inputs: { inputs: {
name: byRole('textbox', { name: 'name' }), name: byRole('textbox', { name: 'name' }),
alertType: byTestId('alert-type-picker'), alertType: byTestId('alert-type-picker'),