123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642 |
- import { Matcher, render, waitFor, screen } from '@testing-library/react';
- import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
- import React from 'react';
- import { Provider } from 'react-redux';
- import { Route, Router } from 'react-router-dom';
- import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
- import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
- import { DataSourceInstanceSettings } from '@grafana/data';
- import { BackendSrv, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
- import { contextSrv } from 'app/core/services/context_srv';
- import { DashboardSearchHit } from 'app/features/search/types';
- import { configureStore } from 'app/store/configureStore';
- import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto';
- import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
- import RuleEditor from './RuleEditor';
- import { discoverFeatures } from './api/buildInfo';
- import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
- import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
- import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks';
- import { getAllDataSources } from './utils/config';
- import { DataSourceType, 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('./utils/config');
- 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>,
- }));
- const mocks = {
- getAllDataSources: jest.mocked(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),
- },
- };
- function renderRuleEditor(identifier?: string) {
- const store = configureStore();
- locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`);
- return render(
- <Provider store={store}>
- <Router history={locationService.getHistory()}>
- <Route path={['/alerting/new', '/alerting/:id/edit']} component={RuleEditor} />
- </Router>
- </Provider>
- );
- }
- const ui = {
- inputs: {
- name: byLabelText('Rule 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/ }),
- // alert type buttons
- grafanaManagedAlert: byRole('button', { name: /Grafana managed/ }),
- lotexAlert: byRole('button', { name: /Mimir or Loki alert/ }),
- lotexRecordingRule: byRole('button', { name: /Mimir or Loki recording rule/ }),
- },
- };
- describe('RuleEditor', () => {
- beforeEach(() => {
- jest.resetAllMocks();
- contextSrv.isEditor = true;
- contextSrv.hasEditPermissionInFolders = true;
- });
- disableRBAC();
- 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: [],
- },
- ],
- });
- mocks.searchFolders.mockResolvedValue([]);
- mocks.api.discoverFeatures.mockResolvedValue({
- application: PromApplication.Lotex,
- features: {
- rulerApiEnabled: true,
- },
- });
- await renderRuleEditor();
- await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
- await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled());
- await userEvent.click(await ui.buttons.lotexAlert.find());
- const dataSourceSelect = ui.inputs.dataSource.get();
- await userEvent.click(byRole('combobox').get(dataSourceSelect));
- await clickSelectOption(dataSourceSelect, 'Prom (default)');
- await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
- await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
- await userEvent.type(ui.inputs.name.get(), 'my great new rule');
- await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
- await clickSelectOption(ui.inputs.group.get(), 'group2');
- await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
- await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
- // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
- await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never });
- 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
- await userEvent.click(ui.buttons.save.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: 'Prom', apiVersion: 'legacy' },
- '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 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: [],
- },
- ],
- });
- mocks.searchFolders.mockResolvedValue([
- {
- title: 'Folder A',
- id: 1,
- },
- {
- title: 'Folder B',
- id: 2,
- },
- ] as DashboardSearchHit[]);
- mocks.api.discoverFeatures.mockResolvedValue({
- application: PromApplication.Prometheus,
- features: {
- rulerApiEnabled: false,
- },
- });
- // fill out the form
- await renderRuleEditor();
- await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
- await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled());
- await userEvent.type(await ui.inputs.name.find(), 'my great new rule');
- const folderInput = await ui.inputs.folder.find();
- await clickSelectOption(folderInput, 'Folder A');
- const groupInput = screen.getByRole('textbox', { name: /^Group/ });
- await userEvent.type(groupInput, 'my group');
- await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
- await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
- // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
- await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never });
- 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
- await userEvent.click(ui.buttons.save.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
- 'Folder A',
- {
- interval: '1m',
- name: 'my group',
- 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('can create a new cloud recording rule', 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: [],
- },
- ],
- });
- mocks.searchFolders.mockResolvedValue([]);
- mocks.api.discoverFeatures.mockResolvedValue({
- application: PromApplication.Lotex,
- features: {
- rulerApiEnabled: true,
- },
- });
- await renderRuleEditor();
- await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
- await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled());
- await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule');
- await userEvent.click(await ui.buttons.lotexRecordingRule.get());
- const dataSourceSelect = ui.inputs.dataSource.get();
- await userEvent.click(byRole('combobox').get(dataSourceSelect));
- await clickSelectOption(dataSourceSelect, 'Prom (default)');
- await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
- await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
- await clickSelectOption(ui.inputs.group.get(), 'group2');
- await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
- // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
- await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never });
- await userEvent.type(ui.inputs.labelKey(1).get(), 'team');
- await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team');
- // try to save, find out that recording rule name is invalid
- await userEvent.click(ui.buttons.save.get());
- await waitFor(() =>
- expect(
- byText(
- 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.'
- ).get()
- ).toBeInTheDocument()
- );
- expect(mocks.api.setRulerRuleGroup).not.toBeCalled();
- // fix name and re-submit
- await userEvent.clear(await ui.inputs.name.find());
- await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule');
- // save and check what was sent to backend
- await userEvent.click(ui.buttons.save.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: 'Prom', apiVersion: 'legacy' },
- 'namespace2',
- {
- name: 'group2',
- rules: [
- {
- record: 'my:great:new:recording:rule',
- labels: { team: 'the a-team' },
- expr: 'up == 1',
- },
- ],
- }
- );
- });
- it('can edit grafana managed rule', async () => {
- const uid = 'FOOBAR123';
- const folder = {
- title: 'Folder A',
- uid: 'abcd',
- id: 1,
- };
- const getFolderByUid = jest.fn().mockResolvedValue({
- ...folder,
- canSave: true,
- });
- const dataSources = {
- default: mockDataSource({
- type: 'prometheus',
- name: 'Prom',
- isDefault: true,
- }),
- };
- const backendSrv = {
- getFolderByUid,
- } as any as BackendSrv;
- setBackendSrv(backendSrv);
- setDataSourceSrv(new MockDataSourceSrv(dataSources));
- mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
- mocks.api.setRulerRuleGroup.mockResolvedValue();
- mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
- mocks.api.fetchRulerRules.mockResolvedValue({
- [folder.title]: [
- {
- 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: {
- uid,
- namespace_uid: 'abcd',
- namespace_id: 1,
- condition: 'B',
- data: getDefaultQueries(),
- exec_err_state: GrafanaAlertStateDecision.Alerting,
- no_data_state: GrafanaAlertStateDecision.NoData,
- title: 'my great new rule',
- },
- },
- ],
- },
- ],
- });
- mocks.searchFolders.mockResolvedValue([folder] as DashboardSearchHit[]);
- await renderRuleEditor(uid);
- await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
- await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled());
- await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
- // check that it's filled in
- const nameInput = await ui.inputs.name.find();
- expect(nameInput).toHaveValue('my great new rule');
- expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
- expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description');
- expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary');
- // add an annotation
- await clickSelectOption(ui.inputs.annotationKey(2).get(), /Add new/);
- await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom');
- await userEvent.type(ui.inputs.annotationValue(2).get(), 'value');
- //add a label
- await userEvent.type(ui.inputs.labelKey(2).get(), 'custom');
- await userEvent.type(ui.inputs.labelValue(2).get(), 'value');
- // save and check what was sent to backend
- await userEvent.click(ui.buttons.save.get());
- await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
- expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
- { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
- 'Folder A',
- {
- interval: '1m',
- name: 'my great new rule',
- rules: [
- {
- annotations: { description: 'some description', summary: 'some summary', custom: 'value' },
- labels: { severity: 'warn', team: 'the a-team', custom: 'value' },
- for: '5m',
- grafana_alert: {
- uid,
- 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<string, DataSourceInstanceSettings<any>> = {
- // can edit rules
- loki: mockDataSource(
- {
- type: DataSourceType.Loki,
- name: 'loki with ruler',
- },
- { alerting: true }
- ),
- loki_disabled: mockDataSource(
- {
- type: DataSourceType.Loki,
- name: 'loki disabled for alerting',
- jsonData: {
- manageAlerts: false,
- },
- },
- { 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.discoverFeatures.mockImplementation(async (dataSourceName) => {
- if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') {
- return {
- application: PromApplication.Lotex,
- features: {
- rulerApiEnabled: true,
- alertManagerConfigApi: false,
- federatedRules: false,
- querySharding: false,
- },
- };
- }
- if (dataSourceName === 'loki with local rule store') {
- return {
- application: PromApplication.Lotex,
- features: {
- rulerApiEnabled: false,
- alertManagerConfigApi: false,
- federatedRules: false,
- querySharding: false,
- },
- };
- }
- if (dataSourceName === 'cortex without ruler api') {
- return {
- application: PromApplication.Lotex,
- features: {
- rulerApiEnabled: false,
- alertManagerConfigApi: false,
- federatedRules: false,
- querySharding: false,
- },
- };
- }
- throw new Error(`${dataSourceName} not handled`);
- });
- mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => {
- 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));
- mocks.searchFolders.mockResolvedValue([]);
- // render rule editor, select mimir/loki managed alerts
- await renderRuleEditor();
- await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled());
- await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
- await ui.inputs.name.find();
- await userEvent.click(await ui.buttons.lotexAlert.get());
- // check that only rules sources that have ruler available are there
- const dataSourceSelect = ui.inputs.dataSource.get();
- await userEvent.click(byRole('combobox').get(dataSourceSelect));
- expect(await byText('loki with ruler').query()).toBeInTheDocument();
- expect(byText('cortex with ruler').query()).toBeInTheDocument();
- expect(byText('loki with local rule store').query()).not.toBeInTheDocument();
- expect(byText('prom without ruler api').query()).not.toBeInTheDocument();
- expect(byText('splunk').query()).not.toBeInTheDocument();
- expect(byText('loki disabled for alerting').query()).not.toBeInTheDocument();
- });
- });
- const clickSelectOption = async (selectElement: HTMLElement, optionText: Matcher): Promise<void> => {
- await userEvent.click(byRole('combobox').get(selectElement));
- await selectOptionInTest(selectElement, optionText as string);
- };
|