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 { 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 { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { SilenceState } from '../../../plugins/datasource/alertmanager/types';
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 { parseMatchers } from './utils/alertmanager';
import { DataSourceType } from './utils/datasource';
@@ -48,10 +50,12 @@ const dataSources = {
};
const ui = {
silencesTable: byTestId('dynamic-table'),
notExpiredTable: byTestId('not-expired-table'),
expiredTable: byTestId('expired-table'),
expiredCaret: byText(/expired/i),
silenceRow: byTestId('row'),
silencedAlertCell: byTestId('alerts'),
addSilenceButton: byRole('button', { name: /add silence/i }),
addSilenceButton: byRole('link', { name: /add silence/i }),
queryBar: byPlaceholderText('Search'),
editor: {
timeRange: byLabelText('Timepicker', { exact: false }),
@@ -75,6 +79,7 @@ const resetMocks = () => {
return Promise.resolve([
mockSilence({ id: '12345' }),
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.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[0]).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.fetchAlerts).toHaveBeenCalled());
const silencedAlertRows = ui.silencedAlertCell.getAll(ui.silencesTable.get());
const silencedAlertRows = ui.silencedAlertCell.getAll(ui.notExpiredTable.get());
expect(silencedAlertRows).toHaveLength(2);
expect(silencedAlertRows[0]).toHaveTextContent('2');
expect(silencedAlertRows[1]).toHaveTextContent('0');
@@ -177,7 +192,7 @@ describe('Silences', () => {
await userEvent.click(queryBar);
await userEvent.paste('foo=bar');
await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(1));
await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(2));
},
TEST_TIMEOUT
);

View File

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

View File

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

View File

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