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']",
|
||||
"wait for element [aria-label='Skip change password button'] to be visible",
|
||||
],
|
||||
threshold: 2,
|
||||
threshold: 3,
|
||||
},
|
||||
{
|
||||
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}>
|
||||
{matchers.map((matcher, index) => {
|
||||
return (
|
||||
<div className={styles.row} key={`${matcher.id}`}>
|
||||
<div className={styles.row} key={`${matcher.id}`} data-testid="matcher">
|
||||
<Field
|
||||
label="Label"
|
||||
invalid={!!errors?.matchers?.[index]?.name}
|
||||
@ -49,9 +49,11 @@ const MatchersField: FC<Props> = ({ className }) => {
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<Select
|
||||
{...field}
|
||||
menuShouldPortal
|
||||
onChange={(value) => onChange(value.value)}
|
||||
className={styles.matcherOptions}
|
||||
options={matcherFieldOptions}
|
||||
aria-label="operator"
|
||||
/>
|
||||
)}
|
||||
defaultValue={matcher.operator || matcherFieldOptions[0].value}
|
||||
|
@ -66,6 +66,7 @@ export const SilencePeriod = () => {
|
||||
onChangeTimeZone={(newValue) => onChangeTimeZone(newValue)}
|
||||
hideTimeZone={false}
|
||||
hideQuickRanges={true}
|
||||
placeholder={'Select time range'}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
@ -40,7 +40,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<tr className={className}>
|
||||
<tr className={className} data-testid="silence-table-row">
|
||||
<td>
|
||||
<CollapseToggle isCollapsed={isCollapsed} onToggle={(value) => setIsCollapsed(value)} />
|
||||
</td>
|
||||
@ -50,7 +50,7 @@ const SilenceTableRow: FC<Props> = ({ silence, className, silencedAlerts, alertM
|
||||
<td className={styles.matchersCell}>
|
||||
<Matchers matchers={matchers} />
|
||||
</td>
|
||||
<td>{silencedAlerts.length}</td>
|
||||
<td data-testid="silenced-alerts">{silencedAlerts.length}</td>
|
||||
<td>
|
||||
{startsAtDate?.format(dateDisplayFormat)} {'-'}
|
||||
<br />
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
|
||||
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 {
|
||||
DefaultTimeZone,
|
||||
GrafanaTheme,
|
||||
parseDuration,
|
||||
intervalToAbbreviatedDurationString,
|
||||
addDurationToDate,
|
||||
dateTime,
|
||||
isValidDate,
|
||||
UrlQueryMap,
|
||||
GrafanaTheme2,
|
||||
} from '@grafana/data';
|
||||
import { useDebounce } from 'react-use';
|
||||
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 formAPI = useForm({ defaultValues });
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles(getStyles);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence);
|
||||
|
||||
@ -196,7 +196,10 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
||||
error={formState.errors.comment?.message}
|
||||
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
|
||||
className={cx(styles.field, styles.createdBy)}
|
||||
@ -205,7 +208,10 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
||||
error={formState.errors.createdBy?.message}
|
||||
invalid={!!formState.errors.createdBy}
|
||||
>
|
||||
<Input {...register('createdBy', { required: { value: true, message: 'Required.' } })} />
|
||||
<Input
|
||||
{...register('createdBy', { required: { value: true, message: 'Required.' } })}
|
||||
placeholder="Username"
|
||||
/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<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`
|
||||
margin: ${theme.spacing.sm} 0;
|
||||
margin: ${theme.spacing(1, 0)};
|
||||
`,
|
||||
textArea: css`
|
||||
width: 600px;
|
||||
@ -244,7 +250,7 @@ const getStyles = (theme: GrafanaTheme) => ({
|
||||
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 { Icon, useStyles2, Link, Button } from '@grafana/ui';
|
||||
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 { getAlertTableStyles } from '../../styles/table';
|
||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { getFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { SilencesFilter } from './SilencesFilter';
|
||||
import { parseMatchers } from '../../utils/alertmanager';
|
||||
interface Props {
|
||||
silences: Silence[];
|
||||
alertManagerAlerts: AlertmanagerAlert[];
|
||||
@ -19,23 +21,22 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
||||
const styles = useStyles2(getStyles);
|
||||
const tableStyles = useStyles2(getAlertTableStyles);
|
||||
const [queryParams] = useQueryParams();
|
||||
const filteredSilences = useFilteredSilences(silences);
|
||||
|
||||
const filteredSilences = useMemo(() => {
|
||||
const silenceIdsString = queryParams?.silenceIds;
|
||||
if (typeof silenceIdsString === 'string') {
|
||||
return silences.filter((silence) => silenceIdsString.split(',').includes(silence.id));
|
||||
}
|
||||
return silences;
|
||||
}, [queryParams, silences]);
|
||||
const { silenceState } = getFiltersFromUrlParams(queryParams);
|
||||
|
||||
const showExpiredSilencesBanner =
|
||||
!!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
|
||||
|
||||
const findSilencedAlerts = (id: string) => {
|
||||
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid="silences-table">
|
||||
{!!silences.length && (
|
||||
<>
|
||||
<SilencesFilter />
|
||||
{contextSrv.isEditor && (
|
||||
<div className={styles.topButtonContainer}>
|
||||
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
|
||||
@ -45,6 +46,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!!filteredSilences.length ? (
|
||||
<table className={tableStyles.table}>
|
||||
<colgroup>
|
||||
<col className={tableStyles.colExpand} />
|
||||
@ -79,17 +81,64 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
||||
})}
|
||||
</tbody>
|
||||
</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}>
|
||||
<Icon className={styles.calloutIcon} name="info-circle" />
|
||||
<span>Expired silences are automatically deleted after 5 days.</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!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) => ({
|
||||
topButtonContainer: css`
|
||||
display: flex;
|
||||
@ -97,7 +146,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
justify-content: flex-end;
|
||||
`,
|
||||
addNewSilence: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
margin: ${theme.spacing(2, 0)};
|
||||
`,
|
||||
colState: css`
|
||||
width: 110px;
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting';
|
||||
import DatasourceSrv from 'app/features/plugins/datasource_srv';
|
||||
import { DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime';
|
||||
import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtime';
|
||||
import {
|
||||
AlertmanagerAlert,
|
||||
AlertManagerCortexConfig,
|
||||
@ -19,6 +19,8 @@ import {
|
||||
AlertmanagerStatus,
|
||||
AlertState,
|
||||
GrafanaManagedReceiverConfig,
|
||||
Silence,
|
||||
SilenceState,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
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 {
|
||||
datasources: Record<string, DataSourceApi> = {};
|
||||
// @ts-ignore
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { urlUtil, UrlQueryMap } from '@grafana/data';
|
||||
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 { getRulesSourceName } from './datasource';
|
||||
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 dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']);
|
||||
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 }> {
|
||||
|
@ -133,4 +133,10 @@ export interface FilterState {
|
||||
dataSource?: string;
|
||||
alertState?: string;
|
||||
groupBy?: string[];
|
||||
silenceState?: string;
|
||||
}
|
||||
|
||||
export interface SilenceFilterState {
|
||||
queryString?: string;
|
||||
silenceState?: string;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user