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 <sonia.aguilar@grafana.com>

* 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 <soniaaguilarpeiron@gmail.com>
Co-authored-by: Sonia Augilar <sonia.aguilar@grafana.com>
pull/107672/head
Konrad Lalik 2 weeks ago committed by GitHub
parent acdb0e151c
commit 3e6d620d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 77
      public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx
  2. 5
      public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
  3. 108
      public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
  4. 172
      public/app/features/alerting/unified/hooks/useAbilities.ts
  5. 8
      public/app/features/alerting/unified/rule-list/FilterView.tsx
  6. 182
      public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx
  7. 98
      public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.tsx
  8. 71
      public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx
  9. 150
      public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx
  10. 74
      public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx
  11. 8
      public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
  12. 4
      public/app/features/alerting/unified/utils/rules.ts
  13. 1
      public/app/types/unified-alerting-dto.ts
  14. 2
      public/locales/en-US/grafana.json

@ -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);

@ -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

@ -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(<RulesTable rules={[grafanaRule]} />);
@ -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(<RulesTable rules={[grafanaRule]} />);
@ -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(<RulesTable rules={[grafanaRule]} />);
@ -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(<RulesTable rules={[grafanaRule]} />);
@ -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(<RulesTable rules={[cloudRule]} />);
@ -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(<RulesTable rules={[cloudRule]} />);
@ -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(<RulesTable rules={[cloudRule]} />);
@ -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(<RulesTable rules={[cloudRule]} />);

@ -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<AlertRuleAction> {
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<AlertRuleAction> {
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<Abilities<AlertRuleAction>>(() => {
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<AlertRul
};
return abilities;
}, [rule, loading, isRulerAvailable, isEditable, isRemovable, rulesSourceName, exportAllowed, canSilence]);
}, [rule, loading, isRulerAvailable, rulesSourceName, isEditable, isRemovable, canSilence, exportAllowed]);
return abilities;
}
export function useAllRulerRuleAbilities(
rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifierV2
): Abilities<AlertRuleAction> {
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<AlertRuleAction> {
// 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<Abilities<AlertRuleAction>>(() => {
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> = {
[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<AlertmanagerAction> {
const {
selectedAlertmanager,

@ -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 (
<GrafanaRuleLoader
key={key}
ruleIdentifier={{ ruleSourceName: 'grafana', uid: rule.uid }}
<GrafanaRuleListItem
rule={rule}
groupIdentifier={groupIdentifier}
namespaceName={ruleWithOrigin.namespaceName}
showLocation={true}
/>
);
case 'datasource':

@ -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(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />);
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(<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />);
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(
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={grafanaRulerNamespace.name} />
);
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);
});
});

@ -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 (
<Alert
title={t(
@ -86,28 +65,11 @@ export function GrafanaGroupLoader({
return (
<>
{rulerResponse?.rules.map((rulerRule) => {
const promRule = matches.get(rulerRule);
if (!promRule) {
return (
<GrafanaRuleListItem
key={rulerRule.grafana_alert.uid}
rule={promRule}
rulerRule={rulerRule}
groupIdentifier={groupIdentifier}
namespaceName={namespaceName}
operation={RuleOperation.Creating}
showLocation={false}
/>
);
}
{promResponse.data.groups.at(0)?.rules.map((promRule) => {
return (
<GrafanaRuleListItem
key={promRule.uid}
rule={promRule}
rulerRule={rulerRule}
groupIdentifier={groupIdentifier}
namespaceName={namespaceName}
// we don't show the location again for rules, it's redundant because they are shown in a folder > group hierarchy
@ -115,58 +77,6 @@ export function GrafanaGroupLoader({
/>
);
})}
{promOnlyRules.map((rule) => (
<RuleOperationListItem
key={rule.uid}
name={rule.name}
namespace={namespaceName}
group={groupIdentifier.groupName}
rulesSource={GrafanaRulesSource}
application="grafana"
operation={RuleOperation.Deleting}
showLocation={false}
/>
))}
</>
);
}
interface MatchingResult {
matches: Map<RulerGrafanaRuleDTO, GrafanaPromRuleDTO>;
/**
* 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<MatchingResult> {
const promRulesMap = new Map(promRules.map((rule) => [rule.uid, rule]));
const matchingResult = rulerRules.reduce<MatchingResult>(
(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;
}

@ -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: <RuleActionsButtons promRule={rule} groupIdentifier={groupIdentifier} compact />,
};
if (prometheusRuleType.grafana.alertingRule(rule)) {
const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined;
const instancesCount = totalFromStats(promAlertingRule?.totals ?? {});
return (
<AlertRuleListItem
{...commonProps}
summary={rule.annotations?.summary}
state={promAlertingRule?.state}
instancesCount={instancesCount}
operation={operation}
showLocation={showLocation}
/>
);
}
if (prometheusRuleType.grafana.recordingRule(rule)) {
return <RecordingRuleListItem {...commonProps} showLocation={showLocation} />;
}
return <UnknownRuleListItem ruleName={name} groupIdentifier={groupIdentifier} ruleDefinition={rule} />;
}

@ -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 <RulerRuleLoadingError ruleIdentifier={ruleIdentifier} error={rulerRuleGroupError || promRuleGroupError} />;
}
if (isRulerRuleGroupLoading || isPromRuleGroupLoading) {
return <AlertRuleListItemSkeleton />;
}
if (!rulerRule) {
return (
<Alert
title={t('alerting.rule-list.cannot-load-rule-details-for', 'Cannot load rule details for UID {{uid}}', {
uid: ruleIdentifier.uid,
})}
severity="error"
>
<Trans i18nKey="alerting.rule-list.cannot-find-rule-details-for">
Cannot find rule details for UID {{ uid: ruleIdentifier.uid ?? '<empty uid>' }}
</Trans>
</Alert>
);
}
return (
<GrafanaRuleListItem
rule={promRule}
rulerRule={rulerRule}
groupIdentifier={groupIdentifier}
namespaceName={namespaceName}
/>
);
}
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: <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />,
showLocation,
};
if (rulerRuleType.grafana.alertingRule(rulerRule)) {
const promAlertingRule = rule && rule.type === PromRuleType.Alerting ? rule : undefined;
const instancesCount = totalFromStats(promAlertingRule?.totals ?? {});
return (
<AlertRuleListItem
{...commonProps}
summary={annotations.summary}
state={promAlertingRule?.state}
instancesCount={instancesCount}
operation={operation}
/>
);
}
if (rulerRuleType.grafana.recordingRule(rulerRule)) {
return <RecordingRuleListItem {...commonProps} />;
}
return <UnknownRuleListItem ruleName={title} groupIdentifier={groupIdentifier} ruleDefinition={rulerRule} />;
}

@ -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 }:
</Stack>
);
}
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;
}

@ -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',

@ -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));
}

@ -132,6 +132,7 @@ interface GrafanaPromRuleDTOBase extends PromRuleDTOBase {
folderUid: string;
isPaused: boolean;
queriedDatasourceUIDs?: string[];
provenance?: string;
}
export interface PromAlertingRuleDTO extends PromRuleDTOBase {

@ -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": {

Loading…
Cancel
Save