mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add rules export on a folder level (#76016)
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -134,7 +134,6 @@ export interface FeatureToggles {
|
||||
idForwarding?: boolean;
|
||||
cloudWatchWildCardDimensionValues?: boolean;
|
||||
externalServiceAccounts?: boolean;
|
||||
alertingModifiedExport?: boolean;
|
||||
panelMonitoring?: boolean;
|
||||
enableNativeHTTPHistogram?: boolean;
|
||||
transformationsVariableSupport?: boolean;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
: () => <Redirect to="/alerting/List" />,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/:sourceName/:id/view',
|
||||
|
||||
@@ -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 && <MenuItem onClick={toggleShowExportDrawer} label="Export all Grafana-managed rules" />}
|
||||
{viewRuleAllowed && <MenuItem onClick={toggleShowExportDrawer} label="Export all Grafana-managed rules" />}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
)}
|
||||
<RuleStats namespaces={filteredNamespaces} />
|
||||
</div>
|
||||
{(canCreateGrafanaRules || canCreateCloudRules || canReadProvisioning) && (
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<MoreActionsRuleButtons />
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<MoreActionsRuleButtons />
|
||||
</Stack>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
<HorizontalGroup height="auto" justify="flex-end">
|
||||
<Button variant="secondary" type="button" onClick={() => setShowYaml(true)} size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper>
|
||||
<AppChromeUpdate actions={actionButtons} />
|
||||
{showYaml && <GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowYaml(false)} />}
|
||||
<Enable feature={AlertingFeature.DetailsViewV2}>
|
||||
<DetailViewV2 {...props} />
|
||||
</Enable>
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
interface ExportRulesParams {
|
||||
format: ExportFormats;
|
||||
folderUid?: string;
|
||||
group?: string;
|
||||
ruleUid?: string;
|
||||
}
|
||||
|
||||
export interface ModifyExportPayload {
|
||||
rules: Array<RulerAlertingRuleDTO | RulerRecordingRuleDTO | PostableRuleGrafanaRuleDTO>;
|
||||
name: string;
|
||||
@@ -192,20 +195,10 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
|
||||
exportRule: build.query<string, { uid: string; format: ExportFormats }>({
|
||||
query: ({ uid, format }) => ({ url: getProvisioningExportUrl(uid, format), responseType: 'text' }),
|
||||
}),
|
||||
exportRuleGroup: build.query<string, { folderUid: string; groupName: string; format: ExportFormats }>({
|
||||
query: ({ folderUid, groupName, format }) => ({
|
||||
url: `/api/v1/provisioning/folder/${folderUid}/rule-groups/${groupName}/export`,
|
||||
params: { format: format },
|
||||
responseType: 'text',
|
||||
}),
|
||||
}),
|
||||
exportRules: build.query<string, { format: ExportFormats }>({
|
||||
query: ({ format }) => ({
|
||||
url: `/api/v1/provisioning/alert-rules/export`,
|
||||
params: { format: format },
|
||||
exportRules: build.query<string, ExportRulesParams>({
|
||||
query: ({ format, folderUid, group, ruleUid }) => ({
|
||||
url: `/api/ruler/grafana/api/v1/export/rules`,
|
||||
params: { format: format, folderUid: folderUid, group: group, ruleUid: ruleUid },
|
||||
responseType: 'text',
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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 }) => <div>{actions}</div>,
|
||||
}));
|
||||
|
||||
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 }) => <textarea data-testid="code-editor" value={value} readOnly />,
|
||||
}));
|
||||
|
||||
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(<Route path="/alerting/:id/modify-export" component={GrafanaModifyExport} />, { 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ExportFormats>('yaml');
|
||||
|
||||
return (
|
||||
<GrafanaExportDrawer
|
||||
title={`Export ${folder.title} rules`}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onClose={onClose}
|
||||
formatProviders={Object.values(allGrafanaExportProviders)}
|
||||
>
|
||||
<GrafanaRuleFolderExportPreview folder={folder} exportFormat={activeTab} onClose={onClose} />
|
||||
</GrafanaExportDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoadingPlaceholder text="Loading...." />;
|
||||
}
|
||||
|
||||
const downloadFileName = `${folder.title}-${folder.uid}`;
|
||||
|
||||
return (
|
||||
<FileExportPreview
|
||||
format={exportFormat}
|
||||
textDefinition={exportFolderDefinition}
|
||||
downloadFileName={downloadFileName}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function GrafanaRuleGroupExporter({ folderUid, groupName, onClose }: Graf
|
||||
|
||||
return (
|
||||
<GrafanaExportDrawer
|
||||
title={`Export ${groupName} rules`}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onClose={onClose}
|
||||
@@ -47,9 +48,9 @@ function GrafanaRuleGroupExportPreview({
|
||||
exportFormat,
|
||||
onClose,
|
||||
}: GrafanaRuleGroupExportPreviewProps) {
|
||||
const { currentData: ruleGroupTextDefinition = '', isFetching } = alertRuleApi.useExportRuleGroupQuery({
|
||||
const { currentData: ruleGroupTextDefinition = '', isFetching } = alertRuleApi.endpoints.exportRules.useQuery({
|
||||
folderUid,
|
||||
groupName,
|
||||
group: groupName,
|
||||
format: exportFormat,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ interface GrafanaRulesExportPreviewProps {
|
||||
}
|
||||
|
||||
function GrafanaRulesExportPreview({ exportFormat, onClose }: GrafanaRulesExportPreviewProps) {
|
||||
const { currentData: rulesDefinition = '', isFetching } = alertRuleApi.useExportRulesQuery({
|
||||
const { currentData: rulesDefinition = '', isFetching } = alertRuleApi.endpoints.exportRules.useQuery({
|
||||
format: exportFormat,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { byRole, byText } from 'testing-library-selector';
|
||||
@@ -57,10 +58,13 @@ const mocks = {
|
||||
const ui = {
|
||||
actionButtons: {
|
||||
edit: byRole('link', { name: /edit/i }),
|
||||
clone: byRole('button', { name: /^copy$/i }),
|
||||
delete: byRole('button', { name: /delete/i }),
|
||||
silence: byRole('link', { name: 'Silence' }),
|
||||
},
|
||||
moreButton: byRole('button', { name: /More/i }),
|
||||
moreButtons: {
|
||||
duplicate: byRole('menuitem', { name: /^Duplicate$/i }),
|
||||
delete: byRole('menuitem', { name: /delete/i }),
|
||||
},
|
||||
loadingIndicator: byText(/Loading rule/i),
|
||||
};
|
||||
|
||||
@@ -208,11 +212,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.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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CombinedRule>();
|
||||
|
||||
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(
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
icon="copy"
|
||||
onClipboardError={(copiedText) => {
|
||||
notifyApp.error('Error while copying URL', copiedText);
|
||||
}}
|
||||
className={style.button}
|
||||
size="sm"
|
||||
getText={buildShareUrl}
|
||||
>
|
||||
Copy link to rule
|
||||
</ClipboardButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (isViewMode) {
|
||||
buttons.push(
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
icon="copy"
|
||||
onClipboardError={(copiedText) => {
|
||||
notifyApp.error('Error while copying URL', copiedText);
|
||||
}}
|
||||
className={style.button}
|
||||
size="sm"
|
||||
getText={buildShareUrl}
|
||||
>
|
||||
Copy link to rule
|
||||
</ClipboardButton>
|
||||
<Tooltip placement="top" content={'Edit'}>
|
||||
<LinkButton
|
||||
title="Edit"
|
||||
className={style.button}
|
||||
size="sm"
|
||||
key="edit"
|
||||
variant="secondary"
|
||||
icon="pen"
|
||||
href={editURL}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<Tooltip placement="top" content={'Edit'}>
|
||||
<LinkButton
|
||||
title="Edit"
|
||||
className={style.button}
|
||||
size="sm"
|
||||
key="edit"
|
||||
variant="secondary"
|
||||
icon="pen"
|
||||
href={editURL}
|
||||
/>
|
||||
</Tooltip>
|
||||
moreActions.push(
|
||||
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isGrafanaRulerRule(rulerRule) && canReadProvisioning) {
|
||||
moreActions.push(<Menu.Item label="Export" icon="download-alt" onClick={toggleShowExportDrawer} />);
|
||||
if (config.featureToggles.alertingModifiedExport) {
|
||||
moreActions.push(
|
||||
<Menu.Item
|
||||
label="Modify export"
|
||||
icon="edit"
|
||||
onClick={() =>
|
||||
locationService.push(
|
||||
createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
|
||||
returnTo: location.pathname + location.search,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isGrafanaRulerRule(rulerRule)) {
|
||||
moreActions.push(
|
||||
<Menu.Item
|
||||
label="Modify export"
|
||||
icon="edit"
|
||||
onClick={() =>
|
||||
locationService.push(
|
||||
createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
|
||||
returnTo: location.pathname + location.search,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
moreActions.push(
|
||||
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
|
||||
moreActions.push(<Menu.Item label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />);
|
||||
}
|
||||
|
||||
if (buttons.length) {
|
||||
if (buttons.length || moreActions.length) {
|
||||
return (
|
||||
<>
|
||||
<Stack gap={1}>
|
||||
@@ -214,9 +207,7 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
|
||||
onDismiss={() => setRuleToDelete(undefined)}
|
||||
/>
|
||||
)}
|
||||
{showExportDrawer && isGrafanaRulerRule(rule.rulerRule) && (
|
||||
<GrafanaRuleExporter alertUid={rule.rulerRule.grafana_alert.uid} onClose={toggleShowExportDrawer} />
|
||||
)}
|
||||
|
||||
{redirectToClone && (
|
||||
<RedirectToCloneRule
|
||||
identifier={redirectToClone.identifier}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2, textUtil, urlUtil } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import {
|
||||
Button,
|
||||
ClipboardButton,
|
||||
ConfirmModal,
|
||||
Dropdown,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
LinkButton,
|
||||
Menu,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction, useDispatch } from 'app/types';
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
@@ -29,9 +40,10 @@ import {
|
||||
} from '../../utils/misc';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { createUrl } from '../../utils/url';
|
||||
import { DeclareIncident } from '../bridges/DeclareIncidentButton';
|
||||
|
||||
import { CloneRuleButton } from './CloneRule';
|
||||
import { RedirectToCloneRule } from './CloneRule';
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
@@ -48,6 +60,9 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
||||
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(
|
||||
<Menu.Item
|
||||
label="Modify export"
|
||||
icon="edit"
|
||||
onClick={() =>
|
||||
locationService.push(
|
||||
createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`)
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCreateRulePermission && !isFederated) {
|
||||
rightButtons.push(
|
||||
<CloneRuleButton key="clone" text="Copy" ruleIdentifier={identifier} isProvisioned={isProvisioned} />
|
||||
moreActionsButtons.push(
|
||||
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isRemovable && !isFederated && !isProvisioned) {
|
||||
rightButtons.push(
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
key="delete"
|
||||
variant="secondary"
|
||||
icon="trash-alt"
|
||||
onClick={() => setRuleToDelete(rule)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
moreActionsButtons.push(<Menu.Divider />);
|
||||
moreActionsButtons.push(
|
||||
<Menu.Item key="delete" label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (buttons.length || rightButtons.length) {
|
||||
if (buttons.length || rightButtons.length || moreActionsButtons.length) {
|
||||
return (
|
||||
<>
|
||||
<div className={style.wrapper}>
|
||||
<HorizontalGroup width="auto">{buttons.length ? buttons : <div />}</HorizontalGroup>
|
||||
<HorizontalGroup width="auto">{rightButtons.length ? rightButtons : <div />}</HorizontalGroup>
|
||||
<HorizontalGroup width="auto">
|
||||
{rightButtons.length && rightButtons}
|
||||
{moreActionsButtons.length && (
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
{moreActionsButtons.map((action) => (
|
||||
<React.Fragment key={uniqueId('action_')}>{action}</React.Fragment>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" size="sm">
|
||||
More
|
||||
<Icon name="angle-down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
{!!ruleToDelete && (
|
||||
<ConfirmModal
|
||||
@@ -261,9 +301,17 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
|
||||
onDismiss={() => setRuleToDelete(undefined)}
|
||||
/>
|
||||
)}
|
||||
{redirectToClone && (
|
||||
<RedirectToCloneRule
|
||||
identifier={redirectToClone.identifier}
|
||||
isProvisioned={redirectToClone.isProvisioned}
|
||||
onDismiss={() => setRedirectToClone(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<ActionIcon
|
||||
aria-label="xport rule group"
|
||||
data-testid="export-group"
|
||||
key="export"
|
||||
icon="download-alt"
|
||||
tooltip="Export rule group"
|
||||
onClick={() => toggleIsExporting(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isListView) {
|
||||
actionIcons.push(
|
||||
<ActionIcon
|
||||
@@ -141,19 +126,45 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
target="__blank"
|
||||
/>
|
||||
);
|
||||
|
||||
if (folder?.canAdmin) {
|
||||
actionIcons.push(
|
||||
<ActionIcon
|
||||
aria-label="manage permissions"
|
||||
key="manage-perms"
|
||||
icon="lock"
|
||||
tooltip="manage permissions"
|
||||
to={baseUrl + '/permissions'}
|
||||
target="__blank"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (folder?.canAdmin && isListView) {
|
||||
actionIcons.push(
|
||||
<ActionIcon
|
||||
aria-label="manage permissions"
|
||||
key="manage-perms"
|
||||
icon="lock"
|
||||
tooltip="manage permissions"
|
||||
to={baseUrl + '/permissions'}
|
||||
target="__blank"
|
||||
/>
|
||||
);
|
||||
if (folder) {
|
||||
if (isListView) {
|
||||
actionIcons.push(
|
||||
<ActionIcon
|
||||
aria-label="export rule folder"
|
||||
data-testid="export-folder"
|
||||
key="export-folder"
|
||||
icon="download-alt"
|
||||
tooltip="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(rulesSource)) {
|
||||
@@ -290,8 +301,15 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
onDismiss={() => setIsDeletingGroup(false)}
|
||||
confirmText="Delete"
|
||||
/>
|
||||
{isExporting && folder?.uid && (
|
||||
<GrafanaRuleGroupExporter folderUid={folder?.uid} groupName={group.name} onClose={toggleIsExporting} />
|
||||
{folder && isExporting === 'folder' && (
|
||||
<GrafanaRuleFolderExporter folder={folder} onClose={() => setIsExporting(undefined)} />
|
||||
)}
|
||||
{folder && isExporting === 'group' && (
|
||||
<GrafanaRuleGroupExporter
|
||||
folderUid={folder.uid}
|
||||
groupName={group.name}
|
||||
onClose={() => setIsExporting(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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<string, string>) => {
|
||||
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<string, string>) => {
|
||||
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<string, string>) => {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user