Alerting: Add details and edit pages for groups (#100884)

* Add basic details page for groups

* Remove unused imports

* Add basic edit page for groups

* Add functional group details page

* Improve form, add namespaces for DS groups

* Add support for multiple actions in useProduceNewRuleGroup

* Attach real actions to form submit

* Add tests for the group details page

* Add basic tests for the group edit page

* Add tests for Mimir update

* Add rule group consistency check

* Extract draggable rules table to a separate file

* Add prom consistency waiting after group saving

* Add duration measure for Prometheus reconciliation time

* Remove a blinking error when redirecting to a new group

* Improve group details page. Use ruler or prom api depending on the ds capabilities

* Add group delete action for DMA

* Fix GroupDetailsPage tests

* Update tests

* Add and improve Edit page tests

* Add Group export for GMA groups

* Fix RulesGroup tests, add translations

* Disable editing plugin provided groups

* Fix alertingApi options, fix tests

* Fix lint errors, update translations

* use name for grafana managed recording rules

* add namespace to nav

* Remove group modals from the list page

* add cancel button to edit form

* add test for cancel butotn

* fix recording rule badge for Grafana managed rules

* Add doc comments, improve code

* Move url changes to be the last action in form submit

* Add returnTo URL handling for alert rule group navigation

* Create dedicated Title component showing breadcrumb navigation between folder
and group name.

Add label distinction between folders and namespaces based on
the rule source (Grafana vs external).

* Address PR feedback, minor refactorings

* Update rule group links to include return path and refactor rule type checks

- Modified `RulesGroup` and `GroupDetailsPage` components to include `includeReturnTo` in edit page links.
- Refactored rule type checks in `DraggableRulesTable` and `GroupDetailsPage` to use `rulerRuleType` for better clarity and maintainability.
- Updated documentation in `useUpdateRuleGroup` to clarify functionality for updating or moving rule groups.

* Refactor RulesGroup component and tests for improved link handling and permissions checks

- Added `includeReturnTo` parameter to rule group detail links in `RulesGroup` for better navigation.
- Updated test cases to verify rendering of edit and view buttons based on user permissions.
- Simplified test setup by removing unnecessary Redux provider wrapping in tests.

* Refactor: Update routing and test assertions in GroupDetails and GroupEdit pages

- Modified route paths in GroupDetailsPage and GroupEditPage tests to use `dataSourceUid` instead of `sourceId`.
- Updated test assertions to reflect changes in folder title and link structure in GroupDetailsPage.
- Simplified Title component by removing folder-related props and logic, focusing solely on the group name.

* Refactor: Simplify Title rendering in GroupDetailsPage

- Updated the renderTitle function in GroupDetailsPage to remove the folder prop from the Title component, focusing solely on the group name.

* Update GroupDetailsPage to prevent editing of provisioned groups

* Fix imports

* Improve styles

* Fix navigation when served from subpath

* Improve group removal handling in Prom consistency check. Fix Delete group button

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
pull/102361/head
Konrad Lalik 4 months ago committed by GitHub
parent fc9e5110d7
commit 321a886b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 25
      .betterer.results
  2. 22
      public/app/features/alerting/routes.tsx
  3. 4
      public/app/features/alerting/unified/api/alertingApi.ts
  4. 2
      public/app/features/alerting/unified/api/featureDiscoveryApi.ts
  5. 12
      public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
  6. 8
      public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx
  7. 7
      public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
  8. 143
      public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx
  9. 198
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  10. 284
      public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx
  11. 334
      public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx
  12. 345
      public/app/features/alerting/unified/group-details/GroupEditPage.test.tsx
  13. 369
      public/app/features/alerting/unified/group-details/GroupEditPage.tsx
  14. 16
      public/app/features/alerting/unified/group-details/Title.tsx
  15. 175
      public/app/features/alerting/unified/group-details/components/DraggableRulesTable.tsx
  16. 2
      public/app/features/alerting/unified/hooks/ruleGroup/useDeleteRuleFromGroup.ts
  17. 2
      public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts
  18. 17
      public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts
  19. 136
      public/app/features/alerting/unified/hooks/ruleGroup/useUpdateRuleGroup.ts
  20. 6
      public/app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup.ts
  21. 182
      public/app/features/alerting/unified/hooks/usePrometheusConsistencyCheck.ts
  22. 10
      public/app/features/alerting/unified/hooks/useReturnTo.ts
  23. 75
      public/app/features/alerting/unified/mocks/server/configure.ts
  24. 11
      public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorCloudRules.test.tsx.snap
  25. 11
      public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorRecordingRule.test.tsx.snap
  26. 45
      public/app/features/alerting/unified/utils/navigation.ts
  27. 25
      public/app/features/alerting/unified/utils/rules.ts
  28. 7
      public/app/features/alerting/unified/utils/time.ts
  29. 15
      public/app/features/alerting/unified/utils/url.ts
  30. 52
      public/locales/en-US/grafana.json

@ -2316,28 +2316,9 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rules/RulesGroup.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "6"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "7"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "8"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "9"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "10"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "12"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "13"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "14"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "15"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "16"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "17"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "18"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "19"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "20"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "21"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "22"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "23"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/alerting/unified/components/rules/central-state-history/utils.ts:5381": [
[0, 0, 0, "\'@grafana/data/src/field/fieldComparers\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]

@ -264,6 +264,28 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer')
),
},
{
path: '/alerting/:dataSourceUid/namespaces/:namespaceId/groups/:groupName/view',
pageClass: 'page-alerting',
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
component: importAlertingComponent(
() =>
import(
/* webpackChunkName: "AlertingGroupDetails" */ 'app/features/alerting/unified/group-details/GroupDetailsPage'
)
),
},
{
path: '/alerting/:dataSourceUid/namespaces/:namespaceId/groups/:groupName/edit',
pageClass: 'page-alerting',
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
component: importAlertingComponent(
() =>
import(
/* webpackChunkName: "AlertingGroupEdit" */ 'app/features/alerting/unified/group-details/GroupEditPage'
)
),
},
{
path: '/alerting/admin',
roles: () => ['Admin'],

@ -1,5 +1,5 @@
import { BaseQueryFn, createApi, defaultSerializeQueryArgs } from '@reduxjs/toolkit/query/react';
import { omit } from 'lodash';
import { isBoolean, omit } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { AppEvents } from '@grafana/data';
@ -64,6 +64,8 @@ export const backendSrvBaseQuery =
const modifiedRequestOptions: BackendSrvRequest = {
...requestOptions,
...(body && { data: body }),
...(isBoolean(showSuccessAlert) && { showSuccessAlert }),
...(isBoolean(showErrorAlert) && { showErrorAlert }),
...(successMessage && { showSuccessAlert: false }),
...((errorMessage || hideErrorMessage) && { showErrorAlert: false }),
};

@ -16,7 +16,7 @@ export const GRAFANA_RULER_CONFIG: RulerDataSourceConfig = {
apiVersion: 'legacy',
};
interface RulesSourceFeatures {
export interface RulesSourceFeatures {
name: string;
uid: string;
application: RulesSourceApplication;

@ -21,7 +21,7 @@ import {
useStyles2,
} from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
@ -31,7 +31,7 @@ import {
isGrafanaAlertingRuleByType,
isGrafanaManagedRuleByType,
isGrafanaRecordingRuleByType,
rulerRuleType,
isProvisionedRuleGroup,
} from '../../utils/rules';
import { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle';
@ -67,7 +67,7 @@ const namespaceToGroupOptions = (rulerNamespace: RulerRulesConfigDTO, enableProv
return folderGroups
.map<SelectableValue<string>>((group) => {
const isProvisioned = isProvisionedGroup(group);
const isProvisioned = isProvisionedRuleGroup(group);
return {
label: group.name,
value: group.name,
@ -81,12 +81,6 @@ const namespaceToGroupOptions = (rulerNamespace: RulerRulesConfigDTO, enableProv
.sort(sortByLabel);
};
const isProvisionedGroup = (group: RulerRuleGroupDTO) => {
return group.rules.some(
(rule) => rulerRuleType.grafana.rule(rule) && Boolean(rule.grafana_alert.provenance) === true
);
};
const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) => {
return a.label?.localeCompare(b.label ?? '') || 0;
};

@ -20,6 +20,7 @@ import { useReturnTo } from '../../hooks/useReturnTo';
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
import { Annotation } from '../../utils/constants';
import { makeDashboardLink, makePanelLink, stringifyErrorLike } from '../../utils/misc';
import { createListFilterLink } from '../../utils/navigation';
import {
RulePluginOrigin,
getRulePluginOrigin,
@ -28,7 +29,6 @@ import {
prometheusRuleType,
rulerRuleType,
} from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { AlertLabels } from '../AlertLabels';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
@ -232,12 +232,6 @@ const createMetadata = (rule: CombinedRule): PageInfoItem[] => {
return metadata;
};
// TODO move somewhere else
export const createListFilterLink = (values: Array<[string, string]>) => {
const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]);
return createRelativeUrl(`/alerting/list`, params);
};
interface TitleProps {
name: string;
paused?: boolean;

@ -172,10 +172,11 @@ export const evaluateEveryValidationOptions = <T extends FieldValues>(rules: Rul
const { forDuration } = getAlertInfo(rule, evaluateEvery);
return forDuration ? safeParsePrometheusDuration(forDuration) : null;
});
const largestPendingPeriod = Math.min(
...rulePendingPeriods.filter((period): period is number => period !== null)
// 0 is a special case which disables the pending period at all
const smallestPendingPeriod = Math.min(
...rulePendingPeriods.filter((period): period is number => period !== null && period !== 0)
);
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(largestPendingPeriod)}".`;
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(smallestPendingPeriod)}".`;
}
} catch (error) {
return error instanceof Error ? error.message : 'Failed to parse duration';

@ -1,19 +1,15 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { Props } from 'react-virtualized-auto-sizer';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { render, screen } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
import * as analytics from '../../Analytics';
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
import { useHasRuler } from '../../hooks/useHasRuler';
import { mockExportApi, mockFolderApi, setupMswServer } from '../../mockApi';
import { grantUserPermissions, mockCombinedRule, mockDataSource, mockFolder, mockGrafanaRulerRule } from '../../mocks';
import { mockFolderApi, setupMswServer } from '../../mockApi';
import { grantUserPermissions, mockCombinedRule, mockFolder, mockGrafanaRulerRule } from '../../mocks';
import { mimirDataSource } from '../../mocks/server/configure';
import { RulesGroup } from './RulesGroup';
@ -22,20 +18,6 @@ jest.mock('../../hooks/useHasRuler');
jest.spyOn(analytics, 'logInfo');
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: Props) =>
children({
height: 600,
scaledHeight: 600,
scaledWidth: 1,
width: 1,
});
});
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
CodeEditor: ({ value }: { value: string }) => <textarea data-testid="code-editor" value={value} readOnly />,
}));
const mocks = {
useHasRuler: jest.mocked(useHasRuler),
};
@ -54,21 +36,8 @@ beforeEach(() => {
});
const ui = {
editGroupButton: byTestId('edit-group'),
deleteGroupButton: byTestId('delete-group'),
exportGroupButton: byRole('button', { name: 'Export rule group' }),
confirmDeleteModal: {
header: byText('Delete group'),
confirmButton: byText('Delete'),
},
export: {
dialog: byRole('dialog', { name: /Drawer title Export .* rules/ }),
jsonTab: byRole('tab', { name: /JSON/ }),
yamlTab: byRole('tab', { name: /YAML/ }),
editor: byTestId('code-editor'),
copyCodeButton: byRole('button', { name: 'Copy code' }),
downloadButton: byRole('button', { name: 'Download' }),
},
detailsButton: byRole('link', { name: 'rule group details' }),
editGroupButton: byRole('link', { name: 'edit rule group' }),
};
const server = setupMswServer();
@ -78,14 +47,10 @@ afterEach(() => {
});
describe('Rules group tests', () => {
const store = configureStore();
function renderRulesGroup(namespace: CombinedRuleNamespace, group: CombinedRuleGroup) {
return render(
<Provider store={store}>
<RulesGroup group={group} namespace={namespace} expandAll={false} viewMode={'grouped'} />
</Provider>
);
return render(<RulesGroup group={group} namespace={namespace} expandAll={false} viewMode={'grouped'} />, {
historyOptions: { initialEntries: ['/alerting/list'] },
});
}
describe('Grafana rules', () => {
@ -107,53 +72,60 @@ describe('Rules group tests', () => {
groups: [group],
};
it('Should hide delete and edit group buttons', async () => {
// Act
beforeEach(() => {
mockUseHasRuler(true, GRAFANA_RULER_CONFIG);
});
it('Should hide edit group button when no folder save permissions', async () => {
// Act
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage', canSave: false }));
renderRulesGroup(namespace, group);
expect(await screen.findByTestId('rule-group')).toBeInTheDocument();
// Assert
expect(ui.deleteGroupButton.query()).not.toBeInTheDocument();
expect(ui.detailsButton.query()).toBeInTheDocument();
expect(ui.editGroupButton.query()).not.toBeInTheDocument();
});
it('Should allow exporting rules group', async () => {
it('Should render view and edit buttons when folder has save permissions and user can edit rules', async () => {
// Arrange
mockUseHasRuler(true, GRAFANA_RULER_CONFIG);
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage' }));
mockExportApi(server).exportRulesGroup('cpu-usage', 'TestGroup', {
yaml: 'Yaml Export Content',
json: 'Json Export Content',
});
const user = userEvent.setup();
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage', canSave: true }));
// Act
renderRulesGroup(namespace, group);
await user.click(await ui.exportGroupButton.find());
expect(await screen.findByTestId('rule-group')).toBeInTheDocument();
// Assert
const drawer = await ui.export.dialog.find();
const detailsLink = await ui.detailsButton.find();
const editLink = await ui.editGroupButton.find();
expect(detailsLink).toHaveAttribute(
'href',
'/alerting/grafana/namespaces/cpu-usage/groups/TestGroup/view?returnTo=%2Falerting%2Flist'
);
expect(editLink).toHaveAttribute(
'href',
'/alerting/grafana/namespaces/cpu-usage/groups/TestGroup/edit?returnTo=%2Falerting%2Flist'
);
});
expect(ui.export.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(ui.export.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
});
it('Should only render view button when folder has save permissions and user cannot edit rules', async () => {
// Arrange
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage', canSave: true }));
await user.click(ui.export.jsonTab.get(drawer));
await waitFor(() => {
expect(ui.export.editor.get(drawer)).toHaveTextContent('Json Export Content');
});
// Act
renderRulesGroup(namespace, group);
expect(await screen.findByTestId('rule-group')).toBeInTheDocument();
expect(ui.export.copyCodeButton.get(drawer)).toBeInTheDocument();
expect(ui.export.downloadButton.get(drawer)).toBeInTheDocument();
// Assert
expect(ui.detailsButton.query()).toBeInTheDocument();
expect(ui.editGroupButton.query()).not.toBeInTheDocument();
});
});
describe('Cloud rules', () => {
const { rulerConfig } = mimirDataSource();
const { dataSource, rulerConfig } = mimirDataSource();
beforeEach(() => {
contextSrv.isEditor = true;
@ -167,24 +139,32 @@ describe('Rules group tests', () => {
const namespace: CombinedRuleNamespace = {
name: 'TestNamespace',
rulesSource: mockDataSource(),
rulesSource: dataSource,
groups: [group],
};
it('When ruler enabled should display delete and edit group buttons', () => {
it('When ruler enabled should display details and edit group buttons', async () => {
// Arrange
mockUseHasRuler(true, rulerConfig);
// Act
renderRulesGroup(namespace, group);
const detailsLink = await ui.detailsButton.find();
const editLink = await ui.editGroupButton.find();
// Assert
expect(mocks.useHasRuler).toHaveBeenCalled();
expect(ui.deleteGroupButton.get()).toBeInTheDocument();
expect(ui.editGroupButton.get()).toBeInTheDocument();
expect(detailsLink).toHaveAttribute(
'href',
'/alerting/mimir/namespaces/TestNamespace/groups/TestGroup/view?returnTo=%2Falerting%2Flist'
);
expect(editLink).toHaveAttribute(
'href',
'/alerting/mimir/namespaces/TestNamespace/groups/TestGroup/edit?returnTo=%2Falerting%2Flist'
);
});
it('When ruler disabled should hide delete and edit group buttons', () => {
it('When ruler disabled should hide edit group button', () => {
// Arrange
mockUseHasRuler(false, rulerConfig);
@ -193,21 +173,8 @@ describe('Rules group tests', () => {
// Assert
expect(mocks.useHasRuler).toHaveBeenCalled();
expect(ui.deleteGroupButton.query()).not.toBeInTheDocument();
expect(ui.detailsButton.query()).toBeInTheDocument();
expect(ui.editGroupButton.query()).not.toBeInTheDocument();
});
it('Delete button click should display confirmation modal', async () => {
// Arrange
mockUseHasRuler(true, rulerConfig);
// Act
renderRulesGroup(namespace, group);
await userEvent.click(ui.deleteGroupButton.get());
// Assert
expect(ui.confirmDeleteModal.header.get()).toBeInTheDocument();
expect(ui.confirmDeleteModal.confirmButton.get()).toBeInTheDocument();
});
});
});

@ -1,30 +1,25 @@
import { css } from '@emotion/css';
import pluralize from 'pluralize';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting';
import { Badge, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { CombinedRuleGroup, CombinedRuleNamespace, RulesSource } from 'app/types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup';
import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc';
import { isFederatedRuleGroup, rulerRuleType } from '../../utils/rules';
import { makeFolderLink } from '../../utils/misc';
import { groups } from '../../utils/navigation';
import { isFederatedRuleGroup, isPluginProvidedRule, rulerRuleType } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter';
import { decodeGrafanaNamespace } from '../expressions/util';
import { ActionIcon } from './ActionIcon';
import { EditRuleGroupModal } from './EditRuleGroupModal';
import { ReorderCloudGroupModal } from './ReorderRuleGroupModal';
import { RuleGroupStats } from './RuleStats';
import { RulesTable, useIsRulesLoading } from './RulesTable';
@ -37,36 +32,29 @@ interface Props {
viewMode: ViewMode;
}
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => {
const { rulesSource } = namespace;
const rulesSourceName = getRulesSourceName(rulesSource);
const rulerRulesLoaded = useIsRulesLoading(rulesSource);
const [deleteRuleGroup] = useDeleteRuleGroup();
const styles = useStyles2(getStyles);
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const [isReorderingGroup, setIsReorderingGroup] = useState(false);
const [isExporting, setIsExporting] = useState<'group' | 'folder' | undefined>(undefined);
const [isExporting, setIsExporting] = useState<'folder' | undefined>(undefined);
const [isCollapsed, setIsCollapsed] = useState(!expandAll);
const { canEditRules } = useRulesAccess();
useEffect(() => {
setIsCollapsed(!expandAll);
}, [expandAll]);
const { hasRuler, rulerConfig } = useHasRuler(namespace.rulesSource);
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName });
const { hasRuler } = useHasRuler(namespace.rulesSource);
const rulerRule = group.rules[0]?.rulerRule;
const folderUID =
(rulerRule && rulerRuleType.grafana.rule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined;
const { folder } = useFolder(folderUID);
const { canEditRules } = useRulesAccess();
// group "is deleting" if rules source has ruler, but this group has no rules that are in ruler
const isDeleting = hasRuler && rulerRulesLoaded && !group.rules.find((rule) => !!rule.rulerRule);
const isFederated = isFederatedRuleGroup(group);
@ -75,24 +63,14 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
const isProvisioned = group.rules.some((rule) => {
return rulerRuleType.grafana.rule(rule.rulerRule) && rule.rulerRule.grafana_alert.provenance;
});
const isPluginProvided = group.rules.some((rule) => isPluginProvidedRule(rule.rulerRule ?? rule.promRule));
const canEditGroup = hasRuler && !isProvisioned && !isFederated && !isPluginProvided && canEditRules(rulesSourceName);
// check what view mode we are in
const isListView = viewMode === 'list';
const isGroupView = viewMode === 'grouped';
const ruleGroupIdentifier = useMemo<RuleGroupIdentifier>(() => {
const namespaceName = namespace.uid ?? namespace.name;
const groupName = group.name;
const dataSourceName = getRulesSourceName(namespace.rulesSource);
return { namespaceName, groupName, dataSourceName };
}, [namespace, group.name]);
const deleteGroup = async () => {
await deleteRuleGroup.execute(ruleGroupIdentifier);
setIsDeletingGroup(false);
};
const actionIcons: React.ReactNode[] = [];
// for grafana, link to folder views
@ -106,36 +84,36 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
} else if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
if (folderUID) {
const baseUrl = makeFolderLink(folderUID);
if (folder?.canSave) {
if (isGroupView && !isProvisioned) {
if (isGroupView) {
actionIcons.push(
<ActionIcon
aria-label={t('alerting.rule-group-action.details', 'rule group details')}
key="rule-group-details"
icon="info-circle"
tooltip={t('alerting.rule-group-action.details', 'rule group details')}
to={groups.detailsPageLink('grafana', folderUID, group.name, { includeReturnTo: true })}
/>
);
if (folder?.canSave && canEditGroup) {
actionIcons.push(
<ActionIcon
aria-label="edit rule group"
data-testid="edit-group"
key="edit"
aria-label={t('alerting.rule-group-action.edit', 'edit rule group')}
key="rule-group-edit"
icon="pen"
tooltip="edit rule group"
onClick={() => setIsEditingGroup(true)}
/>
);
actionIcons.push(
<ActionIcon
data-testid="reorder-group"
key="reorder"
icon="exchange-alt"
tooltip="reorder rules"
className={styles.rotate90}
onClick={() => setIsReorderingGroup(true)}
tooltip={t('alerting.rule-group-action.edit', 'edit rule group')}
to={groups.editPageLink('grafana', folderUID, group.name, { includeReturnTo: true })}
/>
);
}
}
if (folder?.canSave) {
if (isListView) {
actionIcons.push(
<ActionIcon
aria-label="go to folder"
aria-label={t('alerting.rule-group-action.go-to-folder', 'go to folder')}
key="goto"
icon="folder-open"
tooltip="go to folder"
tooltip={t('alerting.rule-group-action.go-to-folder', 'go to folder')}
to={baseUrl}
target="__blank"
/>
@ -144,10 +122,10 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
if (folder?.canAdmin) {
actionIcons.push(
<ActionIcon
aria-label="manage permissions"
aria-label={t('alerting.rule-group-action.manage-permissions', 'manage permissions')}
key="manage-perms"
icon="lock"
tooltip="manage permissions"
tooltip={t('alerting.rule-group-action.manage-permissions', 'manage permissions')}
to={baseUrl + '/permissions'}
target="__blank"
/>
@ -159,62 +137,38 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
if (isListView) {
actionIcons.push(
<ActionIcon
aria-label="export rule folder"
aria-label={t('alerting.rule-group-action.export-rules-folder', 'Export rules folder')}
data-testid="export-folder"
key="export-folder"
icon="download-alt"
tooltip="Export rules folder"
tooltip={t('alerting.rule-group-action.export-rules-folder', 'Export rules folder')}
onClick={() => setIsExporting('folder')}
/>
);
} else if (isGroupView) {
actionIcons.push(
<ActionIcon
aria-label="export rule group"
data-testid="export-group"
key="export-group"
icon="download-alt"
tooltip="Export rule group"
onClick={() => setIsExporting('group')}
/>
);
}
}
}
} else if (canEditRules(rulesSource.name) && hasRuler) {
if (!isFederated) {
} else {
actionIcons.push(
<ActionIcon
aria-label={t('alerting.rule-group-action.details', 'rule group details')}
key="rule-group-details"
icon="info-circle"
tooltip={t('alerting.rule-group-action.details', 'rule group details')}
to={groups.detailsPageLink(rulesSource.uid, namespace.name, group.name, { includeReturnTo: true })}
/>
);
if (canEditGroup) {
actionIcons.push(
<ActionIcon
aria-label="edit rule group"
data-testid="edit-group"
key="edit"
aria-label={t('alerting.rule-group-action.edit', 'edit rule group')}
key="rule-group-edit"
icon="pen"
tooltip="edit rule group"
onClick={() => setIsEditingGroup(true)}
/>
);
actionIcons.push(
<ActionIcon
data-testid="reorder-group"
key="reorder"
icon="exchange-alt"
tooltip="reorder rules"
className={styles.rotate90}
onClick={() => setIsReorderingGroup(true)}
tooltip={t('alerting.rule-group-action.edit', 'edit rule group')}
to={groups.editPageLink(rulesSource.uid, namespace.name, group.name, { includeReturnTo: true })}
/>
);
}
actionIcons.push(
<ActionIcon
aria-label="delete rule group"
data-testid="delete-group"
key="delete-group"
icon="trash-alt"
tooltip="delete rule group"
onClick={() => setIsDeletingGroup(true)}
/>
);
}
// ungrouped rules are rules that are in the "default" group name
@ -224,13 +178,6 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
<RuleLocation namespace={decodeGrafanaNamespace(namespace).name} group={group.name} />
);
const closeEditModal = (saved = false) => {
if (!saved) {
logInfo(LogMessages.leavingRuleGroupEdit);
}
setIsEditingGroup(false);
};
return (
<div className={styles.wrapper} data-testid="rule-group">
<div className={styles.header} data-testid="rule-group-header">
@ -279,50 +226,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
rules={group.rules}
/>
)}
{isEditingGroup && rulerConfig && (
<EditRuleGroupModal
ruleGroupIdentifier={ruleGroupIdentifier}
rulerConfig={rulerConfig}
folderTitle={decodeGrafanaNamespace(namespace).name}
onClose={() => closeEditModal()}
folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder.uid) : undefined}
/>
)}
{isReorderingGroup && dsFeatures?.rulerConfig && (
<ReorderCloudGroupModal
group={group}
folderUid={folderUID}
namespace={namespace}
onClose={() => setIsReorderingGroup(false)}
rulerConfig={dsFeatures.rulerConfig}
/>
)}
<ConfirmModal
isOpen={isDeletingGroup}
title="Delete group"
body={
<div>
<p>
Deleting &quot;<strong>{group.name}</strong>&quot; will permanently remove the group and{' '}
{group.rules.length} alert {pluralize('rule', group.rules.length)} belonging to it.
</p>
<p>Are you sure you want to delete this group?</p>
</div>
}
onConfirm={deleteGroup}
onDismiss={() => setIsDeletingGroup(false)}
confirmText="Delete"
/>
{folder && isExporting === 'folder' && (
<GrafanaRuleFolderExporter folder={folder} onClose={() => setIsExporting(undefined)} />
)}
{folder && isExporting === 'group' && (
<GrafanaRuleGroupExporter
folderUid={folder.uid}
groupName={group.name}
onClose={() => setIsExporting(undefined)}
/>
)}
</div>
);
});

@ -0,0 +1,284 @@
import { HttpResponse } from 'msw';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Props } from 'react-virtualized-auto-sizer';
import { render, screen, waitFor } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions, mockRulerGrafanaRule, mockRulerRuleGroup } from '../mocks';
import {
mimirDataSource,
setFolderResponse,
setGrafanaRuleGroupExportResolver,
setPrometheusRules,
setRulerRuleGroupHandler,
setRulerRuleGroupResolver,
} from '../mocks/server/configure';
import { alertingFactory } from '../mocks/server/db';
import GroupDetailsPage from './GroupDetailsPage';
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: Props) =>
children({
height: 600,
scaledHeight: 600,
scaledWidth: 1,
width: 1,
});
});
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
CodeEditor: ({ value }: { value: string }) => <textarea data-testid="code-editor" value={value} readOnly />,
}));
const ui = {
header: byRole('heading', { level: 1 }),
editLink: byRole('link', { name: 'Edit' }),
exportButton: byRole('button', { name: 'Export' }),
tableRow: byTestId('row'),
rowsTable: byTestId('dynamic-table'),
export: {
dialog: byRole('dialog', { name: /Drawer title Export .* rules/ }),
jsonTab: byRole('tab', { name: /JSON/ }),
yamlTab: byRole('tab', { name: /YAML/ }),
editor: byTestId('code-editor'),
copyCodeButton: byRole('button', { name: 'Copy code' }),
downloadButton: byRole('button', { name: 'Download' }),
},
};
setupMswServer();
describe('GroupDetailsPage', () => {
beforeEach(() => {
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleExternalRead,
AccessControlAction.AlertingRuleExternalWrite,
]);
});
describe('Grafana managed rules', () => {
const rule1 = mockRulerGrafanaRule({ for: '10m' }, { title: 'High CPU Usage' });
const rule2 = mockRulerGrafanaRule({ for: '5m' }, { title: 'Memory Pressure' });
const provisionedRule = mockRulerGrafanaRule({ for: '10m' }, { title: 'Provisioned Rule', provenance: 'api' });
const group = mockRulerRuleGroup({
name: 'test-group-cpu',
interval: '3m',
rules: [rule1, rule2],
});
const provisionedGroup = mockRulerRuleGroup({
name: 'provisioned-group-cpu',
interval: '15m',
rules: [provisionedRule],
});
beforeEach(() => {
setRulerRuleGroupHandler({ response: HttpResponse.json(group) });
setFolderResponse({ uid: 'test-folder-uid', canSave: true, title: 'test-folder-title' });
setGrafanaRuleGroupExportResolver(({ request }) => {
const url = new URL(request.url);
return HttpResponse.text(
url.searchParams.get('format') === 'yaml' ? 'Yaml Export Content' : 'Json Export Content'
);
});
});
it('should render grafana rules group based on the Ruler API', async () => {
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
const header = await ui.header.find();
const editLink = await ui.editLink.find();
// Assert
expect(header).toHaveTextContent('test-group-cpu');
expect(await screen.findByRole('link', { name: /test-folder-title/ })).toBeInTheDocument();
expect(await screen.findByText(/5m/)).toBeInTheDocument();
expect(editLink).toHaveAttribute(
'href',
'/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fgrafana%2Fnamespaces%2Ftest-folder-uid%2Fgroups%2Ftest-group-cpu%2Fview'
);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
expect(tableRows).toHaveLength(2);
expect(tableRows[0]).toHaveTextContent('High CPU Usage');
expect(tableRows[0]).toHaveTextContent('10m');
expect(tableRows[0]).toHaveTextContent('5');
expect(tableRows[1]).toHaveTextContent('Memory Pressure');
expect(tableRows[1]).toHaveTextContent('5m');
expect(tableRows[1]).toHaveTextContent('3');
});
it('should render error alert when API returns an error', async () => {
// Mock an error response from the API
setRulerRuleGroupResolver((req) => {
return HttpResponse.json({ error: 'Failed to fetch rule group' }, { status: 500 });
});
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
// Assert
expect(await screen.findByText('Error loading the group')).toBeInTheDocument();
expect(await screen.findByText('Failed to fetch rule group')).toBeInTheDocument();
});
it('should render "not found" when group does not exist', async () => {
// Mock a 404 response
setRulerRuleGroupResolver((req) => {
return HttpResponse.json({ error: 'rule group does not exist' }, { status: 404 });
});
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', 'non-existing-group');
const notFoundAlert = await screen.findByRole('alert', { name: /Error loading the group/ });
// Assert
expect(notFoundAlert).toBeInTheDocument();
expect(notFoundAlert).toHaveTextContent(/rule group does not exist/);
expect(screen.getByTestId('data-testid entity-not-found')).toHaveTextContent(
'test-folder-uid/non-existing-group'
);
});
it('should not show edit button when user lacks edit permissions', async () => {
// Remove edit permissions
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]);
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
// Assert
expect(tableRows).toHaveLength(2);
expect(ui.editLink.query()).not.toBeInTheDocument(); // Edit button should not be present
});
it('should not show edit button when folder cannot be saved', async () => {
setFolderResponse({ uid: 'test-folder-uid', canSave: false });
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
// Assert
expect(tableRows).toHaveLength(2);
expect(ui.editLink.query()).not.toBeInTheDocument(); // Edit button should not be present
});
it('should not allow editing if the group is provisioned', async () => {
setRulerRuleGroupHandler({ response: HttpResponse.json(provisionedGroup) });
// Act
renderGroupDetailsPage('grafana', 'test-folder-uid', provisionedGroup.name);
const tableRows = await ui.tableRow.findAll(await ui.rowsTable.find());
// Assert
expect(tableRows).toHaveLength(1);
expect(tableRows[0]).toHaveTextContent('Provisioned Rule');
expect(ui.editLink.query()).not.toBeInTheDocument();
expect(ui.exportButton.query()).toBeInTheDocument();
});
it('should allow exporting groups', async () => {
// Act
const { user } = renderGroupDetailsPage('grafana', 'test-folder-uid', group.name);
// Assert
const exportButton = await ui.exportButton.find();
expect(exportButton).toBeInTheDocument();
await user.click(exportButton);
const drawer = await ui.export.dialog.find();
expect(ui.export.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(ui.export.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
});
await user.click(ui.export.jsonTab.get(drawer));
await waitFor(() => {
expect(ui.export.editor.get(drawer)).toHaveTextContent('Json Export Content');
});
expect(ui.export.copyCodeButton.get(drawer)).toBeInTheDocument();
expect(ui.export.downloadButton.get(drawer)).toBeInTheDocument();
});
});
describe('Prometheus rules', () => {
it('should render vanilla prometheus rules group', async () => {
const promDs = alertingFactory.dataSource.build({ uid: 'prometheus', name: 'Prometheus' });
const group = alertingFactory.prometheus.group.build({ name: 'test-group-cpu', interval: 500 });
setPrometheusRules({ uid: promDs.uid }, [group]);
// Act
renderGroupDetailsPage(promDs.uid, 'test-prom-namespace', 'test-group-cpu');
// Assert
const header = await ui.header.find();
expect(header).toHaveTextContent('test-group-cpu');
expect(await screen.findByText(/test-group-cpu/)).toBeInTheDocument();
expect(await screen.findByText(/8m20s/)).toBeInTheDocument();
expect(ui.editLink.query()).not.toBeInTheDocument();
expect(ui.exportButton.query()).not.toBeInTheDocument();
});
});
describe('Mimir rules', () => {
it('should render mimir rules group', async () => {
const { dataSource: mimirDs } = mimirDataSource();
const group = alertingFactory.ruler.group.build({ name: 'test-group-cpu', interval: '11m40s' });
setRulerRuleGroupResolver((req) => {
if (req.params.namespace === 'test-mimir-namespace' && req.params.groupName === 'test-group-cpu') {
return HttpResponse.json(group);
}
return HttpResponse.json({ error: 'Group not found' }, { status: 404 });
});
renderGroupDetailsPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu');
const header = await ui.header.find();
const editLink = await ui.editLink.find();
expect(header).toHaveTextContent('test-group-cpu');
expect(await screen.findByText(/test-mimir-namespace/)).toBeInTheDocument();
expect(await screen.findByText(/11m40s/)).toBeInTheDocument();
expect(editLink).toHaveAttribute(
'href',
`/alerting/mimir/namespaces/test-mimir-namespace/groups/test-group-cpu/edit?returnTo=%2Falerting%2Fmimir%2Fnamespaces%2Ftest-mimir-namespace%2Fgroups%2Ftest-group-cpu%2Fview`
);
expect(ui.exportButton.query()).not.toBeInTheDocument();
});
});
});
function renderGroupDetailsPage(dsUid: string, namespaceId: string, groupName: string) {
return render(
<Routes>
<Route
path="/alerting/:dataSourceUid/namespaces/:namespaceId/groups/:groupName/view"
element={<GroupDetailsPage />}
/>
</Routes>,
{
historyOptions: { initialEntries: [`/alerting/${dsUid}/namespaces/${namespaceId}/groups/${groupName}/view`] },
}
);
}

@ -0,0 +1,334 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { Alert, Badge, Button, LinkButton, Text, TextLink, withErrorBoundary } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { Trans, t } from 'app/core/internationalization';
import { FolderDTO } from 'app/types';
import { GrafanaRulesSourceSymbol, RuleGroup } from 'app/types/unified-alerting';
import { PromRuleType, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { RulesSourceFeatures, featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { DynamicTable, DynamicTableColumnProps } from '../components/DynamicTable';
import { GrafanaRuleGroupExporter } from '../components/export/GrafanaRuleGroupExporter';
import { useFolder } from '../hooks/useFolder';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults';
import { useRulesAccess } from '../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { makeFolderLink, stringifyErrorLike } from '../utils/misc';
import { createListFilterLink, groups } from '../utils/navigation';
import {
calcRuleEvalsToStartAlerting,
getRuleName,
isFederatedRuleGroup,
isProvisionedRuleGroup,
rulerRuleType,
} from '../utils/rules';
import { formatPrometheusDuration, safeParsePrometheusDuration } from '../utils/time';
import { Title } from './Title';
type GroupPageRouteParams = {
dataSourceUid?: string;
namespaceId?: string;
groupName?: string;
};
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
function GroupDetailsPage() {
const { dataSourceUid = '', namespaceId = '', groupName = '' } = useParams<GroupPageRouteParams>();
const isGrafanaRuleGroup = dataSourceUid === GRAFANA_RULES_SOURCE_NAME;
const { folder, loading: isFolderLoading } = useFolder(isGrafanaRuleGroup ? namespaceId : '');
const {
data: dsFeatures,
isLoading: isDsFeaturesLoading,
error: dsFeaturesError,
} = useDiscoverDsFeaturesQuery({ uid: isGrafanaRuleGroup ? GrafanaRulesSourceSymbol : dataSourceUid });
const {
data: promGroup,
isLoading: isRuleNamespacesLoading,
error: ruleNamespacesError,
} = usePrometheusRuleNamespacesQuery(
dsFeatures && !dsFeatures.rulerConfig
? { ruleSourceName: dsFeatures?.name ?? '', namespace: namespaceId, groupName: groupName }
: skipToken,
{
selectFromResult: (result) => ({
...result,
data: result.data?.[0]?.groups.find((g) => g.name === groupName),
}),
}
);
const {
data: rulerGroup,
isLoading: isRuleGroupLoading,
error: ruleGroupError,
} = useGetRuleGroupForNamespaceQuery(
dsFeatures?.rulerConfig
? { rulerConfig: dsFeatures?.rulerConfig, namespace: namespaceId, group: groupName }
: skipToken
);
const isLoading = isFolderLoading || isDsFeaturesLoading || isRuleNamespacesLoading || isRuleGroupLoading;
const groupInterval = promGroup?.interval
? formatPrometheusDuration(promGroup.interval * 1000)
: (rulerGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const namespaceName = folder?.title ?? namespaceId;
const namespaceUrl = createListFilterLink([['namespace', namespaceName]]);
const namespaceLabel = isGrafanaRuleGroup
? t('alerting.group-details.folder', 'Folder')
: t('alerting.group-details.namespace', 'Namespace');
const namespaceValue = folder ? (
<TextLink href={makeFolderLink(folder.uid)} inline={false}>
{folder.title}
</TextLink>
) : (
namespaceId
);
return (
<AlertingPageWrapper
pageNav={{
text: groupName,
parentItem: {
text: namespaceName,
url: namespaceUrl,
},
}}
renderTitle={(title) => <Title name={title} />}
info={[
{ label: namespaceLabel, value: namespaceValue },
{ label: t('alerting.group-details.interval', 'Interval'), value: groupInterval },
]}
navId="alert-list"
isLoading={isLoading}
actions={
<>
{dsFeatures && (
<GroupActions
dsFeatures={dsFeatures}
namespaceId={namespaceId}
groupName={groupName}
folder={folder}
rulerGroup={rulerGroup}
/>
)}
</>
}
>
<>
{Boolean(dsFeaturesError) && (
<Alert
title={t('alerting.group-details.ds-features-error', 'Error loading data source details')}
bottomSpacing={0}
topSpacing={2}
>
<div>{stringifyErrorLike(dsFeaturesError)}</div>
</Alert>
)}
{Boolean(ruleNamespacesError || ruleGroupError) && (
<Alert
title={t('alerting.group-details.group-loading-error', 'Error loading the group')}
bottomSpacing={0}
topSpacing={2}
>
<div>{stringifyErrorLike(ruleNamespacesError || ruleGroupError)}</div>
</Alert>
)}
{promGroup && <GroupDetails group={promRuleGroupToRuleGroupDetails(promGroup)} />}
{rulerGroup && <GroupDetails group={rulerRuleGroupToRuleGroupDetails(rulerGroup)} />}
{!promGroup && !rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />}
</>
</AlertingPageWrapper>
);
}
interface GroupActionsProps {
dsFeatures: RulesSourceFeatures;
namespaceId: string;
groupName: string;
rulerGroup: RulerRuleGroupDTO | undefined;
folder: FolderDTO | undefined;
}
function GroupActions({ dsFeatures, namespaceId, groupName, folder, rulerGroup }: GroupActionsProps) {
const { canEditRules } = useRulesAccess();
const [isExporting, setIsExporting] = useState<boolean>(false);
const isGrafanaSource = dsFeatures.uid === GRAFANA_RULES_SOURCE_NAME;
const canSaveInFolder = isGrafanaSource ? Boolean(folder?.canSave) : true;
const isFederated = rulerGroup ? isFederatedRuleGroup(rulerGroup) : false;
const isProvisioned = rulerGroup ? isProvisionedRuleGroup(rulerGroup) : false;
const canEdit =
Boolean(dsFeatures.rulerConfig) &&
canEditRules(dsFeatures.name) &&
canSaveInFolder &&
!isFederated &&
!isProvisioned;
return (
<>
{isGrafanaSource && (
<Button onClick={() => setIsExporting(true)} icon="file-download" variant="secondary">
<Trans i18nKey="alerting.group-details.export">Export</Trans>
</Button>
)}
{canEdit && (
<LinkButton
icon="pen"
href={groups.editPageLink(dsFeatures.uid, namespaceId, groupName, { includeReturnTo: true })}
variant="secondary"
>
<Trans i18nKey="alerting.group-details.edit">Edit</Trans>
</LinkButton>
)}
{folder && isExporting && (
<GrafanaRuleGroupExporter folderUid={folder.uid} groupName={groupName} onClose={() => setIsExporting(false)} />
)}
</>
);
}
/** An common interface for both Prometheus and Ruler rule groups */
interface RuleGroupDetails {
name: string;
interval: string;
rules: RuleDetails[];
}
interface AlertingRuleDetails {
name: string;
type: 'alerting';
pendingPeriod: string;
evaluationsToFire: number;
}
interface RecordingRuleDetails {
name: string;
type: 'recording';
}
type RuleDetails = AlertingRuleDetails | RecordingRuleDetails;
interface GroupDetailsProps {
group: RuleGroupDetails;
}
function GroupDetails({ group }: GroupDetailsProps) {
return (
<div>
<RulesTable rules={group.rules} />
</div>
);
}
function RulesTable({ rules }: { rules: RuleDetails[] }) {
const rows = rules.map((rule: RuleDetails, index) => ({
id: index,
data: rule,
}));
const columns: Array<DynamicTableColumnProps<RuleDetails>> = useMemo(() => {
return [
{
id: 'alertName',
label: t('alerting.group-details.rule-name', 'Rule name'),
renderCell: ({ data }) => {
return <Text truncate>{data.name}</Text>;
},
size: 0.4,
},
{
id: 'for',
label: t('alerting.group-details.pending-period', 'Pending period'),
renderCell: ({ data }) => {
switch (data.type) {
case 'alerting':
return <>{data.pendingPeriod}</>;
case 'recording':
return <Badge text={t('alerting.group-details.recording', 'Recording')} color="purple" />;
}
},
size: 0.3,
},
{
id: 'numberEvaluations',
label: t('alerting.group-details.evaluations-to-fire', 'Evaluation cycles to fire'),
renderCell: ({ data }) => {
switch (data.type) {
case 'alerting':
return <>{data.evaluationsToFire}</>;
case 'recording':
return null;
}
},
size: 0.3,
},
];
}, []);
return <DynamicTable items={rows} cols={columns} />;
}
function promRuleGroupToRuleGroupDetails(group: RuleGroup): RuleGroupDetails {
const groupIntervalMs = group.interval * 1000;
return {
name: group.name,
interval: formatPrometheusDuration(group.interval * 1000),
rules: group.rules.map<RuleDetails>((rule) => {
switch (rule.type) {
case PromRuleType.Alerting:
return {
name: rule.name,
type: 'alerting',
pendingPeriod: formatPrometheusDuration(rule.duration ? rule.duration * 1000 : 0),
evaluationsToFire: calcRuleEvalsToStartAlerting(rule.duration ? rule.duration * 1000 : 0, groupIntervalMs),
};
case PromRuleType.Recording:
return { name: rule.name, type: 'recording' };
}
}),
};
}
function rulerRuleGroupToRuleGroupDetails(group: RulerRuleGroupDTO): RuleGroupDetails {
const groupIntervalMs = safeParsePrometheusDuration(group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
return {
name: group.name,
interval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
rules: group.rules.map<RuleDetails>((rule) => {
const name = getRuleName(rule);
if (rulerRuleType.any.alertingRule(rule)) {
return {
name,
type: 'alerting',
pendingPeriod: rule.for ?? '0s',
evaluationsToFire: calcRuleEvalsToStartAlerting(
rule.for ? safeParsePrometheusDuration(rule.for) : 0,
groupIntervalMs
),
};
}
return { name, type: 'recording' };
}),
};
}
export default withErrorBoundary(GroupDetailsPage, { style: 'page' });

@ -0,0 +1,345 @@
import { HttpResponse } from 'msw';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { render, screen } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { locationService } from '@grafana/runtime';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
import { AccessControlAction } from 'app/types';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import {
mimirDataSource,
setDeleteRulerRuleNamespaceResolver,
setFolderResponse,
setGrafanaRulerRuleGroupResolver,
setRulerRuleGroupResolver,
setUpdateGrafanaRulerRuleNamespaceResolver,
setUpdateRulerRuleNamespaceResolver,
} from '../mocks/server/configure';
import { alertingFactory } from '../mocks/server/db';
import GroupEditPage from './GroupEditPage';
// Mock the useRuleGroupConsistencyCheck hook
jest.mock('../hooks/usePrometheusConsistencyCheck', () => ({
...jest.requireActual('../hooks/usePrometheusConsistencyCheck'),
useRuleGroupConsistencyCheck: () => ({
waitForGroupConsistency: jest.fn().mockResolvedValue(undefined),
}),
}));
window.performance.mark = jest.fn();
window.performance.measure = jest.fn();
const ui = {
header: byRole('heading', { level: 1 }),
folderInput: byRole('textbox', { name: /Folder/ }),
namespaceInput: byRole('textbox', { name: /Namespace/ }),
nameInput: byRole('textbox', { name: /Evaluation group name/ }),
intervalInput: byRole('textbox', { name: /Evaluation interval/ }),
saveButton: byRole('button', { name: /Save/ }),
cancelButton: byRole('link', { name: /Cancel/ }),
deleteButton: byRole('button', { name: /Delete/ }),
rules: byTestId('reorder-alert-rule'),
successMessage: byText('Successfully updated the rule group'),
errorMessage: byText('Failed to update rule group'),
confirmDeleteModal: {
dialog: byRole('dialog'),
header: byRole('heading', { level: 2, name: /Delete rule group/ }),
confirmButton: byRole('button', { name: /Delete/ }),
},
};
setupMswServer();
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleExternalRead,
AccessControlAction.AlertingRuleExternalWrite,
]);
const { dataSource: mimirDs } = mimirDataSource();
describe('GroupEditPage', () => {
const group = alertingFactory.ruler.group.build({
name: 'test-group-cpu',
interval: '4m30s',
rules: [
alertingFactory.ruler.alertingRule.build({ alert: 'first-rule' }),
alertingFactory.ruler.alertingRule.build({ alert: 'second-rule' }),
],
});
describe('Grafana Managed Rules', () => {
const groupsByName = new Map<string, RulerRuleGroupDTO>([[group.name, group]]);
beforeEach(() => {
setGrafanaRulerRuleGroupResolver(async ({ params: { groupName, folderUid } }) => {
if (groupsByName.has(groupName) && folderUid === 'test-folder-uid') {
return HttpResponse.json(groupsByName.get(groupName));
}
return HttpResponse.json(null, { status: 404 });
});
setFolderResponse({ uid: 'test-folder-uid', canSave: true });
});
it('should render grafana rules group with form fields', async () => {
renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu');
const header = await ui.header.find();
const folderInput = await ui.folderInput.find();
const nameInput = await ui.nameInput.find();
const intervalInput = await ui.intervalInput.find();
const saveButton = await ui.saveButton.find();
const cancelButton = await ui.cancelButton.find();
const rules = await ui.rules.findAll();
expect(header).toHaveTextContent('Edit rule group');
expect(folderInput).toHaveAttribute('readonly', '');
expect(nameInput).toHaveValue('test-group-cpu');
expect(intervalInput).toHaveValue('4m30s');
expect(saveButton).toBeInTheDocument();
expect(cancelButton).toBeInTheDocument();
expect(cancelButton).toHaveProperty(
'href',
'http://localhost/alerting/grafana/namespaces/test-folder-uid/groups/test-group-cpu/view'
);
expect(rules).toHaveLength(2);
expect(rules[0]).toHaveTextContent('first-rule');
expect(rules[1]).toHaveTextContent('second-rule');
// Changing folder is not supported for Grafana Managed Rules
expect(ui.namespaceInput.query()).not.toBeInTheDocument();
});
it('should save updated interval', async () => {
setUpdateGrafanaRulerRuleNamespaceResolver(async ({ request }) => {
const body = await request.json();
if (body.interval === '1m20s') {
return HttpResponse.json({}, { status: 202 });
}
return HttpResponse.json(null, { status: 400 });
});
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu');
const intervalInput = await ui.intervalInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(intervalInput);
await user.type(intervalInput, '1m20s');
await user.click(saveButton);
expect(await ui.successMessage.find()).toBeInTheDocument();
});
it('should save a new group and remove the old when renaming', async () => {
setUpdateGrafanaRulerRuleNamespaceResolver(async ({ request }) => {
const body = await request.json();
if (body.name === 'new-group-name') {
groupsByName.set('new-group-name', body);
return HttpResponse.json({}, { status: 202 });
}
return HttpResponse.json(null, { status: 400 });
});
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu');
const nameInput = await ui.nameInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(nameInput);
await user.type(nameInput, 'new-group-name');
await user.click(saveButton);
expect(await ui.successMessage.find()).toBeInTheDocument();
expect(locationService.getLocation().pathname).toBe(
'/alerting/grafana/namespaces/test-folder-uid/groups/new-group-name/edit'
);
});
});
describe('Mimir Rules', () => {
// Create a map to store groups by name
const groupsByName = new Map<string, RulerRuleGroupDTO>([[group.name, group]]);
beforeEach(() => {
groupsByName.clear();
groupsByName.set(group.name, group);
setRulerRuleGroupResolver(async ({ params: { groupName } }) => {
if (groupsByName.has(groupName)) {
return HttpResponse.json(groupsByName.get(groupName));
}
return HttpResponse.json(null, { status: 404 });
});
setUpdateRulerRuleNamespaceResolver(async ({ request, params }) => {
const body = await request.json();
groupsByName.set(body.name, body);
return HttpResponse.json({}, { status: 202 });
});
setDeleteRulerRuleNamespaceResolver(async ({ params: { groupName } }) => {
if (groupsByName.has(groupName)) {
groupsByName.delete(groupName);
}
return HttpResponse.json({ message: 'group does not exist' }, { status: 404 });
});
});
it('should save updated interval', async () => {
setUpdateRulerRuleNamespaceResolver(async ({ request }) => {
const body = await request.json();
if (body.interval === '2m') {
return HttpResponse.json({}, { status: 202 });
}
return HttpResponse.json(null, { status: 400 });
});
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu');
const intervalInput = await ui.intervalInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(intervalInput);
await user.type(intervalInput, '2m');
await user.click(saveButton);
expect(await ui.successMessage.find()).toBeInTheDocument();
});
it('should save a new group and remove the old when changing the group name', async () => {
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu');
const groupNameInput = await ui.nameInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(groupNameInput);
await user.type(groupNameInput, 'new-group-name');
await user.click(saveButton);
expect(await ui.successMessage.find()).toBeInTheDocument();
expect(locationService.getLocation().pathname).toBe(
'/alerting/mimir/namespaces/test-mimir-namespace/groups/new-group-name/edit'
);
});
it('should save a new group and delete old one when changing the namespace', async () => {
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu');
const namespaceInput = await ui.namespaceInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(namespaceInput);
await user.type(namespaceInput, 'new-namespace-name');
await user.click(saveButton);
expect(await ui.successMessage.find()).toBeInTheDocument();
expect(locationService.getLocation().pathname).toBe(
'/alerting/mimir/namespaces/new-namespace-name/groups/test-group-cpu/edit'
);
});
it('should display confirmation modal before deleting a group', async () => {
const { user } = renderGroupEditPage(mimirDs.uid, 'test-mimir-namespace', 'test-group-cpu');
const deleteButton = await ui.deleteButton.find();
await user.click(deleteButton);
const confirmDialog = await ui.confirmDeleteModal.dialog.find();
expect(confirmDialog).toBeInTheDocument();
expect(ui.confirmDeleteModal.header.get(confirmDialog)).toBeInTheDocument();
expect(ui.confirmDeleteModal.confirmButton.get(confirmDialog)).toBeInTheDocument();
});
});
describe('Form error handling', () => {
const groupsByName = new Map<string, RulerRuleGroupDTO>([[group.name, group]]);
beforeEach(() => {
setGrafanaRulerRuleGroupResolver(async ({ params: { groupName, folderUid } }) => {
if (groupsByName.has(groupName) && folderUid === 'test-folder-uid') {
return HttpResponse.json(groupsByName.get(groupName));
}
return HttpResponse.json(null, { status: 404 });
});
setFolderResponse({ uid: 'test-folder-uid', canSave: true });
});
it('should show validation error for empty group name', async () => {
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu');
const nameInput = await ui.nameInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(nameInput);
await user.click(saveButton);
// Check for validation error message
expect(screen.getByText('Group name is required')).toBeInTheDocument();
});
it('should show validation error for invalid interval', async () => {
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu');
const intervalInput = await ui.intervalInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(intervalInput);
await user.type(intervalInput, 'invalid');
await user.click(saveButton);
// The exact error message depends on your validation logic
// This is a common pattern for testing validation errors
expect(screen.getByText(/must be of format/i)).toBeInTheDocument();
});
it('should handle API error when saving fails', async () => {
setUpdateGrafanaRulerRuleNamespaceResolver(async () => {
return HttpResponse.json({ message: 'Failed to save rule group' }, { status: 500 });
});
const { user } = renderGroupEditPage('grafana', 'test-folder-uid', 'test-group-cpu');
const intervalInput = await ui.intervalInput.find();
const saveButton = await ui.saveButton.find();
await user.clear(intervalInput);
await user.type(intervalInput, '1m');
await user.click(saveButton);
expect(ui.successMessage.query()).not.toBeInTheDocument();
expect(ui.errorMessage.query()).toBeInTheDocument();
});
});
});
function renderGroupEditPage(dsUid: string, namespaceId: string, groupName: string) {
return render(
<>
<AppNotificationList />
<Routes>
<Route
path="/alerting/:dataSourceUid/namespaces/:namespaceId/groups/:groupName/edit"
element={<GroupEditPage />}
/>
</Routes>
</>,
{
historyOptions: { initialEntries: [`/alerting/${dsUid}/namespaces/${namespaceId}/groups/${groupName}/edit`] },
}
);
}

@ -0,0 +1,369 @@
import { css } from '@emotion/css';
import { produce } from 'immer';
import { useCallback, useEffect, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
Alert,
Button,
ConfirmModal,
Field,
Input,
LinkButton,
Stack,
useStyles2,
withErrorBoundary,
} from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { useAppNotification } from 'app/core/copy/appNotification';
import { Trans, t } from 'app/core/internationalization';
import { useDispatch } from 'app/types';
import { GrafanaRulesSourceSymbol, RuleGroupIdentifierV2, RulerDataSourceConfig } from 'app/types/unified-alerting';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { logError } from '../Analytics';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { EvaluationGroupQuickPick } from '../components/rule-editor/EvaluationGroupQuickPick';
import { evaluateEveryValidationOptions } from '../components/rules/EditRuleGroupModal';
import { useDeleteRuleGroup } from '../hooks/ruleGroup/useDeleteRuleGroup';
import { UpdateGroupDelta, useUpdateRuleGroup } from '../hooks/ruleGroup/useUpdateRuleGroup';
import { isLoading, useAsync } from '../hooks/useAsync';
import { useFolder } from '../hooks/useFolder';
import { useRuleGroupConsistencyCheck } from '../hooks/usePrometheusConsistencyCheck';
import { useReturnTo } from '../hooks/useReturnTo';
import { SwapOperation } from '../reducers/ruler/ruleGroups';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults';
import { ruleGroupIdentifierV2toV1 } from '../utils/groupIdentifier';
import { stringifyErrorLike } from '../utils/misc';
import { alertListPageLink, createListFilterLink, groups } from '../utils/navigation';
import { DraggableRulesTable } from './components/DraggableRulesTable';
type GroupEditPageRouteParams = {
dataSourceUid?: string;
namespaceId?: string;
groupName?: string;
};
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
function GroupEditPage() {
const dispatch = useDispatch();
const { dataSourceUid = '', namespaceId = '', groupName = '' } = useParams<GroupEditPageRouteParams>();
const { folder, loading: isFolderLoading } = useFolder(dataSourceUid === 'grafana' ? namespaceId : '');
const ruleSourceUid = dataSourceUid === 'grafana' ? GrafanaRulesSourceSymbol : dataSourceUid;
const {
data: dsFeatures,
isLoading: isDsFeaturesLoading,
error: dsFeaturesError,
} = useDiscoverDsFeaturesQuery({ uid: ruleSourceUid });
// We use useAsync instead of RTKQ query to avoid cache invalidation issues when the group is being deleted
// RTKQ query would refetch the group after it's deleted and we'd end up with a blinking group not found error
const [getGroupAction, groupRequestState] = useAsync(async (rulerConfig: RulerDataSourceConfig) => {
return dispatch(
alertRuleApi.endpoints.getRuleGroupForNamespace.initiate({
rulerConfig: rulerConfig,
namespace: namespaceId,
group: groupName,
})
).unwrap();
});
useEffect(() => {
if (namespaceId && groupName && dsFeatures?.rulerConfig) {
getGroupAction.execute(dsFeatures.rulerConfig);
}
}, [namespaceId, groupName, dsFeatures?.rulerConfig, getGroupAction]);
const isLoadingGroup = isFolderLoading || isDsFeaturesLoading || isLoading(groupRequestState);
const { result: rulerGroup, error: ruleGroupError } = groupRequestState;
const pageNav: NavModelItem = {
text: t('alerting.group-edit.page-title', 'Edit rule group'),
parentItem: {
text: folder?.title ?? namespaceId,
url: createListFilterLink([
['namespace', folder?.title ?? namespaceId],
['group', groupName],
]),
},
};
if (!!dsFeatures && !dsFeatures.rulerConfig) {
return (
<AlertingPageWrapper pageNav={pageNav} title={groupName} navId="alert-list" isLoading={isLoadingGroup}>
<Alert title={t('alerting.group-edit.group-not-editable', 'Selected group cannot be edited')}>
<Trans i18nKey="alerting.group-edit.group-not-editable-description">
This group belongs to a data source that does not support editing.
</Trans>
</Alert>
</AlertingPageWrapper>
);
}
const groupIdentifier: RuleGroupIdentifierV2 =
dataSourceUid === 'grafana'
? {
namespace: { uid: namespaceId },
groupName: groupName,
groupOrigin: 'grafana',
}
: {
rulesSource: { uid: dataSourceUid, name: dsFeatures?.name ?? '', ruleSourceType: 'datasource' },
namespace: { name: namespaceId },
groupName: groupName,
groupOrigin: 'datasource',
};
return (
<AlertingPageWrapper
pageNav={pageNav}
title={t('alerting.group-edit.title', 'Edit evaluation group')}
navId="alert-list"
isLoading={isLoadingGroup}
>
<>
{Boolean(dsFeaturesError) && (
<Alert
title={t('alerting.group-edit.ds-error', 'Error loading data source details')}
bottomSpacing={0}
topSpacing={2}
>
<div>{stringifyErrorLike(dsFeaturesError)}</div>
</Alert>
)}
{/* If the rule group is being deleted, RTKQ will try to referch it due to cache invalidation */}
{/* For a few miliseconds before redirecting, the rule group will be missing and 404 error would blink */}
{Boolean(ruleGroupError) && (
<Alert
title={t('alerting.group-edit.rule-group-error', 'Error loading rule group')}
bottomSpacing={0}
topSpacing={2}
>
{stringifyErrorLike(ruleGroupError)}
</Alert>
)}
</>
{rulerGroup && <GroupEditForm rulerGroup={rulerGroup} groupIdentifier={groupIdentifier} />}
{!rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />}
</AlertingPageWrapper>
);
}
export default withErrorBoundary(GroupEditPage, { style: 'page' });
interface GroupEditFormProps {
rulerGroup: RulerRuleGroupDTO;
groupIdentifier: RuleGroupIdentifierV2;
}
interface GroupEditFormData {
name: string;
interval: string;
namespace?: string;
}
function GroupEditForm({ rulerGroup, groupIdentifier }: GroupEditFormProps) {
const styles = useStyles2(getStyles);
const appInfo = useAppNotification();
const { returnTo } = useReturnTo(groups.detailsPageLinkFromGroupIdentifier(groupIdentifier));
const { folder } = useFolder(groupIdentifier.groupOrigin === 'grafana' ? groupIdentifier.namespace.uid : '');
const { waitForGroupConsistency } = useRuleGroupConsistencyCheck();
const [updateRuleGroup] = useUpdateRuleGroup();
const [deleteRuleGroup] = useDeleteRuleGroup();
const [operations, setOperations] = useState<SwapOperation[]>([]);
const [confirmDeleteOpened, setConfirmDeleteOpened] = useState(false);
const groupIntervalOrDefault = rulerGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL;
const {
register,
handleSubmit,
getValues,
setValue,
formState: { errors, dirtyFields, isSubmitting },
} = useForm<GroupEditFormData>({
mode: 'onBlur',
shouldFocusError: true,
defaultValues: {
name: rulerGroup.name,
interval: rulerGroup.interval,
namespace: groupIdentifier.groupOrigin === 'datasource' ? groupIdentifier.namespace.name : undefined,
},
});
const onSwap = useCallback((swapOperation: SwapOperation) => {
setOperations((prevOperations) => {
return produce(prevOperations, (draft) => {
draft.push(swapOperation);
});
});
}, []);
const onSubmit: SubmitHandler<GroupEditFormData> = async (data) => {
try {
const changeDelta: UpdateGroupDelta = {
namespaceName: dirtyFields.namespace ? data.namespace : undefined,
groupName: dirtyFields.name ? data.name : undefined,
interval: dirtyFields.interval ? data.interval : undefined,
ruleSwaps: operations.length ? operations : undefined,
};
const updatedGroupIdentifier = await updateRuleGroup.execute(
ruleGroupIdentifierV2toV1(groupIdentifier),
changeDelta
);
const shouldWaitForPromConsistency = !!changeDelta.namespaceName || !!changeDelta.groupName;
if (shouldWaitForPromConsistency) {
await waitForGroupConsistency(updatedGroupIdentifier);
}
const successMessage = t('alerting.group-edit.form.update-success', 'Successfully updated the rule group');
appInfo.success(successMessage);
setMatchingGroupPageUrl(updatedGroupIdentifier);
} catch (error) {
logError(error instanceof Error ? error : new Error('Failed to update rule group'));
appInfo.error(
t('alerting.group-edit.form.update-error', 'Failed to update rule group'),
stringifyErrorLike(error)
);
}
};
const onDelete = async () => {
await deleteRuleGroup.execute(ruleGroupIdentifierV2toV1(groupIdentifier));
await waitForGroupConsistency(groupIdentifier);
redirectToListPage();
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
{groupIdentifier.groupOrigin === 'datasource' && (
<Field
label={t('alerting.group-edit.form.namespace-label', 'Namespace')}
required
invalid={!!errors.namespace}
error={errors.namespace?.message}
className={styles.input}
>
<Input
id="namespace"
{...register('namespace', {
required: t('alerting.group-edit.form.namespace-required', 'Namespace is required'),
})}
/>
</Field>
)}
{groupIdentifier.groupOrigin === 'grafana' && (
<Field label={t('alerting.group-edit.form.folder-label', 'Folder')} required>
<Input id="folder" value={folder?.title ?? ''} readOnly />
</Field>
)}
<Field
label={t('alerting.group-edit.form.group-name-label', 'Evaluation group name')}
required
invalid={!!errors.name}
error={errors.name?.message}
className={styles.input}
>
<Input
id="group-name"
{...register('name', {
required: t('alerting.group-edit.form.group-name-required', 'Group name is required'),
})}
/>
</Field>
<Field
label={t('alerting.group-edit.form.interval-label', 'Evaluation interval')}
description={t('alerting.group-edit.form.interval-description', 'How often is the group evaluated')}
invalid={!!errors.interval}
error={errors.interval?.message}
className={styles.input}
htmlFor="interval"
>
<>
<Input
id="interval"
{...register('interval', evaluateEveryValidationOptions(rulerGroup.rules))}
className={styles.intervalInput}
/>
<EvaluationGroupQuickPick
currentInterval={getValues('interval')}
onSelect={(value) => setValue('interval', value, { shouldValidate: true, shouldDirty: true })}
/>
</>
</Field>
<Field
label={t('alerting.group-edit.form.rules-label', 'Alerting and recording rules')}
description={t('alerting.group-edit.form.rules-description', 'Drag rules to reorder')}
>
<DraggableRulesTable rules={rulerGroup.rules} groupInterval={groupIntervalOrDefault} onSwap={onSwap} />
</Field>
<Stack>
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? 'spinner' : undefined}>
<Trans i18nKey="alerting.group-edit.form.save">Save</Trans>
</Button>
<LinkButton variant="secondary" disabled={isSubmitting} href={returnTo}>
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</LinkButton>
</Stack>
</form>
{groupIdentifier.groupOrigin === 'datasource' && (
<Stack direction="row" justifyContent="flex-end">
<Button
type="button"
variant="destructive"
onClick={() => setConfirmDeleteOpened(true)}
disabled={isSubmitting}
>
<Trans i18nKey="alerting.group-edit.form.delete">Delete</Trans>
</Button>
<ConfirmModal
isOpen={confirmDeleteOpened}
title={t('alerting.group-edit.form.delete-title', 'Delete rule group')}
body={t('alerting.group-edit.form.delete-body', 'Are you sure you want to delete this rule group?')}
confirmText={t('alerting.group-edit.form.delete-confirm', 'Delete')}
onConfirm={onDelete}
onDismiss={() => setConfirmDeleteOpened(false)}
/>
</Stack>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
intervalInput: css({
marginBottom: theme.spacing(0.5),
}),
input: css({
maxWidth: '600px',
}),
});
function setMatchingGroupPageUrl(groupIdentifier: RuleGroupIdentifierV2) {
if (groupIdentifier.groupOrigin === 'datasource') {
const { rulesSource, namespace, groupName } = groupIdentifier;
locationService.replace(groups.editPageLink(rulesSource.uid, namespace.name, groupName, { skipSubPath: true }));
} else {
const { namespace, groupName } = groupIdentifier;
locationService.replace(groups.editPageLink('grafana', namespace.uid, groupName, { skipSubPath: true }));
}
}
function redirectToListPage() {
locationService.replace(alertListPageLink(undefined, { skipSubPath: true }));
}

@ -0,0 +1,16 @@
import { LinkButton, Stack, Text } from '@grafana/ui';
import { useReturnTo } from '../hooks/useReturnTo';
export const Title = ({ name }: { name: string }) => {
const { returnTo } = useReturnTo('/alerting/list');
return (
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
<LinkButton variant="secondary" icon="angle-left" href={returnTo} />
<Text element="h1" truncate>
{name}
</Text>
</Stack>
);
};

@ -0,0 +1,175 @@
import { css, cx } from '@emotion/css';
import {
DragDropContext,
Draggable,
DraggableProvided,
DropResult,
Droppable,
DroppableProvided,
} from '@hello-pangea/dnd';
import { produce } from 'immer';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Icon, Stack, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { SwapOperation, swapItems } from '../../reducers/ruler/ruleGroups';
import { hashRulerRule } from '../../utils/rule-id';
import { getNumberEvaluationsToStartAlerting, getRuleName, rulerRuleType } from '../../utils/rules';
interface DraggableRulesTableProps {
rules: RulerRuleDTO[];
groupInterval: string;
onSwap: (swapOperation: SwapOperation) => void;
}
export function DraggableRulesTable({ rules, groupInterval, onSwap }: DraggableRulesTableProps) {
const styles = useStyles2(getStyles);
const [rulesList, setRulesList] = useState<RulerRuleDTO[]>(rules);
const onDragEnd = useCallback(
(result: DropResult) => {
// check for no-ops so we don't update the group unless we have changes
if (!result.destination) {
return;
}
const swapOperation: SwapOperation = [result.source.index, result.destination.index];
onSwap(swapOperation);
// re-order the rules list for the UI rendering
const newOrderedRules = produce(rulesList, (draft) => {
swapItems(draft, swapOperation);
});
setRulesList(newOrderedRules);
},
[rulesList, onSwap]
);
const rulesWithUID = useMemo(() => {
return rulesList.map((rulerRule) => ({ ...rulerRule, uid: hashRulerRule(rulerRule) }));
}, [rulesList]);
return (
<div>
<ListItem
ruleName={t('alerting.draggable-rules-table.rule-name', 'Rule name')}
pendingPeriod={t('alerting.draggable-rules-table.pending-period', 'Pending period')}
evalsToStartAlerting={t(
'alerting.draggable-rules-table.evals-to-start-alerting',
'Evaluations to start alerting'
)}
className={styles.listHeader}
/>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable
droppableId="alert-list"
mode="standard"
renderClone={(provided, _snapshot, rubric) => (
<DraggableListItem
provided={provided}
rule={rulesWithUID[rubric.source.index]}
isClone
groupInterval={groupInterval}
/>
)}
>
{(droppableProvided: DroppableProvided) => (
<Stack direction="column" gap={0} ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
{rulesWithUID.map((rule, index) => (
<Draggable key={rule.uid} draggableId={rule.uid} index={index} isDragDisabled={false}>
{(provided: DraggableProvided) => (
<DraggableListItem key={rule.uid} provided={provided} rule={rule} groupInterval={groupInterval} />
)}
</Draggable>
))}
{droppableProvided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
</div>
);
}
interface DraggableListItemProps extends React.HTMLAttributes<HTMLDivElement> {
provided: DraggableProvided;
rule: RulerRuleDTO;
groupInterval: string;
isClone?: boolean;
}
const DraggableListItem = ({ provided, rule, groupInterval, isClone = false }: DraggableListItemProps) => {
const styles = useStyles2(getStyles);
const ruleName = getRuleName(rule);
const pendingPeriod = rulerRuleType.any.alertingRule(rule) ? rule.for : null;
const numberEvaluationsToStartAlerting = getNumberEvaluationsToStartAlerting(pendingPeriod ?? '0s', groupInterval);
const isRecordingRule = rulerRuleType.any.recordingRule(rule);
return (
<ListItem
dragHandle={<Icon name="draggabledots" />}
ruleName={ruleName}
pendingPeriod={pendingPeriod}
evalsToStartAlerting={
isRecordingRule ? (
<Badge text={t('alerting.draggable-rules-table.recording', 'Recording')} color="purple" />
) : (
numberEvaluationsToStartAlerting
)
}
data-testid="reorder-alert-rule"
className={cx(styles.listItem, { [styles.listItemClone]: isClone })}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
/>
);
};
interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> {
dragHandle?: React.ReactNode;
ruleName: React.ReactNode;
pendingPeriod: React.ReactNode;
evalsToStartAlerting: React.ReactNode;
}
const ListItem = forwardRef<HTMLDivElement, ListItemProps>(
({ dragHandle, ruleName, pendingPeriod, evalsToStartAlerting, className, ...props }, ref) => {
const styles = useStyles2(getStyles);
return (
<div className={cx(styles.listItem, className)} ref={ref} {...props}>
<Stack flex="0 0 24px">{dragHandle}</Stack>
<Stack flex={1}>{ruleName}</Stack>
<Stack basis="30%">{pendingPeriod}</Stack>
<Stack basis="30%">{evalsToStartAlerting}</Stack>
</div>
);
}
);
const getStyles = (theme: GrafanaTheme2) => ({
listItem: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing(1),
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
'&:nth-child(even)': {
background: theme.colors.background.secondary,
},
}),
listItemClone: css({
border: `solid 1px ${theme.colors.primary.shade}`,
}),
listHeader: css({
fontWeight: theme.typography.fontWeightBold,
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
});

@ -22,7 +22,7 @@ export function useDeleteRuleFromGroup() {
const { groupName, namespaceName } = ruleGroup;
const action = deleteRuleAction({ identifier: ruleIdentifier });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
const successMessage = t('alerting.rules.delete-rule.success', 'Rule successfully deleted');

@ -23,7 +23,7 @@ export function usePauseRuleInGroup() {
const groupIdentifierV1 = ruleGroupIdentifierV2toV1(ruleGroup);
const action = pauseRuleAction({ uid, pause });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(groupIdentifierV1, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(groupIdentifierV1, [action]);
return upsertRuleGroup({
rulerConfig,

@ -1,6 +1,6 @@
import { Action } from '@reduxjs/toolkit';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { GrafanaRulesSourceSymbol, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { PostableRulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../api/alertRuleApi';
@ -8,6 +8,9 @@ import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { notFoundToNullOrThrow } from '../../api/util';
import { ruleGroupReducer } from '../../reducers/ruler/ruleGroups';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
import { getDatasourceAPIUid } from '../../utils/datasource';
const PREFER_CACHE_VALUE = true;
const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi;
const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi;
@ -39,10 +42,11 @@ export function useProduceNewRuleGroup() {
* fetch latest rule group apply reducer new rule group
*
*/
const produceNewRuleGroup = async (ruleGroup: RuleGroupIdentifier, action: Action) => {
const produceNewRuleGroup = async (ruleGroup: RuleGroupIdentifier, actions: Action[]) => {
const { dataSourceName, groupName, namespaceName } = ruleGroup;
const { rulerConfig } = await discoverDataSourceFeatures({ rulesSourceName: dataSourceName }).unwrap();
const ruleSourceUid = dataSourceName === 'grafana' ? GrafanaRulesSourceSymbol : getDatasourceAPIUid(dataSourceName);
const { rulerConfig } = await discoverDataSourceFeatures({ uid: ruleSourceUid }, PREFER_CACHE_VALUE).unwrap();
if (!rulerConfig) {
throw RulerNotSupportedError(dataSourceName);
}
@ -57,9 +61,10 @@ export function useProduceNewRuleGroup() {
.unwrap()
.catch(notFoundToNullOrThrow);
const newRuleGroupDefinition = ruleGroupReducer(
latestRuleGroupDefinition ?? createBlankRuleGroup(ruleGroup.groupName),
action
const initialRuleGroupDefinition = latestRuleGroupDefinition ?? createBlankRuleGroup(groupName);
const newRuleGroupDefinition = actions.reduce(
(ruleGroup, action) => ruleGroupReducer(ruleGroup, action),
initialRuleGroupDefinition
);
return { newRuleGroupDefinition, rulerConfig };

@ -1,9 +1,18 @@
import { Action } from '@reduxjs/toolkit';
import { t } from 'app/core/internationalization';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import {
DataSourceRuleGroupIdentifier,
GrafanaRuleGroupIdentifier,
RuleGroupIdentifier,
RuleGroupIdentifierV2,
} from 'app/types/unified-alerting';
import { logError } from '../../Analytics';
import { alertRuleApi } from '../../api/alertRuleApi';
import { notFoundToNullOrThrow } from '../../api/util';
import {
SwapOperation,
moveRuleGroupAction,
renameRuleGroupAction,
reorderRulesInRuleGroupAction,
@ -16,6 +25,123 @@ import { useProduceNewRuleGroup } from './useProduceNewRuleGroup';
const ruleUpdateSuccessMessage = () => t('alerting.rule-groups.update.success', 'Successfully updated rule group');
export interface UpdateGroupDelta {
namespaceName?: string;
groupName?: string;
interval?: string;
ruleSwaps?: SwapOperation[];
}
/**
* Update or move an existing rule group. Supports renaming a group and moving to a different namespace
*/
export function useUpdateRuleGroup() {
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [fetchRuleGroup] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.useMutation();
return useAsync(async (ruleGroup: RuleGroupIdentifier, delta: UpdateGroupDelta) => {
const updateActions: Action[] = [];
const isGrafanaSource = isGrafanaRulesSource(ruleGroup.dataSourceName);
if (delta.namespaceName) {
// we could technically support moving rule groups to another folder, though we don't have a "move" wizard yet.
if (isGrafanaSource) {
throw new Error('Moving a Grafana-managed rule group to another folder is currently not supported.');
}
updateActions.push(moveRuleGroupAction({ newNamespaceName: delta.namespaceName }));
}
if (delta.groupName) {
updateActions.push(renameRuleGroupAction({ groupName: delta.groupName }));
}
if (delta.interval) {
updateActions.push(updateRuleGroupAction({ interval: delta.interval }));
}
if (delta.ruleSwaps) {
updateActions.push(reorderRulesInRuleGroupAction({ swaps: delta.ruleSwaps }));
}
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, updateActions);
const oldNamespace = ruleGroup.namespaceName;
const targetNamespace = delta.namespaceName ?? oldNamespace;
const oldGroupName = ruleGroup.groupName;
const targetGroupName = newRuleGroupDefinition.name;
const isNamespaceChanged = oldNamespace !== targetNamespace;
const isGroupRenamed = oldGroupName !== targetGroupName;
// if we're also renaming the group, check if the target does not already exist
if (targetGroupName && isGroupRenamed) {
const targetGroup = await fetchRuleGroup({
rulerConfig,
namespace: targetNamespace,
group: targetGroupName,
// since this could throw 404
notificationOptions: { showErrorAlert: false },
})
.unwrap()
.catch(notFoundToNullOrThrow);
if (targetGroup?.rules?.length) {
throw new Error('Target group already has rules, merging rule groups is currently not supported.');
}
}
// create the new group in the target namespace or update the existing one
// ⚠ it's important to do this before we remove the old group – better to have two groups than none if one of these requests fails
await upsertRuleGroup({
rulerConfig,
namespace: targetNamespace,
payload: newRuleGroupDefinition,
notificationOptions: { showSuccessAlert: false },
}).unwrap();
const newGroupIdentifier: RuleGroupIdentifierV2 =
rulerConfig.dataSourceName === 'grafana'
? ({
groupName: targetGroupName,
namespace: { uid: targetNamespace },
groupOrigin: 'grafana',
} satisfies GrafanaRuleGroupIdentifier)
: ({
groupName: targetGroupName,
namespace: { name: targetNamespace },
groupOrigin: 'datasource',
rulesSource: {
uid: rulerConfig.dataSourceUid,
name: rulerConfig.dataSourceName,
ruleSourceType: 'datasource',
},
} satisfies DataSourceRuleGroupIdentifier);
// Removing groups is only necessary for Datasource-managed groups
const shouldRemoveOldGroup = (isNamespaceChanged || isGroupRenamed) && !isGrafanaSource;
// TODO How to make this safer?
if (shouldRemoveOldGroup) {
// now remove the old one
await deleteRuleGroup({
rulerConfig,
namespace: oldNamespace,
group: oldGroupName,
notificationOptions: { showSuccessAlert: false },
})
.unwrap()
.catch((e) => {
logError(e);
});
}
return newGroupIdentifier;
});
}
/**
* Update an existing rule group, currently only supports updating the interval.
* Use "useRenameRuleGroup" or "useMoveRuleGroup" for updating the namespace or group name.
@ -28,7 +154,7 @@ export function useUpdateRuleGroupConfiguration() {
const { namespaceName } = ruleGroup;
const action = updateRuleGroupAction({ interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
return upsertRuleGroup({
rulerConfig,
@ -61,7 +187,7 @@ export function useMoveRuleGroup() {
}
const action = moveRuleGroupAction({ newNamespaceName: namespaceName, groupName, interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
const oldNamespace = ruleGroup.namespaceName;
const targetNamespace = action.payload.newNamespaceName;
@ -122,7 +248,7 @@ export function useRenameRuleGroup() {
return useAsync(async (ruleGroup: RuleGroupIdentifier, groupName: string, interval?: string) => {
const action = renameRuleGroupAction({ groupName, interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
const oldGroupName = ruleGroup.groupName;
const newGroupName = action.payload.groupName;
@ -178,7 +304,7 @@ export function useReorderRuleForRuleGroup() {
const { namespaceName } = ruleGroup;
const action = reorderRulesInRuleGroupAction({ swaps });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
return upsertRuleGroup({
rulerConfig,

@ -27,7 +27,7 @@ export function useAddRuleToRuleGroup() {
// the new rule might have to be created in a new group, pass name and interval (optional) to the action
const action = addRuleAction({ rule, interval, groupName: ruleGroup.groupName });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
const result = upsertRuleGroup({
rulerConfig,
@ -69,7 +69,7 @@ export function useUpdateRuleInRuleGroup() {
}
const action = updateRuleAction({ identifier: ruleIdentifier, rule: finalRuleDefinition });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, [action]);
return upsertRuleGroup({
rulerConfig,
@ -106,7 +106,7 @@ export function useMoveRuleToRuleGroup() {
const addRuleToGroup = addRuleAction({ rule: finalRuleDefinition, interval });
const { newRuleGroupDefinition: newTargetGroup, rulerConfig: targetGroupRulerConfig } = await produceNewRuleGroup(
targetRuleGroup,
addRuleToGroup
[addRuleToGroup]
);
const result = await upsertRuleGroup({

@ -1,14 +1,23 @@
import { zip } from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { CloudRuleIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
import {
CloudRuleIdentifier,
GrafanaRulesSourceSymbol,
RuleGroupIdentifierV2,
RuleIdentifier,
} from 'app/types/unified-alerting';
import { logError, logMeasurement } from '../Analytics';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import * as ruleId from '../utils/rule-id';
import { isCloudRuleIdentifier } from '../utils/rules';
import { getRuleName, isCloudRuleIdentifier } from '../utils/rules';
import { useAsync } from './useAsync';
const { useLazyPrometheusRuleNamespacesQuery } = alertRuleApi;
const { useLazyPrometheusRuleNamespacesQuery, useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi;
const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const CONSISTENCY_CHECK_POOL_INTERVAL = 3 * 1000; // 3 seconds;
const CONSISTENCY_CHECK_TIMEOUT = 90 * 1000; // 90 seconds
@ -43,6 +52,173 @@ function useMatchingPromRuleExists() {
return { matchingPromRuleExists };
}
const PREFER_CACHE_VALUE = true;
export function useRuleGroupIsInSync() {
const [discoverDsFeatures] = useLazyDiscoverDsFeaturesQuery();
const [fetchPrometheusRuleGroups] = useLazyPrometheusRuleNamespacesQuery();
const [fetchRuleGroup] = useLazyGetRuleGroupForNamespaceQuery();
const isGroupInSync = useCallback(
async (ruleIdentifier: RuleGroupIdentifierV2) => {
const dsUid =
ruleIdentifier.groupOrigin === 'datasource' ? ruleIdentifier.rulesSource.uid : GrafanaRulesSourceSymbol;
const dsFeatures = await discoverDsFeatures({ uid: dsUid }, PREFER_CACHE_VALUE).unwrap();
if (!dsFeatures.rulerConfig) {
throw new Error('Datasource does not support ruler. Unable to determine group consistency');
}
const namespace =
ruleIdentifier.groupOrigin === 'datasource' ? ruleIdentifier.namespace.name : ruleIdentifier.namespace.uid;
const promQueryParams: Parameters<typeof fetchPrometheusRuleGroups>[0] = {
ruleSourceName: dsFeatures.name,
namespace: namespace,
groupName: ruleIdentifier.groupName,
};
const rulerParams: Parameters<typeof fetchRuleGroup>[0] = {
namespace,
group: ruleIdentifier.groupName,
rulerConfig: dsFeatures.rulerConfig,
notificationOptions: { showSuccessAlert: false, showErrorAlert: false },
};
const [promResponse, rulerResponse] = await Promise.allSettled([
fetchPrometheusRuleGroups(promQueryParams).unwrap(),
fetchRuleGroup(rulerParams).unwrap(),
]);
if (promResponse.status === 'rejected' && rulerResponse.status === 'rejected') {
// This means both requests failed. We can't determine if the state is consistent or not
// and most probably mean there is a connectivity issue with the datasource
// Let's return true so the UI is not disruptive for the user, but log an error to investigate how often this happens
logError(
new Error('Error fetching Prometheus and Ruler rule groups', {
cause: [promResponse.reason, rulerResponse.reason],
})
);
return true;
}
if (promResponse.status === 'rejected' && rulerResponse.status === 'fulfilled') {
// This means Prometheus request error. It shouldn't reject even if there are no groups
// matching the query params
// Let's return true so the UI is not disruptive for the user, but log an error to investigate how often this happens
logError(new Error('Error fetching Prometheus rule groups', { cause: promResponse.reason }));
return true;
}
if (rulerResponse.status === 'rejected' && promResponse.status === 'fulfilled') {
// We assume the group no longer exists in the ruler
// The state is consistent if the group is not present in the Prometheus response
const promGroups = promResponse.value.flatMap((ns) => ns.groups);
return promGroups.every((g) => g.name !== ruleIdentifier.groupName);
}
if (promResponse.status === 'fulfilled' && rulerResponse.status === 'fulfilled') {
const promGroup = promResponse.value
.flatMap((ns) => ns.groups)
.find((g) => g.name === ruleIdentifier.groupName);
const rulerGroup = rulerResponse.value;
if (promGroup && rulerGroup) {
const rulesCountMatches = promGroup.rules.length === rulerGroup.rules.length;
if (!rulesCountMatches) {
return false;
}
const promRuleNames = promGroup.rules.map((r) => r.name);
const rulerRuleNames = rulerGroup.rules.map(getRuleName);
for (const [promName, rulerName] of zip(promRuleNames, rulerRuleNames)) {
if (promName !== rulerName) {
return false;
}
}
return true;
}
}
return false;
},
[discoverDsFeatures, fetchPrometheusRuleGroups, fetchRuleGroup]
);
return { isGroupInSync };
}
export function useRuleGroupConsistencyCheck() {
const { isGroupInSync } = useRuleGroupIsInSync();
const consistencyInterval = useRef<number | undefined>();
useEffect(() => {
return () => {
clearConsistencyInterval();
};
}, []);
const clearConsistencyInterval = () => {
if (consistencyInterval.current) {
clearInterval(consistencyInterval.current);
consistencyInterval.current = undefined;
}
};
/**
* Waits for the rule group to be consistent between Prometheus and the Ruler.
* It periodically fetches the group from the Prometheus and the Ruler and compares them.
* Times out after 90 seconds of waiting.
*/
async function waitForGroupConsistency(groupIdentifier: RuleGroupIdentifierV2) {
// We can wait only for one rule group at a time
clearConsistencyInterval();
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => {
clearConsistencyInterval();
const error = new Error('Timeout while waiting for rule group consistency');
logError(error, { groupOrigin: groupIdentifier.groupOrigin });
reject(error);
}, CONSISTENCY_CHECK_TIMEOUT);
});
const waitPromise = new Promise<void>((resolve, reject) => {
performance.mark('waitForGroupConsistency:started');
consistencyInterval.current = setInterval(() => {
isGroupInSync(groupIdentifier)
.then((inSync) => {
if (inSync) {
performance.mark('waitForGroupConsistency:finished');
const duration = performance.measure(
'waitForGroupConsistency',
'waitForGroupConsistency:started',
'waitForGroupConsistency:finished'
);
logMeasurement(
'alerting:wait-for-group-consistency',
{ duration: duration.duration },
{ groupOrigin: groupIdentifier.groupOrigin }
);
clearConsistencyInterval();
resolve();
}
})
.catch((error) => {
clearConsistencyInterval();
reject(error);
});
}, CONSISTENCY_CHECK_POOL_INTERVAL);
});
return Promise.race([timeoutPromise, waitPromise]);
}
return { waitForGroupConsistency };
}
export function usePrometheusConsistencyCheck() {
const { matchingPromRuleExists } = useMatchingPromRuleExists();

@ -1,5 +1,5 @@
import { textUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { logWarning } from '../Analytics';
@ -9,6 +9,8 @@ import { useURLSearchParams } from './useURLSearchParams';
* This hook provides a safe way to obtain the `returnTo` URL from the query string parameter
* It validates the origin and protocol to ensure the URL is withing the Grafana app
*/
export function useReturnTo(): { returnTo: string | undefined };
export function useReturnTo(fallback: string): { returnTo: string };
export function useReturnTo(fallback?: string): { returnTo: string | undefined } {
const emptyResult = { returnTo: fallback };
@ -38,6 +40,12 @@ export function useReturnTo(fallback?: string): { returnTo: string | undefined }
return { returnTo: `${pathname}${search}` };
}
/* Create a "returnTo" URL */
export function createReturnTo(includeSearch = true): string {
const { pathname, search } = locationService.getLocation();
return pathname + (includeSearch ? search : '');
}
// Tries to mimic URL.parse method https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static
function tryParseURL(sanitizedReturnTo: string, baseUrl: string) {
try {

@ -1,6 +1,5 @@
import { HttpResponse, http } from 'msw';
import { HttpResponse, HttpResponseResolver, PathParams, http } from 'msw';
import { DataSourceInstanceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import server from 'app/features/alerting/unified/mockApi';
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
@ -21,7 +20,7 @@ import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types';
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { PromRuleGroupDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { setupDataSources } from '../../testSetup/datasources';
import { DataSourceType } from '../../utils/datasource';
@ -62,6 +61,39 @@ export const setFolderResponse = (response: Partial<FolderDTO>) => {
server.use(handler);
};
export const setUpdateGrafanaRulerRuleNamespaceResolver = (
resolver: HttpResponseResolver<{ folderUid: string }, RulerRuleGroupDTO, undefined>
) => {
server.use(
http.post<{ folderUid: string }, RulerRuleGroupDTO, undefined>(
`/api/ruler/grafana/api/v1/rules/:folderUid`,
resolver
)
);
};
export const setUpdateRulerRuleNamespaceResolver = (
resolver: HttpResponseResolver<{ dataSourceUid: string; namespace: string }, RulerRuleGroupDTO, undefined>
) => {
server.use(
http.post<{ dataSourceUid: string; namespace: string }, RulerRuleGroupDTO, undefined>(
`/api/ruler/:dataSourceUid/api/v1/rules/:namespace`,
resolver
)
);
};
export const setDeleteRulerRuleNamespaceResolver = (
resolver: HttpResponseResolver<{ dataSourceUid: string; namespace: string; groupName: string }, undefined, undefined>
) => {
server.use(
http.delete<{ dataSourceUid: string; namespace: string; groupName: string }, undefined, undefined>(
`/api/ruler/:dataSourceUid/api/v1/rules/:namespace/:groupName`,
resolver
)
);
};
/**
* Makes the mock server respond with different responses for updating a ruler namespace
*/
@ -72,6 +104,32 @@ export const setUpdateRulerRuleNamespaceHandler = (options?: HandlerOptions) =>
return handler;
};
export const setGrafanaRulerRuleGroupResolver = (
resolver: HttpResponseResolver<{ folderUid: string; groupName: string }, RulerRuleGroupDTO, undefined>
) => {
server.use(
http.get<{ folderUid: string; groupName: string }, RulerRuleGroupDTO, undefined>(
`/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`,
resolver
)
);
};
export const setRulerRuleGroupResolver = (
resolver: HttpResponseResolver<
{ dataSourceUid: string; namespace: string; groupName: string },
RulerRuleGroupDTO,
undefined
>
) => {
server.use(
http.get<{ dataSourceUid: string; namespace: string; groupName: string }, RulerRuleGroupDTO, undefined>(
`/api/ruler/:dataSourceUid/api/v1/rules/:namespace/:groupName`,
resolver
)
);
};
/**
* Makes the mock server respond with different responses for a ruler rule group
*/
@ -82,6 +140,11 @@ export const setRulerRuleGroupHandler = (options?: HandlerOptions) => {
return handler;
};
export const setGrafanaRuleGroupExportResolver = (
resolver: HttpResponseResolver<PathParams<never>, string, undefined>
) => {
server.use(http.get('/api/ruler/grafana/api/v1/export/rules', resolver));
};
/**
* Makes the mock server respond with an error when fetching list of mute timings
*/
@ -120,7 +183,11 @@ export function mimirDataSource() {
return { dataSource, rulerConfig };
}
export function setPrometheusRules(ds: DataSourceInstanceSettings, groups: PromRuleGroupDTO[]) {
interface DataSourceLike {
uid: string;
}
export function setPrometheusRules(ds: DataSourceLike, groups: PromRuleGroupDTO[]) {
server.use(http.get(`/api/prometheus/${ds.uid}/api/v1/rules`, paginatedHandlerFor(groups)));
}

@ -2,17 +2,6 @@
exports[`RuleEditor cloud can create a new cloud alert 1`] = `
[
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/datasources/proxy/uid/mimir/api/v1/status/buildinfo",
},
{
"body": "",
"headers": [

@ -2,17 +2,6 @@
exports[`RuleEditor recording rules can create a new cloud recording rule 1`] = `
[
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/datasources/proxy/uid/mimir/api/v1/status/buildinfo",
},
{
"body": "",
"headers": [

@ -0,0 +1,45 @@
import { RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { createReturnTo } from '../hooks/useReturnTo';
import { createRelativeUrl } from './url';
export const createListFilterLink = (values: Array<[string, string]>) => {
const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]);
return createRelativeUrl(`/alerting/list`, params);
};
export const alertListPageLink = (queryParams: Record<string, string> = {}, options?: { skipSubPath?: boolean }) =>
createRelativeUrl(`/alerting/list`, queryParams, { skipSubPath: options?.skipSubPath });
export const groups = {
detailsPageLink: (dsUid: string, namespaceId: string, groupName: string, options?: { includeReturnTo: boolean }) => {
const params: Record<string, string> = options?.includeReturnTo ? { returnTo: createReturnTo() } : {};
return createRelativeUrl(
`/alerting/${dsUid}/namespaces/${encodeURIComponent(namespaceId)}/groups/${encodeURIComponent(groupName)}/view`,
params
);
},
detailsPageLinkFromGroupIdentifier: (groupIdentifier: RuleGroupIdentifierV2) => {
const { groupOrigin, namespace, groupName } = groupIdentifier;
const isGrafanaOrigin = groupOrigin === 'grafana';
return isGrafanaOrigin
? groups.detailsPageLink('grafana', namespace.uid, groupName)
: groups.detailsPageLink(groupIdentifier.rulesSource.uid, namespace.name, groupName);
},
editPageLink: (
dsUid: string,
namespaceId: string,
groupName: string,
options?: { includeReturnTo?: boolean; skipSubPath?: boolean }
) => {
const params: Record<string, string> = options?.includeReturnTo ? { returnTo: createReturnTo() } : {};
return createRelativeUrl(
`/alerting/${dsUid}/namespaces/${encodeURIComponent(namespaceId)}/groups/${encodeURIComponent(groupName)}/edit`,
params,
{ skipSubPath: options?.skipSubPath }
);
},
};

@ -35,6 +35,7 @@ import {
RulerGrafanaRuleDTO,
RulerRecordingRuleDTO,
RulerRuleDTO,
RulerRuleGroupDTO,
mapStateWithReasonToBaseState,
} from 'app/types/unified-alerting-dto';
@ -146,6 +147,10 @@ export function isProvisionedRule(rulerRule: RulerRuleDTO): boolean {
return isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance);
}
export function isProvisionedRuleGroup(group: RulerRuleGroupDTO): boolean {
return group.rules.some((rule) => isProvisionedRule(rule));
}
export function getRuleHealth(health: string): RuleHealth | undefined {
switch (health) {
case 'ok':
@ -310,7 +315,7 @@ export function getFirstActiveAt(promRule?: AlertingRule) {
*
* see https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation
*/
export function isFederatedRuleGroup(group: CombinedRuleGroup) {
export function isFederatedRuleGroup(group: CombinedRuleGroup | RulerRuleGroupDTO): boolean {
return Array.isArray(group.source_tenants);
}
@ -373,6 +378,24 @@ export const getNumberEvaluationsToStartAlerting = (forDuration: string, current
}
};
/**
* Calculates the number of rule evaluations before the alerting rule will fire
* @param pendingPeriodMs - The pending period of the alerting rule in milliseconds
* @param groupIntervalMs - The group's evaluation interval in milliseconds
* @returns The number of rule evaluations before the rule will fire
*/
export function calcRuleEvalsToStartAlerting(pendingPeriodMs: number, groupIntervalMs: number) {
if (pendingPeriodMs === 0) {
return 1; // No pending period, the rule will fire immediately
}
if (groupIntervalMs === 0) {
return 0; // Invalid case. Group interval is never 0. The default interval will be used.
}
const evaluationsBeforeCeil = pendingPeriodMs / groupIntervalMs;
return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(pendingPeriodMs / groupIntervalMs) + 1;
}
/*
* Extracts a rule group identifier from a CombinedRule
*/

@ -134,6 +134,13 @@ export function formatPrometheusDuration(milliseconds: number): string {
);
}
/**
* Parses a Prometheus duration string and returns the duration in milliseconds.
* If the duration is invalid, it returns 0.
*
* @param duration - The Prometheus duration string to parse.
* @returns The duration in milliseconds.
*/
export const safeParsePrometheusDuration = (duration: string): number => {
try {
return parsePrometheusDuration(duration);

@ -1,14 +1,25 @@
import { config } from '@grafana/runtime';
export type RelativeUrl = `/${string}`;
interface CreateRelativeUrlOptions {
/**
* If true, the sub path will not be added to the URL
* If the URL will be used by react-router or history (e.g. locationService.push), you should set this to true because react-router adds the sub path by itself
*/
skipSubPath?: boolean;
}
export function createRelativeUrl(
path: RelativeUrl,
queryParams?: string[][] | Record<string, string> | string | URLSearchParams
queryParams?: string[][] | Record<string, string> | string | URLSearchParams,
options: CreateRelativeUrlOptions = { skipSubPath: false }
) {
const searchParams = new URLSearchParams(queryParams);
const searchParamsString = searchParams.toString();
return `${config.appSubUrl}${path}${searchParamsString ? `?${searchParamsString}` : ''}`;
const subPath = options.skipSubPath ? '' : config.appSubUrl;
return `${subPath}${path}${searchParamsString ? `?${searchParamsString}` : ''}`;
}
export function createAbsoluteUrl(

@ -353,6 +353,12 @@
"missing-reference": "Expression \"{{source}}\" failed to run because \"{{target}}\" is missing or also failed.",
"self-reference": "You can't link an expression to itself"
},
"draggable-rules-table": {
"evals-to-start-alerting": "Evaluations to start alerting",
"pending-period": "Pending period",
"recording": "Recording",
"rule-name": "Rule name"
},
"export": {
"subtitle": {
"formats": "Select the format and download the file or copy the contents to clipboard",
@ -376,6 +382,45 @@
"export": "Export",
"reorder": "Re-order rules"
},
"group-details": {
"ds-features-error": "Error loading data source details",
"edit": "Edit",
"evaluations-to-fire": "Evaluation cycles to fire",
"export": "Export",
"folder": "Folder",
"group-loading-error": "Error loading the group",
"interval": "Interval",
"namespace": "Namespace",
"pending-period": "Pending period",
"recording": "Recording",
"rule-name": "Rule name"
},
"group-edit": {
"ds-error": "Error loading data source details",
"form": {
"delete": "Delete",
"delete-body": "Are you sure you want to delete this rule group?",
"delete-confirm": "Delete",
"delete-title": "Delete rule group",
"folder-label": "Folder",
"group-name-label": "Evaluation group name",
"group-name-required": "Group name is required",
"interval-description": "How often is the group evaluated",
"interval-label": "Evaluation interval",
"namespace-label": "Namespace",
"namespace-required": "Namespace is required",
"rules-description": "Drag rules to reorder",
"rules-label": "Alerting and recording rules",
"save": "Save",
"update-error": "Failed to update rule group",
"update-success": "Successfully updated the rule group"
},
"group-not-editable": "Selected group cannot be edited",
"group-not-editable-description": "This group belongs to a data source that does not support editing.",
"page-title": "Edit rule group",
"rule-group-error": "Error loading rule group",
"title": "Edit evaluation group"
},
"irm-integration": {
"connection-method": "How to connect to IRM",
"disabled-description": "Enable Grafana IRM to use this integration",
@ -600,6 +645,13 @@
}
}
},
"rule-group-action": {
"details": "rule group details",
"edit": "edit rule group",
"export-rules-folder": "Export rules folder",
"go-to-folder": "go to folder",
"manage-permissions": "manage permissions"
},
"rule-groups": {
"delete": {
"success": "Successfully deleted rule group"

Loading…
Cancel
Save