Alerting: Fix silence / delete on new list view (#107885)

This commit is contained in:
Gilles De Mey
2025-07-09 13:44:44 +00:00
committed by GitHub
parent 8283d35e56
commit 5d92f3eee5
7 changed files with 113 additions and 54 deletions
@@ -1,15 +1,14 @@
import { Menu } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule';
import { isLoading } from '../hooks/useAsync';
import { stringifyErrorLike } from '../utils/misc';
import { isPausedRule } from '../utils/rules';
interface Props {
rule: RulerGrafanaRuleDTO;
uid: string;
isPaused: boolean;
groupIdentifier: GrafanaRuleGroupIdentifier;
/**
* Method invoked after the request to change the paused state has completed
@@ -21,20 +20,18 @@ interface Props {
* Menu item to display correct text for pausing/resuming an alert,
* and triggering API call to do so
*/
const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => {
const MenuItemPauseRule = ({ uid, isPaused, groupIdentifier, onPauseChange }: Props) => {
const notifyApp = useAppNotification();
const [pauseRule, updateState] = usePauseRuleInGroup();
const [icon, title] = isPausedRule(rule)
? ['play' as const, 'Resume evaluation']
: ['pause' as const, 'Pause evaluation'];
const [icon, title] = isPaused ? ['play' as const, 'Resume evaluation'] : ['pause' as const, 'Pause evaluation'];
/**
* Triggers API call to update the current rule to the new `is_paused` state
*/
const setRulePause = async (newIsPaused: boolean) => {
try {
await pauseRule.execute(groupIdentifier, rule.grafana_alert.uid, newIsPaused);
await pauseRule.execute(groupIdentifier, uid, newIsPaused);
} catch (error) {
notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`);
return;
@@ -49,7 +46,7 @@ const MenuItemPauseRule = ({ rule, groupIdentifier, onPauseChange }: Props) => {
icon={icon}
disabled={isLoading(updateState)}
onClick={() => {
setRulePause(!rule.grafana_alert.is_paused);
setRulePause(!isPaused);
}}
/>
);
@@ -7,7 +7,7 @@ import appEvents from 'app/core/app_events';
import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule';
import MoreButton from 'app/features/alerting/unified/components/MoreButton';
import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions';
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { EditableRuleIdentifier, Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import {
@@ -18,7 +18,13 @@ import {
} from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { prometheusRuleType, rulerRuleType } from '../../utils/rules';
import {
getRuleUID,
isEditableRuleIdentifier,
isPausedRule,
prometheusRuleType,
rulerRuleType,
} from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
@@ -28,7 +34,7 @@ interface Props {
identifier: RuleIdentifier;
groupIdentifier: RuleGroupIdentifierV2;
handleSilence: () => void;
handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void;
handleDelete: (identifier: EditableRuleIdentifier, groupIdentifier: RuleGroupIdentifierV2) => void;
handleDuplicateRule: (identifier: RuleIdentifier) => void;
onPauseChange?: () => void;
buttonSize?: ComponentSize;
@@ -118,10 +124,22 @@ const AlertRuleMenu = ({
const showDivider =
[canPause, canSilence, shouldShowDeclareIncidentButton, canDuplicate].some(Boolean) && [canExport].some(Boolean);
// grab the UID from either rulerRule or promRule
const ruleUid = getRuleUID(rulerRule ?? promRule);
const isPaused =
(rulerRuleType.grafana.rule(rulerRule) && isPausedRule(rulerRule)) ||
(prometheusRuleType.grafana.rule(promRule) && promRule.isPaused);
const menuItems = (
<>
{canPause && rulerRuleType.grafana.rule(rulerRule) && groupIdentifier.groupOrigin === 'grafana' && (
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
{canPause && ruleUid && groupIdentifier.groupOrigin === 'grafana' && (
<MenuItemPauseRule
uid={ruleUid}
isPaused={isPaused}
groupIdentifier={groupIdentifier}
onPauseChange={onPauseChange}
/>
)}
{canSilence && (
<Menu.Item
@@ -162,14 +180,19 @@ const AlertRuleMenu = ({
))}
</>
)}
{canDelete && rulerRule && (
{canDelete && (
<>
<Menu.Divider />
<Menu.Item
label={t('alerting.common.delete', 'Delete')}
icon="trash-alt"
destructive
onClick={() => handleDelete(rulerRule, groupIdentifier)}
onClick={() => {
// if the identifier is not for a editable rule I wonder how you even got here.
if (isEditableRuleIdentifier(identifier)) {
handleDelete(identifier, groupIdentifier);
}
}}
/>
</>
)}
@@ -1,3 +1,4 @@
import { isString } from 'lodash';
import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
@@ -16,7 +17,7 @@ import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datas
import { groupIdentifier } from '../../utils/groupIdentifier';
import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { rulerRuleType } from '../../utils/rules';
import { getRuleUID, prometheusRuleType, rulerRuleType } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { RedirectToCloneRule } from './CloneRule';
@@ -104,6 +105,12 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
return null;
}
// determine if this rule can be silenced by checking for Grafana Alert rule type and extracting the UID
const ruleUid = getRuleUID(rule.rulerRule ?? rule.promRule);
const silenceableRule =
isString(ruleUid) &&
(rulerRuleType.grafana.alertingRule(rule.rulerRule) || prometheusRuleType.grafana.alertingRule(rule.promRule));
return (
<Stack gap={1} alignItems="center" wrap="nowrap">
{buttons}
@@ -132,8 +139,8 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
buttonSize={buttonSize}
/>
{deleteModal}
{rulerRuleType.grafana.alertingRule(rule.rulerRule) && showSilenceDrawer && (
<SilenceGrafanaRuleDrawer rulerRule={rule.rulerRule} onClose={() => setShowSilenceDrawer(false)} />
{silenceableRule && showSilenceDrawer && (
<SilenceGrafanaRuleDrawer ruleUid={ruleUid} onClose={() => setShowSilenceDrawer(false)} />
)}
{redirectToClone?.identifier && (
<RedirectToCloneRule
@@ -4,12 +4,11 @@ import { t } from '@grafana/i18n';
import { Drawer, Stack } from '@grafana/ui';
import { SilencesEditor } from 'app/features/alerting/unified/components/silences/SilencesEditor';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
type Props = {
rulerRule: RulerGrafanaRuleDTO;
ruleUid: string;
onClose: () => void;
};
@@ -17,32 +16,26 @@ type Props = {
* For a given Grafana managed rule, renders a drawer containing silences editor and Alertmanager selection
*/
const SilenceGrafanaRuleDrawer = React.memo(
({ rulerRule, onClose }: Props) => {
const { uid } = rulerRule.grafana_alert;
return (
<Drawer
title={t('alerting.silence-grafana-rule-drawer.title-silence-alert-rule', 'Silence alert rule')}
subtitle="Configure silences to stop notifications from a particular alert rule."
onClose={onClose}
size="md"
>
<Stack direction={'column'}>
<AlertmanagerProvider accessType="instance">
<SilencesEditor
ruleUid={uid}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
onSilenceCreated={onClose}
onCancel={onClose}
/>
</AlertmanagerProvider>
</Stack>
</Drawer>
);
},
(prevProps, nextProps) => {
return prevProps.rulerRule.grafana_alert.uid === nextProps.rulerRule.grafana_alert.uid;
}
({ ruleUid, onClose }: Props) => (
<Drawer
title={t('alerting.silence-grafana-rule-drawer.title-silence-alert-rule', 'Silence alert rule')}
subtitle="Configure silences to stop notifications from a particular alert rule."
onClose={onClose}
size="md"
>
<Stack direction={'column'}>
<AlertmanagerProvider accessType="instance">
<SilencesEditor
ruleUid={ruleUid}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
onSilenceCreated={onClose}
onCancel={onClose}
/>
</AlertmanagerProvider>
</Stack>
</Drawer>
),
(prevProps, nextProps) => prevProps.ruleUid === nextProps.ruleUid
);
export default SilenceGrafanaRuleDrawer;
@@ -40,6 +40,7 @@ const ui = {
copyLink: () => byRole('menuitem', { name: /copy link/i }),
export: () => byRole('menuitem', { name: /export/i }),
delete: () => byRole('menuitem', { name: /delete/i }),
pause: () => byRole('menuitem', { name: /pause/i }),
},
};
@@ -188,7 +189,7 @@ describe('GrafanaGroupLoader', () => {
const menu = byRole('menu').get();
expect(menu).toBeInTheDocument();
// With proper permissions, all 4 menu actions should be available:
// With proper permissions, all 6 menu actions should be available:
// 1. Silence notifications - available for alerting rules (AlertingSilenceCreate permission)
expect(ui.menuItems.silence().get()).toBeInTheDocument();
@@ -202,9 +203,15 @@ describe('GrafanaGroupLoader', () => {
// 4. Export - should be available for Grafana alerting rules (AlertingRuleRead permission)
expect(ui.menuItems.export().get()).toBeInTheDocument();
// Verify that the menu contains all 4 expected menu items
// 5. Delete - should be available for Grafana alerting rules (AlertingRuleDelete permission)
expect(ui.menuItems.delete().get()).toBeInTheDocument();
// 6. Pause - should be available for Grafana alerting rules (AlertingRuleUpdate permission)
expect(ui.menuItems.pause().get()).toBeInTheDocument();
// Verify that the menu contains all 6 expected menu items
const menuItems = byRole('menuitem').getAll();
expect(menuItems.length).toBe(4);
expect(menuItems.length).toBe(6);
});
});
@@ -1,3 +1,4 @@
import { isString } from 'lodash';
import { useState } from 'react';
import { RequireAtLeastOne } from 'type-fest';
@@ -19,7 +20,13 @@ import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { logWarning } from '../../Analytics';
import { AlertRuleAction, skipToken, useGrafanaPromRuleAbility, useRulerRuleAbility } from '../../hooks/useAbilities';
import * as ruleId from '../../utils/rule-id';
import { isProvisionedPromRule, isProvisionedRule, prometheusRuleType, rulerRuleType } from '../../utils/rules';
import {
getRuleUID,
isProvisionedPromRule,
isProvisionedRule,
prometheusRuleType,
rulerRuleType,
} from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
type RuleProps = RequireAtLeastOne<{
@@ -69,6 +76,12 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
return null;
}
// determine if this rule can be silenced by checking for Grafana Alert rule type and extracting the UID
const ruleUid = getRuleUID(rule ?? promRule);
const silenceableRule =
isString(ruleUid) &&
(rulerRuleType.grafana.alertingRule(rule) || prometheusRuleType.grafana.alertingRule(promRule));
if (canEditRule) {
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`);
@@ -96,13 +109,13 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
promRule={promRule}
groupIdentifier={groupIdentifier}
identifier={identifier}
handleDelete={() => showDeleteModal(identifier, groupIdentifier)}
handleDelete={(identifier, groupIdentifier) => showDeleteModal(identifier, groupIdentifier)}
handleSilence={() => setShowSilenceDrawer(true)}
handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })}
/>
{deleteModal}
{rulerRuleType.grafana.alertingRule(rule) && showSilenceDrawer && (
<SilenceGrafanaRuleDrawer rulerRule={rule} onClose={() => setShowSilenceDrawer(false)} />
{silenceableRule && showSilenceDrawer && (
<SilenceGrafanaRuleDrawer ruleUid={ruleUid} onClose={() => setShowSilenceDrawer(false)} />
)}
{redirectToClone?.identifier && (
<RedirectToCloneRule
@@ -533,3 +533,22 @@ export function isRecordingRuleByType(type?: RuleFormType) {
export function isDataSourceManagedRuleByType(type?: RuleFormType) {
return isCloudAlertingRuleByType(type) || isCloudRecordingRuleByType(type);
}
/*
* Grab the UID from either a rulerRule definition or a Prometheus rule definition, only Grafana-managed rules will have a UID.
*/
export function getRuleUID(rule?: RulerRuleDTO | Rule) {
if (!rule) {
return;
}
let ruleUid: string | undefined;
if ('grafana_alert' in rule && rulerRuleType.grafana.rule(rule)) {
ruleUid = rule.grafana_alert.uid;
} else if ('uid' in rule && prometheusRuleType.grafana.rule(rule)) {
ruleUid = rule.uid;
}
return ruleUid;
}