diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 95b2bda7ed5..7649fbc7d0a 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -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( diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 1fadb5528d3..aa91a0c7910 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -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); - }); -}); diff --git a/public/app/features/alerting/unified/CloneRuleEditor.tsx b/public/app/features/alerting/unified/CloneRuleEditor.tsx index e9a75b76a03..ba82b6555b6 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.tsx @@ -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 ( 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; diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index f53aa5e6700..ec5162d84e6 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -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 = () => { + + {({ match }: RouteChildrenProps<{ name: string }>) => + match?.params.name && ( + + ) + } + {({ match }: RouteChildrenProps<{ name: string }>) => match?.params.name && ( diff --git a/public/app/features/alerting/unified/RuleViewer.test.tsx b/public/app/features/alerting/unified/RuleViewer.test.tsx index 5e9ff4f1e80..f0efa02ecd2 100644 --- a/public/app/features/alerting/unified/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/RuleViewer.test.tsx @@ -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' }), }, diff --git a/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx b/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx new file mode 100644 index 00000000000..a72ace2d2fb --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/DuplicateTemplateView.tsx @@ -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 = ({ config, templateName, alertManagerSourceName }) => { + const template = config.template_files?.[templateName]; + + if (!template) { + return ( + + Sorry, this template does not seem to exists. + + ); + } + + const duplicatedName = generateCopiedName(templateName, Object.keys(config.template_files)); + + return ( + + ); +}; diff --git a/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx b/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx index bbdceb3b3f3..cb3b4a4664b 100644 --- a/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx +++ b/public/app/features/alerting/unified/components/receivers/EditTemplateView.tsx @@ -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 = ({ config, templateName, alertManager if (!template) { return ( - - Sorry, this template does not seem to exit. - + + Sorry, this template does not seem to exists. + ); } return ( diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx new file mode 100644 index 00000000000..77e0aa0efbd --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.test.tsx @@ -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( + + + + + + ); +}; + +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(); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx index 47febd864e1..292b0fc1c7e 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatesTable.tsx @@ -102,24 +102,35 @@ export const TemplatesTable: FC = ({ config, alertManagerName }) => { /> )} {!provenance && ( - - - - - - setTemplateToDelete(name)} - tooltip="delete template" - icon="trash-alt" - /> - + + + + )} + {contextSrv.hasPermission(permissions.create) && ( + + )} + + {!provenance && ( + + setTemplateToDelete(name)} + tooltip="delete template" + icon="trash-alt" + /> )} diff --git a/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx b/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx index 860ba3c6de5..19384afdc9f 100644 --- a/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx +++ b/public/app/features/alerting/unified/components/rules/CloneRuleButton.tsx @@ -28,7 +28,7 @@ export const CloneRuleButton = React.forwardRef

The new rule will NOT be marked as a provisioned rule.

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

} - confirmText="Clone" + confirmText="Copy" onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)} onDismiss={() => setProvRuleCloneUrl(undefined)} /> diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 03fb2340630..e912a06f172 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -129,7 +129,7 @@ export const RuleActionsButtons: FC = ({ rule, rulesSource }) => { } buttons.push( - + ); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index fd8932f323c..83f809526f8 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -219,7 +219,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM if (hasCreateRulePermission && !isFederated) { rightButtons.push( - + ); } diff --git a/public/app/features/alerting/unified/utils/duplicate.test.ts b/public/app/features/alerting/unified/utils/duplicate.test.ts new file mode 100644 index 00000000000..908205f212e --- /dev/null +++ b/public/app/features/alerting/unified/utils/duplicate.test.ts @@ -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); + }); +}); diff --git a/public/app/features/alerting/unified/utils/duplicate.ts b/public/app/features/alerting/unified/utils/duplicate.ts new file mode 100644 index 00000000000..5ab464fd149 --- /dev/null +++ b/public/app/features/alerting/unified/utils/duplicate.ts @@ -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; +} diff --git a/public/app/features/alerting/unified/utils/templates.test.ts b/public/app/features/alerting/unified/utils/templates.test.ts new file mode 100644 index 00000000000..ada7530aa2f --- /dev/null +++ b/public/app/features/alerting/unified/utils/templates.test.ts @@ -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` + ); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/templates.ts b/public/app/features/alerting/unified/utils/templates.ts index 30aabae1eb0..77c7d11ab13 100644 --- a/public/app/features/alerting/unified/utils/templates.ts +++ b/public/app/features/alerting/unified/utils/templates.ts @@ -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*\"(?.*)\"\s*\}\}/g, getNewValue); +}