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