Alerting: Add rules export on a folder level (#76016)

pull/76488/head
Konrad Lalik 2 years ago committed by GitHub
parent c21e2bee1d
commit 42f4244026
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 7
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 15
      public/app/features/alerting/routes.tsx
  7. 14
      public/app/features/alerting/unified/MoreActionsRuleButtons.tsx
  8. 3
      public/app/features/alerting/unified/RuleEditor.tsx
  9. 47
      public/app/features/alerting/unified/RuleList.test.tsx
  10. 11
      public/app/features/alerting/unified/RuleList.tsx
  11. 25
      public/app/features/alerting/unified/RuleViewer.tsx
  12. 29
      public/app/features/alerting/unified/api/alertRuleApi.ts
  13. 138
      public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx
  14. 4
      public/app/features/alerting/unified/components/export/GrafanaRuleExporter.tsx
  15. 59
      public/app/features/alerting/unified/components/export/GrafanaRuleFolderExporter.tsx
  16. 5
      public/app/features/alerting/unified/components/export/GrafanaRuleGroupExporter.tsx
  17. 2
      public/app/features/alerting/unified/components/export/GrafanaRulesExporter.tsx
  18. 27
      public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
  19. 115
      public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
  20. 86
      public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
  21. 6
      public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx
  22. 78
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  23. 5
      public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
  24. 58
      public/app/features/alerting/unified/mockApi.ts

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

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
115 idForwarding experimental @grafana/grafana-authnz-team true false false false
116 cloudWatchWildCardDimensionValues GA @grafana/aws-datasources false false false false
117 externalServiceAccounts experimental @grafana/grafana-authnz-team true false false false
alertingModifiedExport experimental @grafana/alerting-squad false false false false
118 panelMonitoring experimental @grafana/dataviz-squad false false false true
119 enableNativeHTTPHistogram experimental @grafana/hosted-grafana-team false false false false
120 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,25 +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.fetchRulerRules.mockResolvedValue({});
renderRuleList();
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.fetchRules.mockResolvedValue([
mockPromRuleNamespace({
name: 'foofolder',
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groups: [
mockPromRuleGroup({
name: 'grafana-group',
rules: [
mockPromAlertingRule({
query: '[]',
}),
],
}),
],
}),
]);
mocks.api.fetchRulerRules.mockResolvedValue({});
renderRuleList();
@ -710,19 +712,6 @@ describe('RuleList', () => {
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]);
const user = userEvent.setup();
await renderRuleViewer();
await user.click(ui.moreButton.get());
expect(ui.actionButtons.clone.get()).toBeInTheDocument();
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();

Loading…
Cancel
Save