mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
1781c8ec7d
commit
d03f75726b
@ -16,7 +16,7 @@ var config = {
|
|||||||
"click element button[aria-label='Login button']",
|
"click element button[aria-label='Login button']",
|
||||||
"wait for element [aria-label='Skip change password button'] to be visible",
|
"wait for element [aria-label='Skip change password button'] to be visible",
|
||||||
],
|
],
|
||||||
threshold: 2,
|
threshold: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: '${HOST}/?orgId=1',
|
url: '${HOST}/?orgId=1',
|
||||||
|
247
public/app/features/alerting/unified/Silences.test.tsx
Normal file
247
public/app/features/alerting/unified/Silences.test.tsx
Normal file
@ -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' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -29,7 +29,7 @@ const MatchersField: FC<Props> = ({ className }) => {
|
|||||||
<div className={styles.matchers}>
|
<div className={styles.matchers}>
|
||||||
{matchers.map((matcher, index) => {
|
{matchers.map((matcher, index) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} key={`${matcher.id}`}>
|
<div className={styles.row} key={`${matcher.id}`} data-testid="matcher">
|
||||||
<Field
|
<Field
|
||||||
label="Label"
|
label="Label"
|
||||||
invalid={!!errors?.matchers?.[index]?.name}
|
invalid={!!errors?.matchers?.[index]?.name}
|
||||||
@ -49,9 +49,11 @@ const MatchersField: FC<Props> = ({ className }) => {
|
|||||||
render={({ field: { onChange, ref, ...field } }) => (
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
|
menuShouldPortal
|
||||||
onChange={(value) => onChange(value.value)}
|
onChange={(value) => onChange(value.value)}
|
||||||
className={styles.matcherOptions}
|
className={styles.matcherOptions}
|
||||||
options={matcherFieldOptions}
|
options={matcherFieldOptions}
|
||||||
|
aria-label="operator"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
defaultValue={matcher.operator || matcherFieldOptions[0].value}
|
defaultValue={matcher.operator || matcherFieldOptions[0].value}
|
||||||
|
@ -66,6 +66,7 @@ export const SilencePeriod = () => {
|
|||||||
onChangeTimeZone={(newValue) => onChangeTimeZone(newValue)}
|
onChangeTimeZone={(newValue) => onChangeTimeZone(newValue)}
|
||||||
hideTimeZone={false}
|
hideTimeZone={false}
|
||||||
hideQuickRanges={true}
|
hideQuickRanges={true}
|
||||||
|
placeholder={'Select time range'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
|
@ -40,7 +40,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<tr className={className}>
|
<tr className={className} data-testid="silence-table-row">
|
||||||
<td>
|
<td>
|
||||||
<CollapseToggle isCollapsed={isCollapsed} onToggle={(value) => setIsCollapsed(value)} />
|
<CollapseToggle isCollapsed={isCollapsed} onToggle={(value) => setIsCollapsed(value)} />
|
||||||
</td>
|
</td>
|
||||||
@ -50,7 +50,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
|||||||
<td className={styles.matchersCell}>
|
<td className={styles.matchersCell}>
|
||||||
<Matchers matchers={matchers} />
|
<Matchers matchers={matchers} />
|
||||||
</td>
|
</td>
|
||||||
<td>{silencedAlerts.length}</td>
|
<td data-testid="silenced-alerts">{silencedAlerts.length}</td>
|
||||||
<td>
|
<td>
|
||||||
{startsAtDate?.format(dateDisplayFormat)} {'-'}
|
{startsAtDate?.format(dateDisplayFormat)} {'-'}
|
||||||
<br />
|
<br />
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
import { MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import React, { FC, useMemo, useState } from 'react';
|
import React, { FC, useMemo, useState } from 'react';
|
||||||
import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles } from '@grafana/ui';
|
import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui';
|
||||||
import {
|
import {
|
||||||
DefaultTimeZone,
|
DefaultTimeZone,
|
||||||
GrafanaTheme,
|
|
||||||
parseDuration,
|
parseDuration,
|
||||||
intervalToAbbreviatedDurationString,
|
intervalToAbbreviatedDurationString,
|
||||||
addDurationToDate,
|
addDurationToDate,
|
||||||
dateTime,
|
dateTime,
|
||||||
isValidDate,
|
isValidDate,
|
||||||
UrlQueryMap,
|
UrlQueryMap,
|
||||||
|
GrafanaTheme2,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { useDebounce } from 'react-use';
|
import { useDebounce } from 'react-use';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
@ -99,7 +99,7 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
const defaultValues = useMemo(() => getDefaultFormValues(queryParams, silence), [silence, queryParams]);
|
const defaultValues = useMemo(() => getDefaultFormValues(queryParams, silence), [silence, queryParams]);
|
||||||
const formAPI = useForm({ defaultValues });
|
const formAPI = useForm({ defaultValues });
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence);
|
const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence);
|
||||||
|
|
||||||
@ -196,7 +196,10 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
error={formState.errors.comment?.message}
|
error={formState.errors.comment?.message}
|
||||||
invalid={!!formState.errors.comment}
|
invalid={!!formState.errors.comment}
|
||||||
>
|
>
|
||||||
<TextArea {...register('comment', { required: { value: true, message: 'Required.' } })} />
|
<TextArea
|
||||||
|
{...register('comment', { required: { value: true, message: 'Required.' } })}
|
||||||
|
placeholder="Details about the silence"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
className={cx(styles.field, styles.createdBy)}
|
className={cx(styles.field, styles.createdBy)}
|
||||||
@ -205,7 +208,10 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
error={formState.errors.createdBy?.message}
|
error={formState.errors.createdBy?.message}
|
||||||
invalid={!!formState.errors.createdBy}
|
invalid={!!formState.errors.createdBy}
|
||||||
>
|
>
|
||||||
<Input {...register('createdBy', { required: { value: true, message: 'Required.' } })} />
|
<Input
|
||||||
|
{...register('createdBy', { required: { value: true, message: 'Required.' } })}
|
||||||
|
placeholder="Username"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
<div className={styles.flexRow}>
|
<div className={styles.flexRow}>
|
||||||
@ -228,9 +234,9 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
field: css`
|
field: css`
|
||||||
margin: ${theme.spacing.sm} 0;
|
margin: ${theme.spacing(1, 0)};
|
||||||
`,
|
`,
|
||||||
textArea: css`
|
textArea: css`
|
||||||
width: 600px;
|
width: 600px;
|
||||||
@ -244,7 +250,7 @@ const getStyles = (theme: GrafanaTheme) => ({
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
margin-right: ${theme.spacing.sm};
|
margin-right: ${theme.spacing(1)};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
`,
|
||||||
|
});
|
@ -2,13 +2,15 @@ import React, { FC, useMemo } from 'react';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Icon, useStyles2, Link, Button } from '@grafana/ui';
|
import { Icon, useStyles2, Link, Button } from '@grafana/ui';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { AlertmanagerAlert, Silence } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import SilenceTableRow from './SilenceTableRow';
|
import SilenceTableRow from './SilenceTableRow';
|
||||||
import { getAlertTableStyles } from '../../styles/table';
|
import { getAlertTableStyles } from '../../styles/table';
|
||||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||||
import { makeAMLink } from '../../utils/misc';
|
import { getFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
|
import { SilencesFilter } from './SilencesFilter';
|
||||||
|
import { parseMatchers } from '../../utils/alertmanager';
|
||||||
interface Props {
|
interface Props {
|
||||||
silences: Silence[];
|
silences: Silence[];
|
||||||
alertManagerAlerts: AlertmanagerAlert[];
|
alertManagerAlerts: AlertmanagerAlert[];
|
||||||
@ -19,23 +21,22 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
const tableStyles = useStyles2(getAlertTableStyles);
|
||||||
const [queryParams] = useQueryParams();
|
const [queryParams] = useQueryParams();
|
||||||
|
const filteredSilences = useFilteredSilences(silences);
|
||||||
|
|
||||||
const filteredSilences = useMemo(() => {
|
const { silenceState } = getFiltersFromUrlParams(queryParams);
|
||||||
const silenceIdsString = queryParams?.silenceIds;
|
|
||||||
if (typeof silenceIdsString === 'string') {
|
const showExpiredSilencesBanner =
|
||||||
return silences.filter((silence) => silenceIdsString.split(',').includes(silence.id));
|
!!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
|
||||||
}
|
|
||||||
return silences;
|
|
||||||
}, [queryParams, silences]);
|
|
||||||
|
|
||||||
const findSilencedAlerts = (id: string) => {
|
const findSilencedAlerts = (id: string) => {
|
||||||
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="silences-table">
|
||||||
{!!silences.length && (
|
{!!silences.length && (
|
||||||
<>
|
<>
|
||||||
|
<SilencesFilter />
|
||||||
{contextSrv.isEditor && (
|
{contextSrv.isEditor && (
|
||||||
<div className={styles.topButtonContainer}>
|
<div className={styles.topButtonContainer}>
|
||||||
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
|
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
|
||||||
@ -45,6 +46,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!!filteredSilences.length ? (
|
||||||
<table className={tableStyles.table}>
|
<table className={tableStyles.table}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className={tableStyles.colExpand} />
|
<col className={tableStyles.colExpand} />
|
||||||
@ -79,17 +81,64 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className={styles.callout}>
|
||||||
|
<Icon className={styles.calloutIcon} name="info-circle" />
|
||||||
|
<span>No silences match your filters</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showExpiredSilencesBanner && (
|
||||||
<div className={styles.callout}>
|
<div className={styles.callout}>
|
||||||
<Icon className={styles.calloutIcon} name="info-circle" />
|
<Icon className={styles.calloutIcon} name="info-circle" />
|
||||||
<span>Expired silences are automatically deleted after 5 days.</span>
|
<span>Expired silences are automatically deleted after 5 days.</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
|
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useFilteredSilences = (silences: Silence[]) => {
|
||||||
|
const [queryParams] = useQueryParams();
|
||||||
|
return useMemo(() => {
|
||||||
|
const { queryString, silenceState } = getFiltersFromUrlParams(queryParams);
|
||||||
|
const silenceIdsString = queryParams?.silenceIds;
|
||||||
|
return silences.filter((silence) => {
|
||||||
|
if (typeof silenceIdsString === 'string') {
|
||||||
|
const idsIncluded = silenceIdsString.split(',').includes(silence.id);
|
||||||
|
if (!idsIncluded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queryString) {
|
||||||
|
const matchers = parseMatchers(queryString);
|
||||||
|
const matchersMatch = matchers.every((matcher) =>
|
||||||
|
silence.matchers?.some(
|
||||||
|
({ name, value, isEqual, isRegex }) =>
|
||||||
|
matcher.name === name &&
|
||||||
|
matcher.value === value &&
|
||||||
|
matcher.isEqual === isEqual &&
|
||||||
|
matcher.isRegex === isRegex
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!matchersMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (silenceState) {
|
||||||
|
const stateMatches = silence.status.state === silenceState;
|
||||||
|
if (!stateMatches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [queryParams, silences]);
|
||||||
|
};
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
topButtonContainer: css`
|
topButtonContainer: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -97,7 +146,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
`,
|
`,
|
||||||
addNewSilence: css`
|
addNewSilence: css`
|
||||||
margin-bottom: ${theme.spacing(1)};
|
margin: ${theme.spacing(2, 0)};
|
||||||
`,
|
`,
|
||||||
colState: css`
|
colState: css`
|
||||||
width: 110px;
|
width: 110px;
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
} from 'app/types/unified-alerting-dto';
|
} from 'app/types/unified-alerting-dto';
|
||||||
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
|
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
|
||||||
import DatasourceSrv from 'app/features/plugins/datasource_srv';
|
import DatasourceSrv from 'app/features/plugins/datasource_srv';
|
||||||
import { DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime';
|
import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
AlertmanagerAlert,
|
AlertmanagerAlert,
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
@ -19,6 +19,8 @@ import {
|
|||||||
AlertmanagerStatus,
|
AlertmanagerStatus,
|
||||||
AlertState,
|
AlertState,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
|
Silence,
|
||||||
|
SilenceState,
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
let nextDataSourceId = 1;
|
let nextDataSourceId = 1;
|
||||||
@ -204,6 +206,21 @@ export const mockAlertGroup = (partial: Partial<AlertmanagerGroup> = {}): Alertm
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mockSilence = (partial: Partial<Silence> = {}): Silence => {
|
||||||
|
return {
|
||||||
|
id: '1a2b3c4d5e6f',
|
||||||
|
matchers: [{ name: 'foo', value: 'bar', isEqual: true, isRegex: false }],
|
||||||
|
startsAt: new Date().toISOString(),
|
||||||
|
endsAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdBy: config.bootData.user.name || 'admin',
|
||||||
|
comment: 'Silence noisy alerts',
|
||||||
|
status: {
|
||||||
|
state: SilenceState.Active,
|
||||||
|
},
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
};
|
||||||
export class MockDataSourceSrv implements DataSourceSrv {
|
export class MockDataSourceSrv implements DataSourceSrv {
|
||||||
datasources: Record<string, DataSourceApi> = {};
|
datasources: Record<string, DataSourceApi> = {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { urlUtil, UrlQueryMap } from '@grafana/data';
|
import { urlUtil, UrlQueryMap } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { CombinedRule, FilterState, RulesSource } from 'app/types/unified-alerting';
|
import { CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting';
|
||||||
import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
|
import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
|
||||||
import { getRulesSourceName } from './datasource';
|
import { getRulesSourceName } from './datasource';
|
||||||
import * as ruleId from './rule-id';
|
import * as ruleId from './rule-id';
|
||||||
@ -38,7 +38,15 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): FilterState =
|
|||||||
const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']);
|
const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']);
|
||||||
const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']);
|
const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']);
|
||||||
const groupBy = queryParams['groupBy'] === undefined ? undefined : String(queryParams['groupBy']).split(',');
|
const groupBy = queryParams['groupBy'] === undefined ? undefined : String(queryParams['groupBy']).split(',');
|
||||||
return { queryString, alertState, dataSource, groupBy };
|
const silenceState = queryParams['silenceState'] === undefined ? undefined : String(queryParams['silenceState']);
|
||||||
|
return { queryString, alertState, dataSource, groupBy, silenceState };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSilenceFiltersFromUrlParams = (queryParams: UrlQueryMap): SilenceFilterState => {
|
||||||
|
const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']);
|
||||||
|
const silenceState = queryParams['silenceState'] === undefined ? undefined : String(queryParams['silenceState']);
|
||||||
|
|
||||||
|
return { queryString, silenceState };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function recordToArray(record: Record<string, string>): Array<{ key: string; value: string }> {
|
export function recordToArray(record: Record<string, string>): Array<{ key: string; value: string }> {
|
||||||
|
@ -133,4 +133,10 @@ export interface FilterState {
|
|||||||
dataSource?: string;
|
dataSource?: string;
|
||||||
alertState?: string;
|
alertState?: string;
|
||||||
groupBy?: string[];
|
groupBy?: string[];
|
||||||
|
silenceState?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SilenceFilterState {
|
||||||
|
queryString?: string;
|
||||||
|
silenceState?: string;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user