Alerting: Split silences view expired/not-expired (#66562)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar
2023-04-17 16:30:41 +02:00
committed by GitHub
parent 850f2baaf3
commit 8485deb2c0
4 changed files with 108 additions and 74 deletions

View File

@@ -5,13 +5,15 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
import { dateTime } from '@grafana/data'; import { dateTime } from '@grafana/data';
import { locationService, setDataSourceSrv, config } from '@grafana/runtime'; import { config, locationService, setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { SilenceState } from '../../../plugins/datasource/alertmanager/types';
import Silences from './Silences'; import Silences from './Silences';
import { fetchSilences, fetchAlerts, createOrUpdateSilence } from './api/alertmanager'; import { createOrUpdateSilence, fetchAlerts, fetchSilences } from './api/alertmanager';
import { mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks'; import { mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks';
import { parseMatchers } from './utils/alertmanager'; import { parseMatchers } from './utils/alertmanager';
import { DataSourceType } from './utils/datasource'; import { DataSourceType } from './utils/datasource';
@@ -48,10 +50,12 @@ const dataSources = {
}; };
const ui = { const ui = {
silencesTable: byTestId('dynamic-table'), notExpiredTable: byTestId('not-expired-table'),
expiredTable: byTestId('expired-table'),
expiredCaret: byText(/expired/i),
silenceRow: byTestId('row'), silenceRow: byTestId('row'),
silencedAlertCell: byTestId('alerts'), silencedAlertCell: byTestId('alerts'),
addSilenceButton: byRole('button', { name: /add silence/i }), addSilenceButton: byRole('link', { name: /add silence/i }),
queryBar: byPlaceholderText('Search'), queryBar: byPlaceholderText('Search'),
editor: { editor: {
timeRange: byLabelText('Timepicker', { exact: false }), timeRange: byLabelText('Timepicker', { exact: false }),
@@ -75,6 +79,7 @@ const resetMocks = () => {
return Promise.resolve([ return Promise.resolve([
mockSilence({ id: '12345' }), mockSilence({ id: '12345' }),
mockSilence({ id: '67890', matchers: parseMatchers('foo!=bar'), comment: 'Catch all' }), mockSilence({ id: '67890', matchers: parseMatchers('foo!=bar'), comment: 'Catch all' }),
mockSilence({ id: '1111', status: { state: SilenceState.Expired } }),
]); ]);
}); });
@@ -128,9 +133,19 @@ describe('Silences', () => {
await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled());
await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled());
expect(ui.silencesTable.query()).not.toBeNull(); await userEvent.click(ui.expiredCaret.get());
expect(ui.notExpiredTable.get()).not.toBeNull();
expect(ui.expiredTable.get()).not.toBeNull();
let silences = ui.silenceRow.queryAll();
expect(silences).toHaveLength(3);
expect(silences[0]).toHaveTextContent('foo=bar');
expect(silences[1]).toHaveTextContent('foo!=bar');
expect(silences[2]).toHaveTextContent('foo=bar');
const silences = ui.silenceRow.queryAll(); await userEvent.click(ui.expiredCaret.getAll()[0]);
expect(ui.notExpiredTable.get()).not.toBeNull();
expect(ui.expiredTable.query()).toBeNull();
silences = ui.silenceRow.queryAll();
expect(silences).toHaveLength(2); expect(silences).toHaveLength(2);
expect(silences[0]).toHaveTextContent('foo=bar'); expect(silences[0]).toHaveTextContent('foo=bar');
expect(silences[1]).toHaveTextContent('foo!=bar'); expect(silences[1]).toHaveTextContent('foo!=bar');
@@ -158,7 +173,7 @@ describe('Silences', () => {
await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled());
await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled());
const silencedAlertRows = ui.silencedAlertCell.getAll(ui.silencesTable.get()); const silencedAlertRows = ui.silencedAlertCell.getAll(ui.notExpiredTable.get());
expect(silencedAlertRows).toHaveLength(2); expect(silencedAlertRows).toHaveLength(2);
expect(silencedAlertRows[0]).toHaveTextContent('2'); expect(silencedAlertRows[0]).toHaveTextContent('2');
expect(silencedAlertRows[1]).toHaveTextContent('0'); expect(silencedAlertRows[1]).toHaveTextContent('0');
@@ -177,7 +192,7 @@ describe('Silences', () => {
await userEvent.click(queryBar); await userEvent.click(queryBar);
await userEvent.paste('foo=bar'); await userEvent.paste('foo=bar');
await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(1)); await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(2));
}, },
TEST_TIMEOUT TEST_TIMEOUT
); );

View File

@@ -28,6 +28,7 @@ export interface DynamicTableItemProps<T = unknown> {
export interface DynamicTableProps<T = unknown> { export interface DynamicTableProps<T = unknown> {
cols: Array<DynamicTableColumnProps<T>>; cols: Array<DynamicTableColumnProps<T>>;
items: Array<DynamicTableItemProps<T>>; items: Array<DynamicTableItemProps<T>>;
dataTestId?: string;
isExpandable?: boolean; isExpandable?: boolean;
pagination?: DynamicTablePagination; pagination?: DynamicTablePagination;
@@ -70,6 +71,7 @@ export const DynamicTable = <T extends object>({
renderPrefixCell, renderPrefixCell,
renderPrefixHeader, renderPrefixHeader,
footerRow, footerRow,
dataTestId,
}: DynamicTableProps<T>) => { }: DynamicTableProps<T>) => {
const defaultPaginationStyles = useStyles2(getPaginationStyles); const defaultPaginationStyles = useStyles2(getPaginationStyles);
@@ -98,7 +100,7 @@ export const DynamicTable = <T extends object>({
return ( return (
<> <>
<div className={styles.container} data-testid="dynamic-table"> <div className={styles.container} data-testid={dataTestId ?? 'dynamic-table'}>
<div className={styles.row} data-testid="header"> <div className={styles.row} data-testid="header">
{renderPrefixHeader && renderPrefixHeader()} {renderPrefixHeader && renderPrefixHeader()}
{isExpandable && <div className={styles.cell} />} {isExpandable && <div className={styles.cell} />}

View File

@@ -2,26 +2,20 @@ import { css } from '@emotion/css';
import { debounce, uniqueId } from 'lodash'; import { debounce, uniqueId } from 'lodash';
import React, { FormEvent, useState } from 'react'; import React, { FormEvent, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Label, Icon, Input, Tooltip, RadioButtonGroup, useStyles2, Button, Field } from '@grafana/ui'; import { Button, Field, Icon, Input, Label, Tooltip, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { parseMatchers } from '../../utils/alertmanager'; import { parseMatchers } from '../../utils/alertmanager';
import { getSilenceFiltersFromUrlParams } from '../../utils/misc'; import { getSilenceFiltersFromUrlParams } from '../../utils/misc';
const stateOptions: SelectableValue[] = Object.entries(SilenceState).map(([key, value]) => ({
label: key,
value,
}));
const getQueryStringKey = () => uniqueId('query-string-'); const getQueryStringKey = () => uniqueId('query-string-');
export const SilencesFilter = () => { export const SilencesFilter = () => {
const [queryStringKey, setQueryStringKey] = useState(getQueryStringKey()); const [queryStringKey, setQueryStringKey] = useState(getQueryStringKey());
const [queryParams, setQueryParams] = useQueryParams(); const [queryParams, setQueryParams] = useQueryParams();
const { queryString, silenceState } = getSilenceFiltersFromUrlParams(queryParams); const { queryString } = getSilenceFiltersFromUrlParams(queryParams);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const handleQueryStringChange = debounce((e: FormEvent<HTMLInputElement>) => { const handleQueryStringChange = debounce((e: FormEvent<HTMLInputElement>) => {
@@ -29,10 +23,6 @@ export const SilencesFilter = () => {
setQueryParams({ queryString: target.value || null }); setQueryParams({ queryString: target.value || null });
}, 400); }, 400);
const handleSilenceStateChange = (state: string) => {
setQueryParams({ silenceState: state });
};
const clearFilters = () => { const clearFilters = () => {
setQueryParams({ setQueryParams({
queryString: null, queryString: null,
@@ -77,10 +67,8 @@ export const SilencesFilter = () => {
data-testid="search-query-input" data-testid="search-query-input"
/> />
</Field> </Field>
<Field className={styles.rowChild} label="State">
<RadioButtonGroup options={stateOptions} value={silenceState} onChange={handleSilenceStateChange} /> {queryString && (
</Field>
{(queryString || silenceState) && (
<div className={styles.rowChild}> <div className={styles.rowChild}>
<Button variant="secondary" icon="times" onClick={clearFilters}> <Button variant="secondary" icon="times" onClick={clearFilters}>
Clear filters Clear filters
@@ -99,8 +87,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-end; align-items: flex-end;
padding-bottom: ${theme.spacing(2)}; padding-bottom: ${theme.spacing(3)};
border-bottom: 1px solid ${theme.colors.border.strong}; border-bottom: 1px solid ${theme.colors.border.medium};
`, `,
rowChild: css` rowChild: css`
margin-right: ${theme.spacing(1)}; margin-right: ${theme.spacing(1)};

View File

@@ -1,9 +1,9 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { GrafanaTheme2, dateMath } from '@grafana/data'; import { dateMath, GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Icon, useStyles2, Link, Button } from '@grafana/ui'; import { CollapsableSection, Icon, Link, LinkButton, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
@@ -39,72 +39,105 @@ interface Props {
const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: Props) => { const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
const filteredSilences = useFilteredSilences(silences); const filteredSilencesNotExpired = useFilteredSilences(silences, false);
const filteredSilencesExpired = useFilteredSilences(silences, true);
const permissions = getInstancesPermissions(alertManagerSourceName); const permissions = getInstancesPermissions(alertManagerSourceName);
const { silenceState } = getSilenceFiltersFromUrlParams(queryParams); const { silenceState: silenceStateInParams } = getSilenceFiltersFromUrlParams(queryParams);
const showExpiredFromUrl = silenceStateInParams === SilenceState.Expired;
const showExpiredSilencesBanner = const itemsNotExpired = useMemo((): SilenceTableItemProps[] => {
!!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
const columns = useColumns(alertManagerSourceName);
const items = useMemo((): SilenceTableItemProps[] => {
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 filteredSilences.map((silence) => { return filteredSilencesNotExpired.map((silence) => {
const silencedAlerts = findSilencedAlerts(silence.id); const silencedAlerts = findSilencedAlerts(silence.id);
return { return {
id: silence.id, id: silence.id,
data: { ...silence, silencedAlerts }, data: { ...silence, silencedAlerts },
}; };
}); });
}, [filteredSilences, alertManagerAlerts]); }, [filteredSilencesNotExpired, alertManagerAlerts]);
const itemsExpired = useMemo((): SilenceTableItemProps[] => {
const findSilencedAlerts = (id: string) => {
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
};
return filteredSilencesExpired.map((silence) => {
const silencedAlerts = findSilencedAlerts(silence.id);
return {
id: silence.id,
data: { ...silence, silencedAlerts },
};
});
}, [filteredSilencesExpired, alertManagerAlerts]);
return ( return (
<div data-testid="silences-table"> <div data-testid="silences-table">
{!!silences.length && ( {!!silences.length && (
<> <Stack direction="column">
<SilencesFilter /> <SilencesFilter />
<Authorize actions={[permissions.create]} fallback={contextSrv.isEditor}> <Authorize actions={[permissions.create]} fallback={contextSrv.isEditor}>
<div className={styles.topButtonContainer}> <div className={styles.topButtonContainer}>
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}> <LinkButton href={makeAMLink('/alerting/silence/new', alertManagerSourceName)} icon="plus">
<Button className={styles.addNewSilence} icon="plus"> Add Silence
Add Silence </LinkButton>
</Button>
</Link>
</div> </div>
</Authorize> </Authorize>
{!!items.length ? ( <SilenceList
<> items={itemsNotExpired}
<DynamicTable alertManagerSourceName={alertManagerSourceName}
items={items} dataTestId="not-expired-table"
cols={columns} />
isExpandable {itemsExpired.length > 0 && (
renderExpandedContent={({ data }) => <SilenceDetails silence={data} />} <CollapsableSection label={`Expired silences (${itemsExpired.length})`} isOpen={showExpiredFromUrl}>
<div className={styles.callout}>
<Icon className={styles.calloutIcon} name="info-circle" />
<span>Expired silences are automatically deleted after 5 days.</span>
</div>
<SilenceList
items={itemsExpired}
alertManagerSourceName={alertManagerSourceName}
dataTestId="expired-table"
/> />
{showExpiredSilencesBanner && ( </CollapsableSection>
<div className={styles.callout}>
<Icon className={styles.calloutIcon} name="info-circle" />
<span>Expired silences are automatically deleted after 5 days.</span>
</div>
)}
</>
) : (
'No matching silences found'
)} )}
</> </Stack>
)} )}
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />} {!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
</div> </div>
); );
}; };
const useFilteredSilences = (silences: Silence[]) => { function SilenceList({
items,
alertManagerSourceName,
dataTestId,
}: {
items: SilenceTableItemProps[];
alertManagerSourceName: string;
dataTestId: string;
}) {
const columns = useColumns(alertManagerSourceName);
if (!!items.length) {
return (
<DynamicTable
items={items}
cols={columns}
isExpandable
dataTestId={dataTestId}
renderExpandedContent={({ data }) => <SilenceDetails silence={data} />}
/>
);
} else {
return <>No matching silences found</>;
}
}
const useFilteredSilences = (silences: Silence[], expired = false) => {
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
return useMemo(() => { return useMemo(() => {
const { queryString, silenceState } = getSilenceFiltersFromUrlParams(queryParams); const { queryString } = getSilenceFiltersFromUrlParams(queryParams);
const silenceIdsString = queryParams?.silenceIds; const silenceIdsString = queryParams?.silenceIds;
return silences.filter((silence) => { return silences.filter((silence) => {
if (typeof silenceIdsString === 'string') { if (typeof silenceIdsString === 'string') {
@@ -128,15 +161,13 @@ const useFilteredSilences = (silences: Silence[]) => {
return false; return false;
} }
} }
if (silenceState) { if (expired) {
const stateMatches = silence.status.state === silenceState; return silence.status.state === SilenceState.Expired;
if (!stateMatches) { } else {
return false; return silence.status.state !== SilenceState.Expired;
}
} }
return true;
}); });
}, [queryParams, silences]); }, [queryParams, silences, expired]);
}; };
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
@@ -156,7 +187,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-top: ${theme.spacing(2)};
& > * { & > * {
margin-left: ${theme.spacing(1)}; margin-left: ${theme.spacing(1)};
@@ -255,5 +285,4 @@ function useColumns(alertManagerSourceName: string) {
return columns; return columns;
}, [alertManagerSourceName, dispatch, styles, permissions]); }, [alertManagerSourceName, dispatch, styles, permissions]);
} }
export default SilencesTable; export default SilencesTable;