mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Split silences view expired/not-expired (#66562)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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)};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user