grafana/public/app/features/alerting/unified/components/silences/SilencesTable.tsx
Nathan Rodman 5a25ada3d0
Alerting: Add FGAC for Silences (#46479)
* add FGAC actions for silences table

* redirect users without permissions

* hide silence button in rules list

* add permissions checks to routes

* add read action for silences page

* add permissions checks to navigation

* add additional access checks for rule viewing

* create authorize component

* add tests for silences

* hide alerting nav for users without access

* nolint: gocyclo

* add permission check to alert details

* add check for external instances

* remove unecessary new lines

* use correct actions for alert details

* fix failing tests

Co-authored-by: Yuriy Tseretyan <yuriy.tseretyan@grafana.com>
2022-03-21 16:54:37 -07:00

269 lines
9.0 KiB
TypeScript

import React, { FC, useMemo } from 'react';
import { GrafanaTheme2, dateMath } from '@grafana/data';
import { Icon, useStyles2, Link, Button } from '@grafana/ui';
import { css } from '@emotion/css';
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { NoSilencesSplash } from './NoSilencesCTA';
import { getSilenceFiltersFromUrlParams, 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';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { SilenceStateTag } from './SilenceStateTag';
import { Matchers } from './Matchers';
import { ActionButton } from '../rules/ActionButton';
import { ActionIcon } from '../rules/ActionIcon';
import { useDispatch } from 'react-redux';
import { expireSilenceAction } from '../../state/actions';
import { SilenceDetails } from './SilenceDetails';
import { Stack } from '@grafana/experimental';
import { AccessControlAction } from '../../../../../types';
import { Authorize } from '../Authorize';
import { isGrafanaRulesSource } from '../../utils/datasource';
export interface SilenceTableItem extends Silence {
silencedAlerts: AlertmanagerAlert[];
}
type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
interface Props {
silences: Silence[];
alertManagerAlerts: AlertmanagerAlert[];
alertManagerSourceName: string;
}
const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
const filteredSilences = useFilteredSilences(silences);
const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
const { silenceState } = getSilenceFiltersFromUrlParams(queryParams);
const showExpiredSilencesBanner =
!!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
const columns = useColumns(alertManagerSourceName);
const items = useMemo((): SilenceTableItemProps[] => {
const findSilencedAlerts = (id: string) => {
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
};
return filteredSilences.map((silence) => {
const silencedAlerts = findSilencedAlerts(silence.id);
return {
id: silence.id,
data: { ...silence, silencedAlerts },
};
});
}, [filteredSilences, alertManagerAlerts]);
return (
<div data-testid="silences-table">
{!!silences.length && (
<>
<SilencesFilter />
<Authorize
actions={
isExternalAM
? [AccessControlAction.AlertingInstancesExternalWrite]
: [AccessControlAction.AlertingInstanceCreate]
}
fallback={contextSrv.isEditor}
>
<div className={styles.topButtonContainer}>
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
<Button className={styles.addNewSilence} icon="plus">
New Silence
</Button>
</Link>
</div>
</Authorize>
{!!items.length ? (
<>
<DynamicTable
items={items}
cols={columns}
isExpandable
renderExpandedContent={({ data }) => <SilenceDetails silence={data} />}
/>
{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'
)}
</>
)}
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
</div>
);
};
const useFilteredSilences = (silences: Silence[]) => {
const [queryParams] = useQueryParams();
return useMemo(() => {
const { queryString, silenceState } = getSilenceFiltersFromUrlParams(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;
flex-direction: row;
justify-content: flex-end;
`,
addNewSilence: css`
margin: ${theme.spacing(2, 0)};
`,
callout: css`
background-color: ${theme.colors.background.secondary};
border-top: 3px solid ${theme.colors.info.border};
border-radius: 2px;
height: 62px;
display: flex;
flex-direction: row;
align-items: center;
margin-top: ${theme.spacing(2)};
& > * {
margin-left: ${theme.spacing(1)};
}
`,
calloutIcon: css`
color: ${theme.colors.info.text};
`,
editButton: css`
margin-left: ${theme.spacing(0.5)};
`,
});
function useColumns(alertManagerSourceName: string) {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
return useMemo((): SilenceTableColumnProps[] => {
const handleExpireSilenceClick = (id: string) => {
dispatch(expireSilenceAction(alertManagerSourceName, id));
};
const showActions = contextSrv.hasAccess(
isExternalAM ? AccessControlAction.AlertingInstancesExternalWrite : AccessControlAction.AlertingInstanceUpdate,
contextSrv.isEditor
);
const columns: SilenceTableColumnProps[] = [
{
id: 'state',
label: 'State',
renderCell: function renderStateTag({ data: { status } }) {
return <SilenceStateTag state={status.state} />;
},
size: '88px',
},
{
id: 'matchers',
label: 'Matching labels',
renderCell: function renderMatchers({ data: { matchers } }) {
return <Matchers matchers={matchers || []} />;
},
size: 9,
},
{
id: 'alerts',
label: 'Alerts',
renderCell: function renderSilencedAlerts({ data: { silencedAlerts } }) {
return <span data-testid="alerts">{silencedAlerts.length}</span>;
},
size: 1,
},
{
id: 'schedule',
label: 'Schedule',
renderCell: function renderSchedule({ data: { startsAt, endsAt } }) {
const startsAtDate = dateMath.parse(startsAt);
const endsAtDate = dateMath.parse(endsAt);
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
return (
<>
{' '}
{startsAtDate?.format(dateDisplayFormat)} {'-'}
<br />
{endsAtDate?.format(dateDisplayFormat)}
</>
);
},
size: '150px',
},
];
if (showActions) {
columns.push({
id: 'actions',
label: 'Actions',
renderCell: function renderActions({ data: silence }) {
return (
<Stack gap={0.5}>
{silence.status.state === 'expired' ? (
<Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}>
<ActionButton icon="sync">Recreate</ActionButton>
</Link>
) : (
<ActionButton icon="bell" onClick={() => handleExpireSilenceClick(silence.id)}>
Unsilence
</ActionButton>
)}
{silence.status.state !== 'expired' && (
<ActionIcon
className={styles.editButton}
to={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
icon="pen"
tooltip="edit"
/>
)}
</Stack>
);
},
size: '147px',
});
}
return columns;
}, [alertManagerSourceName, dispatch, styles, isExternalAM]);
}
export default SilencesTable;