diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx
index 5b6a8943da4..77e751238cf 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx
@@ -10,7 +10,12 @@ import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugin
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-dto';
-import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
+import {
+ AlertRuleAction,
+ skipToken,
+ useGrafanaPromRuleAbilities,
+ useRulerRuleAbilities,
+} from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { prometheusRuleType, rulerRuleType } from '../../utils/rules';
@@ -33,6 +38,8 @@ interface Props {
/**
* Get a list of menu items + divider elements for rendering in an alert rule's
* dropdown menu
+ * If the consumer of this component comes from the alert list view, we need to use promRule to check abilities and permissions,
+ * as we have removed all requests to the ruler API in the list view.
*/
const AlertRuleMenu = ({
promRule,
@@ -46,29 +53,51 @@ const AlertRuleMenu = ({
buttonSize,
fill,
}: Props) => {
- // check all abilities and permissions
- const [pauseSupported, pauseAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Pause);
- const canPause = pauseSupported && pauseAllowed;
-
- const [deleteSupported, deleteAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Delete);
- const canDelete = deleteSupported && deleteAllowed;
-
- const [duplicateSupported, duplicateAllowed] = useRulerRuleAbility(
- rulerRule,
- groupIdentifier,
- AlertRuleAction.Duplicate
- );
- const canDuplicate = duplicateSupported && duplicateAllowed;
-
- const [silenceSupported, silenceAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Silence);
- const canSilence = silenceSupported && silenceAllowed;
-
- const [exportSupported, exportAllowed] = useRulerRuleAbility(
- rulerRule,
- groupIdentifier,
- AlertRuleAction.ModifyExport
- );
- const canExport = exportSupported && exportAllowed;
+ // check all abilities and permissions using rulerRule
+ const [rulerPauseAbility, rulerDeleteAbility, rulerDuplicateAbility, rulerSilenceAbility, rulerExportAbility] =
+ useRulerRuleAbilities(rulerRule, groupIdentifier, [
+ AlertRuleAction.Pause,
+ AlertRuleAction.Delete,
+ AlertRuleAction.Duplicate,
+ AlertRuleAction.Silence,
+ AlertRuleAction.ModifyExport,
+ ]);
+
+ // check all abilities and permissions using promRule
+ const [
+ grafanaPauseAbility,
+ grafanaDeleteAbility,
+ grafanaDuplicateAbility,
+ grafanaSilenceAbility,
+ grafanaExportAbility,
+ ] = useGrafanaPromRuleAbilities(prometheusRuleType.grafana.rule(promRule) ? promRule : skipToken, [
+ AlertRuleAction.Pause,
+ AlertRuleAction.Delete,
+ AlertRuleAction.Duplicate,
+ AlertRuleAction.Silence,
+ AlertRuleAction.ModifyExport,
+ ]);
+
+ const [pauseSupported, pauseAllowed] = rulerPauseAbility;
+ const [grafanaPauseSupported, grafanaPauseAllowed] = grafanaPauseAbility;
+ const canPause = (pauseSupported && pauseAllowed) || (grafanaPauseSupported && grafanaPauseAllowed);
+
+ const [deleteSupported, deleteAllowed] = rulerDeleteAbility;
+ const [grafanaDeleteSupported, grafanaDeleteAllowed] = grafanaDeleteAbility;
+ const canDelete = (deleteSupported && deleteAllowed) || (grafanaDeleteSupported && grafanaDeleteAllowed);
+
+ const [duplicateSupported, duplicateAllowed] = rulerDuplicateAbility;
+ const [grafanaDuplicateSupported, grafanaDuplicateAllowed] = grafanaDuplicateAbility;
+ const canDuplicate =
+ (duplicateSupported && duplicateAllowed) || (grafanaDuplicateSupported && grafanaDuplicateAllowed);
+
+ const [silenceSupported, silenceAllowed] = rulerSilenceAbility;
+ const [grafanaSilenceSupported, grafanaSilenceAllowed] = grafanaSilenceAbility;
+ const canSilence = (silenceSupported && silenceAllowed) || (grafanaSilenceSupported && grafanaSilenceAllowed);
+
+ const [exportSupported, exportAllowed] = rulerExportAbility;
+ const [grafanaExportSupported, grafanaExportAllowed] = grafanaExportAbility;
+ const canExport = (exportSupported && exportAllowed) || (grafanaExportSupported && grafanaExportAllowed);
const ruleExtensionLinks = useRulePluginLinkExtension(promRule, groupIdentifier);
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
index 6d55821a49f..ac0d694f601 100644
--- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
@@ -7,6 +7,7 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { getCloudRule, getGrafanaRule } from '../../mocks';
+import { mimirDataSource } from '../../mocks/server/configure';
import { RuleDetails } from './RuleDetails';
@@ -32,6 +33,8 @@ const ui = {
setupMswServer();
+const { dataSource: mimirDs } = mimirDataSource();
+
beforeAll(() => {
jest.clearAllMocks();
});
@@ -81,7 +84,7 @@ describe('RuleDetails RBAC', () => {
});
describe('Cloud rules action buttons', () => {
- const cloudRule = getCloudRule({ name: 'Cloud' });
+ const cloudRule = getCloudRule({ name: 'Cloud' }, { rulesSource: mimirDs });
it('Should not render Edit button for users with the update permission', async () => {
// Arrange
diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
index 22cb1bb8b7c..a4be47bbf81 100644
--- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
@@ -4,7 +4,14 @@ import { byRole } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
-import { AlertRuleAction, useAlertRuleAbility, useRulerRuleAbility } from '../../hooks/useAbilities';
+import {
+ AlertRuleAction,
+ useAlertRuleAbility,
+ useGrafanaPromRuleAbilities,
+ useGrafanaPromRuleAbility,
+ useRulerRuleAbilities,
+ useRulerRuleAbility,
+} from '../../hooks/useAbilities';
import { getCloudRule, getGrafanaRule } from '../../mocks';
import { mimirDataSource } from '../../mocks/server/configure';
@@ -13,11 +20,15 @@ import { RulesTable } from './RulesTable';
jest.mock('../../hooks/useAbilities');
const mocks = {
- // This is a bit unfortunate, but we need to mock both abilities
- // RuleActionButtons still needs to use the useAlertRuleAbility hook
- // whereas AlertRuleMenu has already been refactored to use useRulerRuleAbility
+ // Mock the hooks that are actually used by the components:
+ // RuleActionsButtons uses: useAlertRuleAbility (singular)
+ // AlertRuleMenu uses: useRulerRuleAbilities and useGrafanaPromRuleAbilities (plural)
+ // We can also use useGrafanaPromRuleAbility (singular) for simpler mocking
useRulerRuleAbility: jest.mocked(useRulerRuleAbility),
useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
+ useGrafanaPromRuleAbility: jest.mocked(useGrafanaPromRuleAbility),
+ useRulerRuleAbilities: jest.mocked(useRulerRuleAbilities),
+ useGrafanaPromRuleAbilities: jest.mocked(useGrafanaPromRuleAbilities),
};
setPluginLinksHook(() => ({
@@ -46,18 +57,40 @@ describe('RulesTable RBAC', () => {
jest.clearAllMocks();
jest.restoreAllMocks();
jest.resetAllMocks();
+
+ // Set up default neutral mocks for all hooks
+ // Singular hooks (used by RuleActionsButtons and can simplify mocking)
+ mocks.useAlertRuleAbility.mockReturnValue([false, false]);
+ mocks.useRulerRuleAbility.mockReturnValue([false, false]);
+ mocks.useGrafanaPromRuleAbility.mockReturnValue([false, false]);
+
+ // Plural hooks (used by AlertRuleMenu) - need to return arrays based on input actions
+ mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
+ return actions.map(() => [false, false]);
+ });
+ mocks.useGrafanaPromRuleAbilities.mockImplementation((_rule, actions) => {
+ return actions.map(() => [false, false]);
+ });
});
describe('Grafana rules action buttons', () => {
const grafanaRule = getGrafanaRule({ name: 'Grafana' });
it('Should not render Edit button for users without the update permission', async () => {
- mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => {
+ // Mock the specific hooks needed for Grafana rules
+ // Using singular hook for simpler mocking
+ mocks.useAlertRuleAbility.mockImplementation((rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true];
});
- mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
+ mocks.useGrafanaPromRuleAbility.mockImplementation((rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true];
});
+ // Still need plural hook for AlertRuleMenu component
+ mocks.useGrafanaPromRuleAbilities.mockImplementation((rule, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Update ? [true, false] : [true, true];
+ });
+ });
render();
@@ -65,11 +98,14 @@ describe('RulesTable RBAC', () => {
});
it('Should not render Delete button for users without the delete permission', async () => {
- mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => {
+ // Mock the specific hooks needed for Grafana rules
+ mocks.useAlertRuleAbility.mockImplementation((rule, action) => {
return action === AlertRuleAction.Delete ? [true, false] : [true, true];
});
- mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
- return action === AlertRuleAction.Delete ? [true, false] : [true, true];
+ mocks.useGrafanaPromRuleAbilities.mockImplementation((rule, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Delete ? [true, false] : [true, true];
+ });
});
render();
@@ -80,11 +116,14 @@ describe('RulesTable RBAC', () => {
});
it('Should render Edit button for users with the update permission', async () => {
- mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => {
+ // Mock the specific hooks needed for Grafana rules
+ mocks.useAlertRuleAbility.mockImplementation((rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
- mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
- return action === AlertRuleAction.Update ? [true, true] : [false, false];
+ mocks.useGrafanaPromRuleAbilities.mockImplementation((rule, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Update ? [true, true] : [false, false];
+ });
});
render();
@@ -93,11 +132,14 @@ describe('RulesTable RBAC', () => {
});
it('Should render Delete button for users with the delete permission', async () => {
- mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => {
+ // Mock the specific hooks needed for Grafana rules
+ mocks.useAlertRuleAbility.mockImplementation((rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
- mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
- return action === AlertRuleAction.Delete ? [true, true] : [false, false];
+ mocks.useGrafanaPromRuleAbilities.mockImplementation((rule, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Delete ? [true, true] : [false, false];
+ });
});
render();
@@ -123,11 +165,15 @@ describe('RulesTable RBAC', () => {
};
beforeEach(() => {
- mocks.useRulerRuleAbility.mockImplementation(() => {
- return [true, true];
+ // Mock all hooks needed for the creating/deleting state tests
+ mocks.useRulerRuleAbility.mockImplementation(() => [true, true]);
+ mocks.useAlertRuleAbility.mockImplementation(() => [true, true]);
+ // Mock plural hooks for AlertRuleMenu
+ mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
+ return actions.map(() => [true, true]);
});
- mocks.useAlertRuleAbility.mockImplementation(() => {
- return [true, true];
+ mocks.useGrafanaPromRuleAbilities.mockImplementation((_rule, actions) => {
+ return actions.map(() => [true, true]);
});
});
@@ -164,6 +210,12 @@ describe('RulesTable RBAC', () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true];
});
+ // Cloud rules only need useRulerRuleAbilities mock (useGrafanaPromRuleAbilities gets skipToken)
+ mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Update ? [true, false] : [true, true];
+ });
+ });
render();
@@ -177,6 +229,12 @@ describe('RulesTable RBAC', () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, false] : [true, true];
});
+ // Cloud rules only need useRulerRuleAbilities mock (useGrafanaPromRuleAbilities gets skipToken)
+ mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Delete ? [true, false] : [true, true];
+ });
+ });
render();
@@ -191,6 +249,12 @@ describe('RulesTable RBAC', () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
+ // Cloud rules only need useRulerRuleAbilities mock (useGrafanaPromRuleAbilities gets skipToken)
+ mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Update ? [true, true] : [false, false];
+ });
+ });
render();
@@ -204,6 +268,12 @@ describe('RulesTable RBAC', () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
+ // Cloud rules only need useRulerRuleAbilities mock (useGrafanaPromRuleAbilities gets skipToken)
+ mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
+ return actions.map((action) => {
+ return action === AlertRuleAction.Delete ? [true, true] : [false, false];
+ });
+ });
render();
diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts
index c2e96434d89..91fbe9cc603 100644
--- a/public/app/features/alerting/unified/hooks/useAbilities.ts
+++ b/public/app/features/alerting/unified/hooks/useAbilities.ts
@@ -14,15 +14,20 @@ import { useFolder } from 'app/features/alerting/unified/hooks/useFolder';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
-import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
+import { GrafanaPromRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
-import { getRulesSourceName } from '../utils/datasource';
-import { getGroupOriginName } from '../utils/groupIdentifier';
+import { getGroupOriginName, groupIdentifier } from '../utils/groupIdentifier';
import { isAdmin } from '../utils/misc';
-import { isFederatedRuleGroup, isPluginProvidedRule, rulerRuleType } from '../utils/rules';
+import {
+ isPluginProvidedRule,
+ isProvisionedPromRule,
+ isProvisionedRule,
+ prometheusRuleType,
+ rulerRuleType,
+} from '../utils/rules';
import { useIsRuleEditable } from './useIsRuleEditable';
@@ -200,7 +205,7 @@ export function useRulerRuleAbility(
}
export function useRulerRuleAbilities(
- rule: RulerRuleDTO,
+ rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifierV2,
actions: AlertRuleAction[]
): Ability[] {
@@ -211,28 +216,35 @@ export function useRulerRuleAbilities(
}, [abilities, actions]);
}
-// This hook is being called a lot in different places
-// In some cases multiple times for ~80 rules (e.g. on the list page)
-// We need to investigate further if some of these calls are redundant
-// In the meantime, memoizing the result helps
+/**
+ * @deprecated Use {@link useAllRulerRuleAbilities} instead
+ */
export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities {
- const rulesSourceName = getRulesSourceName(rule.namespace.rulesSource);
+ // This hook is being called a lot in different places
+ // In some cases multiple times for ~80 rules (e.g. on the list page)
+ // We need to investigate further if some of these calls are redundant
+ // In the meantime, memoizing the result helps
+ const groupIdentifierV2 = useMemo(() => groupIdentifier.fromCombinedRule(rule), [rule]);
+ return useAllRulerRuleAbilities(rule.rulerRule, groupIdentifierV2);
+}
- const {
- isEditable,
- isRemovable,
- isRulerAvailable = false,
- loading,
- } = useIsRuleEditable(rulesSourceName, rule.rulerRule);
+export function useAllRulerRuleAbilities(
+ rule: RulerRuleDTO | undefined,
+ groupIdentifier: RuleGroupIdentifierV2
+): Abilities {
+ const rulesSourceName = getGroupOriginName(groupIdentifier);
+
+ const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
- const canSilence = useCanSilence(rule.rulerRule);
+ const canSilence = useCanSilence(rule);
const abilities = useMemo>(() => {
- const isProvisioned =
- rulerRuleType.grafana.rule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
- const isFederated = isFederatedRuleGroup(rule.group);
- const isGrafanaManagedAlertRule = rulerRuleType.grafana.rule(rule.rulerRule);
- const isPluginProvided = isPluginProvidedRule(rule.rulerRule);
+ const isProvisioned = rule ? isProvisionedRule(rule) : false;
+ // TODO: Add support for federated rules
+ // const isFederated = isFederatedRuleGroup();
+ const isFederated = false;
+ const isGrafanaManagedAlertRule = rulerRuleType.grafana.rule(rule);
+ const isPluginProvided = isPluginProvidedRule(rule);
// if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited
const immutableRule = isProvisioned || isFederated || isPluginProvided;
@@ -263,39 +275,44 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities {
- const rulesSourceName = getGroupOriginName(groupIdentifier);
-
- const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule);
+/**
+ * Hook for checking abilities on Grafana Prometheus rules (GrafanaPromRuleDTO)
+ * This is the next version of useAllRulerRuleAbilities designed to work with GrafanaPromRuleDTO
+ */
+export function useAllGrafanaPromRuleAbilities(rule: GrafanaPromRuleDTO | undefined): Abilities {
+ // For GrafanaPromRuleDTO, we use useIsGrafanaPromRuleEditable instead
+ const { isEditable, isRemovable, loading } = useIsGrafanaPromRuleEditable(rule); // duplicate
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
- const canSilence = useCanSilence(rule);
+
+ const silenceSupported = useGrafanaRulesSilenceSupport();
+ const canSilenceInFolder = useCanSilenceInFolder(rule?.folderUid);
const abilities = useMemo>(() => {
- const isProvisioned = rulerRuleType.grafana.rule(rule) && Boolean(rule.grafana_alert.provenance);
- // const isFederated = isFederatedRuleGroup();
+ const isProvisioned = rule ? isProvisionedPromRule(rule) : false;
+
+ // Note: Grafana managed rules can't be federated - this is strictly a Mimir feature
+ // See: https://grafana.com/docs/mimir/latest/references/architecture/components/ruler/#federated-rule-groups
const isFederated = false;
- const isGrafanaManagedAlertRule = rulerRuleType.grafana.rule(rule);
+ // All GrafanaPromRuleDTO rules are Grafana-managed by definition
+ const isAlertingRule = prometheusRuleType.grafana.alertingRule(rule);
const isPluginProvided = isPluginProvidedRule(rule);
// if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited
const immutableRule = isProvisioned || isFederated || isPluginProvided;
- // while we gather info, pretend it's not supported
- const MaybeSupported = loading ? NotSupported : isRulerAvailable;
+ // GrafanaPromRuleDTO rules are always supported (no loading state for ruler availability)
+ const MaybeSupported = loading ? NotSupported : AlwaysSupported;
const MaybeSupportedUnlessImmutable = immutableRule ? NotSupported : MaybeSupported;
// Creating duplicates of plugin-provided rules does not seem to make a lot of sense
const duplicateSupported = isPluginProvided ? NotSupported : MaybeSupported;
- const rulesPermissions = getRulesPermissions(rulesSourceName);
+ const rulesPermissions = getRulesPermissions('grafana');
const abilities: Abilities = {
[AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create),
@@ -303,22 +320,91 @@ export function useAllRulerRuleAbilities(
[AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false],
[AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false],
[AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore),
- [AlertRuleAction.Silence]: canSilence,
- [AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed],
- [AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
- [AlertRuleAction.Restore]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
+ [AlertRuleAction.Silence]: [silenceSupported, canSilenceInFolder && isAlertingRule],
+ [AlertRuleAction.ModifyExport]: [isAlertingRule, exportAllowed],
+ [AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isAlertingRule, isEditable ?? false],
+ [AlertRuleAction.Restore]: [MaybeSupportedUnlessImmutable && isAlertingRule, isEditable ?? false],
[AlertRuleAction.DeletePermanently]: [
- MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule,
+ MaybeSupportedUnlessImmutable && isAlertingRule,
(isRemovable && isAdmin()) ?? false,
],
};
return abilities;
- }, [rule, loading, isRulerAvailable, rulesSourceName, isEditable, isRemovable, canSilence, exportAllowed]);
+ }, [rule, loading, isEditable, isRemovable, canSilenceInFolder, exportAllowed, silenceSupported]);
return abilities;
}
+interface IsGrafanaPromRuleEditableResult {
+ isEditable: boolean;
+ isRemovable: boolean;
+ loading: boolean;
+}
+
+/**
+ * Hook for checking if a GrafanaPromRuleDTO is editable
+ * Adapted version of useIsRuleEditable for GrafanaPromRuleDTO
+ */
+function useIsGrafanaPromRuleEditable(rule?: GrafanaPromRuleDTO): IsGrafanaPromRuleEditableResult {
+ const folderUID = rule?.folderUid;
+ const { folder, loading } = useFolder(folderUID);
+
+ return useMemo(() => {
+ if (!rule || !folderUID) {
+ return { isEditable: false, isRemovable: false, loading: false };
+ }
+
+ if (!folder) {
+ // Loading or invalid folder UID
+ return {
+ isEditable: false,
+ isRemovable: false,
+ loading,
+ };
+ }
+
+ // For Grafana-managed rules, check folder permissions
+ const rulesPermissions = getRulesPermissions('grafana');
+ const canEditGrafanaRules = ctx.hasPermissionInMetadata(rulesPermissions.update, folder);
+ const canRemoveGrafanaRules = ctx.hasPermissionInMetadata(rulesPermissions.delete, folder);
+
+ return {
+ isEditable: canEditGrafanaRules,
+ isRemovable: canRemoveGrafanaRules,
+ loading,
+ };
+ }, [rule, folderUID, folder, loading]);
+}
+
+export const skipToken = Symbol('ability-skip-token');
+type SkipToken = typeof skipToken;
+
+/**
+ * Hook for checking a single ability on a GrafanaPromRuleDTO
+ */
+export function useGrafanaPromRuleAbility(rule: GrafanaPromRuleDTO | SkipToken, action: AlertRuleAction): Ability {
+ const abilities = useAllGrafanaPromRuleAbilities(rule === skipToken ? undefined : rule);
+
+ return useMemo(() => {
+ return abilities[action];
+ }, [abilities, action]);
+}
+
+/**
+ * Hook for checking multiple abilities on a GrafanaPromRuleDTO
+ */
+export function useGrafanaPromRuleAbilities(
+ rule: GrafanaPromRuleDTO | SkipToken,
+ actions: AlertRuleAction[]
+): Ability[] {
+ const abilities = useAllGrafanaPromRuleAbilities(rule === skipToken ? undefined : rule);
+
+ return useMemo(() => {
+ return actions.map((action) => abilities[action]);
+ }, [abilities, actions]);
+}
+
export function useAllAlertmanagerAbilities(): Abilities {
const {
selectedAlertmanager,
diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx
index e29367da11a..08a8ecbce43 100644
--- a/public/app/features/alerting/unified/rule-list/FilterView.tsx
+++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx
@@ -12,7 +12,7 @@ import { hashRule } from '../utils/rule-id';
import { DataSourceRuleLoader } from './DataSourceRuleLoader';
import { FilterProgressState, FilterStatus } from './FilterViewStatus';
-import { GrafanaRuleLoader } from './GrafanaRuleLoader';
+import { GrafanaRuleListItem } from './GrafanaRuleListItem';
import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
@@ -154,11 +154,11 @@ function FilterViewResults({ filterState }: FilterViewProps) {
switch (origin) {
case 'grafana':
return (
-
);
case 'datasource':
diff --git a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx
index 621cd6ecd2e..68913940e8e 100644
--- a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx
+++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx
@@ -2,6 +2,7 @@ import { render } from 'test/test-utils';
import { byRole, byTitle } from 'testing-library-selector';
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
+import { AccessControlAction } from 'app/types';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import {
GrafanaPromRuleDTO,
@@ -13,13 +14,13 @@ import {
} from 'app/types/unified-alerting-dto';
import { setupMswServer } from '../mockApi';
-import { mockGrafanaPromAlertingRule, mockGrafanaRulerRule } from '../mocks';
+import { grantUserPermissions } from '../mocks';
import { grafanaRulerGroup, grafanaRulerNamespace } from '../mocks/grafanaRulerApi';
-import { setGrafanaPromRules } from '../mocks/server/configure';
+import { setFolderAccessControl, setGrafanaPromRules } from '../mocks/server/configure';
import { rulerRuleType } from '../utils/rules';
import { intervalToSeconds } from '../utils/time';
-import { GrafanaGroupLoader, matchRules } from './GrafanaGroupLoader';
+import { GrafanaGroupLoader } from './GrafanaGroupLoader';
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -32,9 +33,35 @@ const ui = {
ruleLink: (ruleName: string) => byRole('link', { name: ruleName }),
editButton: () => byRole('link', { name: 'Edit' }),
moreButton: () => byRole('button', { name: 'More' }),
+ // Menu items that appear when More button is clicked
+ menuItems: {
+ silence: () => byRole('menuitem', { name: /silence/i }),
+ duplicate: () => byRole('menuitem', { name: /duplicate/i }),
+ copyLink: () => byRole('menuitem', { name: /copy link/i }),
+ export: () => byRole('menuitem', { name: /export/i }),
+ delete: () => byRole('menuitem', { name: /delete/i }),
+ },
};
describe('GrafanaGroupLoader', () => {
+ beforeEach(() => {
+ grantUserPermissions([
+ AccessControlAction.AlertingRuleUpdate,
+ AccessControlAction.AlertingRuleDelete,
+ AccessControlAction.AlertingSilenceCreate,
+ AccessControlAction.AlertingRuleCreate,
+ AccessControlAction.AlertingRuleRead,
+ ]);
+ // Grant necessary permissions for editing rules
+ setFolderAccessControl({
+ [AccessControlAction.AlertingRuleUpdate]: true,
+ [AccessControlAction.AlertingRuleDelete]: true,
+ [AccessControlAction.AlertingSilenceCreate]: true,
+ [AccessControlAction.AlertingRuleCreate]: true, // For duplicate action
+ [AccessControlAction.AlertingRuleRead]: true, // For export action
+ });
+ });
+
it('should render rule with url when ruler and prom rule exist', async () => {
setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
@@ -55,8 +82,8 @@ describe('GrafanaGroupLoader', () => {
);
});
- it('should render rule with url and creating state when only ruler rule exists', async () => {
- setGrafanaPromRules([]);
+ it('should render More button with action menu options', async () => {
+ setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
@@ -65,92 +92,119 @@ describe('GrafanaGroupLoader', () => {
const [rule1] = grafanaRulerGroup.rules;
const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
- const creatingIcon = ui.ruleStatus('Creating').get(ruleListItem);
- expect(creatingIcon).toBeInTheDocument();
+ // Check that More button is present
+ const moreButton = ui.moreButton().get(ruleListItem);
+ expect(moreButton).toBeInTheDocument();
- const ruleLink = ui.ruleLink(rule1.grafana_alert.title).get(ruleListItem);
- expect(ruleLink).toHaveAttribute(
- 'href',
- expect.stringContaining(`/alerting/grafana/${rule1.grafana_alert.uid}/view`)
- );
+ // Verify More button accessibility
+ expect(moreButton).toHaveAttribute('aria-label', 'More');
+ expect(moreButton).toHaveTextContent('More');
});
- it('should render delete rule operation list item when only prom rule exists', async () => {
- const promOnlyGroup: GrafanaPromRuleGroupDTO = {
- ...rulerGroupToPromGroup(grafanaRulerGroup),
- name: 'prom-only-group',
+ it('should render multiple rules with their own action buttons', async () => {
+ // Create a group with multiple rules
+ const multiRuleGroup = {
+ ...grafanaRulerGroup,
+ rules: [
+ grafanaRulerGroup.rules[0],
+ {
+ ...grafanaRulerGroup.rules[0],
+ grafana_alert: {
+ ...grafanaRulerGroup.rules[0].grafana_alert,
+ uid: 'second-rule-uid',
+ title: 'Second Rule',
+ },
+ },
+ ],
};
- setGrafanaPromRules([promOnlyGroup]);
+ setGrafanaPromRules([rulerGroupToPromGroup(multiRuleGroup)]);
- const groupIdentifier = getGroupIdentifier(promOnlyGroup);
+ const groupIdentifier = getGroupIdentifier(multiRuleGroup);
render();
- const [rule1] = promOnlyGroup.rules;
- const promRule = await ui.ruleItem(rule1.name).find();
+ // Check first rule
+ const [rule1, rule2] = multiRuleGroup.rules;
+ const ruleListItem1 = await ui.ruleItem(rule1.grafana_alert.title).find();
+ const ruleListItem2 = await ui.ruleItem(rule2.grafana_alert.title).find();
+
+ // Each rule should have its own More button
+ expect(ui.moreButton().get(ruleListItem1)).toBeInTheDocument();
+ expect(ui.moreButton().get(ruleListItem2)).toBeInTheDocument();
- const deletingIcon = ui.ruleStatus('Deleting').get(promRule);
- expect(deletingIcon).toBeInTheDocument();
+ // Check that edit buttons are present and have correct URLs
+ const editButton1 = ui.editButton().get(ruleListItem1);
+ const editButton2 = ui.editButton().get(ruleListItem2);
- expect(ui.editButton().query(promRule)).not.toBeInTheDocument();
- expect(ui.moreButton().query(promRule)).not.toBeInTheDocument();
+ expect(editButton1).toBeInTheDocument();
+ expect(editButton2).toBeInTheDocument();
+
+ // Check that edit buttons have correct URLs (the actual format is simpler)
+ expect(editButton1).toHaveAttribute('href', expect.stringContaining(`/alerting/${rule1.grafana_alert.uid}/edit`));
+ expect(editButton2).toHaveAttribute('href', expect.stringContaining(`/alerting/${rule2.grafana_alert.uid}/edit`));
});
-});
-describe('matchRules', () => {
- it('should return matches for all items and have empty promOnlyRules if all rules are matched by uid', () => {
- const rulerRules = [
- mockGrafanaRulerRule({ uid: '1' }),
- mockGrafanaRulerRule({ uid: '2' }),
- mockGrafanaRulerRule({ uid: '3' }),
- ];
+ it('should not render edit button when user lacks edit permissions', async () => {
+ // Override permissions to deny editing
+ setFolderAccessControl({
+ [AccessControlAction.AlertingRuleUpdate]: false,
+ [AccessControlAction.AlertingRuleDelete]: false,
+ });
- const promRules = rulerRules.map(rulerRuleToPromRule);
+ setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
- const { matches, promOnlyRules } = matchRules(promRules, rulerRules);
+ const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
- expect(matches.size).toBe(rulerRules.length);
- expect(promOnlyRules).toHaveLength(0);
+ render();
- for (const [rulerRule, promRule] of matches) {
- expect(rulerRule.grafana_alert.uid).toBe(promRule.uid);
- }
+ const [rule1] = grafanaRulerGroup.rules;
+ const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
+
+ // Edit button should not be present
+ expect(ui.editButton().query(ruleListItem)).not.toBeInTheDocument();
+
+ // More button should still be present (for other actions like viewing)
+ expect(ui.moreButton().get(ruleListItem)).toBeInTheDocument();
});
- it('should return unmatched prometheus rules in promOnlyRules array', () => {
- const rulerRules = [mockGrafanaRulerRule({ uid: '1' }), mockGrafanaRulerRule({ uid: '2' })];
+ it('should render correct menu actions when More button is clicked', async () => {
+ setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]);
- const matchingPromRules = rulerRules.map(rulerRuleToPromRule);
- const unmatchedPromRules = [mockGrafanaPromAlertingRule({ uid: '3' }), mockGrafanaPromAlertingRule({ uid: '4' })];
+ const groupIdentifier = getGroupIdentifier(grafanaRulerGroup);
- const allPromRules = [...matchingPromRules, ...unmatchedPromRules];
- const { matches, promOnlyRules } = matchRules(allPromRules, rulerRules);
+ const { user } = render(
+
+ );
- expect(matches.size).toBe(rulerRules.length);
- expect(promOnlyRules).toHaveLength(unmatchedPromRules.length);
- expect(promOnlyRules).toEqual(expect.arrayContaining(unmatchedPromRules));
- });
+ const [rule1] = grafanaRulerGroup.rules;
+ const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find();
+
+ // Find and click the More button
+ const moreButton = ui.moreButton().get(ruleListItem);
+ await user.click(moreButton);
+
+ // Check that the dropdown menu appears
+ const menu = byRole('menu').get();
+ expect(menu).toBeInTheDocument();
+
+ // With proper permissions, all 4 menu actions should be available:
- it('should not include ruler rules in matches if they have no prometheus counterpart', () => {
- const rulerRules = [
- mockGrafanaRulerRule({ uid: '1' }),
- mockGrafanaRulerRule({ uid: '2' }),
- mockGrafanaRulerRule({ uid: '3' }),
- ];
+ // 1. Silence notifications - available for alerting rules (AlertingSilenceCreate permission)
+ expect(ui.menuItems.silence().get()).toBeInTheDocument();
- // Only create prom rule for the second ruler rule
- const promRules = [rulerRuleToPromRule(rulerRules[1])];
+ // 2. Copy link - always available
+ expect(ui.menuItems.copyLink().get()).toBeInTheDocument();
- const { matches, promOnlyRules } = matchRules(promRules, rulerRules);
+ // 3. Duplicate - should be available with create permissions (AlertingRuleCreate permission)
+ expect(ui.menuItems.duplicate().get()).toBeInTheDocument();
- expect(matches.size).toBe(1);
- expect(promOnlyRules).toHaveLength(0);
+ // 4. Export - should be available for Grafana alerting rules (AlertingRuleRead permission)
+ expect(ui.menuItems.export().get()).toBeInTheDocument();
- // Verify that only the second ruler rule is in matches
- expect(matches.has(rulerRules[0])).toBe(false);
- expect(matches.get(rulerRules[1])).toBe(promRules[0]);
- expect(matches.has(rulerRules[2])).toBe(false);
+ // Verify that the menu contains all 4 expected menu items
+ const menuItems = byRole('menuitem').getAll();
+ expect(menuItems.length).toBe(4);
});
});
diff --git a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx
index c092a748388..2dee977835d 100644
--- a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx
+++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx
@@ -1,22 +1,13 @@
-import { useMemo } from 'react';
-
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
-import { GrafanaPromRuleDTO, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
-import { logWarning } from '../Analytics';
-import { alertRuleApi } from '../api/alertRuleApi';
import { prometheusApi } from '../api/prometheusApi';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
-import { GrafanaRulesSource } from '../utils/datasource';
-import { GrafanaRuleListItem } from './GrafanaRuleLoader';
-import { RuleOperationListItem } from './components/AlertRuleListItem';
+import { GrafanaRuleListItem } from './GrafanaRuleListItem';
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
-import { RuleOperation } from './components/RuleListIcon';
-const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
const { useGetGrafanaGroupsQuery } = prometheusApi;
export interface GrafanaGroupLoaderProps {
@@ -48,20 +39,8 @@ export function GrafanaGroupLoader({
},
{ pollingInterval: RULE_LIST_POLL_INTERVAL_MS }
);
- const { data: rulerResponse, isLoading: isRulerGroupLoading } = useGetGrafanaRulerGroupQuery({
- folderUid: groupIdentifier.namespace.uid,
- groupName: groupIdentifier.groupName,
- });
-
- const { matches, promOnlyRules } = useMemo(() => {
- const promRules = promResponse?.data.groups.at(0)?.rules ?? [];
- const rulerRules = rulerResponse?.rules ?? [];
-
- return matchRules(promRules, rulerRules);
- }, [promResponse, rulerResponse]);
- const isLoading = isPromResponseLoading || isRulerGroupLoading;
- if (isLoading) {
+ if (isPromResponseLoading) {
return (
<>
{Array.from({ length: expectedRulesCount }).map((_, index) => (
@@ -71,7 +50,7 @@ export function GrafanaGroupLoader({
);
}
- if (!rulerResponse && !promResponse) {
+ if (!promResponse) {
return (
- {rulerResponse?.rules.map((rulerRule) => {
- const promRule = matches.get(rulerRule);
-
- if (!promRule) {
- return (
-
- );
- }
-
+ {promResponse.data.groups.at(0)?.rules.map((promRule) => {
return (
group hierarchy
@@ -115,58 +77,6 @@ export function GrafanaGroupLoader({
/>
);
})}
- {promOnlyRules.map((rule) => (
-
- ))}
>
);
}
-
-interface MatchingResult {
- matches: Map;
- /**
- * Rules that were already removed from the Ruler but the changes has not been yet propagated to Prometheus
- */
- promOnlyRules: GrafanaPromRuleDTO[];
-}
-
-export function matchRules(
- promRules: GrafanaPromRuleDTO[],
- rulerRules: RulerGrafanaRuleDTO[]
-): Readonly {
- const promRulesMap = new Map(promRules.map((rule) => [rule.uid, rule]));
-
- const matchingResult = rulerRules.reduce(
- (acc, rulerRule) => {
- const { matches } = acc;
- const promRule = promRulesMap.get(rulerRule.grafana_alert.uid);
- if (promRule) {
- matches.set(rulerRule, promRule);
- promRulesMap.delete(rulerRule.grafana_alert.uid);
- }
- return acc;
- },
- { matches: new Map(), promOnlyRules: [] }
- );
-
- matchingResult.promOnlyRules.push(...promRulesMap.values());
-
- if (matchingResult.promOnlyRules.length > 0) {
- // Grafana Prometheus rules should be strongly consistent now so each Ruler rule should have a matching Prometheus rule
- // If not, log it as a warning
- logWarning('Grafana Managed Rules: No matching Prometheus rule found for Ruler rule', {
- promOnlyRulesCount: matchingResult.promOnlyRules.length.toString(),
- });
- }
-
- return matchingResult;
-}
diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx
new file mode 100644
index 00000000000..3113c991e09
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx
@@ -0,0 +1,71 @@
+import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
+import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto';
+
+import { GrafanaRulesSource } from '../utils/datasource';
+import { totalFromStats } from '../utils/ruleStats';
+import { prometheusRuleType } from '../utils/rules';
+import { createRelativeUrl } from '../utils/url';
+
+import {
+ AlertRuleListItem,
+ RecordingRuleListItem,
+ RuleListItemCommonProps,
+ UnknownRuleListItem,
+} from './components/AlertRuleListItem';
+import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
+import { RuleOperation } from './components/RuleListIcon';
+
+interface GrafanaRuleListItemProps {
+ rule: GrafanaPromRuleDTO;
+ groupIdentifier: GrafanaRuleGroupIdentifier;
+ namespaceName: string;
+ operation?: RuleOperation;
+ showLocation?: boolean;
+}
+
+export function GrafanaRuleListItem({
+ rule,
+ groupIdentifier,
+ namespaceName,
+ operation,
+ showLocation = true,
+}: GrafanaRuleListItemProps) {
+ const { name, uid, labels, provenance } = rule;
+
+ const commonProps: RuleListItemCommonProps = {
+ name,
+ rulesSource: GrafanaRulesSource,
+ group: groupIdentifier.groupName,
+ namespace: namespaceName,
+ href: createRelativeUrl(`/alerting/grafana/${uid}/view`),
+ health: rule?.health,
+ error: rule?.lastError,
+ labels: labels,
+ isProvisioned: Boolean(provenance),
+ isPaused: rule?.isPaused,
+ application: 'grafana' as const,
+ actions: ,
+ };
+
+ if (prometheusRuleType.grafana.alertingRule(rule)) {
+ const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined;
+ const instancesCount = totalFromStats(promAlertingRule?.totals ?? {});
+
+ return (
+
+ );
+ }
+
+ if (prometheusRuleType.grafana.recordingRule(rule)) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx
deleted file mode 100644
index 3ca5e6a7496..00000000000
--- a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import { Trans, t } from '@grafana/i18n';
-import { Alert } from '@grafana/ui';
-import { GrafanaRuleGroupIdentifier, GrafanaRuleIdentifier } from 'app/types/unified-alerting';
-import { GrafanaPromRuleDTO, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
-
-import { alertRuleApi } from '../api/alertRuleApi';
-import { prometheusApi } from '../api/prometheusApi';
-import { createReturnTo } from '../hooks/useReturnTo';
-import { GrafanaRulesSource } from '../utils/datasource';
-import { totalFromStats } from '../utils/ruleStats';
-import { rulerRuleType } from '../utils/rules';
-import { createRelativeUrl } from '../utils/url';
-
-import {
- AlertRuleListItem,
- RecordingRuleListItem,
- RuleListItemCommonProps,
- UnknownRuleListItem,
-} from './components/AlertRuleListItem';
-import { AlertRuleListItemSkeleton, RulerRuleLoadingError } from './components/AlertRuleListItemLoader';
-import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
-import { RuleOperation } from './components/RuleListIcon';
-
-const { useGetGrafanaRulerGroupQuery } = alertRuleApi;
-const { useGetGrafanaGroupsQuery } = prometheusApi;
-
-interface GrafanaRuleLoaderProps {
- ruleIdentifier: GrafanaRuleIdentifier;
- groupIdentifier: GrafanaRuleGroupIdentifier;
- namespaceName: string;
-}
-
-export function GrafanaRuleLoader({ ruleIdentifier, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) {
- const {
- data: rulerRuleGroup,
- error: rulerRuleGroupError,
- isLoading: isRulerRuleGroupLoading,
- } = useGetGrafanaRulerGroupQuery({
- folderUid: groupIdentifier.namespace.uid,
- groupName: groupIdentifier.groupName,
- });
- const {
- data: promRuleGroup,
- error: promRuleGroupError,
- isLoading: isPromRuleGroupLoading,
- } = useGetGrafanaGroupsQuery({
- folderUid: groupIdentifier.namespace.uid,
- groupName: groupIdentifier.groupName,
- });
-
- const rulerRule = rulerRuleGroup?.rules.find((rulerRule) => rulerRule.grafana_alert.uid === ruleIdentifier.uid);
- const promRule = promRuleGroup?.data.groups
- .flatMap((group) => group.rules)
- .find((promRule) => promRule.uid === ruleIdentifier.uid);
-
- if (rulerRuleGroupError || promRuleGroupError) {
- return ;
- }
-
- if (isRulerRuleGroupLoading || isPromRuleGroupLoading) {
- return ;
- }
-
- if (!rulerRule) {
- return (
-
-
- Cannot find rule details for UID {{ uid: ruleIdentifier.uid ?? '' }}
-
-
- );
- }
-
- return (
-
- );
-}
-
-interface GrafanaRuleListItemProps {
- rule?: GrafanaPromRuleDTO;
- rulerRule: RulerGrafanaRuleDTO;
- groupIdentifier: GrafanaRuleGroupIdentifier;
- namespaceName: string;
- operation?: RuleOperation;
- showLocation?: boolean;
-}
-
-export function GrafanaRuleListItem({
- rule,
- rulerRule,
- groupIdentifier,
- namespaceName,
- operation,
- showLocation = true,
-}: GrafanaRuleListItemProps) {
- const returnTo = createReturnTo();
-
- const {
- grafana_alert: { uid, title, provenance, is_paused },
- annotations = {},
- labels = {},
- } = rulerRule;
-
- const commonProps: RuleListItemCommonProps = {
- name: title,
- rulesSource: GrafanaRulesSource,
- group: groupIdentifier.groupName,
- namespace: namespaceName,
- href: createRelativeUrl(`/alerting/grafana/${uid}/view`, { returnTo }),
- health: rule?.health,
- error: rule?.lastError,
- labels: labels,
- isProvisioned: Boolean(provenance),
- isPaused: rule?.isPaused ?? is_paused,
- application: 'grafana' as const,
- actions: ,
- showLocation,
- };
-
- if (rulerRuleType.grafana.alertingRule(rulerRule)) {
- const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined;
- const instancesCount = totalFromStats(promAlertingRule?.totals ?? {});
-
- return (
-
- );
- }
-
- if (rulerRuleType.grafana.recordingRule(rulerRule)) {
- return ;
- }
-
- return ;
-}
diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx
index 2c183bc6059..37e56db4058 100644
--- a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx
+++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { RequireAtLeastOne } from 'type-fest';
import { Trans, t } from '@grafana/i18n';
import { LinkButton, Stack } from '@grafana/ui';
@@ -6,24 +7,34 @@ import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
import { RedirectToCloneRule } from 'app/features/alerting/unified/components/rules/CloneRule';
import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer';
-import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
+import {
+ EditableRuleIdentifier,
+ GrafanaRuleIdentifier,
+ Rule,
+ RuleGroupIdentifierV2,
+ RuleIdentifier,
+} from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
-import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
+import { logWarning } from '../../Analytics';
+import { AlertRuleAction, skipToken, useGrafanaPromRuleAbility, useRulerRuleAbility } from '../../hooks/useAbilities';
import * as ruleId from '../../utils/rule-id';
-import { isProvisionedRule, rulerRuleType } from '../../utils/rules';
+import { isProvisionedPromRule, isProvisionedRule, prometheusRuleType, rulerRuleType } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
-interface Props {
- rule: RulerRuleDTO;
+type RuleProps = RequireAtLeastOne<{
+ rule?: RulerRuleDTO;
promRule?: Rule;
+}>;
+
+type Props = RuleProps & {
groupIdentifier: RuleGroupIdentifierV2;
/**
* Should we show the buttons in a "compact" state?
* i.e. without text and using smaller button sizes
*/
compact?: boolean;
-}
+};
// For now this is just a copy of RuleActionsButtons.tsx but with the View button removed.
// This is only done to keep the new list behind a feature flag and limit changes in the existing components
@@ -37,16 +48,26 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined);
- const isProvisioned = isProvisionedRule(rule);
+ const isProvisioned = getIsProvisioned(rule, promRule);
const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update);
+ // If the consumer of this component comes from the alert list view, we need to use promRule to check abilities and permissions,
+ // as we have removed all requests to the ruler API in the list view.
+ const [grafanaEditRuleSupported, grafanaEditRuleAllowed] = useGrafanaPromRuleAbility(
+ prometheusRuleType.grafana.rule(promRule) ? promRule : skipToken,
+ AlertRuleAction.Update
+ );
- const canEditRule = editRuleSupported && editRuleAllowed;
+ const canEditRule = (editRuleSupported && editRuleAllowed) || (grafanaEditRuleSupported && grafanaEditRuleAllowed);
const buttons: JSX.Element[] = [];
const buttonSize = compact ? 'sm' : 'md';
- const identifier = ruleId.fromRulerRuleAndGroupIdentifierV2(groupIdentifier, rule);
+ const identifier = getEditableIdentifier(groupIdentifier, rule, promRule);
+
+ if (!identifier) {
+ return null;
+ }
if (canEditRule) {
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`);
@@ -93,3 +114,38 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
);
}
+
+function getIsProvisioned(rule?: RulerRuleDTO, promRule?: Rule): boolean {
+ if (rule) {
+ return isProvisionedRule(rule);
+ }
+
+ if (promRule) {
+ return isProvisionedPromRule(promRule);
+ }
+
+ return false;
+}
+
+function getEditableIdentifier(
+ groupIdentifier: RuleGroupIdentifierV2,
+ rule?: RulerRuleDTO,
+ promRule?: Rule
+): EditableRuleIdentifier | undefined {
+ if (rule) {
+ return ruleId.fromRulerRuleAndGroupIdentifierV2(groupIdentifier, rule);
+ }
+
+ if (prometheusRuleType.grafana.rule(promRule)) {
+ return {
+ ruleSourceName: 'grafana',
+ uid: promRule.uid,
+ } satisfies GrafanaRuleIdentifier;
+ }
+
+ logWarning('Unable to construct an editable rule identifier');
+
+ // Returning undefined is safer than throwing here as it allows the component to gracefully handle
+ // the error by returning null instead of crashing the entire component tree
+ return undefined;
+}
diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
index 40d786f03f7..184489ccdc2 100644
--- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
+++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
@@ -4,7 +4,6 @@ import { useDispatch } from 'app/types/store';
import { DataSourceRulesSourceIdentifier, RuleHealth } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
-import { alertRuleApi } from '../../api/alertRuleApi';
import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
@@ -83,13 +82,6 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions =
// Because the user waits a bit longer for the initial load but doesn't need to wait for each group to be loaded
if (hookOptions.populateCache) {
const cacheAndRulerPreload = response.data.groups.map(async (group) => {
- dispatch(
- alertRuleApi.util.prefetch(
- 'getGrafanaRulerGroup',
- { folderUid: group.folderUid, groupName: group.name },
- { force: true }
- )
- );
await dispatch(
prometheusApi.util.upsertQueryData(
'getGrafanaGroups',
diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts
index bbd7a08db95..91ca9915e34 100644
--- a/public/app/features/alerting/unified/utils/rules.ts
+++ b/public/app/features/alerting/unified/utils/rules.ts
@@ -168,6 +168,10 @@ export function isProvisionedRule(rulerRule: RulerRuleDTO): boolean {
return isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance);
}
+export function isProvisionedPromRule(promRule: PromRuleDTO): boolean {
+ return prometheusRuleType.grafana.rule(promRule) && Boolean(promRule.provenance);
+}
+
export function isProvisionedRuleGroup(group: RulerRuleGroupDTO): boolean {
return group.rules.some((rule) => isProvisionedRule(rule));
}
diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts
index 0978062b808..c6440d0bac1 100644
--- a/public/app/types/unified-alerting-dto.ts
+++ b/public/app/types/unified-alerting-dto.ts
@@ -132,6 +132,7 @@ interface GrafanaPromRuleDTOBase extends PromRuleDTOBase {
folderUid: string;
isPaused: boolean;
queriedDatasourceUIDs?: string[];
+ provenance?: string;
}
export interface PromAlertingRuleDTO extends PromRuleDTOBase {
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 629a553e9da..9eb177b113c 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -2415,8 +2415,6 @@
"title-inspect-alert-rule": "Inspect Alert rule"
},
"rule-list": {
- "cannot-find-rule-details-for": "Cannot find rule details for UID {{uid}}",
- "cannot-load-rule-details-for": "Cannot load rule details for UID {{uid}}",
"configure-datasource": "Configure",
"draft-new-rule": "Draft a new rule",
"ds-error": {