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>
This commit is contained in:
co-authored by
Gilles De Mey
parent
fc9e5110d7
commit
321a886b8b
+3
-22
@@ -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;
|
||||
|
||||
+3
-9
@@ -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(ui.export.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
|
||||
await waitFor(() => {
|
||||
expect(ui.export.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
|
||||
});
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(ui.export.jsonTab.get(drawer));
|
||||
await waitFor(() => {
|
||||
expect(ui.export.editor.get(drawer)).toHaveTextContent('Json 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 }));
|
||||
|
||||
expect(ui.export.copyCodeButton.get(drawer)).toBeInTheDocument();
|
||||
expect(ui.export.downloadButton.get(drawer)).toBeInTheDocument();
|
||||
// Act
|
||||
renderRulesGroup(namespace, group);
|
||||
expect(await screen.findByTestId('rule-group')).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 "<strong>{group.name}</strong>" 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>
|
||||
);
|
||||
};
|
||||
+175
@@ -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 { t } from 'app/core/internationalization';
|
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { Action } from '@reduxjs/toolkit';
|
||||
|
||||
import { t } from 'app/core/internationalization';
|
||||
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)));
|
||||
}
|
||||
|
||||
|
||||
-11
@@ -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": [
|
||||
|
||||
-11
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user