diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx
new file mode 100644
index 00000000000..8a8a745253b
--- /dev/null
+++ b/public/app/features/alerting/unified/RuleEditor.test.tsx
@@ -0,0 +1,320 @@
+import { Matcher, render, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { locationService, setDataSourceSrv, setBackendSrv, BackendSrv } from '@grafana/runtime';
+import { configureStore } from 'app/store/configureStore';
+import RuleEditor from './RuleEditor';
+import { Router, Route } from 'react-router-dom';
+import React from 'react';
+import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
+import { contextSrv } from 'app/core/services/context_srv';
+import { mockDataSource, MockDataSourceSrv } from './mocks';
+import userEvent from '@testing-library/user-event';
+import { DataSourceInstanceSettings } from '@grafana/data';
+import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
+import { getAllDataSources } from './utils/config';
+import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
+import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
+import { DashboardSearchHit } from 'app/features/search/types';
+import { getDefaultQueries } from './utils/rule-form';
+import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
+
+jest.mock('./components/rule-editor/ExpressionEditor', () => ({
+ // eslint-disable-next-line react/display-name
+ ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
+ onChange(e.target.value)} />
+ ),
+}));
+
+jest.mock('./api/ruler');
+jest.mock('./utils/config');
+
+// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
+// lets just skip it
+jest.mock('app/features/query/components/QueryEditorRow', () => ({
+ // eslint-disable-next-line react/display-name
+ QueryEditorRow: () =>
hi
,
+}));
+
+const mocks = {
+ getAllDataSources: typeAsJestMock(getAllDataSources),
+
+ api: {
+ fetchRulerRulesGroup: typeAsJestMock(fetchRulerRulesGroup),
+ setRulerRuleGroup: typeAsJestMock(setRulerRuleGroup),
+ fetchRulerRulesNamespace: typeAsJestMock(fetchRulerRulesNamespace),
+ fetchRulerRules: typeAsJestMock(fetchRulerRules),
+ },
+};
+
+function renderRuleEditor(identifier?: string) {
+ const store = configureStore();
+
+ locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`);
+
+ return render(
+
+
+
+
+
+ );
+}
+
+const ui = {
+ inputs: {
+ name: byLabelText('Alert name'),
+ alertType: byTestId('alert-type-picker'),
+ dataSource: byTestId('datasource-picker'),
+ folder: byTestId('folder-picker'),
+ namespace: byTestId('namespace-picker'),
+ group: byTestId('group-picker'),
+ annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`),
+ annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
+ labelKey: (idx: number) => byTestId(`label-key-${idx}`),
+ labelValue: (idx: number) => byTestId(`label-value-${idx}`),
+ expr: byTestId('expr'),
+ },
+ buttons: {
+ save: byRole('button', { name: 'Save' }),
+ addAnnotation: byRole('button', { name: /Add info/ }),
+ addLabel: byRole('button', { name: /Add label/ }),
+ },
+};
+
+describe('RuleEditor', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ contextSrv.isEditor = true;
+ });
+
+ it('can create a new cloud alert', async () => {
+ const dataSources = {
+ default: mockDataSource(
+ {
+ type: 'prometheus',
+ name: 'Prom',
+ isDefault: true,
+ },
+ { alerting: true }
+ ),
+ };
+
+ setDataSourceSrv(new MockDataSourceSrv(dataSources));
+ mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
+ mocks.api.setRulerRuleGroup.mockResolvedValue();
+ mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
+ mocks.api.fetchRulerRulesGroup.mockResolvedValue({
+ name: 'group2',
+ rules: [],
+ });
+ mocks.api.fetchRulerRules.mockResolvedValue({
+ namespace1: [
+ {
+ name: 'group1',
+ rules: [],
+ },
+ ],
+ namespace2: [
+ {
+ name: 'group2',
+ rules: [],
+ },
+ ],
+ });
+
+ await renderRuleEditor();
+ await userEvent.type(await ui.inputs.name.find(), 'my great new rule');
+ clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/);
+ const dataSourceSelect = ui.inputs.dataSource.get();
+ userEvent.click(byRole('textbox').get(dataSourceSelect));
+ userEvent.click(await byText('Prom (default)').find(dataSourceSelect));
+ await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
+ clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
+ clickSelectOption(ui.inputs.group.get(), 'group2');
+
+ await userEvent.type(ui.inputs.expr.get(), 'up == 1');
+
+ await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
+ await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
+
+ userEvent.click(ui.buttons.addLabel.get());
+
+ await userEvent.type(ui.inputs.labelKey(0).get(), 'severity');
+ await userEvent.type(ui.inputs.labelValue(0).get(), 'warn');
+ await userEvent.type(ui.inputs.labelKey(1).get(), 'team');
+ await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team');
+
+ // save and check what was sent to backend
+ userEvent.click(ui.buttons.save.get());
+ await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
+ expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith('Prom', 'namespace2', {
+ name: 'group2',
+ rules: [
+ {
+ alert: 'my great new rule',
+ annotations: { description: 'some description', summary: 'some summary' },
+ labels: { severity: 'warn', team: 'the a-team' },
+ expr: 'up == 1',
+ for: '1m',
+ },
+ ],
+ });
+ });
+
+ it('can create new grafana managed alert', async () => {
+ const searchFolderMock = jest.fn().mockResolvedValue([
+ {
+ title: 'Folder A',
+ id: 1,
+ },
+ {
+ title: 'Folder B',
+ id: 2,
+ },
+ ] as DashboardSearchHit[]);
+
+ const dataSources = {
+ default: mockDataSource({
+ type: 'prometheus',
+ name: 'Prom',
+ isDefault: true,
+ }),
+ };
+
+ const backendSrv = ({
+ search: searchFolderMock,
+ } as any) as BackendSrv;
+ setBackendSrv(backendSrv);
+ setDataSourceSrv(new MockDataSourceSrv(dataSources));
+ mocks.api.setRulerRuleGroup.mockResolvedValue();
+ mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
+
+ // fill out the form
+ await renderRuleEditor();
+ userEvent.type(await ui.inputs.name.find(), 'my great new rule');
+ clickSelectOption(ui.inputs.alertType.get(), /Classic Grafana alerts based on thresholds/);
+ const folderInput = await ui.inputs.folder.find();
+ await waitFor(() => expect(searchFolderMock).toHaveBeenCalled());
+ clickSelectOption(folderInput, 'Folder A');
+
+ await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
+ await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
+
+ userEvent.click(ui.buttons.addLabel.get());
+
+ await userEvent.type(ui.inputs.labelKey(0).get(), 'severity');
+ await userEvent.type(ui.inputs.labelValue(0).get(), 'warn');
+ await userEvent.type(ui.inputs.labelKey(1).get(), 'team');
+ await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team');
+
+ // save and check what was sent to backend
+ userEvent.click(ui.buttons.save.get());
+ await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
+ expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, 'Folder A', {
+ interval: '1m',
+ name: 'my great new rule',
+ rules: [
+ {
+ annotations: { description: 'some description', summary: 'some summary' },
+ labels: { severity: 'warn', team: 'the a-team' },
+ for: '5m',
+ grafana_alert: {
+ condition: 'B',
+ data: getDefaultQueries(),
+ exec_err_state: 'Alerting',
+ no_data_state: 'NoData',
+ title: 'my great new rule',
+ },
+ },
+ ],
+ });
+ });
+
+ it('for cloud alerts, should only allow to select editable rules sources', async () => {
+ const dataSources: Record> = {
+ // can edit rules
+ loki: mockDataSource(
+ {
+ type: DataSourceType.Loki,
+ name: 'loki with ruler',
+ },
+ { alerting: true }
+ ),
+ // can edit rules
+ prom: mockDataSource(
+ {
+ type: DataSourceType.Prometheus,
+ name: 'cortex with ruler',
+ },
+ { alerting: true }
+ ),
+ // cannot edit rules
+ loki_local_rule_store: mockDataSource(
+ {
+ type: DataSourceType.Loki,
+ name: 'loki with local rule store',
+ },
+ { alerting: true }
+ ),
+ // cannot edit rules
+ prom_no_ruler_api: mockDataSource(
+ {
+ type: DataSourceType.Loki,
+ name: 'cortex without ruler api',
+ },
+ { alerting: true }
+ ),
+ // not a supported datasource type
+ splunk: mockDataSource(
+ {
+ type: 'splunk',
+ name: 'splunk',
+ },
+ { alerting: true }
+ ),
+ };
+
+ mocks.api.fetchRulerRulesGroup.mockImplementation(async (dataSourceName: string) => {
+ if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') {
+ return null;
+ }
+ if (dataSourceName === 'loki with local rule store') {
+ throw {
+ status: 400,
+ data: {
+ message: 'GetRuleGroup unsupported in rule local store',
+ },
+ };
+ }
+ if (dataSourceName === 'cortex without ruler api') {
+ throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?');
+ }
+ return null;
+ });
+
+ setDataSourceSrv(new MockDataSourceSrv(dataSources));
+ mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
+
+ // render rule editor, select cortex/loki managed alerts
+ await renderRuleEditor();
+ await ui.inputs.name.find();
+ clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/);
+
+ // wait for ui theck each datasource if it supports rule editing
+ await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalledTimes(4));
+
+ // check that only rules sources that have ruler available are there
+ const dataSourceSelect = ui.inputs.dataSource.get();
+ userEvent.click(byRole('textbox').get(dataSourceSelect));
+ expect(await byText('loki with ruler').find(dataSourceSelect)).toBeInTheDocument();
+ expect(byText('cortex with ruler').query(dataSourceSelect)).toBeInTheDocument();
+ expect(byText('loki with local rule store').query(dataSourceSelect)).not.toBeInTheDocument();
+ expect(byText('prom without ruler api').query(dataSourceSelect)).not.toBeInTheDocument();
+ expect(byText('splunk').query(dataSourceSelect)).not.toBeInTheDocument();
+ });
+});
+
+const clickSelectOption = (selectElement: HTMLElement, optionText: Matcher): void => {
+ userEvent.click(byRole('textbox').get(selectElement));
+ userEvent.click(byText(optionText).get(selectElement));
+};
diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx
index f4c2e445cac..d22917be733 100644
--- a/public/app/features/alerting/unified/RuleEditor.tsx
+++ b/public/app/features/alerting/unified/RuleEditor.tsx
@@ -22,7 +22,7 @@ const ExistingRuleEditor: FC = ({ identifier }) => {
useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule);
const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
const dispatch = useDispatch();
- const { isEditable } = useIsRuleEditable(result?.rule);
+ const { isEditable } = useIsRuleEditable(ruleId.ruleIdentifierToRuleSourceName(identifier), result?.rule);
useEffect(() => {
if (!dispatched) {
diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts
index 407dff5cdf9..fb16081bb08 100644
--- a/public/app/features/alerting/unified/api/prometheus.ts
+++ b/public/app/features/alerting/unified/api/prometheus.ts
@@ -1,7 +1,7 @@
import { getBackendSrv } from '@grafana/runtime';
import { RuleNamespace } from 'app/types/unified-alerting';
import { PromRulesResponse } from 'app/types/unified-alerting-dto';
-import { getAllRulesSourceNames, getDatasourceAPIId } from '../utils/datasource';
+import { getDatasourceAPIId } from '../utils/datasource';
export async function fetchRules(dataSourceName: string): Promise {
const response = await getBackendSrv()
@@ -10,12 +10,18 @@ export async function fetchRules(dataSourceName: string): Promise {
+ if ('status' in e && e.status === 404) {
+ throw new Error('404 from rule state endpoint. Perhaps ruler API is not enabled?');
+ }
+ throw e;
+ });
const nsMap: { [key: string]: RuleNamespace } = {};
response.data.data.groups.forEach((group) => {
group.rules.forEach((rule) => {
- rule.query = rule.query || ''; // @TODO temp fix, backend response ism issing query. remove once it's there
+ rule.query = rule.query || '';
});
if (!nsMap[group.file]) {
nsMap[group.file] = {
@@ -30,17 +36,3 @@ export async function fetchRules(dataSourceName: string): Promise {
- const namespaces = [] as Array>;
- getAllRulesSourceNames().forEach(async (name) => {
- namespaces.push(
- fetchRules(name).catch((e) => {
- return [];
- // TODO add error comms
- })
- );
- });
- const promises = await Promise.all(namespaces);
- return promises.flat();
-}
diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts
index 23dca2687ea..2508dcb5601 100644
--- a/public/app/features/alerting/unified/api/ruler.ts
+++ b/public/app/features/alerting/unified/api/ruler.ts
@@ -75,8 +75,11 @@ async function rulerGetRequest(url: string, empty: T): Promise {
.toPromise();
return response.data;
} catch (e) {
- if (e?.status === 404 || e?.data?.message?.includes('group does not exist')) {
- return empty;
+ if (e?.status === 404) {
+ if (e?.data?.message?.includes('group does not exist') || e?.data?.message?.includes('no rule groups found')) {
+ return empty;
+ }
+ throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?');
} else if (
e?.status === 500 &&
e?.data?.message?.includes('unexpected content type from upstream. expected YAML, got text/html')
diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
index 231827715e3..29d91ee093b 100644
--- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx
@@ -1,32 +1,14 @@
-import React, { FC, useCallback, useEffect } from 'react';
+import React, { FC, useMemo } from 'react';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Field, Input, InputControl, Select, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
-
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
-import { DataSourcePicker } from '@grafana/runtime';
-import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler';
import { RuleFolderPicker } from './RuleFolderPicker';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { contextSrv } from 'app/core/services/context_srv';
-
-const alertTypeOptions: SelectableValue[] = [
- {
- label: 'Grafana managed alert',
- value: RuleFormType.grafana,
- description: 'Classic Grafana alerts based on thresholds.',
- },
-];
-
-if (contextSrv.isEditor) {
- alertTypeOptions.push({
- label: 'Cortex/Loki managed alert',
- value: RuleFormType.cloud,
- description: 'Alert based on a system or application behavior. Based on Prometheus.',
- });
-}
+import { CloudRulesSourcePicker } from './CloudRulesSourcePicker';
interface Props {
editingExistingRule: boolean;
@@ -46,21 +28,25 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => {
const ruleFormType = watch('type');
const dataSourceName = watch('dataSourceName');
- useEffect(() => {}, [ruleFormType]);
+ const alertTypeOptions = useMemo((): SelectableValue[] => {
+ const result = [
+ {
+ label: 'Grafana managed alert',
+ value: RuleFormType.grafana,
+ description: 'Classic Grafana alerts based on thresholds.',
+ },
+ ];
- const rulesSourcesWithRuler = useRulesSourcesWithRuler();
+ if (contextSrv.isEditor) {
+ result.push({
+ label: 'Cortex/Loki managed alert',
+ value: RuleFormType.cloud,
+ description: 'Alert based on a system or application behavior. Based on Prometheus.',
+ });
+ }
- const dataSourceFilter = useCallback(
- (ds: DataSourceInstanceSettings): boolean => {
- if (ruleFormType === RuleFormType.grafana) {
- return !!ds.meta.alerting;
- } else {
- // filter out only rules sources that support ruler and thus can have alerts edited
- return !!rulesSourcesWithRuler.find(({ id }) => id === ds.id);
- }
- },
- [ruleFormType, rulesSourcesWithRuler]
- );
+ return result;
+ }, []);
return (
@@ -71,6 +57,7 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => {
invalid={!!errors.name?.message}
>
@@ -82,25 +69,11 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => {
className={styles.formInput}
error={errors.type?.message}
invalid={!!errors.type?.message}
+ data-testid="alert-type-picker"
>
(
-