Alerting: Add Copy action to templates table (#62135)

* Add duplicate action to templates table

Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>

* Move duplicate icon, and remove provenance when rendering TemplateForm

* Create generic generateCopiedName method to avoid duplication and use it also in the CloneRuleEditor

* Use 'Copy' for duplicating templates and cloning alert rules

* Improve updating the template content with new unique define values when copying

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>

* Fix typo

---------

Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>
Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Sonia Aguilar 2023-02-01 14:14:10 +01:00 committed by GitHub
parent 4c8c243c6e
commit 151e57df70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 276 additions and 133 deletions

View File

@ -203,6 +203,16 @@ const unifiedRoutes: RouteDescriptor[] = [
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/:type/:id/duplicate',
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsWrite, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/:type',
roles: evaluateAccess(

View File

@ -10,11 +10,10 @@ import { selectors } from '@grafana/e2e-selectors/src';
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import 'whatwg-fetch';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto';
import { CloneRuleEditor, generateCopiedRuleTitle } from './CloneRuleEditor';
import { CloneRuleEditor } from './CloneRuleEditor';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks';
import { mockSearchApiResponse } from './mocks/grafanaApi';
@ -200,85 +199,3 @@ describe('CloneRuleEditor', function () {
});
});
});
describe('generateCopiedRuleTitle', () => {
it('should generate copy name', () => {
const fileName = 'my file';
const expectedDuplicateName = 'my file (copy)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [],
},
} as unknown as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from original file', () => {
const fileName = 'my file';
const duplicatedName = 'my file (copy)';
const expectedDuplicateName = 'my file (copy 2)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 2)';
const expectedDuplicateName = 'my file (copy 3)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file in gap', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 3)';
const expectedDuplicateName = 'my file (copy 2)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [
{
grafana_alert: { title: fileName },
},
{ grafana_alert: { title: duplicatedName } },
],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
});

View File

@ -6,11 +6,12 @@ import { locationService } from '@grafana/runtime/src';
import { Alert, LoadingPlaceholder } from '@grafana/ui/src';
import { useDispatch } from '../../../types';
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting';
import { RuleIdentifier } from '../../../types/unified-alerting';
import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { fetchEditableRuleAction } from './state/actions';
import { generateCopiedName } from './utils/duplicate';
import { rulerRuleToFormValues } from './utils/rule-form';
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules';
import { createUrl } from './utils/url';
@ -30,7 +31,10 @@ export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier
if (rule) {
const ruleClone = cloneDeep(rule);
changeRuleName(ruleClone.rule, generateCopiedRuleTitle(ruleClone));
changeRuleName(
ruleClone.rule,
generateCopiedName(getRuleName(ruleClone.rule), ruleClone.group.rules.map(getRuleName))
);
const formPrefill = rulerRuleToFormValues(ruleClone);
// Provisioned alert rules have provisioned alert group which cannot be used in UI
@ -51,28 +55,13 @@ export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier
return (
<Alert
title="Cannot duplicate. The rule does not exist"
title="Cannot copy the rule. The rule does not exist"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
);
}
export function generateCopiedRuleTitle(originRuleWithLocation: RuleWithLocation): string {
const originName = getRuleName(originRuleWithLocation.rule);
const existingRulesNames = originRuleWithLocation.group.rules.map(getRuleName);
const nonDuplicateName = originName.replace(/\(copy( [0-9]+)?\)$/, '').trim();
let newName = `${nonDuplicateName} (copy)`;
for (let i = 2; existingRulesNames.includes(newName); i++) {
newName = `${nonDuplicateName} (copy ${i})`;
}
return newName;
}
function changeRuleName(rule: RulerRuleDTO, newName: string) {
if (isGrafanaRulerRule(rule)) {
rule.grafana_alert.title = newName;

View File

@ -3,9 +3,9 @@ import pluralize from 'pluralize';
import React, { useEffect } from 'react';
import { Redirect, Route, RouteChildrenProps, Switch, useLocation, useParams } from 'react-router-dom';
import { NavModelItem, GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Alert, LoadingPlaceholder, withErrorBoundary, useStyles2, Icon } from '@grafana/ui';
import { Alert, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { ContactPointsState } from '../../../types';
@ -16,6 +16,7 @@ import { AlertManagerPicker } from './components/AlertManagerPicker';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { NoAlertManagerWarning } from './components/NoAlertManagerWarning';
import { DuplicateTemplateView } from './components/receivers/DuplicateTemplateView';
import { EditReceiverView } from './components/receivers/EditReceiverView';
import { EditTemplateView } from './components/receivers/EditTemplateView';
import { GlobalConfigForm } from './components/receivers/GlobalConfigForm';
@ -143,6 +144,17 @@ const Receivers = () => {
<Route exact={true} path="/alerting/notifications/templates/new">
<NewTemplateView config={config} alertManagerSourceName={alertManagerSourceName} />
</Route>
<Route exact={true} path="/alerting/notifications/templates/:name/duplicate">
{({ match }: RouteChildrenProps<{ name: string }>) =>
match?.params.name && (
<DuplicateTemplateView
alertManagerSourceName={alertManagerSourceName}
config={config}
templateName={decodeURIComponent(match?.params.name)}
/>
)
}
</Route>
<Route exact={true} path="/alerting/notifications/templates/:name/edit">
{({ match }: RouteChildrenProps<{ name: string }>) =>
match?.params.name && (

View File

@ -61,7 +61,7 @@ const renderRuleViewer = () => {
const ui = {
actionButtons: {
edit: byRole('link', { name: /edit/i }),
clone: byRole('link', { name: /clone/i }),
clone: byRole('link', { name: /copy/i }),
delete: byRole('button', { name: /delete/i }),
silence: byRole('link', { name: 'Silence' }),
},

View File

@ -0,0 +1,37 @@
import React, { FC } from 'react';
import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { generateCopiedName } from '../../utils/duplicate';
import { updateDefinesWithUniqueValue } from '../../utils/templates';
import { TemplateForm } from './TemplateForm';
interface Props {
templateName: string;
config: AlertManagerCortexConfig;
alertManagerSourceName: string;
}
export const DuplicateTemplateView: FC<Props> = ({ config, templateName, alertManagerSourceName }) => {
const template = config.template_files?.[templateName];
if (!template) {
return (
<Alert severity="error" title="Template not found">
Sorry, this template does not seem to exists.
</Alert>
);
}
const duplicatedName = generateCopiedName(templateName, Object.keys(config.template_files));
return (
<TemplateForm
alertManagerSourceName={alertManagerSourceName}
config={config}
existing={{ name: duplicatedName, content: updateDefinesWithUniqueValue(template) }}
/>
);
};

View File

@ -1,6 +1,6 @@
import React, { FC } from 'react';
import { InfoBox } from '@grafana/ui';
import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { TemplateForm } from './TemplateForm';
@ -17,9 +17,9 @@ export const EditTemplateView: FC<Props> = ({ config, templateName, alertManager
if (!template) {
return (
<InfoBox severity="error" title="Template not found">
Sorry, this template does not seem to exit.
</InfoBox>
<Alert severity="error" title="Template not found">
Sorry, this template does not seem to exists.
</Alert>
);
}
return (

View File

@ -0,0 +1,78 @@
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { TemplatesTable } from './TemplatesTable';
const defaultConfig: AlertManagerCortexConfig = {
template_files: {
template1: `{{ define "define1" }}`,
},
alertmanager_config: {
templates: ['template1'],
},
};
jest.mock('app/types', () => ({
...jest.requireActual('app/types'),
useDispatch: () => jest.fn(),
}));
jest.mock('app/core/services/context_srv');
const contextSrvMock = jest.mocked(contextSrv);
const renderWithProvider = () => {
const store = configureStore();
render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<TemplatesTable config={defaultConfig} alertManagerName={'potato'} />
</Router>
</Provider>
);
};
describe('TemplatesTable', () => {
beforeEach(() => {
jest.resetAllMocks();
contextSrvMock.hasAccess.mockImplementation(() => true);
contextSrvMock.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return permissions.includes(action as AccessControlAction);
});
});
it('Should render templates table with the correct rows', () => {
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).getByRole('cell', { name: /template1/i })).toBeInTheDocument();
});
it('Should render duplicate template button when having permissions', () => {
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).getByRole('cell', { name: /Copy/i })).toBeInTheDocument();
});
it('Should not render duplicate template button when not having write permissions', () => {
contextSrvMock.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
];
return permissions.includes(action as AccessControlAction);
});
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).queryByRole('cell', { name: /Copy/i })).not.toBeInTheDocument();
});
});

View File

@ -102,24 +102,35 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
/>
)}
{!provenance && (
<Authorize actions={[permissions.update, permissions.delete]}>
<Authorize actions={[permissions.update]}>
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
alertManagerName
)}
tooltip="edit template"
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => setTemplateToDelete(name)}
tooltip="delete template"
icon="trash-alt"
/>
</Authorize>
<Authorize actions={[permissions.update]}>
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
alertManagerName
)}
tooltip="edit template"
icon="pen"
/>
</Authorize>
)}
{contextSrv.hasPermission(permissions.create) && (
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/duplicate`,
alertManagerName
)}
tooltip="Copy template"
icon="copy"
/>
)}
{!provenance && (
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => setTemplateToDelete(name)}
tooltip="delete template"
icon="trash-alt"
/>
</Authorize>
)}
</td>

View File

@ -28,7 +28,7 @@ export const CloneRuleButton = React.forwardRef<HTMLAnchorElement, CloneRuleButt
return (
<>
<LinkButton
title="Clone"
title="Copy"
className={className}
size="sm"
key="clone"
@ -43,19 +43,19 @@ export const CloneRuleButton = React.forwardRef<HTMLAnchorElement, CloneRuleButt
<ConfirmModal
isOpen={!!provRuleCloneUrl}
title="Clone provisioned rule"
title="Copy provisioned alert rule"
body={
<div>
<p>
The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule.
</p>
<p>
You will need to set a new alert group for the cloned rule because the original one has been provisioned
You will need to set a new alert group for the copied rule because the original one has been provisioned
and cannot be used for rules created in the UI.
</p>
</div>
}
confirmText="Clone"
confirmText="Copy"
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
onDismiss={() => setProvRuleCloneUrl(undefined)}
/>

View File

@ -129,7 +129,7 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
}
buttons.push(
<Tooltip placement="top" content="Clone">
<Tooltip placement="top" content="Copy">
<CloneRuleButton ruleIdentifier={identifier} isProvisioned={isProvisioned} className={style.button} />
</Tooltip>
);

View File

@ -219,7 +219,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (hasCreateRulePermission && !isFederated) {
rightButtons.push(
<CloneRuleButton key="clone" text="Clone" ruleIdentifier={identifier} isProvisioned={isProvisioned} />
<CloneRuleButton key="clone" text="Copy" ruleIdentifier={identifier} isProvisioned={isProvisioned} />
);
}

View File

@ -0,0 +1,34 @@
import { generateCopiedName } from './duplicate';
describe('generateCopiedName', () => {
it('should generate copy name', () => {
const fileName = 'my file';
const expectedDuplicateName = 'my file (copy)';
expect(generateCopiedName(fileName, [])).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from original file', () => {
const fileName = 'my file';
const duplicatedName = 'my file (copy)';
const expectedDuplicateName = 'my file (copy 2)';
expect(generateCopiedName(fileName, [fileName, duplicatedName])).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 2)';
const expectedDuplicateName = 'my file (copy 3)';
expect(generateCopiedName(fileName, [fileName, duplicatedName])).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file in gap', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 3)';
const expectedDuplicateName = 'my file (copy 2)';
expect(generateCopiedName(fileName, [fileName, duplicatedName])).toEqual(expectedDuplicateName);
});
});

View File

@ -0,0 +1,11 @@
export function generateCopiedName(originalName: string, exisitingNames: string[]) {
const nonDuplicateName = originalName.replace(/\(copy( [0-9]+)?\)$/, '').trim();
let newName = `${nonDuplicateName} (copy)`;
for (let i = 2; exisitingNames.includes(newName); i++) {
newName = `${nonDuplicateName} (copy ${i})`;
}
return newName;
}

View File

@ -0,0 +1,36 @@
import { updateDefinesWithUniqueValue } from './templates';
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
now: jest.fn().mockImplementation(() => 99),
}));
describe('updateDefinesWithUniqueValue method', () => {
describe('only onw define', () => {
it('Should update the define values with a unique new one', () => {
expect(updateDefinesWithUniqueValue(`{{ define "t" }}\n{{.Alerts.Firing}}\n{{ end }}`)).toEqual(
`{{ define "t_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}`
);
});
});
describe('more than one define in the template', () => {
it('Should update the define values with a unique new one ', () => {
expect(
updateDefinesWithUniqueValue(
`{{ define "t1" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
)
).toEqual(
`{{ define "t1_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
);
});
it('Should update the define values with a unique new one, special chars included in the value', () => {
expect(
updateDefinesWithUniqueValue(
`{{ define "t1 /^*;$@" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
)
).toEqual(
`{{ define "t1 /^*;$@_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
);
});
});
});

View File

@ -1,3 +1,5 @@
import { now } from 'lodash';
export function ensureDefine(templateName: string, templateContent: string): string {
// notification template content must be wrapped in {{ define "name" }} tag,
// but this is not obvious because user also has to provide name separately in the form.
@ -12,3 +14,9 @@ export function ensureDefine(templateName: string, templateContent: string): str
}
return content;
}
export function updateDefinesWithUniqueValue(templateContent: string): string {
const getNewValue = (match_: string, originalDefineName: string) => {
return `{{ define "${originalDefineName}_NEW_${now()}" }}`;
};
return templateContent.replace(/\{\{\s*define\s*\"(?<defineName>.*)\"\s*\}\}/g, getNewValue);
}