diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
index 372f5ee8754..8e54ac4c557 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -141,7 +141,6 @@ Experimental features might be changed or removed without prior notice.
| `externalCorePlugins` | Allow core plugins to be loaded as external |
| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins |
| `httpSLOLevels` | Adds SLO level to http request metrics |
-| `alertingModifiedExport` | Enables using UI for provisioned rules modification and export |
| `panelMonitoring` | Enables panel monitoring through logs and measurements |
| `enableNativeHTTPHistogram` | Enables native HTTP Histograms |
| `transformationsVariableSupport` | Allows using variables in transformations |
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index b099c5c8d1f..912bae6fb32 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -134,7 +134,6 @@ export interface FeatureToggles {
idForwarding?: boolean;
cloudWatchWildCardDimensionValues?: boolean;
externalServiceAccounts?: boolean;
- alertingModifiedExport?: boolean;
panelMonitoring?: boolean;
enableNativeHTTPHistogram?: boolean;
transformationsVariableSupport?: boolean;
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 9f31c513c05..76203671901 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -810,13 +810,6 @@ var (
RequiresDevMode: true,
Owner: grafanaAuthnzSquad,
},
- {
- Name: "alertingModifiedExport",
- Description: "Enables using UI for provisioned rules modification and export",
- Stage: FeatureStageExperimental,
- FrontendOnly: false,
- Owner: grafanaAlertingSquad,
- },
{
Name: "panelMonitoring",
Description: "Enables panel monitoring through logs and measurements",
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index d0366d7b2fa..2a03ae63894 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -115,7 +115,6 @@ httpSLOLevels,experimental,@grafana/hosted-grafana-team,false,false,true,false
idForwarding,experimental,@grafana/grafana-authnz-team,true,false,false,false
cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,false,false,false,false
externalServiceAccounts,experimental,@grafana/grafana-authnz-team,true,false,false,false
-alertingModifiedExport,experimental,@grafana/alerting-squad,false,false,false,false
panelMonitoring,experimental,@grafana/dataviz-squad,false,false,false,true
enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,false,false
transformationsVariableSupport,experimental,@grafana/grafana-bi-squad,false,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 40e731ea05d..2b93a1f7310 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -471,10 +471,6 @@ const (
// Automatic service account and token setup for plugins
FlagExternalServiceAccounts = "externalServiceAccounts"
- // FlagAlertingModifiedExport
- // Enables using UI for provisioned rules modification and export
- FlagAlertingModifiedExport = "alertingModifiedExport"
-
// FlagPanelMonitoring
// Enables panel monitoring through logs and measurements
FlagPanelMonitoring = "panelMonitoring"
diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx
index c1d39295cd0..5e67be4822d 100644
--- a/public/app/features/alerting/routes.tsx
+++ b/public/app/features/alerting/routes.tsx
@@ -1,6 +1,5 @@
import { uniq } from 'lodash';
import React from 'react';
-import { Redirect } from 'react-router-dom';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPage';
@@ -247,15 +246,13 @@ const unifiedRoutes: RouteDescriptor[] = [
{
path: '/alerting/:id/modify-export',
pageClass: 'page-alerting',
- roles: evaluateAccess([AccessControlAction.AlertingRuleUpdate]),
- component: config.featureToggles.alertingModifiedExport
- ? SafeDynamicImport(
- () =>
- import(
- /* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/export/GrafanaModifyExport'
- )
+ roles: evaluateAccess([AccessControlAction.AlertingRuleRead]),
+ component: SafeDynamicImport(
+ () =>
+ import(
+ /* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/export/GrafanaModifyExport'
)
- : () => ,
+ ),
},
{
path: '/alerting/:sourceName/:id/view',
diff --git a/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx b/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx
index ba1d61121ec..fcf0f5e6b48 100644
--- a/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx
+++ b/public/app/features/alerting/unified/MoreActionsRuleButtons.tsx
@@ -7,12 +7,20 @@ import { Button, Dropdown, Icon, LinkButton, Menu, MenuItem } from '@grafana/ui'
import { logInfo, LogMessages } from './Analytics';
import { GrafanaRulesExporter } from './components/export/GrafanaRulesExporter';
-import { useRulesAccess } from './utils/accessControlHooks';
+import { AlertSourceAction, useAlertSourceAbility } from './hooks/useAbilities';
interface Props {}
export function MoreActionsRuleButtons({}: Props) {
- const { canCreateGrafanaRules, canCreateCloudRules, canReadProvisioning } = useRulesAccess();
+ const [_, viewRuleAllowed] = useAlertSourceAbility(AlertSourceAction.ViewAlertRule);
+ const [createRuleSupported, createRuleAllowed] = useAlertSourceAbility(AlertSourceAction.CreateAlertRule);
+ const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertSourceAbility(
+ AlertSourceAction.CreateExternalAlertRule
+ );
+
+ const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
+ const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
+
const location = useLocation();
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const newMenu = (
@@ -25,7 +33,7 @@ export function MoreActionsRuleButtons({}: Props) {
label="New recording rule"
/>
)}
- {canReadProvisioning && }
+ {viewRuleAllowed && }
);
diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx
index 23487beea4d..4928fc27cb5 100644
--- a/public/app/features/alerting/unified/RuleEditor.tsx
+++ b/public/app/features/alerting/unified/RuleEditor.tsx
@@ -57,6 +57,9 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
if (identifier) {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: identifier.ruleSourceName }));
}
+ if (copyFromIdentifier) {
+ await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: copyFromIdentifier.ruleSourceName }));
+ }
}, [dispatch]);
const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess();
diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx
index 07de53cb27a..ff1eddc5d38 100644
--- a/public/app/features/alerting/unified/RuleList.test.tsx
+++ b/public/app/features/alerting/unified/RuleList.test.tsx
@@ -684,12 +684,27 @@ describe('RuleList', () => {
describe('RBAC Enabled', () => {
describe('Export button', () => {
- it('Export button should be visible when the user has alert provisioning read permissions', async () => {
- grantUserPermissions([AccessControlAction.AlertingProvisioningRead]);
+ it('Export button should be visible when the user has alert read permissions', async () => {
+ grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.FoldersRead]);
mocks.getAllDataSourcesMock.mockReturnValue([]);
setDataSourceSrv(new MockDataSourceSrv({}));
- mocks.api.fetchRules.mockResolvedValue([]);
+ mocks.api.fetchRules.mockResolvedValue([
+ mockPromRuleNamespace({
+ name: 'foofolder',
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ groups: [
+ mockPromRuleGroup({
+ name: 'grafana-group',
+ rules: [
+ mockPromAlertingRule({
+ query: '[]',
+ }),
+ ],
+ }),
+ ],
+ }),
+ ]);
mocks.api.fetchRulerRules.mockResolvedValue({});
renderRuleList();
@@ -697,32 +712,6 @@ describe('RuleList', () => {
await userEvent.click(ui.moreButton.get());
expect(ui.exportButton.get()).toBeInTheDocument();
});
- it('Export button should be visible when the user has alert provisioning read secrets permissions', async () => {
- grantUserPermissions([AccessControlAction.AlertingProvisioningReadSecrets]);
-
- mocks.getAllDataSourcesMock.mockReturnValue([]);
- setDataSourceSrv(new MockDataSourceSrv({}));
- mocks.api.fetchRules.mockResolvedValue([]);
- mocks.api.fetchRulerRules.mockResolvedValue({});
-
- renderRuleList();
-
- await userEvent.click(ui.moreButton.get());
- expect(ui.exportButton.get()).toBeInTheDocument();
- });
- it('Export button should not be visible when the user has no alert provisioning read permissions', async () => {
- grantUserPermissions([AccessControlAction.AlertingRuleCreate, AccessControlAction.FoldersRead]);
-
- mocks.getAllDataSourcesMock.mockReturnValue([]);
- setDataSourceSrv(new MockDataSourceSrv({}));
- mocks.api.fetchRules.mockResolvedValue([]);
- mocks.api.fetchRulerRules.mockResolvedValue({});
-
- renderRuleList();
-
- await userEvent.click(ui.moreButton.get());
- expect(ui.exportButton.query()).not.toBeInTheDocument();
- });
});
describe('Grafana Managed Alerts', () => {
it('New alert button should be visible when the user has alert rule create and folder read permissions and no rules exists', async () => {
diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx
index 6aaaec90e1d..655b52a9f3d 100644
--- a/public/app/features/alerting/unified/RuleList.tsx
+++ b/public/app/features/alerting/unified/RuleList.tsx
@@ -24,7 +24,6 @@ import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRulesAction } from './state/actions';
-import { useRulesAccess } from './utils/accessControlHooks';
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
import { getAllRulesSourceNames } from './utils/datasource';
@@ -91,8 +90,6 @@ const RuleList = withErrorBoundary(
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
- const { canCreateGrafanaRules, canCreateCloudRules, canReadProvisioning } = useRulesAccess();
-
return (
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
@@ -116,11 +113,9 @@ const RuleList = withErrorBoundary(
)}
- {(canCreateGrafanaRules || canCreateCloudRules || canReadProvisioning) && (
-
-
-
- )}
+
+
+
>
)}
diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx
index 05f84437288..864d68d104e 100644
--- a/public/app/features/alerting/unified/RuleViewer.tsx
+++ b/public/app/features/alerting/unified/RuleViewer.tsx
@@ -1,16 +1,12 @@
-import React, { useState } from 'react';
+import React from 'react';
import { Disable, Enable } from 'react-enable';
-import { useParams } from 'react-router-dom';
-import { Button, HorizontalGroup, withErrorBoundary } from '@grafana/ui';
-import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
+import { withErrorBoundary } from '@grafana/ui';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
-import { GrafanaRuleExporter } from './components/export/GrafanaRuleExporter';
import { AlertingFeature } from './features';
-import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
const DetailViewV1 = SafeDynamicImport(() => import('./components/rule-viewer/RuleViewer.v1'));
const DetailViewV2 = SafeDynamicImport(() => import('./components/rule-viewer/v2/RuleViewer.v2'));
@@ -21,25 +17,8 @@ type RuleViewerProps = GrafanaRouteComponentProps<{
}>;
const RuleViewer = (props: RuleViewerProps): JSX.Element => {
- const routeParams = useParams<{ type: string; id: string }>();
- const uidFromParams = routeParams.id;
-
- const sourceName = props.match.params.sourceName;
-
- const [showYaml, setShowYaml] = useState(false);
- const actionButtons =
- sourceName === GRAFANA_RULES_SOURCE_NAME ? (
-
-
-
- ) : null;
-
return (
-
- {showYaml && setShowYaml(false)} />}
diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts
index bee885c2e0d..85c07792765 100644
--- a/public/app/features/alerting/unified/api/alertRuleApi.ts
+++ b/public/app/features/alerting/unified/api/alertRuleApi.ts
@@ -43,10 +43,6 @@ export interface Datasource {
export const PREVIEW_URL = '/api/v1/rule/test/grafana';
export const PROM_RULES_URL = 'api/prometheus/grafana/api/v1/rules';
-function getProvisioningExportUrl(ruleUid: string, format: 'yaml' | 'json' | 'hcl' = 'yaml') {
- return `/api/v1/provisioning/alert-rules/${ruleUid}/export?format=${format}`;
-}
-
export interface Data {
refId: string;
relativeTimeRange: RelativeTimeRange;
@@ -71,6 +67,13 @@ export interface Rule {
export type AlertInstances = Record;
+interface ExportRulesParams {
+ format: ExportFormats;
+ folderUid?: string;
+ group?: string;
+ ruleUid?: string;
+}
+
export interface ModifyExportPayload {
rules: Array;
name: string;
@@ -192,20 +195,10 @@ export const alertRuleApi = alertingApi.injectEndpoints({
},
}),
- exportRule: build.query({
- query: ({ uid, format }) => ({ url: getProvisioningExportUrl(uid, format), responseType: 'text' }),
- }),
- exportRuleGroup: build.query({
- query: ({ folderUid, groupName, format }) => ({
- url: `/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`,
- params: { format: format },
- responseType: 'text',
- }),
- }),
- exportRules: build.query({
- query: ({ format }) => ({
- url: `/api/v1/provisioning/alert-rules/export`,
- params: { format: format },
+ exportRules: build.query({
+ query: ({ format, folderUid, group, ruleUid }) => ({
+ url: `/api/ruler/grafana/api/v1/export/rules`,
+ params: { format: format, folderUid: folderUid, group: group, ruleUid: ruleUid },
responseType: 'text',
}),
}),
diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx
new file mode 100644
index 00000000000..0f44e23c14f
--- /dev/null
+++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx
@@ -0,0 +1,138 @@
+import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { AutoSizerProps } from 'react-virtualized-auto-sizer';
+import { byRole, byTestId, byText } from 'testing-library-selector';
+
+import { selectors } from '@grafana/e2e-selectors';
+import { locationService } from '@grafana/runtime';
+
+import { TestProvider } from '../../../../../../test/helpers/TestProvider';
+import { AlertmanagerChoice } from '../../../../../plugins/datasource/alertmanager/types';
+import { DashboardSearchItemType } from '../../../../search/types';
+import { mockAlertRuleApi, mockApi, mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi';
+import { getGrafanaRule, mockDataSource } from '../../mocks';
+import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
+import { setupDataSources } from '../../testSetup/datasources';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+
+import GrafanaModifyExport from './GrafanaModifyExport';
+
+jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
+ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => {actions}
,
+}));
+
+jest.mock('react-virtualized-auto-sizer', () => {
+ return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 });
+});
+jest.mock('@grafana/ui', () => ({
+ ...jest.requireActual('@grafana/ui'),
+ CodeEditor: ({ value }: { value: string }) => ,
+}));
+
+const ui = {
+ loading: byText('Loading the rule'),
+ form: {
+ nameInput: byRole('textbox', { name: 'name' }),
+ folder: byTestId('folder-picker'),
+ folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
+ group: byTestId('group-picker'),
+ annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`),
+ annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
+ labelKey: (idx: number) => byTestId(`label-key-${idx}`),
+ labelValue: (idx: number) => byTestId(`label-value-${idx}`),
+ },
+ exportButton: byRole('button', { name: 'Export' }),
+ exportDrawer: {
+ dialog: byRole('dialog', { name: /Export Group/ }),
+ jsonTab: byRole('tab', { name: /JSON/ }),
+ yamlTab: byRole('tab', { name: /YAML/ }),
+ editor: byTestId('code-editor'),
+ loadingSpinner: byTestId('Spinner'),
+ },
+};
+
+const dataSources = {
+ default: mockDataSource({ type: 'prometheus', name: 'Prom', isDefault: true }, { alerting: true }),
+};
+
+function renderModifyExport(ruleId: string) {
+ locationService.push(`/alerting/${ruleId}/modify-export`);
+ render(, { wrapper: TestProvider });
+}
+
+const server = setupMswServer();
+
+mockAlertmanagerChoiceResponse(server, {
+ alertmanagersChoice: AlertmanagerChoice.Internal,
+ numExternalAlertmanagers: 0,
+});
+
+describe('GrafanaModifyExport', () => {
+ setupDataSources(dataSources.default);
+
+ const grafanaRule = getGrafanaRule(undefined, {
+ uid: 'test-rule-uid',
+ title: 'cpu-usage',
+ namespace_uid: 'folder-test-uid',
+ namespace_id: 1,
+ data: [
+ {
+ refId: 'A',
+ datasourceUid: dataSources.default.uid,
+ queryType: 'alerting',
+ relativeTimeRange: { from: 1000, to: 2000 },
+ model: {
+ refId: 'A',
+ expression: 'vector(1)',
+ queryType: 'alerting',
+ datasource: { uid: dataSources.default.uid, type: 'prometheus' },
+ },
+ },
+ ],
+ });
+
+ it('Should render edit form for the specified rule', async () => {
+ mockApi(server).eval({ results: { A: { frames: [] } } });
+ mockSearchApi(server).search([
+ {
+ title: grafanaRule.namespace.name,
+ uid: 'folder-test-uid',
+ id: 1,
+ url: '',
+ tags: [],
+ type: DashboardSearchItemType.DashFolder,
+ },
+ ]);
+ mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, {
+ [grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }],
+ });
+ mockAlertRuleApi(server).rulerRuleGroup(
+ GRAFANA_RULES_SOURCE_NAME,
+ grafanaRule.namespace.name,
+ grafanaRule.group.name,
+ { name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }
+ );
+ mockExportApi(server).modifiedExport(grafanaRule.namespace.name, {
+ yaml: 'Yaml Export Content',
+ });
+
+ const user = userEvent.setup();
+
+ renderModifyExport('test-rule-uid');
+
+ await waitForElementToBeRemoved(() => ui.loading.get());
+ expect(await ui.form.nameInput.find()).toHaveValue('cpu-usage');
+
+ await user.click(ui.exportButton.get());
+
+ const drawer = await ui.exportDrawer.dialog.find();
+ expect(drawer).toBeInTheDocument();
+
+ expect(ui.exportDrawer.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
+ await waitFor(() => {
+ expect(ui.exportDrawer.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
+ });
+ });
+});
diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx
index 852eb4914ec..20347d656c8 100644
--- a/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx
+++ b/public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx
@@ -15,8 +15,8 @@ interface GrafanaRuleExportPreviewProps {
}
const GrafanaRuleExportPreview = ({ alertUid, exportFormat, onClose }: GrafanaRuleExportPreviewProps) => {
- const { currentData: ruleTextDefinition = '', isFetching } = alertRuleApi.useExportRuleQuery({
- uid: alertUid,
+ const { currentData: ruleTextDefinition = '', isFetching } = alertRuleApi.endpoints.exportRules.useQuery({
+ ruleUid: alertUid,
format: exportFormat,
});
diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleFolderExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleFolderExporter.tsx
new file mode 100644
index 00000000000..f7832957c01
--- /dev/null
+++ b/public/app/features/alerting/unified/components/export/GrafanaRuleFolderExporter.tsx
@@ -0,0 +1,59 @@
+import React, { useState } from 'react';
+
+import { LoadingPlaceholder } from '@grafana/ui';
+
+import { FolderDTO } from '../../../../../types';
+import { alertRuleApi } from '../../api/alertRuleApi';
+
+import { FileExportPreview } from './FileExportPreview';
+import { GrafanaExportDrawer } from './GrafanaExportDrawer';
+import { allGrafanaExportProviders, ExportFormats } from './providers';
+
+interface GrafanaRuleFolderExporterProps {
+ folder: FolderDTO;
+ onClose: () => void;
+}
+
+export function GrafanaRuleFolderExporter({ folder, onClose }: GrafanaRuleFolderExporterProps) {
+ const [activeTab, setActiveTab] = useState('yaml');
+
+ return (
+
+
+
+ );
+}
+
+interface GrafanaRuleFolderExportPreviewProps {
+ folder: FolderDTO;
+ exportFormat: ExportFormats;
+ onClose: () => void;
+}
+
+function GrafanaRuleFolderExportPreview({ folder, exportFormat, onClose }: GrafanaRuleFolderExportPreviewProps) {
+ const { currentData: exportFolderDefinition = '', isFetching } = alertRuleApi.endpoints.exportRules.useQuery({
+ folderUid: folder.uid,
+ format: exportFormat,
+ });
+
+ if (isFetching) {
+ return ;
+ }
+
+ const downloadFileName = `${folder.title}-${folder.uid}`;
+
+ return (
+
+ );
+}
diff --git a/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx b/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx
index f16c4dbc296..ab7286c82f4 100644
--- a/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx
+++ b/public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx
@@ -19,6 +19,7 @@ export function GrafanaRuleGroupExporter({ folderUid, groupName, onClose }: Graf
return (
{
});
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
+ const user = userEvent.setup();
+
// Act
await renderRuleViewer();
+ await user.click(ui.moreButton.get());
// Assert
- expect(ui.actionButtons.delete.get()).toBeInTheDocument();
+ expect(ui.moreButtons.delete.get()).toBeInTheDocument();
});
it('Should not render Silence button for users wihout the instance create permission', async () => {
@@ -266,9 +273,12 @@ describe('RuleDetails RBAC', () => {
});
grantUserPermissions([AccessControlAction.AlertingRuleCreate]);
- await renderRuleViewer();
+ const user = userEvent.setup();
- expect(ui.actionButtons.clone.get()).toBeInTheDocument();
+ await renderRuleViewer();
+ await user.click(ui.moreButton.get());
+
+ expect(ui.moreButtons.duplicate.get()).toBeInTheDocument();
});
it('Should NOT render clone button for users without create rule permission', async () => {
@@ -281,10 +291,12 @@ describe('RuleDetails RBAC', () => {
const { AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete } = AccessControlAction;
grantUserPermissions([AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete]);
+ const user = userEvent.setup();
await renderRuleViewer();
+ await user.click(ui.moreButton.get());
- expect(ui.actionButtons.clone.query()).not.toBeInTheDocument();
+ expect(ui.moreButtons.duplicate.query()).not.toBeInTheDocument();
});
});
describe('Cloud rules action buttons', () => {
@@ -326,11 +338,14 @@ describe('RuleDetails RBAC', () => {
});
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
+ const user = userEvent.setup();
+
// Act
await renderRuleViewer();
+ await user.click(ui.moreButton.get());
// Assert
- expect(ui.actionButtons.delete.query()).toBeInTheDocument();
+ expect(ui.moreButtons.delete.query()).toBeInTheDocument();
});
});
});
diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
index 286c58c8d35..80e42a858de 100644
--- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
@@ -2,11 +2,10 @@ import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
-import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
-import { config, locationService } from '@grafana/runtime';
+import { locationService } from '@grafana/runtime';
import {
Button,
ClipboardButton,
@@ -22,16 +21,13 @@ import { useAppNotification } from 'app/core/copy/appNotification';
import { useDispatch } from 'app/types';
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
-import { contextSrv } from '../../../../../core/services/context_srv';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { deleteRuleAction } from '../../state/actions';
-import { provisioningPermissions } from '../../utils/access-control';
import { getRulesSourceName } from '../../utils/datasource';
import { createShareLink, createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
-import { GrafanaRuleExporter } from '../export/GrafanaRuleExporter';
import { RedirectToCloneRule } from './CloneRule';
@@ -51,14 +47,12 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
const [redirectToClone, setRedirectToClone] = useState<
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined);
- const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState();
const rulesSourceName = getRulesSourceName(rulesSource);
- const canReadProvisioning = contextSrv.hasPermission(provisioningPermissions.read);
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
const buttons: JSX.Element[] = [];
@@ -103,75 +97,74 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
);
}
- if (isEditable && rulerRule && !isFederated) {
+ if (rulerRule && !isFederated) {
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
- if (!isProvisioned) {
- const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
- returnTo,
- });
+ if (isEditable) {
+ if (!isProvisioned) {
+ const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
+ returnTo,
+ });
+
+ if (isViewMode) {
+ buttons.push(
+ {
+ notifyApp.error('Error while copying URL', copiedText);
+ }}
+ className={style.button}
+ size="sm"
+ getText={buildShareUrl}
+ >
+ Copy link to rule
+
+ );
+ }
- if (isViewMode) {
buttons.push(
- {
- notifyApp.error('Error while copying URL', copiedText);
- }}
- className={style.button}
- size="sm"
- getText={buildShareUrl}
- >
- Copy link to rule
-
+
+
+
);
}
- buttons.push(
-
-
-
+ moreActions.push(
+ setRedirectToClone({ identifier, isProvisioned })} />
);
}
- if (isGrafanaRulerRule(rulerRule) && canReadProvisioning) {
- moreActions.push();
- if (config.featureToggles.alertingModifiedExport) {
- moreActions.push(
-
- locationService.push(
- createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
- returnTo: location.pathname + location.search,
- })
- )
- }
- />
- );
- }
+ if (isGrafanaRulerRule(rulerRule)) {
+ moreActions.push(
+
+ locationService.push(
+ createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
+ returnTo: location.pathname + location.search,
+ })
+ )
+ }
+ />
+ );
}
-
- moreActions.push(
- setRedirectToClone({ identifier, isProvisioned })} />
- );
}
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
moreActions.push( setRuleToDelete(rule)} />);
}
- if (buttons.length) {
+ if (buttons.length || moreActions.length) {
return (
<>
@@ -214,9 +207,7 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
onDismiss={() => setRuleToDelete(undefined)}
/>
)}
- {showExportDrawer && isGrafanaRulerRule(rule.rulerRule) && (
-
- )}
+
{redirectToClone && (
();
+ const [redirectToClone, setRedirectToClone] = useState<
+ { identifier: RuleIdentifier; isProvisioned: boolean } | undefined
+ >(undefined);
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
? rulesSource
@@ -57,6 +72,7 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
const buttons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = [];
+ const moreActionsButtons: React.ReactElement[] = [];
const deleteRule = () => {
if (ruleToDelete && ruleToDelete.rulerRule) {
@@ -221,34 +237,58 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
}
}
+ if (isGrafanaRulerRule(rulerRule)) {
+ moreActionsButtons.push(
+
+ locationService.push(
+ createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`)
+ )
+ }
+ />
+ );
+ }
+
if (hasCreateRulePermission && !isFederated) {
- rightButtons.push(
-
+ moreActionsButtons.push(
+ setRedirectToClone({ identifier, isProvisioned })} />
);
}
if (isRemovable && !isFederated && !isProvisioned) {
- rightButtons.push(
-
+ moreActionsButtons.push();
+ moreActionsButtons.push(
+ setRuleToDelete(rule)} />
);
}
}
- if (buttons.length || rightButtons.length) {
+ if (buttons.length || rightButtons.length || moreActionsButtons.length) {
return (
<>
{buttons.length ? buttons : }
-
{rightButtons.length ? rightButtons : }
+
+ {rightButtons.length && rightButtons}
+ {moreActionsButtons.length && (
+
+ {moreActionsButtons.map((action) => (
+ {action}
+ ))}
+
+ }
+ >
+
+
+ )}
+
{!!ruleToDelete && (
setRuleToDelete(undefined)}
/>
)}
+ {redirectToClone && (
+ setRedirectToClone(undefined)}
+ />
+ )}
>
);
}
+
return null;
};
diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx
index 044e358d278..7bdfa495c24 100644
--- a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx
@@ -13,7 +13,7 @@ import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-aler
import { LogMessages } from '../../Analytics';
import { useHasRuler } from '../../hooks/useHasRuler';
-import { mockFolderApi, mockProvisioningApi, setupMswServer } from '../../mockApi';
+import { mockExportApi, mockFolderApi, setupMswServer } from '../../mockApi';
import { grantUserPermissions, mockCombinedRule, mockDataSource, mockFolder, mockGrafanaRulerRule } from '../../mocks';
import { RulesGroup } from './RulesGroup';
@@ -61,7 +61,7 @@ const ui = {
},
moreActionsButton: byRole('button', { name: 'More' }),
export: {
- dialog: byRole('dialog', { name: 'Drawer title Export' }),
+ dialog: byRole('dialog', { name: /Drawer title Export .* rules/ }),
jsonTab: byRole('tab', { name: /JSON/ }),
yamlTab: byRole('tab', { name: /YAML/ }),
editor: byTestId('code-editor'),
@@ -121,7 +121,7 @@ describe('Rules group tests', () => {
// Arrange
mockUseHasRuler(true, true);
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage' }));
- mockProvisioningApi(server).exportRuleGroup('cpu-usage', 'TestGroup', {
+ mockExportApi(server).exportRulesGroup('cpu-usage', 'TestGroup', {
yaml: 'Yaml Export Content',
json: 'Json Export Content',
});
diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
index b6270635cba..df792d74b08 100644
--- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx
@@ -1,7 +1,6 @@
import { css } from '@emotion/css';
import pluralize from 'pluralize';
import React, { useEffect, useState } from 'react';
-import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
@@ -10,18 +9,17 @@ import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles
import { useDispatch } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
-import { contextSrv } from '../../../../../core/services/context_srv';
import { LogMessages } from '../../Analytics';
import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions';
-import { provisioningPermissions } from '../../utils/access-control';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
+import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter';
import { ActionIcon } from './ActionIcon';
@@ -47,7 +45,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const [isReorderingGroup, setIsReorderingGroup] = useState(false);
- const [isExporting, toggleIsExporting] = useToggle(false);
+ const [isExporting, setIsExporting] = useState<'group' | 'folder' | undefined>(undefined);
const [isCollapsed, setIsCollapsed] = useState(!expandAll);
const { canEditRules } = useRulesAccess();
@@ -66,7 +64,6 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
hasRuler(rulesSource) && rulerRulesLoaded(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule);
const isFederated = isFederatedRuleGroup(group);
- const canReadProvisioning = contextSrv.hasPermission(provisioningPermissions.read);
// check if group has provisioned items
const isProvisioned = group.rules.some((rule) => {
return isGrafanaRulerRule(rule.rulerRule) && rule.rulerRule.grafana_alert.provenance;
@@ -118,18 +115,6 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
/>
);
}
- if (isGroupView && canReadProvisioning) {
- actionIcons.push(
- toggleIsExporting(true)}
- />
- );
- }
if (isListView) {
actionIcons.push(
);
+
+ if (folder?.canAdmin) {
+ actionIcons.push(
+
+ );
+ }
}
}
- if (folder?.canAdmin && isListView) {
- actionIcons.push(
-
- );
+ if (folder) {
+ if (isListView) {
+ actionIcons.push(
+ setIsExporting('folder')}
+ />
+ );
+ } else if (isGroupView) {
+ actionIcons.push(
+ setIsExporting('group')}
+ />
+ );
+ }
}
}
} else if (canEditRules(rulesSource.name) && hasRuler(rulesSource)) {
@@ -290,8 +301,15 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
onDismiss={() => setIsDeletingGroup(false)}
confirmText="Delete"
/>
- {isExporting && folder?.uid && (
-
+ {folder && isExporting === 'folder' && (
+ setIsExporting(undefined)} />
+ )}
+ {folder && isExporting === 'group' && (
+ setIsExporting(undefined)}
+ />
)}
);
diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
index da7cccf7a55..ce123239e63 100644
--- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
@@ -53,9 +53,12 @@ describe('RulesTable RBAC', () => {
it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
+ const user = userEvent.setup();
renderRulesTable(grafanaRule);
- expect(ui.actionButtons.more.query()).not.toBeInTheDocument();
+ await user.click(ui.actionButtons.more.get());
+
+ expect(ui.moreActionItems.delete.query()).not.toBeInTheDocument();
});
it('Should render Edit button for users with the update permission', () => {
diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts
index 5ca8ea06d8a..87814055a13 100644
--- a/public/app/features/alerting/unified/mockApi.ts
+++ b/public/app/features/alerting/unified/mockApi.ts
@@ -23,6 +23,7 @@ import {
Route,
} from '../../../plugins/datasource/alertmanager/types';
import { FolderDTO, NotifierDTO } from '../../../types';
+import { DashboardSearchHit } from '../../search/types';
import { CreateIntegrationDTO, NewOnCallIntegrationDTO, OnCallIntegrationDTO } from './api/onCallApi';
import { AlertingQueryResponse } from './state/AlertingQueryRunner';
@@ -337,6 +338,55 @@ export function mockProvisioningApi(server: SetupServer) {
};
}
+export function mockExportApi(server: SetupServer) {
+ // exportRule, exportRulesGroup, exportRulesFolder use the same API endpoint but with different parameters
+ return {
+ // exportRule requires ruleUid parameter and doesn't allow folderUid and group parameters
+ exportRule: (ruleUid: string, response: Record) => {
+ server.use(
+ rest.get('/api/ruler/grafana/api/v1/export/rules', (req, res, ctx) => {
+ if (req.url.searchParams.get('ruleUid') === ruleUid) {
+ return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']));
+ }
+
+ return res(ctx.status(500));
+ })
+ );
+ },
+ // exportRulesGroup requires folderUid and group parameters and doesn't allow ruleUid parameter
+ exportRulesGroup: (folderUid: string, group: string, response: Record) => {
+ server.use(
+ rest.get('/api/ruler/grafana/api/v1/export/rules', (req, res, ctx) => {
+ if (req.url.searchParams.get('folderUid') === folderUid && req.url.searchParams.get('group') === group) {
+ return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']));
+ }
+
+ return res(ctx.status(500));
+ })
+ );
+ },
+ // exportRulesFolder requires folderUid parameter
+ exportRulesFolder: (folderUid: string, response: Record) => {
+ server.use(
+ rest.get('/api/ruler/grafana/api/v1/export/rules', (req, res, ctx) => {
+ if (req.url.searchParams.get('folderUid') === folderUid) {
+ return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']));
+ }
+
+ return res(ctx.status(500));
+ })
+ );
+ },
+ modifiedExport: (namespace: string, response: Record) => {
+ server.use(
+ rest.post(`/api/ruler/grafana/api/v1/rules/${namespace}/export`, (req, res, ctx) => {
+ return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']));
+ })
+ );
+ },
+ };
+}
+
export function mockFolderApi(server: SetupServer) {
return {
folder: (folderUid: string, response: FolderDTO) => {
@@ -345,6 +395,14 @@ export function mockFolderApi(server: SetupServer) {
};
}
+export function mockSearchApi(server: SetupServer) {
+ return {
+ search: (results: DashboardSearchHit[]) => {
+ server.use(rest.get(`/api/search`, (_, res, ctx) => res(ctx.status(200), ctx.json(results))));
+ },
+ };
+}
+
// Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it
export function setupMswServer() {
const server = setupServer();