mirror of https://github.com/grafana/grafana
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 functionpull/33195/head^2
parent
1781c8ec7d
commit
d03f75726b
@ -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( |
||||
<Provider store={store}> |
||||
<Router history={locationService.getHistory()}> |
||||
<Silences /> |
||||
</Router> |
||||
</Provider> |
||||
); |
||||
}; |
||||
|
||||
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' }, |
||||
], |
||||
}) |
||||
) |
||||
); |
||||
}); |
||||
}); |
@ -0,0 +1,109 @@ |
||||
import React, { FormEvent, useState } from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import { Label, Icon, Input, Tooltip, RadioButtonGroup, useStyles2, Button, Field } from '@grafana/ui'; |
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; |
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams'; |
||||
import { getSilenceFiltersFromUrlParams } from '../../utils/misc'; |
||||
import { SilenceState } from 'app/plugins/datasource/alertmanager/types'; |
||||
import { parseMatchers } from '../../utils/alertmanager'; |
||||
import { debounce } from 'lodash'; |
||||
|
||||
const stateOptions: SelectableValue[] = Object.entries(SilenceState).map(([key, value]) => ({ |
||||
label: key, |
||||
value, |
||||
})); |
||||
|
||||
export const SilencesFilter = () => { |
||||
const [queryStringKey, setQueryStringKey] = useState(`queryString-${Math.random() * 100}`); |
||||
const [queryParams, setQueryParams] = useQueryParams(); |
||||
const { queryString, silenceState } = getSilenceFiltersFromUrlParams(queryParams); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const handleQueryStringChange = debounce((e: FormEvent<HTMLInputElement>) => { |
||||
const target = e.target as HTMLInputElement; |
||||
setQueryParams({ queryString: target.value || null }); |
||||
}, 400); |
||||
|
||||
const handleSilenceStateChange = (state: string) => { |
||||
setQueryParams({ silenceState: state }); |
||||
}; |
||||
|
||||
const clearFilters = () => { |
||||
setQueryParams({ |
||||
queryString: null, |
||||
silenceState: null, |
||||
}); |
||||
setTimeout(() => setQueryStringKey('')); |
||||
}; |
||||
|
||||
const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; |
||||
|
||||
return ( |
||||
<div className={styles.flexRow}> |
||||
<Field |
||||
className={styles.rowChild} |
||||
label={ |
||||
<span className={styles.fieldLabel}> |
||||
<Tooltip |
||||
content={ |
||||
<div> |
||||
Filter silences by matchers using a comma separated list of matchers, ie: |
||||
<pre>{`severity=critical, instance=~cluster-us-.+`}</pre> |
||||
</div> |
||||
} |
||||
> |
||||
<Icon name="info-circle" /> |
||||
</Tooltip>{' '} |
||||
Search by matchers |
||||
</span> |
||||
} |
||||
invalid={inputInvalid} |
||||
error={inputInvalid ? 'Query must use valid matcher syntax' : null} |
||||
> |
||||
<Input |
||||
key={queryStringKey} |
||||
className={styles.searchInput} |
||||
prefix={<Icon name="search" />} |
||||
onChange={handleQueryStringChange} |
||||
defaultValue={queryString ?? ''} |
||||
placeholder="Search" |
||||
data-testid="search-query-input" |
||||
/> |
||||
</Field> |
||||
|
||||
<div className={styles.rowChild}> |
||||
<Label>State</Label> |
||||
<RadioButtonGroup options={stateOptions} value={silenceState} onChange={handleSilenceStateChange} /> |
||||
</div> |
||||
{(queryString || silenceState) && ( |
||||
<div className={styles.rowChild}> |
||||
<Button variant="secondary" icon="times" onClick={clearFilters}> |
||||
Clear filters |
||||
</Button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
searchInput: css` |
||||
width: 360px; |
||||
`,
|
||||
flexRow: css` |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: flex-end; |
||||
padding-bottom: ${theme.spacing(2)}; |
||||
border-bottom: 1px solid ${theme.colors.border.strong}; |
||||
`,
|
||||
rowChild: css` |
||||
margin-right: ${theme.spacing(1)}; |
||||
margin-bottom: 0; |
||||
max-height: 52px; |
||||
`,
|
||||
fieldLabel: css` |
||||
font-size: 12px; |
||||
font-weight: 500; |
||||
`,
|
||||
}); |
Loading…
Reference in new issue