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 { 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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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)};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user