From d03f75726b8135f33e6c284c00dc8814f875f77e Mon Sep 17 00:00:00 2001 From: Nathan Rodman Date: Mon, 20 Sep 2021 15:32:25 -0700 Subject: [PATCH] Alerting: Add filtering for Silences (#39109) * Add filtering for Silences page * Add tests Silences and SilenceEditor * pr feedback: add field validation and test refactor * Add test for checking content * fix overflow for validation error message * increase login threshold for pa11y * Make silence filter state its own type and function --- .pa11yci-pr.conf.js | 2 +- .../alerting/unified/Silences.test.tsx | 247 ++++++++++++++++++ .../components/silences/MatchersField.tsx | 4 +- .../components/silences/SilencePeriod.tsx | 1 + .../components/silences/SilenceTableRow.tsx | 4 +- .../components/silences/SilencesEditor.tsx | 22 +- .../components/silences/SilencesFilter.tsx | 109 ++++++++ .../components/silences/SilencesTable.tsx | 149 +++++++---- public/app/features/alerting/unified/mocks.ts | 19 +- .../features/alerting/unified/utils/misc.ts | 12 +- public/app/types/unified-alerting.ts | 6 + 11 files changed, 510 insertions(+), 65 deletions(-) create mode 100644 public/app/features/alerting/unified/Silences.test.tsx create mode 100644 public/app/features/alerting/unified/components/silences/SilencesFilter.tsx diff --git a/.pa11yci-pr.conf.js b/.pa11yci-pr.conf.js index 9e5f55daaa2..fde20070e96 100644 --- a/.pa11yci-pr.conf.js +++ b/.pa11yci-pr.conf.js @@ -16,7 +16,7 @@ var config = { "click element button[aria-label='Login button']", "wait for element [aria-label='Skip change password button'] to be visible", ], - threshold: 2, + threshold: 3, }, { url: '${HOST}/?orgId=1', diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx new file mode 100644 index 00000000000..4b8280c9c65 --- /dev/null +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { dateTime } from '@grafana/data'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { fetchSilences, fetchAlerts, createOrUpdateSilence } from './api/alertmanager'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { configureStore } from 'app/store/configureStore'; +import Silences from './Silences'; +import { mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks'; +import { DataSourceType } from './utils/datasource'; +import { parseMatchers } from './utils/alertmanager'; +import { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; +import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; +import userEvent from '@testing-library/user-event'; + +jest.mock('./api/alertmanager'); + +const mocks = { + api: { + fetchSilences: typeAsJestMock(fetchSilences), + fetchAlerts: typeAsJestMock(fetchAlerts), + createOrUpdateSilence: typeAsJestMock(createOrUpdateSilence), + }, +}; + +const renderSilences = (location = '/alerting/silences/') => { + const store = configureStore(); + locationService.push(location); + + return render( + + + + + + ); +}; + +const dataSources = { + am: mockDataSource({ + name: 'Alertmanager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + silencesTable: byTestId('silences-table'), + silenceRow: byTestId('silence-table-row'), + silencedAlertCell: byTestId('silenced-alerts'), + queryBar: byPlaceholderText('Search'), + editor: { + timeRange: byLabelText('Timepicker', { exact: false }), + durationField: byLabelText('Duration'), + durationInput: byRole('textbox', { name: /duration/i }), + matchersField: byTestId('matcher'), + matcherName: byPlaceholderText('label'), + matcherValue: byPlaceholderText('value'), + comment: byPlaceholderText('Details about the silence'), + createdBy: byPlaceholderText('Username'), + matcherOperatorSelect: byLabelText('operator'), + matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }), + addMatcherButton: byRole('button', { name: 'Add matcher' }), + submit: byText('Submit'), + }, +}; + +const resetMocks = () => { + jest.resetAllMocks(); + mocks.api.fetchSilences.mockImplementation(() => { + return Promise.resolve([ + mockSilence({ id: '12345' }), + mockSilence({ id: '67890', matchers: parseMatchers('foo!=bar'), comment: 'Catch all' }), + ]); + }); + + mocks.api.fetchAlerts.mockImplementation(() => { + return Promise.resolve([ + mockAlertmanagerAlert({ + labels: { foo: 'bar' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + mockAlertmanagerAlert({ + labels: { foo: 'buzz' }, + status: { state: AlertState.Suppressed, silencedBy: ['67890'], inhibitedBy: [] }, + }), + ]); + }); + + mocks.api.createOrUpdateSilence.mockResolvedValue(mockSilence()); +}; + +describe('Silences', () => { + beforeAll(resetMocks); + afterEach(resetMocks); + + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + }); + + it('loads and shows silences', async () => { + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + expect(ui.silencesTable.query()).not.toBeNull(); + + const silences = ui.silenceRow.queryAll(); + expect(silences).toHaveLength(2); + expect(silences[0]).toHaveTextContent('foo=bar'); + expect(silences[1]).toHaveTextContent('foo!=bar'); + }); + + it('shows the correct number of silenced alerts', async () => { + mocks.api.fetchAlerts.mockImplementation(() => { + return Promise.resolve([ + mockAlertmanagerAlert({ + labels: { foo: 'bar', buzz: 'bazz' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + mockAlertmanagerAlert({ + labels: { foo: 'bar', buzz: 'bazz' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + ]); + }); + + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + const silencedAlertRows = ui.silencedAlertCell.getAll(ui.silencesTable.get()); + expect(silencedAlertRows).toHaveLength(2); + expect(silencedAlertRows[0]).toHaveTextContent('2'); + expect(silencedAlertRows[1]).toHaveTextContent('0'); + }); + + it('filters silences by matchers', async () => { + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + const queryBar = ui.queryBar.get(); + userEvent.paste(queryBar, 'foo=bar'); + + await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(1)); + }); +}); + +describe('Silence edit', () => { + const baseUrlPath = '/alerting/silence/new'; + beforeAll(resetMocks); + afterEach(resetMocks); + + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + }); + + it('prefills the matchers field with matchers params', async () => { + renderSilences( + `${baseUrlPath}?matchers=${encodeURIComponent('foo=bar,bar=~ba.+,hello!=world,cluster!~us-central.*')}` + ); + await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + + const matchers = ui.editor.matchersField.queryAll(); + expect(matchers).toHaveLength(4); + + expect(ui.editor.matcherName.query(matchers[0])).toHaveValue('foo'); + expect(ui.editor.matcherOperator(MatcherOperator.equal).query(matchers[0])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[0])).toHaveValue('bar'); + + expect(ui.editor.matcherName.query(matchers[1])).toHaveValue('bar'); + expect(ui.editor.matcherOperator(MatcherOperator.regex).query(matchers[1])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[1])).toHaveValue('ba.+'); + + expect(ui.editor.matcherName.query(matchers[2])).toHaveValue('hello'); + expect(ui.editor.matcherOperator(MatcherOperator.notEqual).query(matchers[2])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[2])).toHaveValue('world'); + + expect(ui.editor.matcherName.query(matchers[3])).toHaveValue('cluster'); + expect(ui.editor.matcherOperator(MatcherOperator.notRegex).query(matchers[3])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[3])).toHaveValue('us-central.*'); + }); + + it('creates a new silence', async () => { + renderSilences(baseUrlPath); + await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + + const start = new Date(); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + + const startDateString = dateTime(start).format('YYYY-MM-DD'); + const endDateString = dateTime(end).format('YYYY-MM-DD'); + + userEvent.clear(ui.editor.durationInput.get()); + await userEvent.type(ui.editor.durationInput.get(), '1d'); + + await waitFor(() => expect(ui.editor.durationInput.query()).toHaveValue('1d')); + await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(startDateString)); + await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(endDateString)); + + await userEvent.type(ui.editor.matcherName.get(), 'foo'); + await userEvent.type(ui.editor.matcherOperatorSelect.get(), '='); + userEvent.tab(); + await userEvent.type(ui.editor.matcherValue.get(), 'bar'); + + userEvent.click(ui.editor.addMatcherButton.get()); + await userEvent.type(ui.editor.matcherName.getAll()[1], 'bar'); + await userEvent.type(ui.editor.matcherOperatorSelect.getAll()[1], '!='); + userEvent.tab(); + await userEvent.type(ui.editor.matcherValue.getAll()[1], 'buzz'); + + userEvent.click(ui.editor.addMatcherButton.get()); + await userEvent.type(ui.editor.matcherName.getAll()[2], 'region'); + await userEvent.type(ui.editor.matcherOperatorSelect.getAll()[2], '=~'); + userEvent.tab(); + await userEvent.type(ui.editor.matcherValue.getAll()[2], 'us-west-.*'); + + userEvent.click(ui.editor.addMatcherButton.get()); + await userEvent.type(ui.editor.matcherName.getAll()[3], 'env'); + await userEvent.type(ui.editor.matcherOperatorSelect.getAll()[3], '!~'); + userEvent.tab(); + await userEvent.type(ui.editor.matcherValue.getAll()[3], 'dev|staging'); + + await userEvent.type(ui.editor.comment.get(), 'Test'); + await userEvent.type(ui.editor.createdBy.get(), 'Homer Simpson'); + + userEvent.click(ui.editor.submit.get()); + + await waitFor(() => + expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( + 'grafana', + expect.objectContaining({ + comment: 'Test', + createdBy: 'Homer Simpson', + matchers: [ + { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, + { isEqual: false, isRegex: false, name: 'bar', value: 'buzz' }, + { isEqual: true, isRegex: true, name: 'region', value: 'us-west-.*' }, + { isEqual: false, isRegex: true, name: 'env', value: 'dev|staging' }, + ], + }) + ) + ); + }); +}); diff --git a/public/app/features/alerting/unified/components/silences/MatchersField.tsx b/public/app/features/alerting/unified/components/silences/MatchersField.tsx index ae3774500ab..96ee773c510 100644 --- a/public/app/features/alerting/unified/components/silences/MatchersField.tsx +++ b/public/app/features/alerting/unified/components/silences/MatchersField.tsx @@ -29,7 +29,7 @@ const MatchersField: FC = ({ className }) => {
{matchers.map((matcher, index) => { return ( -
+
= ({ className }) => { render={({ field: { onChange, ref, ...field } }) => (