From 3e6d620d2c1d1f6c10611fba8b0ee062d6fb2a68 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Mon, 7 Jul 2025 09:13:09 +0200 Subject: [PATCH] Alerting: Remove ruler from alert list view2 (#106778) * wip * Add working actions for GMA rules based on Prom-only API * Remove Ruler-loader related code for Grafana rules Co-authored-by: Sonia Augilar * Remove outdated tests * add some comments * remove commented code * remove showLocation property * Add missing mocks in tests * Add showLocation to GrafanaRuleListItem, improve useAbilities, address PR feedback * Enhance GrafanaGroupLoader tests: Add permission checks and More button functionality - Introduced user permission grants for alerting actions in tests. - Added tests for rendering the More button with action menu options. - Verified that each rule has its own action buttons and handles permissions correctly. - Ensured the edit button is not rendered when user lacks edit permissions. - Confirmed the correct menu actions are displayed when the More button is clicked. * Update translations --------- Co-authored-by: Sonia Aguilar Co-authored-by: Sonia Augilar --- .../components/rule-viewer/AlertRuleMenu.tsx | 77 +++++--- .../components/rules/RuleDetails.test.tsx | 5 +- .../components/rules/RulesTable.test.tsx | 108 +++++++++-- .../alerting/unified/hooks/useAbilities.ts | 172 ++++++++++++----- .../alerting/unified/rule-list/FilterView.tsx | 8 +- .../rule-list/GrafanaGroupLoader.test.tsx | 182 ++++++++++++------ .../unified/rule-list/GrafanaGroupLoader.tsx | 98 +--------- .../unified/rule-list/GrafanaRuleListItem.tsx | 71 +++++++ .../unified/rule-list/GrafanaRuleLoader.tsx | 150 --------------- .../components/RuleActionsButtons.V2.tsx | 74 ++++++- .../hooks/prometheusGroupsGenerator.ts | 8 - .../features/alerting/unified/utils/rules.ts | 4 + public/app/types/unified-alerting-dto.ts | 1 + public/locales/en-US/grafana.json | 2 - 14 files changed, 542 insertions(+), 418 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx delete mode 100644 public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx 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": {