398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import { css } from '@emotion/css';
|
|
import { useMemo } from 'react';
|
|
|
|
import { GrafanaTheme2, dateMath } from '@grafana/data';
|
|
import {
|
|
Alert,
|
|
CollapsableSection,
|
|
Divider,
|
|
Icon,
|
|
Link,
|
|
LinkButton,
|
|
LoadingPlaceholder,
|
|
Stack,
|
|
useStyles2,
|
|
} from '@grafana/ui';
|
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
|
import { Trans } from 'app/core/internationalization';
|
|
import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi';
|
|
import { featureDiscoveryApi } from 'app/features/alerting/unified/api/featureDiscoveryApi';
|
|
import { MATCHER_ALERT_RULE_UID, SILENCES_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants';
|
|
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
|
|
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
|
|
|
|
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
|
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
|
import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers';
|
|
import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc';
|
|
import { withPageErrorBoundary } from '../../withPageErrorBoundary';
|
|
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
|
|
import { Authorize } from '../Authorize';
|
|
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
|
import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning';
|
|
|
|
import { Matchers } from './Matchers';
|
|
import { NoSilencesSplash } from './NoSilencesCTA';
|
|
import { SilenceDetails } from './SilenceDetails';
|
|
import { SilenceStateTag } from './SilenceStateTag';
|
|
import { SilencesFilter } from './SilencesFilter';
|
|
|
|
export interface SilenceTableItem extends Silence {
|
|
silencedAlerts: AlertmanagerAlert[] | undefined;
|
|
}
|
|
|
|
type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
|
|
type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
|
|
|
|
const API_QUERY_OPTIONS = { pollingInterval: SILENCES_POLL_INTERVAL_MS, refetchOnFocus: true };
|
|
|
|
const SilencesTable = () => {
|
|
const { selectedAlertmanager: alertManagerSourceName = '' } = useAlertmanager();
|
|
const [previewAlertsSupported, previewAlertsAllowed] = useAlertmanagerAbility(
|
|
AlertmanagerAction.PreviewSilencedInstances
|
|
);
|
|
const canPreview = previewAlertsSupported && previewAlertsAllowed;
|
|
|
|
const { data: alertManagerAlerts = [], isLoading: amAlertsIsLoading } =
|
|
alertmanagerApi.endpoints.getAlertmanagerAlerts.useQuery(
|
|
{ amSourceName: alertManagerSourceName, filter: { silenced: true, active: true, inhibited: true } },
|
|
{ ...API_QUERY_OPTIONS, skip: !canPreview }
|
|
);
|
|
|
|
const {
|
|
data: silences = [],
|
|
isLoading,
|
|
error,
|
|
} = alertSilencesApi.endpoints.getSilences.useQuery(
|
|
{ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), ruleMetadata: true, accessControl: true },
|
|
API_QUERY_OPTIONS
|
|
);
|
|
|
|
const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery(
|
|
{ amSourceName: alertManagerSourceName ?? '' },
|
|
{ skip: !alertManagerSourceName }
|
|
);
|
|
|
|
const mimirLazyInitError =
|
|
stringifyErrorLike(error).includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit;
|
|
|
|
const styles = useStyles2(getStyles);
|
|
const [queryParams] = useQueryParams();
|
|
const filteredSilencesNotExpired = useFilteredSilences(silences, false);
|
|
const filteredSilencesExpired = useFilteredSilences(silences, true);
|
|
|
|
const { silenceState: silenceStateInParams } = getSilenceFiltersFromUrlParams(queryParams);
|
|
const showExpiredFromUrl = silenceStateInParams === SilenceState.Expired;
|
|
|
|
const itemsNotExpired = useMemo((): SilenceTableItemProps[] => {
|
|
const findSilencedAlerts = (id: string) => {
|
|
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
|
};
|
|
return filteredSilencesNotExpired.map((silence) => {
|
|
const silencedAlerts = canPreview ? findSilencedAlerts(silence.id) : undefined;
|
|
return {
|
|
id: silence.id,
|
|
data: { ...silence, silencedAlerts },
|
|
};
|
|
});
|
|
}, [filteredSilencesNotExpired, alertManagerAlerts, canPreview]);
|
|
|
|
const itemsExpired = useMemo((): SilenceTableItemProps[] => {
|
|
const findSilencedAlerts = (id: string) => {
|
|
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
|
};
|
|
return filteredSilencesExpired.map((silence) => {
|
|
const silencedAlerts = canPreview ? findSilencedAlerts(silence.id) : undefined;
|
|
return {
|
|
id: silence.id,
|
|
data: { ...silence, silencedAlerts },
|
|
};
|
|
});
|
|
}, [filteredSilencesExpired, alertManagerAlerts, canPreview]);
|
|
|
|
if (isLoading || amAlertsIsLoading) {
|
|
return <LoadingPlaceholder text="Loading silences..." />;
|
|
}
|
|
|
|
if (mimirLazyInitError) {
|
|
return (
|
|
<Alert title="The selected Alertmanager has no configuration" severity="warning">
|
|
<Trans i18nKey="silences.table.noConfig">
|
|
Create a new contact point to create a configuration using the default values or contact your administrator to
|
|
set up the Alertmanager.
|
|
</Trans>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
const errMessage = stringifyErrorLike(error) || 'Unknown error.';
|
|
return (
|
|
<Alert severity="error" title="Error loading silences">
|
|
{errMessage}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div data-testid="silences-table">
|
|
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={alertManagerSourceName} />
|
|
{!!silences.length && (
|
|
<Stack direction="column">
|
|
<SilencesFilter />
|
|
<Authorize actions={[AlertmanagerAction.CreateSilence]}>
|
|
<Stack justifyContent="end">
|
|
<LinkButton href={makeAMLink('/alerting/silence/new', alertManagerSourceName)} icon="plus">
|
|
<Trans i18nKey="silences.table.add-silence-button">Add Silence</Trans>
|
|
</LinkButton>
|
|
</Stack>
|
|
</Authorize>
|
|
<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>
|
|
<Trans i18nKey="silences.table.expired-silences">
|
|
Expired silences are automatically deleted after 5 days.
|
|
</Trans>
|
|
</span>
|
|
</div>
|
|
<SilenceList
|
|
items={itemsExpired}
|
|
alertManagerSourceName={alertManagerSourceName}
|
|
dataTestId="expired-table"
|
|
/>
|
|
</CollapsableSection>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function SilenceList({
|
|
items,
|
|
alertManagerSourceName,
|
|
dataTestId,
|
|
}: {
|
|
items: SilenceTableItemProps[];
|
|
alertManagerSourceName: string;
|
|
dataTestId: string;
|
|
}) {
|
|
const columns = useColumns(alertManagerSourceName);
|
|
if (!!items.length) {
|
|
return (
|
|
<DynamicTable
|
|
pagination={{ itemsPerPage: 25 }}
|
|
items={items}
|
|
cols={columns}
|
|
isExpandable
|
|
dataTestId={dataTestId}
|
|
renderExpandedContent={({ data }) => {
|
|
return (
|
|
<>
|
|
<Divider />
|
|
<SilenceDetails silence={data} />
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
} else {
|
|
return <Trans i18nKey="silences.table.no-matching-silences">No matching silences found;</Trans>;
|
|
}
|
|
}
|
|
|
|
const useFilteredSilences = (silences: Silence[], expired = false) => {
|
|
const [queryParams] = useQueryParams();
|
|
return useMemo(() => {
|
|
const { queryString } = 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 = parsePromQLStyleMatcherLooseSafe(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 (expired) {
|
|
return silence.status.state === SilenceState.Expired;
|
|
} else {
|
|
return silence.status.state !== SilenceState.Expired;
|
|
}
|
|
});
|
|
}, [queryParams, silences, expired]);
|
|
};
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => ({
|
|
callout: css({
|
|
backgroundColor: theme.colors.background.secondary,
|
|
borderTop: `3px solid ${theme.colors.info.border}`,
|
|
borderRadius: theme.shape.radius.default,
|
|
height: '62px',
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
|
|
'& > *': {
|
|
marginLeft: theme.spacing(1),
|
|
},
|
|
}),
|
|
calloutIcon: css({
|
|
color: theme.colors.info.text,
|
|
}),
|
|
});
|
|
|
|
function useColumns(alertManagerSourceName: string) {
|
|
const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence);
|
|
const [expireSilence] = alertSilencesApi.endpoints.expireSilence.useMutation();
|
|
const isGrafanaFlavoredAlertmanager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
|
|
|
|
return useMemo((): SilenceTableColumnProps[] => {
|
|
const handleExpireSilenceClick = (silenceId: string) => {
|
|
expireSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), silenceId });
|
|
};
|
|
const columns: SilenceTableColumnProps[] = [
|
|
{
|
|
id: 'state',
|
|
label: 'State',
|
|
renderCell: function renderStateTag({ data: { status } }) {
|
|
return <SilenceStateTag state={status.state} />;
|
|
},
|
|
size: 3,
|
|
},
|
|
{
|
|
id: 'alert-rule',
|
|
label: 'Alert rule targeted',
|
|
renderCell: function renderAlertRuleLink({ data: { metadata } }) {
|
|
return metadata?.rule_title ? (
|
|
<Link
|
|
href={`/alerting/grafana/${metadata?.rule_uid}/view?returnTo=${encodeURIComponent('/alerting/silences')}`}
|
|
>
|
|
{metadata.rule_title}
|
|
</Link>
|
|
) : (
|
|
'None'
|
|
);
|
|
},
|
|
size: 8,
|
|
},
|
|
{
|
|
id: 'matchers',
|
|
label: 'Matching labels',
|
|
renderCell: function renderMatchers({ data: { matchers } }) {
|
|
const filteredMatchers = matchers?.filter((matcher) => matcher.name !== MATCHER_ALERT_RULE_UID) || [];
|
|
return <Matchers matchers={filteredMatchers} />;
|
|
},
|
|
size: 7,
|
|
},
|
|
{
|
|
id: 'alerts',
|
|
label: 'Alerts silenced',
|
|
renderCell: function renderSilencedAlerts({ data: { silencedAlerts } }) {
|
|
return <span data-testid="alerts">{Array.isArray(silencedAlerts) ? silencedAlerts.length : '-'}</span>;
|
|
},
|
|
size: 2,
|
|
},
|
|
{
|
|
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)} - ${endsAtDate?.format(dateDisplayFormat)}`;
|
|
},
|
|
size: 7,
|
|
},
|
|
];
|
|
if (updateSupported) {
|
|
columns.push({
|
|
id: 'actions',
|
|
label: 'Actions',
|
|
renderCell: function renderActions({ data: silence }) {
|
|
const isExpired = silence.status.state === SilenceState.Expired;
|
|
|
|
const canCreate = silence?.accessControl?.create;
|
|
const canWrite = silence?.accessControl?.write;
|
|
|
|
const canRecreate = isExpired && (isGrafanaFlavoredAlertmanager ? canCreate : updateAllowed);
|
|
const canEdit = !isExpired && (isGrafanaFlavoredAlertmanager ? canWrite : updateAllowed);
|
|
|
|
return (
|
|
<Stack gap={0.5} wrap="wrap">
|
|
{canRecreate && (
|
|
<LinkButton
|
|
title="Recreate"
|
|
size="sm"
|
|
variant="secondary"
|
|
icon="sync"
|
|
href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
|
|
>
|
|
<Trans i18nKey="silences.table.recreate-button">Recreate</Trans>
|
|
</LinkButton>
|
|
)}
|
|
{canEdit && (
|
|
<>
|
|
<LinkButton
|
|
title="Unsilence"
|
|
size="sm"
|
|
variant="secondary"
|
|
icon="bell"
|
|
onClick={() => handleExpireSilenceClick(silence.id)}
|
|
>
|
|
<Trans i18nKey="silences.table.unsilence-button">Unsilence</Trans>
|
|
</LinkButton>
|
|
<LinkButton
|
|
title="Edit"
|
|
size="sm"
|
|
variant="secondary"
|
|
icon="pen"
|
|
href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
|
|
>
|
|
<Trans i18nKey="silences.table.edit-button">Edit</Trans>
|
|
</LinkButton>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
);
|
|
},
|
|
size: 5,
|
|
});
|
|
}
|
|
return columns;
|
|
}, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAllowed, updateSupported]);
|
|
}
|
|
|
|
function SilencesTablePage() {
|
|
return (
|
|
<AlertmanagerPageWrapper navId="silences" accessType="instance">
|
|
<SilencesTable />
|
|
</AlertmanagerPageWrapper>
|
|
);
|
|
}
|
|
|
|
export default withPageErrorBoundary(SilencesTablePage);
|