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
pull/33195/head^2
Nathan Rodman 4 years ago committed by GitHub
parent 1781c8ec7d
commit d03f75726b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .pa11yci-pr.conf.js
  2. 247
      public/app/features/alerting/unified/Silences.test.tsx
  3. 4
      public/app/features/alerting/unified/components/silences/MatchersField.tsx
  4. 1
      public/app/features/alerting/unified/components/silences/SilencePeriod.tsx
  5. 4
      public/app/features/alerting/unified/components/silences/SilenceTableRow.tsx
  6. 22
      public/app/features/alerting/unified/components/silences/SilencesEditor.tsx
  7. 109
      public/app/features/alerting/unified/components/silences/SilencesFilter.tsx
  8. 149
      public/app/features/alerting/unified/components/silences/SilencesTable.tsx
  9. 19
      public/app/features/alerting/unified/mocks.ts
  10. 12
      public/app/features/alerting/unified/utils/misc.ts
  11. 6
      public/app/types/unified-alerting.ts

@ -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',

@ -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,51 +46,99 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
</Link>
</div>
)}
<table className={tableStyles.table}>
<colgroup>
<col className={tableStyles.colExpand} />
<col className={styles.colState} />
<col className={styles.colMatchers} />
<col />
<col />
{contextSrv.isEditor && <col />}
</colgroup>
<thead>
<tr>
<th />
<th>State</th>
<th>Matching labels</th>
<th>Alerts</th>
<th>Schedule</th>
{contextSrv.isEditor && <th>Action</th>}
</tr>
</thead>
<tbody>
{filteredSilences.map((silence, index) => {
const silencedAlerts = findSilencedAlerts(silence.id);
return (
<SilenceTableRow
key={silence.id}
silence={silence}
className={index % 2 === 0 ? tableStyles.evenRow : undefined}
silencedAlerts={silencedAlerts}
alertManagerSourceName={alertManagerSourceName}
/>
);
})}
</tbody>
</table>
<div className={styles.callout}>
<Icon className={styles.calloutIcon} name="info-circle" />
<span>Expired silences are automatically deleted after 5 days.</span>
</div>
{!!filteredSilences.length ? (
<table className={tableStyles.table}>
<colgroup>
<col className={tableStyles.colExpand} />
<col className={styles.colState} />
<col className={styles.colMatchers} />
<col />
<col />
{contextSrv.isEditor && <col />}
</colgroup>
<thead>
<tr>
<th />
<th>State</th>
<th>Matching labels</th>
<th>Alerts</th>
<th>Schedule</th>
{contextSrv.isEditor && <th>Action</th>}
</tr>
</thead>
<tbody>
{filteredSilences.map((silence, index) => {
const silencedAlerts = findSilencedAlerts(silence.id);
return (
<SilenceTableRow
key={silence.id}
silence={silence}
className={index % 2 === 0 ? tableStyles.evenRow : undefined}
silencedAlerts={silencedAlerts}
alertManagerSourceName={alertManagerSourceName}
/>
);
})}
</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…
Cancel
Save