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

View File

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

View File

@ -1,16 +1,13 @@
import React, { useEffect } from 'react';
import React from 'react';
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useDispatch } from 'app/types';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from './AlertWarning';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
import { useRuleWithLocation } from './hooks/useCombinedRule';
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchEditableRuleAction } from './state/actions';
import { initialAsyncRequestState } from './utils/redux';
import { stringifyErrorLike } from './utils/misc';
import * as ruleId from './utils/rule-id';
interface ExistingRuleEditorProps {
@ -19,42 +16,31 @@ interface ExistingRuleEditorProps {
}
export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) {
useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState));
const {
loading: loadingAlertRule,
result,
result: ruleWithLocation,
error,
dispatched,
} = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
} = useRuleWithLocation({ ruleIdentifier: identifier });
const dispatch = useDispatch();
const { isEditable, loading: loadingEditable } = useIsRuleEditable(
ruleId.ruleIdentifierToRuleSourceName(identifier),
result?.rule
);
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
const { isEditable, loading: loadingEditable } = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule);
const loading = loadingAlertRule || loadingEditable;
useEffect(() => {
if (!dispatched) {
dispatch(fetchEditableRuleAction(identifier));
}
}, [dispatched, dispatch, identifier]);
if (loading || isEditable === undefined) {
if (loading) {
return <LoadingPlaceholder text="Loading rule..." />;
}
if (error) {
return (
<Alert severity="error" title="Failed to load rule">
{error.message}
{stringifyErrorLike(error)}
</Alert>
);
}
if (!result) {
if (!ruleWithLocation) {
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 <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 { ui } from 'test/helpers/alertingRuleEditor';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
import { backendSrv } from '../../../core/services/backend_srv';
import { AccessControlAction } from '../../../types';
import RuleEditor from './RuleEditor';
import { discoverFeatures } from './api/buildInfo';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource, mockFolder } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource, mockFolder } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/alertRuleApi';
import { setupDataSources } from './testSetup/datasources';
import { Annotation } from './utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
<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>,
}));
jest.mock('./api/buildInfo');
jest.mock('./api/ruler');
jest.mock('../../../../app/features/manage-dashboards/state/actions');
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it
jest.mock('app/features/query/components/QueryEditorRow', () => ({
// eslint-disable-next-line react/display-name
QueryEditorRow: () => <p>hi</p>,
}));
jest.spyOn(config, 'getAllDataSources');
jest.setTimeout(60 * 1000);
const mocks = {
getAllDataSources: jest.mocked(config.getAllDataSources),
searchFolders: jest.mocked(searchFolders),
api: {
discoverFeatures: jest.mocked(discoverFeatures),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
setRulerRuleGroup: jest.spyOn(ruler, 'setRulerRuleGroup'),
},
};
setupMswServer();
function renderRuleEditor(identifier?: string) {
locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`);
@ -95,10 +84,9 @@ describe('RuleEditor grafana managed rules', () => {
});
it('can edit grafana managed rule', async () => {
const uid = 'FOOBAR123';
const folder = {
title: 'Folder A',
uid: 'abcd',
uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1,
type: DashboardSearchItemType.DashDB,
};
@ -127,46 +115,20 @@ describe('RuleEditor grafana managed rules', () => {
},
});
setDataSourceSrv(new MockDataSourceSrv(dataSources));
setupDataSources(dataSources.default);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.api.fetchRulerRules.mockResolvedValue({
[folder.title]: [
{
interval: '1m',
name: 'group1',
rules: [
{
annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' },
for: '1m',
grafana_alert: {
uid,
namespace_uid: 'abcd',
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
no_data_state: GrafanaAlertStateDecision.NoData,
title: 'my great new rule',
},
},
],
},
],
});
// mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]);
renderRuleEditor(uid);
renderRuleEditor(grafanaRulerRule.grafana_alert.uid);
// check that it's filled in
const nameInput = await ui.inputs.name.find();
expect(nameInput).toHaveValue('my great new rule');
expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title);
//check that folder is in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.annotationValue(0).get()).toHaveValue('some summary');
expect(ui.inputs.annotationValue(1).get()).toHaveValue('some description');
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]);
//check that slashed folders are not in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
@ -195,25 +157,15 @@ describe('RuleEditor grafana managed rules', () => {
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'abcd',
grafanaRulerRule.grafana_alert.namespace_uid,
{
interval: '1m',
name: 'group1',
interval: grafanaRulerGroup.interval,
name: grafanaRulerGroup.name,
rules: [
{
annotations: { description: 'some description', summary: 'some summary', custom: 'value' },
labels: { severity: 'warn', team: 'the a-team' },
for: '1m',
grafana_alert: {
uid,
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
notification_settings: undefined,
is_paused: false,
no_data_state: 'NoData',
title: 'my great new rule',
},
...grafanaRulerRule,
annotations: { ...grafanaRulerRule.annotations, custom: 'value' },
grafana_alert: { ...grafanaRulerRule.grafana_alert, namespace_uid: undefined, rule_group: undefined },
},
],
}

View File

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

View File

@ -203,6 +203,13 @@ export const alertRuleApi = alertingApi.injectEndpoints({
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
rulerRuleGroup: build.query<
RulerRuleGroupDTO,
@ -219,6 +226,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({
// TODO: In future, if supported in other rulers, parametrize ruler source name
// For now, to make the consumption of this hook clearer, only support Grafana ruler
query: ({ uid }) => ({ url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rule/${uid}` }),
providesTags: (_result, _error, { uid }) => [{ type: 'GrafanaRulerRule', id: uid }],
}),
exportRules: build.query<string, ExportRulesParams>({

View File

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

View File

@ -5,10 +5,10 @@ import { render, waitFor, waitForElementToBeRemoved, userEvent } from 'test/test
import { byRole, byTestId, byText } from 'testing-library-selector';
import { DashboardSearchItemType } from '../../../../search/types';
import { mockAlertRuleApi, mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi';
import { getGrafanaRule, mockDashboardSearchItem, mockDataSource } from '../../mocks';
import { mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi';
import { mockDashboardSearchItem, mockDataSource } from '../../mocks';
import { grafanaRulerRule } from '../../mocks/alertRuleApi';
import { setupDataSources } from '../../testSetup/datasources';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import GrafanaModifyExport from './GrafanaModifyExport';
@ -31,7 +31,7 @@ jest.mock('@grafana/ui', () => ({
}));
const ui = {
loading: byText('Loading the rule'),
loading: byText('Loading the rule...'),
form: {
nameInput: byRole('textbox', { name: 'name' }),
folder: byTestId('folder-picker'),
@ -66,55 +66,27 @@ const server = setupMswServer();
describe('GrafanaModifyExport', () => {
setupDataSources(dataSources.default);
const grafanaRule = getGrafanaRule(undefined, {
uid: 'test-rule-uid',
title: 'cpu-usage',
namespace_uid: 'folderUID1',
data: [
{
refId: 'A',
datasourceUid: dataSources.default.uid,
queryType: 'alerting',
relativeTimeRange: { from: 1000, to: 2000 },
model: {
refId: 'A',
expression: 'vector(1)',
queryType: 'alerting',
datasource: { uid: dataSources.default.uid, type: 'prometheus' },
},
},
],
});
it('Should render edit form for the specified rule', async () => {
mockSearchApi(server).search([
mockDashboardSearchItem({
title: grafanaRule.namespace.name,
uid: 'folderUID1',
title: grafanaRulerRule.grafana_alert.title,
uid: grafanaRulerRule.grafana_alert.namespace_uid,
url: '',
tags: [],
type: DashboardSearchItemType.DashFolder,
}),
]);
mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, {
[grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }],
});
mockAlertRuleApi(server).rulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, 'folderUID1', grafanaRule.group.name, {
name: grafanaRule.group.name,
interval: '1m',
rules: [grafanaRule.rulerRule!],
});
mockExportApi(server).modifiedExport('folderUID1', {
mockExportApi(server).modifiedExport(grafanaRulerRule.grafana_alert.namespace_uid, {
yaml: 'Yaml Export Content',
json: 'Json Export Content',
});
const user = userEvent.setup();
renderModifyExport('test-rule-uid');
renderModifyExport(grafanaRulerRule.grafana_alert.uid);
await waitForElementToBeRemoved(() => ui.loading.get());
expect(await ui.form.nameInput.find()).toHaveValue('cpu-usage');
expect(await ui.form.nameInput.find()).toHaveValue('Grafana-rule');
await user.click(ui.exportButton.get());

View File

@ -1,14 +1,13 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { useMemo } from 'react';
import { locationService } from '@grafana/runtime';
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { GrafanaRouteComponentProps } from '../../../../../core/navigation/types';
import { useDispatch } from '../../../../../types';
import { RuleIdentifier } from '../../../../../types/unified-alerting';
import { fetchEditableRuleAction, fetchRulesSourceBuildInfoAction } from '../../state/actions';
import { useRuleWithLocation } from '../../hooks/useCombinedRule';
import { stringifyErrorLike } from '../../utils/misc';
import { formValuesFromExistingRule } from '../../utils/rule-form';
import * as ruleId from '../../utils/rule-id';
import { isGrafanaRulerRule } from '../../utils/rules';
@ -19,79 +18,34 @@ import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExpor
interface GrafanaModifyExportProps extends GrafanaRouteComponentProps<{ id?: string }> {}
export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) {
const dispatch = useDispatch();
// Get rule source build info
const [ruleIdentifier, setRuleIdentifier] = useState<RuleIdentifier | undefined>(undefined);
useEffect(() => {
const identifier = ruleId.tryParse(match.params.id, true);
setRuleIdentifier(identifier);
const ruleIdentifier = useMemo<RuleIdentifier | undefined>(() => {
return ruleId.tryParse(match.params.id, true);
}, [match.params.id]);
const { loading: loadingBuildInfo = true } = useAsync(async () => {
if (ruleIdentifier) {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: ruleIdentifier.ruleSourceName }));
}
}, [dispatch, ruleIdentifier]);
// Get rule
const {
loading,
value: alertRule,
error,
} = useAsync(async () => {
if (!ruleIdentifier) {
return;
}
return await dispatch(fetchEditableRuleAction(ruleIdentifier)).unwrap();
}, [ruleIdentifier, loadingBuildInfo]);
if (!ruleIdentifier) {
return <div>Rule not found</div>;
}
if (loading) {
return <LoadingPlaceholder text="Loading the rule" />;
}
if (error) {
return (
<Alert title="Cannot load modify export" severity="error">
{error.message}
<ModifyExportWrapper>
<Alert title="Invalid rule ID" severity="error">
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>
<ModifyExportWrapper>
<RuleModifyExport ruleIdentifier={ruleIdentifier} />
</ModifyExportWrapper>
);
}
}
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>
);
}
interface ModifyExportWrapperProps {
children: React.ReactNode;
}
function ModifyExportWrapper({ children }: ModifyExportWrapperProps) {
return (
<AlertingPageWrapper
isLoading={loading}
navId="alert-list"
pageNav={{
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.',
}}
>
{alertRule && (
<ModifyExportRuleForm ruleForm={formValuesFromExistingRule(alertRule)} alertUid={match.params.id ?? ''} />
)}
{children}
</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 { debounce, take, uniqueId } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { FormProvider, useForm, useFormContext, Controller } from 'react-hook-form';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
@ -9,15 +9,12 @@ import { AsyncSelect, Box, Button, Field, Input, Label, Modal, Stack, Text, useS
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/services/context_srv';
import { createFolder } from 'app/features/manage-dashboards/state/actions';
import { AccessControlAction, useDispatch } from 'app/types';
import { CombinedRuleGroup } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { AccessControlAction } from 'app/types';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesAction } from '../../state/actions';
import { alertRuleApi } from '../../api/alertRuleApi';
import { grafanaRulerConfig } from '../../hooks/useCombinedRule';
import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules';
import { ProvisioningBadge } from '../Provisioning';
@ -30,21 +27,31 @@ import { checkForPathSeparator } from './util';
export const MAX_GROUP_RESULTS = 1000;
export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => {
const dispatch = useDispatch();
// fetch the ruler rules from the database so we can figure out what other "groups" are already defined
// for our folders
useEffect(() => {
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
}, [dispatch]);
const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } =
alertRuleApi.endpoints.rulerNamespace.useQuery(
{
namespace: folderUid,
rulerConfig: grafanaRulerConfig,
},
{
skip: !folderUid,
refetchOnMountOrArgChange: true,
}
);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
// There should be only one entry in the rulerNamespace object
// However it uses folder name as key, so to avoid fetching folder name, we use Object.values
const groupOptions = useMemo(() => {
if (!rulerNamespace) {
// still waiting for namespace information to be fetched
return [];
}
const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? [];
const folderGroups = Object.values(rulerNamespace).flat() ?? [];
const groupOptions = folderGroups
return folderGroups
.map<SelectableValue<string>>((group) => {
const isProvisioned = isProvisionedGroup(group);
return {
@ -58,14 +65,13 @@ export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups
})
.sort(sortByLabel);
}, [rulerNamespace, enableProvisionedGroups]);
return { groupOptions, loading: groupfoldersForGrafana?.loading };
return { groupOptions, loading: isLoadingRulerNamespace };
};
const isProvisionedGroup = (group: CombinedRuleGroup) => {
return group.rules.some(
(rule) => isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance) === true
);
const isProvisionedGroup = (group: RulerRuleGroupDTO) => {
return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true);
};
const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) => {

View File

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

View File

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

View File

@ -2,7 +2,14 @@ import { useEffect, useMemo } from 'react';
import { useAsync } from 'react-use';
import { useDispatch } from 'app/types';
import { CombinedRule, RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
import {
CombinedRule,
RuleIdentifier,
RuleNamespace,
RulerDataSourceConfig,
RulesSource,
RuleWithLocation,
} from 'app/types/unified-alerting';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
@ -18,11 +25,7 @@ import {
isRulerNotSupportedResponse,
} from '../utils/rules';
import {
attachRulerRulesToCombinedRules,
combineRulesNamespaces,
useCombinedRuleNamespaces,
} from './useCombinedRuleNamespaces';
import { attachRulerRulesToCombinedRules, useCombinedRuleNamespaces } from './useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
export function useCombinedRulesMatching(
@ -162,15 +165,25 @@ function getRequestState(
return state;
}
export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): {
interface RequestState<T> {
result?: T;
loading: boolean;
result?: CombinedRule;
error?: unknown;
} {
}
// Many places still use the old way of fetching code so synchronizing cache expiration is difficult
// Hence, this hook fetches a fresh version of a rule most of the time
// Due to enabled filtering for Prometheus and Ruler rules it shouldn't be a problem
export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): RequestState<CombinedRule> {
const { ruleSourceName } = ruleIdentifier;
const dsSettings = getDataSourceByName(ruleSourceName);
const ruleSource = getRulesSourceFromIdentifier(ruleIdentifier);
const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName);
const {
loading: isLoadingRuleLocation,
error: ruleLocationError,
result: ruleLocation,
} = useRuleLocation(ruleIdentifier);
const {
currentData: promRuleNs,
@ -178,85 +191,47 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti
error: promRuleNsError,
} = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery(
{
// TODO Refactor parameters
ruleSourceName: ruleIdentifier.ruleSourceName,
namespace:
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
? ruleIdentifier.namespace
: undefined,
groupName:
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
? ruleIdentifier.groupName
: undefined,
ruleName:
isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
? ruleIdentifier.ruleName
: undefined,
namespace: ruleLocation?.namespace,
groupName: ruleLocation?.group,
ruleName: ruleLocation?.ruleName,
},
{
skip: !ruleLocation || isLoadingRuleLocation,
refetchOnMountOrArgChange: true,
}
// TODO experiment with enabling these now that we request a single alert rule more efficiently.
// Requires a recent version of Prometheus with support for query params on /api/v1/rules
// {
// refetchOnFocus: true,
// refetchOnReconnect: true,
// }
);
const [
fetchRulerRuleGroup,
{ currentData: rulerRuleGroup, isLoading: isLoadingRulerGroup, error: rulerRuleGroupError },
{
currentData: rulerRuleGroup,
isLoading: isLoadingRulerGroup,
error: rulerRuleGroupError,
isUninitialized: rulerRuleGroupUninitialized,
},
] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery();
const [fetchRulerRules, { currentData: rulerRules, isLoading: isLoadingRulerRules, error: rulerRulesError }] =
alertRuleApi.endpoints.rulerRules.useLazyQuery();
useEffect(() => {
if (!dsFeatures?.rulerConfig) {
if (!dsFeatures?.rulerConfig || !ruleLocation) {
return;
}
if (dsFeatures.rulerConfig && isCloudRuleIdentifier(ruleIdentifier)) {
fetchRulerRuleGroup({
rulerConfig: dsFeatures.rulerConfig,
namespace: ruleIdentifier.namespace,
group: ruleIdentifier.groupName,
namespace: ruleLocation.namespace,
group: ruleLocation.group,
});
} 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]);
}, [dsFeatures, fetchRulerRuleGroup, ruleLocation]);
const rule = useMemo(() => {
if (!promRuleNs) {
if (!promRuleNs || !ruleSource) {
return;
}
if (isGrafanaRuleIdentifier(ruleIdentifier)) {
const combinedNamespaces = combineRulesNamespaces('grafana', promRuleNs, rulerRules);
for (const namespace of combinedNamespaces) {
for (const group of namespace.groups) {
for (const rule of group.rules) {
const id = ruleId.fromCombinedRule(ruleSourceName, rule);
if (ruleId.equal(id, ruleIdentifier)) {
return rule;
}
}
}
}
}
if (!dsSettings) {
return;
}
if (
promRuleNs.length > 0 &&
(isCloudRuleIdentifier(ruleIdentifier) || isPrometheusRuleIdentifier(ruleIdentifier))
) {
if (promRuleNs.length > 0) {
const namespaces = promRuleNs.map((ns) =>
attachRulerRulesToCombinedRules(dsSettings, ns, rulerRuleGroup ? [rulerRuleGroup] : [])
attachRulerRulesToCombinedRules(ruleSource, ns, rulerRuleGroup ? [rulerRuleGroup] : [])
);
for (const namespace of namespaces) {
@ -273,15 +248,147 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti
}
return;
}, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, rulerRules, dsSettings]);
}, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource]);
return {
loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || isLoadingRulerRules,
error: promRuleNsError ?? rulerRuleGroupError ?? rulerRulesError,
loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || rulerRuleGroupUninitialized,
error: ruleLocationError ?? promRuleNsError ?? rulerRuleGroupError,
result: rule,
};
}
interface RuleLocation {
namespace: string;
group: string;
ruleName: string;
}
function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<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 = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
apiVersion: 'legacy',

View File

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

View File

@ -1,9 +1,15 @@
import { http, HttpResponse } from 'msw';
import { SetupServer } from 'msw/node';
import { PromRulesResponse } from 'app/types/unified-alerting-dto';
import {
GrafanaAlertStateDecision,
PromRulesResponse,
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
} from 'app/types/unified-alerting-dto';
import { PreviewResponse, PREVIEW_URL, PROM_RULES_URL } from '../api/alertRuleApi';
import { Annotation } from '../utils/constants';
export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) {
server.use(http.post(PREVIEW_URL, () => HttpResponse.json(result)));
@ -12,3 +18,65 @@ export function mockPreviewApiResponse(server: SetupServer, result: PreviewRespo
export function mockPromRulesApiResponse(server: SetupServer, result: PromRulesResponse) {
server.use(http.get(PROM_RULES_URL, () => HttpResponse.json(result)));
}
const grafanaRulerGroupName = 'grafana-group-1';
export const grafanaRulerNamespace = { name: 'test-folder-1', uid: 'uuid020c61ef' };
export const grafanaRulerNamespace2 = { name: 'test-folder-2', uid: '6abdb25bc1eb' };
export const grafanaRulerRule: RulerGrafanaRuleDTO = {
for: '5m',
labels: {
severity: 'critical',
region: 'nasa',
},
annotations: {
[Annotation.summary]: 'Test alert',
},
grafana_alert: {
uid: '4d7125fee983',
title: 'Grafana-rule',
namespace_uid: 'uuid020c61ef',
rule_group: grafanaRulerGroupName,
data: [
{
refId: 'A',
datasourceUid: 'datasource-uid',
queryType: 'alerting',
relativeTimeRange: { from: 1000, to: 2000 },
model: {
refId: 'A',
expression: 'vector(1)',
queryType: 'alerting',
datasource: { uid: 'datasource-uid', type: 'prometheus' },
},
},
],
condition: 'A',
no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Error,
is_paused: false,
notification_settings: undefined,
},
};
export const grafanaRulerGroup: RulerRuleGroupDTO = {
name: grafanaRulerGroupName,
interval: '1m',
rules: [grafanaRulerRule],
};
export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = {
name: 'empty-group',
interval: '1m',
rules: [],
};
export const namespaceByUid: Record<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,
...pluginsHandlers,
...silenceHandlers,
...alertRuleHandlers,
];
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';
const alertRuleDetailsHandler = () =>
http.get<{ folderUid: string }>(`/api/ruler/:ruler/api/v1/rule/:uid`, () => {
// TODO: Scaffold out alert rule response logic as this endpoint is used more in tests
return HttpResponse.json({
grafana_alert: {
title: MOCK_GRAFANA_ALERT_RULE_TITLE,
},
});
});
import {
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from '../../../../../../types/unified-alerting-dto';
import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi';
const handlers = [alertRuleDetailsHandler()];
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 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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