Merge "Quick fix for Composer slowdown after a lot of typing - use “debounce” straw to collect key strokes - rift15919"
diff --git a/skyquake/plugins/accounts/src/account/account.jsx b/skyquake/plugins/accounts/src/account/account.jsx
index b7dbf35..4f011cb 100644
--- a/skyquake/plugins/accounts/src/account/account.jsx
+++ b/skyquake/plugins/accounts/src/account/account.jsx
@@ -106,8 +106,8 @@
             self.props.router.push({pathname:'accounts'});
             self.props.flux.actions.global.hideScreenLoader.defer();
         },
-         function() {
-            self.props.flux.actions.global.showNotification("There was an error creating your account. Please contact your system administrator.");
+         function(error) {
+            self.props.flux.actions.global.showNotification(error);
             self.props.flux.actions.global.hideScreenLoader.defer();
          });
     }
diff --git a/skyquake/plugins/accounts/src/account/accountSource.js b/skyquake/plugins/accounts/src/account/accountSource.js
index 52af857..45da0fb 100644
--- a/skyquake/plugins/accounts/src/account/accountSource.js
+++ b/skyquake/plugins/accounts/src/account/accountSource.js
@@ -50,6 +50,7 @@
               }).fail(function(xhr){
                 //Authentication and the handling of fail states should be wrapped up into a connection class.
                 Utils.checkAuthentication(xhr.status);
+                reject(xhr.responseText || 'An error occurred. Check your logs for more information');
               });;
             });
           },
@@ -133,7 +134,7 @@
               }).fail(function(xhr){
                 //Authentication and the handling of fail states should be wrapped up into a connection class.
                 Utils.checkAuthentication(xhr.status);
-                reject();
+                reject(xhr.responseText || 'An error occurred. Check your logs for more information');
               });
 
             });
@@ -177,7 +178,7 @@
               }).fail(function(xhr){
                 //Authentication and the handling of fail states should be wrapped up into a connection class.
                 Utils.checkAuthentication(xhr.status);
-                reject('error');
+                reject(xhr.responseText || 'An error occurred. Check your logs for more information');
               });
 
             });
@@ -204,7 +205,7 @@
               }).fail(function(xhr){
                 //Authentication and the handling of fail states should be wrapped up into a connection class.
                 Utils.checkAuthentication(xhr.status);
-                reject('error');
+                reject(xhr.responseText || 'An error occurred. Check your logs for more information');
               });
             })
           },
diff --git a/skyquake/plugins/composer/api/composer.js b/skyquake/plugins/composer/api/composer.js
index ebe5636..f9f0591 100644
--- a/skyquake/plugins/composer/api/composer.js
+++ b/skyquake/plugins/composer/api/composer.js
@@ -29,6 +29,7 @@
 
 var Composer = {};
 var FileManager = {};
+var PackageManager = {};
 var DataCenters = {};
 // Catalog module methods
 Composer.get = function(req) {
@@ -303,7 +304,63 @@
     });
 }
 
-Composer.update = function(req) {
+PackageManager.upload = function(req) {
+    console.log(' Uploading file', req.file.originalname, 'as', req.file.filename);
+    var api_server = req.query['api_server'];
+    // dev_download_server is for testing purposes.
+    // It is the direct IP address of the Node server where the
+    // package will be hosted.
+    var download_host = req.query['dev_download_server'];
+
+    if (!download_host) {
+        download_host = req.protocol + '://' + req.get('host');//req.api_server + ':' + utils.getPortForProtocol(req.protocol);
+    }
+
+    return new Promise(function(resolve, reject) {
+        Promise.all([
+            rp({
+                uri: utils.confdPort(api_server) + '/api/operations/package-create',
+                method: 'POST',
+                headers: _.extend({}, constants.HTTP_HEADERS.accept.collection, {
+                    'Authorization': req.get('Authorization')
+                }),
+                forever: constants.FOREVER_ON,
+                rejectUnauthorized: false,
+                resolveWithFullResponse: true,
+                json: true,
+                body: {
+                    input: {
+                        'external-url': download_host + '/composer/upload/' + req.file.filename,
+                        'package-type': 'VNFD',
+                        'package-id': uuid()
+                    }
+                }
+            })
+        ]).then(function(result) {
+            var data = {};
+            data['transaction_id'] = result[0].body['output']['transaction-id'];
+
+            // Add a status checker on the transaction and then to delete the file later
+            PackageFileHandler.checkCreatePackageStatusAndHandleFile(req, data['transaction_id']);
+
+            // Return status to composer UI to update the status.
+            resolve({
+                statusCode: constants.HTTP_RESPONSE_CODES.SUCCESS.OK,
+                data: data
+            });
+        }).catch(function(error) {
+            var res = {};
+            console.log('Problem with PackageManager.upload', error);
+            res.statusCode = error.statusCode || 500;
+            res.errorMessage = {
+                error: 'Failed to upload package ' + req.file.originalname + '. Error: ' + error
+            };
+            reject(res);
+        });
+    });
+};
+
+PackageManager.update = function(req) {
     console.log(' Updating file', req.file.originalname, 'as', req.file.filename);
     var api_server = req.query['api_server'];
     // dev_download_server is for testing purposes.
@@ -359,22 +416,13 @@
     });
 };
 
-Composer.upload = function(req) {
-    console.log(' Uploading file', req.file.originalname, 'as', req.file.filename);
+PackageManager.export = function(req) {
+    // /api/operations/package-export
     var api_server = req.query['api_server'];
-    // dev_download_server is for testing purposes.
-    // It is the direct IP address of the Node server where the
-    // package will be hosted.
-    var download_host = req.query['dev_download_server'];
-
-    if (!download_host) {
-        download_host = req.protocol + '://' + req.get('host');//req.api_server + ':' + utils.getPortForProtocol(req.protocol);
-    }
-
     return new Promise(function(resolve, reject) {
         Promise.all([
             rp({
-                uri: utils.confdPort(api_server) + '/api/operations/package-create',
+                uri: utils.confdPort(api_server) + '/api/operations/package-export',
                 method: 'POST',
                 headers: _.extend({}, constants.HTTP_HEADERS.accept.collection, {
                     'Authorization': req.get('Authorization')
@@ -383,41 +431,94 @@
                 rejectUnauthorized: false,
                 resolveWithFullResponse: true,
                 json: true,
-                body: {
-                    input: {
-                        'external-url': download_host + '/composer/upload/' + req.file.filename,
-                        'package-type': 'VNFD',
-                        'package-id': uuid()
-                    }
-                }
+                body: { "input": req.body}
             })
         ]).then(function(result) {
             var data = {};
-            data['transaction_id'] = result[0].body['output']['transaction-id'];
-
-            // Add a status checker on the transaction and then to delete the file later
-            PackageFileHandler.checkCreatePackageStatusAndHandleFile(req, data['transaction_id']);
-
-            // Return status to composer UI to update the status.
             resolve({
                 statusCode: constants.HTTP_RESPONSE_CODES.SUCCESS.OK,
-                data: data
+                data: result[0].body
             });
         }).catch(function(error) {
             var res = {};
-            console.log('Problem with Composer.upload', error);
+            console.log('Problem with PackageManager.export', error);
             res.statusCode = error.statusCode || 500;
             res.errorMessage = {
-                error: 'Failed to upload package ' + req.file.originalname + '. Error: ' + error
+                error: error
             };
             reject(res);
         });
     });
-};
+}
 
+PackageManager.copy = function(req) {
+    // /api/operations/package-copy
+    var api_server = req.query['api_server'];
+    return new Promise(function(resolve, reject) {
+        Promise.all([
+            rp({
+                uri: utils.confdPort(api_server) + '/api/operations/package-copy',
+                method: 'POST',
+                headers: _.extend({}, constants.HTTP_HEADERS.accept.collection, {
+                    'Authorization': req.get('Authorization')
+                }),
+                forever: constants.FOREVER_ON,
+                rejectUnauthorized: false,
+                resolveWithFullResponse: true,
+                json: true,
+                body: { "input": req.body}
+            })
+        ]).then(function(result) {
+            var data = {};
+            resolve({
+                statusCode: constants.HTTP_RESPONSE_CODES.SUCCESS.OK,
+                data: result[0].body
+            });
+        }).catch(function(error) {
+            var res = {};
+            console.log('Problem with PackageManager.copy', error);
+            res.statusCode = error.statusCode || 500;
+            res.errorMessage = {
+                error: error
+            };
+            reject(res);
+        });
+    });
+}
 
+PackageManager.getJobStatus = function(req) {
+    var api_server = req.query["api_server"];
+    var uri = utils.confdPort(api_server);
+    var url = '/api/operational/copy-jobs';
+    var id = req.params['id'];
+    return new Promise(function(resolve, reject) {
+        request({
+            url: uri + url + '?deep',
+            method: 'GET',
+            headers: _.extend({}, constants.HTTP_HEADERS.accept.data, {
+                'Authorization': req.get('Authorization')
+            }),
+            forever: constants.FOREVER_ON,
+            rejectUnauthorized: false,
+        }, function(error, response, body) {
+            if (utils.validateResponse('restconfAPI.streams', error, response, body, resolve, reject)) {
+                var data = JSON.parse(response.body)['rw-pkg-mgmt:copy-jobs'];
+                var returnData = [];
+                data && data.job.map(function(d) {
+                    if(d['transaction-id'] == id) {
+                        returnData.push(d)
+                    }
+                })
+                resolve({
+                    statusCode: response.statusCode,
+                    data: returnData
+                })
+            };
+        })
+    })
+}
 
-Composer.addFile = function(req) {
+FileManager.addFile = function(req) {
     console.log(' Uploading file', req.file.originalname, 'as', req.file.filename);
     var api_server = req.query['api_server'];
     var download_host = req.query['dev_download_server'];
@@ -468,41 +569,6 @@
     });
 }
 
-Composer.exportPackage = function(req) {
-    // /api/operations/package-export
-    var api_server = req.query['api_server'];
-    return new Promise(function(resolve, reject) {
-        Promise.all([
-            rp({
-                uri: utils.confdPort(api_server) + '/api/operations/package-export',
-                method: 'POST',
-                headers: _.extend({}, constants.HTTP_HEADERS.accept.collection, {
-                    'Authorization': req.get('Authorization')
-                }),
-                forever: constants.FOREVER_ON,
-                rejectUnauthorized: false,
-                resolveWithFullResponse: true,
-                json: true,
-                body: { "input": req.body}
-            })
-        ]).then(function(result) {
-            var data = {};
-            resolve({
-                statusCode: constants.HTTP_RESPONSE_CODES.SUCCESS.OK,
-                data: result[0].body
-            });
-        }).catch(function(error) {
-            var res = {};
-            console.log('Problem with Composer.exportPackage', error);
-            res.statusCode = error.statusCode || 500;
-            res.errorMessage = {
-                error: error
-            };
-            reject(res);
-        });
-    });
-}
-
 FileManager.get = function(req) {
     var api_server = req.query['api_server'];
     var type = req.query['package_type'] && req.query['package_type'].toUpperCase();
@@ -654,5 +720,6 @@
 }
 module.exports = {
     Composer:Composer,
-    FileManager: FileManager
+    FileManager: FileManager,
+    PackageManager: PackageManager
 };
diff --git a/skyquake/plugins/composer/routes.js b/skyquake/plugins/composer/routes.js
index 3782209..b3641aa 100644
--- a/skyquake/plugins/composer/routes.js
+++ b/skyquake/plugins/composer/routes.js
@@ -23,6 +23,7 @@
 var C = require('./api/composer.js');
 var Composer = C.Composer;
 var FileManager = C.FileManager;
+var PackageManager = C.PackageManager;
 var multer = require('multer');
 var fs = require('fs');
 var path = require('path');
@@ -103,28 +104,9 @@
         res.send(error.errorMessage);
     });
 });
-router.post('/upload', cors(), upload.single('package'), function (req, res, next) {
-    Composer.upload(req).then(function(data) {
-        utils.sendSuccessResponse(data, res);
-    }, function(error) {
-        utils.sendErrorResponse(error, res);
-    });
-});
-router.use('/upload', cors(), express.static('upload/packages'));
-
-router.post('/update', cors(), upload.single('package'), function (req, res, next) {
-    Composer.update(req).then(function(data) {
-        utils.sendSuccessResponse(data, res);
-    }, function(error) {
-        utils.sendErrorResponse(error, res);
-    });
-});
-router.use('/update', cors(), express.static('upload/packages'));
-
-
 
 router.post('/api/file-manager', cors(), upload.single('package'), function (req, res, next) {
-    Composer.addFile(req).then(function(data) {
+    FileManager.addFile(req).then(function(data) {
         utils.sendSuccessResponse(data, res);
     }, function(error) {
         utils.sendErrorResponse(error, res);
@@ -153,8 +135,42 @@
     });
 });
 
+// Catalog operations via package manager
+
+router.post('/upload', cors(), upload.single('package'), function (req, res, next) {
+    PackageManager.upload(req).then(function(data) {
+        utils.sendSuccessResponse(data, res);
+    }, function(error) {
+        utils.sendErrorResponse(error, res);
+    });
+});
+router.use('/upload', cors(), express.static('upload/packages'));
+
+router.post('/update', cors(), upload.single('package'), function (req, res, next) {
+    PackageManager.update(req).then(function(data) {
+        utils.sendSuccessResponse(data, res);
+    }, function(error) {
+        utils.sendErrorResponse(error, res);
+    });
+});
+router.use('/update', cors(), express.static('upload/packages'));
+
 router.post('/api/package-export', cors(), function (req, res, next) {
-    Composer.exportPackage(req).then(function(data) {
+    PackageManager.export(req).then(function(data) {
+        utils.sendSuccessResponse(data, res);
+    }, function(error) {
+        utils.sendErrorResponse(error, res);
+    });
+});
+router.post('/api/package-copy', cors(), function (req, res, next) {
+    PackageManager.copy(req).then(function(data) {
+        utils.sendSuccessResponse(data, res);
+    }, function(error) {
+        utils.sendErrorResponse(error, res);
+    });
+});
+router.get('/api/package-manager/jobs/:id', cors(), function (req, res, next) {
+    PackageManager.getJobStatus(req).then(function(data) {
         utils.sendSuccessResponse(data, res);
     }, function(error) {
         utils.sendErrorResponse(error, res);
diff --git a/skyquake/plugins/composer/src/src/actions/CatalogPackageManagerActions.js b/skyquake/plugins/composer/src/src/actions/CatalogPackageManagerActions.js
index 22e32d3..2769c33 100644
--- a/skyquake/plugins/composer/src/src/actions/CatalogPackageManagerActions.js
+++ b/skyquake/plugins/composer/src/src/actions/CatalogPackageManagerActions.js
@@ -21,7 +21,17 @@
 class CatalogPackageManagerActions {
 
 	constructor() {
-		this.generateActions('downloadCatalogPackage', 'downloadCatalogPackageStatusUpdated', 'downloadCatalogPackageError', 'uploadCatalogPackage', 'uploadCatalogPackageStatusUpdated', 'uploadCatalogPackageError', 'removeCatalogPackage');
+		this.generateActions(
+			'downloadCatalogPackage', 
+			'downloadCatalogPackageStatusUpdated',
+			'downloadCatalogPackageError', 
+			'uploadCatalogPackage', 
+			'uploadCatalogPackageStatusUpdated', 
+			'uploadCatalogPackageError',
+			'copyCatalogPackage', 
+			'updateStatus', 
+			'removeCatalogOperation'
+			);
 	}
 
 }
diff --git a/skyquake/plugins/composer/src/src/components/CatalogPackageManager.js b/skyquake/plugins/composer/src/src/components/CatalogPackageManager.js
index c0b996c..0811093 100644
--- a/skyquake/plugins/composer/src/src/components/CatalogPackageManager.js
+++ b/skyquake/plugins/composer/src/src/components/CatalogPackageManager.js
@@ -88,7 +88,7 @@
 
 		var createItem = function (catalogPackage) {
 			const onClickRemove = function () {
-				CatalogPackageManagerActions.removeCatalogPackage(catalogPackage);
+				CatalogPackageManagerActions.removeCatalogOperation(catalogPackage);
 			};
 			const classNames = ClassNames('item', {'-error': catalogPackage.error, '-success': catalogPackage.success});
 			return (
@@ -106,11 +106,11 @@
 			);
 		};
 
-		const packages = this.state.packages || [];
+		const operations = this.state.operations || [];
 		return (
 			<div className="CatalogPackageManager">
 				<div className="items">
-					{packages.map(createItem)}
+					{operations.map(createItem)}
 				</div>
 			</div>
 		);
diff --git a/skyquake/plugins/composer/src/src/components/CatalogPanelToolbar.js b/skyquake/plugins/composer/src/src/components/CatalogPanelToolbar.js
index 5e3e3c0..1501ecd 100644
--- a/skyquake/plugins/composer/src/src/components/CatalogPanelToolbar.js
+++ b/skyquake/plugins/composer/src/src/components/CatalogPanelToolbar.js
@@ -94,6 +94,7 @@
 		CatalogItemsActions.createCatalogItem(type);
 	},
 	onClickDuplicateCatalogItem() {
+		CatalogPanelTrayActions.open();
 		CatalogItemsActions.duplicateSelectedCatalogItem();
 	},
 	onClickExportCatalogItems() {
diff --git a/skyquake/plugins/composer/src/src/sources/CatalogPackageManagerSource.js b/skyquake/plugins/composer/src/src/sources/CatalogPackageManagerSource.js
index 48d697b..290d715 100644
--- a/skyquake/plugins/composer/src/src/sources/CatalogPackageManagerSource.js
+++ b/skyquake/plugins/composer/src/src/sources/CatalogPackageManagerSource.js
@@ -18,71 +18,98 @@
  */
 'use strict';
 
-import $ from 'jquery'
 import alt from '../alt'
-import utils from '../libraries/utils'
+import catalogUtils from '../libraries/utils'
 import CatalogPackageManagerActions from '../actions/CatalogPackageManagerActions'
-let Utils = require('utils/utils.js');
-function getApiServerOrigin() {
-	return utils.getSearchParams(window.location).upload_server + ':4567';
-}
+import Utils from 'utils/utils.js';
 
-function ajaxRequest(path, catalogPackage, resolve, reject, method = 'GET', input, urlOverride) {
-	let options = {
-		url: getApiServerOrigin() + path,
-		type: method,
-		beforeSend: Utils.addAuthorizationStub,
-		dataType: 'json',
-		success: function(data) {
-			if (typeof data == 'string') {
-				data = JSON.parse(data);
-			}
-			resolve({
-				data: data,
-				state: catalogPackage
-			});
-		},
-		error: function(error) {
-			if (typeof error == 'string') {
-				error = JSON.parse(error);
-			}
-			reject({
-				data: error,
-				state: catalogPackage
-			});
-		}
+const getAuthorization = () => 'Basic ' + window.sessionStorage.getItem("auth");
+
+const getStateApiPath = (operation, id) => 
+	catalogUtils.getSearchParams(window.location).upload_server + ':4567/api/' + operation + '/' + id + '/state';
+
+const getComposerApiPath = (api) =>
+	window.location.origin + '/composer/api/' + api + '?api_server=' + catalogUtils.getSearchParams(window.location).api_server;
+
+const SUCCESS = {
+		pending: false,
+		success: true,
+		error: false,
+		message: "Completely successfully"
 	};
-	if(input) {
-		options.data = input;
+const FAILED = {
+		pending: false,
+		success: false,
+		error: true,
+		message: "Failed"
+	};
+const PENDING = {
+		pending: true,
+		success: false,
+		error: false,
+		message: "In progress"
+	};
+
+function ajaxFetch(url, operation, resolve, reject, method = 'GET', input, urlOverride) {
+	let credentials = 'same-origin';
+	let body = input ? JSON.stringify(input) : null;
+	let headers = new Headers();
+	headers.append('Authorization', getAuthorization());
+	headers.append('Accept', 'application/json');
+	if (input) {
+		headers.append('Content-Type', 'application/json');
 	}
-	if (urlOverride) {
-		options.url = window.location.origin + path;
+
+	fetch(url, {method, credentials, headers, body})
+		.then(checkStatusGetJson)
+		.then(handleSuccess)
+		.catch(handleError);
+
+	function checkStatusGetJson(response) {
+		if (response.status >= 200 && response.status < 300) {
+			return response.json();
+		} else {
+			var error = new Error(response.statusText)
+			error.status = response.status;
+			error.statusText = response.statusText;
+			throw error
+		}
 	}
-	$.ajax(options).fail(function(xhr){
-			            //Authentication and the handling of fail states should be wrapped up into a connection class.
-			            Utils.checkAuthentication(xhr.status);
-		          	});
+
+	function handleSuccess (data) {
+		if (typeof data == 'string') {
+			data = JSON.parse(data);
+		}
+		resolve({
+			state: operation,
+			data,
+			operation
+		});
+	}
+
+	function handleError (data) {
+		if (typeof data == 'string') {
+			data = JSON.parse(data);
+		}
+		reject({
+			state: operation,
+			data,
+			operation
+		});
+	}
 }
 
-
-
 const CatalogPackageManagerSource = {
 
-		requestCatalogPackageDownload: function () {
+	requestCatalogPackageDownload: function () {
 		return {
 			remote: function (state, download, format, grammar, schema) {
 				return new Promise((resolve, reject) => {
-					// the server does not add a status in the payload
-					// so we add one so that the success handler will
-					// be able to follow the flow of this download
-					const setStatusBeforeResolve = (response = {}) => {
+					// we need an initial status for UI (server does not send)
+					const setStatusBeforeResolve = (response) => {
 						response.data.status = 'download-requested';
 						resolve(response);
 					};
-					// RIFT-13485 requires to send type (nsd/vnfd) as a path element.
-					// Backend no longer supports mixed multi-package download.
-					// Probably does not even support multi-package download of same type.
-					// Hence, pick the type from the first element.
 					const data = {
 						"package-type": download['catalogItems'][0]['uiState']['type'].toUpperCase(),
 						"package-id": download.ids,
@@ -90,13 +117,9 @@
 						"export-grammar": grammar && grammar.toUpperCase() || 'OSM',
 						"export-schema": schema && schema.toUpperCase() || "RIFT"
 					}
-					const path = "/composer/api/package-export?api_server=" + utils.getSearchParams(window.location).api_server;
-					ajaxRequest(path, download, setStatusBeforeResolve, reject, 'POST', data, true);
+					const path = getComposerApiPath('package-export');
+					ajaxFetch(path, download, setStatusBeforeResolve, reject, 'POST', data, true);
 				})
-				//.then(function(data) {
-				//	let filename = data.data.output.filename;
-				//	window.open(getApiServerOrigin() + "/api/export/" + filename, "_blank")
-				//});
 			},
 			success: CatalogPackageManagerActions.downloadCatalogPackageStatusUpdated,
 			error: CatalogPackageManagerActions.downloadCatalogPackageError
@@ -108,8 +131,8 @@
 			remote: function(state, download) {
 				const transactionId = download.transactionId;
 				return new Promise(function(resolve, reject) {
-					const path = '/api/export/' + transactionId + '/state';
-					ajaxRequest(path, download, resolve, reject);
+					const path = getStateApiPath('export', transactionId);
+					ajaxFetch(path, download, resolve, reject);
 				});
 			},
 			success: CatalogPackageManagerActions.downloadCatalogPackageStatusUpdated,
@@ -117,14 +140,69 @@
 		}
 	},
 
+	requestCatalogPackageCopy: function () {
+		return {
+			remote: function (state, operationInfo) {
+				return new Promise((resolve, reject) => {
+					// we need an initial status for UI (server does not send)
+					const successHandler = (response) => {
+						const status = response.data.output.status;
+						const state = status === "COMPLETED" ? SUCCESS : status === "FAILED" ? FAILED : PENDING;
+						state.progress = 25; // put something
+						let operation = Object.assign({}, operationInfo, state);
+						operation.transactionId = response.data.output['transaction-id'];
+						resolve(operation);
+					}
+					const failHandler = (response) => {
+						let operation = Object.assign({}, this, FAILED);
+						reject(operation);
+					};
+					const data = {
+						"package-type": operationInfo.packageType,
+						"package-id": operationInfo.id,
+						"package-name": operationInfo.name
+					}
+					const path = getComposerApiPath('package-copy');
+					ajaxFetch(path, operationInfo, successHandler, failHandler, 'POST', data, true);
+				})
+			},
+			success: CatalogPackageManagerActions.updateStatus,
+			error: CatalogPackageManagerActions.updateStatus
+		};
+	},
+
+	requestCatalogPackageCopyStatus: function() {
+		return {
+			remote: function(state, operation) {
+				return new Promise(function(resolve, reject) {
+					const successHandler = (response) => {
+						const status = response.data[0].status;
+						const state = status === "COMPLETED" ? SUCCESS : status === "FAILED" ? FAILED : PENDING;
+						state.progress = state.pending ? operation.progress + ((100 - operation.progress) / 2) : 100;
+						let newOp = Object.assign({}, operation, state);
+						resolve(newOp);
+					};
+					const failHandler = (response) => {
+						let operation = Object.assign({}, this, FAILED);
+						reject(operation);
+					};
+					const path = getComposerApiPath('package-manager/jobs/' + operation.transactionId);
+					ajaxFetch(path, operation, successHandler, failHandler);
+				});
+			},
+			success: CatalogPackageManagerActions.updateStatus,
+			error: CatalogPackageManagerActions.updateStatus
+		}
+	},
+
 	requestCatalogPackageUploadStatus: function () {
 		return {
 			remote: function (state, upload) {
 				const transactionId = upload.transactionId;
 				return new Promise(function (resolve, reject) {
 					const action = upload.riftAction === 'onboard' ? 'upload' : 'update';
-					const path = '/api/' + action + '/' + transactionId + '/state';
-					ajaxRequest(path, upload, resolve, reject);
+					const path = getStateApiPath(action, transactionId);
+					ajaxFetch(path, upload, resolve, reject);
 				});
 			},
 			success: CatalogPackageManagerActions.uploadCatalogPackageStatusUpdated,
diff --git a/skyquake/plugins/composer/src/src/stores/CatalogDataStore.js b/skyquake/plugins/composer/src/src/stores/CatalogDataStore.js
index 3e4ac7f..06d1342 100644
--- a/skyquake/plugins/composer/src/src/stores/CatalogDataStore.js
+++ b/skyquake/plugins/composer/src/src/stores/CatalogDataStore.js
@@ -460,27 +460,10 @@
 	}
 
 	duplicateSelectedCatalogItem() {
-		const item = this.getFirstSelectedCatalogItem();
-		if (item) {
-			const newItem = _cloneDeep(item);
-			newItem.name = newItem.name + ' Copy';
-			newItem.id = guid();
-			UID.assignUniqueId(newItem.uiState);
-			const nsd = this.addNewItemToCatalog(newItem);
-			this.selectCatalogItem(nsd);
-			nsd.uiState.isNew = true;
-			nsd.uiState.modified = true;
-			nsd.uiState['instance-ref-count'] = 0;
-			// note duplicated items get a new id, map the layout position
-			// of the old id to the new id in order to preserve the layout
-			if (nsd.uiState.containerPositionMap) {
-				nsd.uiState.containerPositionMap[nsd.id] = nsd.uiState.containerPositionMap[item.id];
-				delete nsd.uiState.containerPositionMap[item.id];
-			}
-			setTimeout(() => {
-				this.selectCatalogItem(nsd);
-				CatalogItemsActions.editCatalogItem.defer(nsd);
-			}, 200);
+		// make request to backend to duplicate an item
+		const srcItem = this.getFirstSelectedCatalogItem();
+		if (srcItem) {
+			CatalogPackageManagerActions.copyCatalogPackage.defer(srcItem);
 		}
 	}
 
diff --git a/skyquake/plugins/composer/src/src/stores/CatalogPackageManagerStore.js b/skyquake/plugins/composer/src/src/stores/CatalogPackageManagerStore.js
index 5ffe83f..c964c67 100644
--- a/skyquake/plugins/composer/src/src/stores/CatalogPackageManagerStore.js
+++ b/skyquake/plugins/composer/src/src/stores/CatalogPackageManagerStore.js
@@ -32,8 +32,21 @@
 import imgDownload from '../../../node_modules/open-iconic/svg/cloud-download.svg'
 import imgOnboard from '../../../node_modules/open-iconic/svg/cloud-upload.svg'
 import imgUpdate from '../../../node_modules/open-iconic/svg/data-transfer-upload.svg'
+import imgCopy from '../../../node_modules/open-iconic/svg/layers.svg'
 
 const defaults = {
+	operation: {
+		id: '',
+		name: '',
+		icon: '',
+		transactionId: '',
+		progress: 0,
+		message: 'Requested',
+		args: {},
+		pending: false,
+		success: false,
+		error: false,
+	},
 	downloadPackage: {
 		id: '',
 		name: '',
@@ -60,13 +73,13 @@
 	return utils.getSearchParams(window.location).upload_server + ':4567';
 }
 
-function delayStatusCheck(statusCheckFunction, catalogPackage) {
-	if (!catalogPackage.checkStatusTimeoutId) {
+function delayStatusCheck(statusCheckFunction, operation) {
+	if (!operation.checkStatusTimeoutId) {
 		const delayCallback = function () {
-			delete catalogPackage.checkStatusTimeoutId;
-			statusCheckFunction(catalogPackage).catch(exception);
+			delete operation.checkStatusTimeoutId;
+			statusCheckFunction(operation).catch(exception);
 		};
-		catalogPackage.checkStatusTimeoutId = _delay(delayCallback, defaults.checkStatusDelayInSeconds * 1000);
+		operation.checkStatusTimeoutId = _delay(delayCallback, defaults.checkStatusDelayInSeconds * 1000);
 	}
 }
 
@@ -74,52 +87,76 @@
 
 	constructor() {
 
-		this.packages = [];
+		this.operations = [];
 
 		this.registerAsync(CatalogDataSource);
 		this.registerAsync(CatalogPackageManagerSource);
-		this.bindAction(CatalogPackageManagerActions.REMOVE_CATALOG_PACKAGE, this.removeCatalogPackage);
+		this.bindAction(CatalogPackageManagerActions.REMOVE_CATALOG_OPERATION, this.removeCatalogOperation);
 		this.bindAction(CatalogPackageManagerActions.DOWNLOAD_CATALOG_PACKAGE, this.downloadCatalogPackage);
 		this.bindAction(CatalogPackageManagerActions.DOWNLOAD_CATALOG_PACKAGE_STATUS_UPDATED, this.onDownloadCatalogPackageStatusUpdated);
 		this.bindAction(CatalogPackageManagerActions.DOWNLOAD_CATALOG_PACKAGE_ERROR, this.onDownloadCatalogPackageError);
 		this.bindAction(CatalogPackageManagerActions.UPLOAD_CATALOG_PACKAGE, this.uploadCatalogPackage);
 		this.bindAction(CatalogPackageManagerActions.UPLOAD_CATALOG_PACKAGE_STATUS_UPDATED, this.onUploadCatalogPackageStatusUpdated);
 		this.bindAction(CatalogPackageManagerActions.UPLOAD_CATALOG_PACKAGE_ERROR, this.onUploadCatalogPackageError);
-
+		this.bindAction(CatalogPackageManagerActions.COPY_CATALOG_PACKAGE, this.copyCatalogPackage);
+		this.bindAction(CatalogPackageManagerActions.UPDATE_STATUS, this.updateOperationStatus);
 	}
 
-	addPackage(catalogPackage) {
-		const packages = [catalogPackage].concat(this.packages);
-		this.setState({packages: packages});
+	addOperation(operation) {
+		const operations = [operation].concat(this.operations);
+		this.setState({operations});
 	}
 
-	updatePackage(catalogPackage) {
-		const packages = this.packages.map(d => {
-			if (d.id === catalogPackage.id) {
-				return Object.assign({}, d, catalogPackage);
+	updateOperation(operation) {
+		const operations = this.operations.map(d => {
+			if (d.id === operation.id) {
+				return Object.assign({}, d, operation);
 			}
 			return d;
 		});
-		this.setState({packages: packages});
+		this.setState({operations});
 	}
 
-	removeCatalogPackage(catalogPackage) {
-		const packages = this.packages.filter(d => d.id !== catalogPackage.id);
-		this.setState({packages: packages});
+	removeCatalogOperation(operation) {
+		const operations = this.operations.filter(d => d.id !== operation.id);
+		this.setState({operations});
+	}
+
+	copyCatalogPackage(sourcePackage) {
+		let operationInfo = Object.assign({}, defaults.operation);
+		operationInfo.name =  "Duplication of " + sourcePackage.name;
+		operationInfo.id = guid();
+		operationInfo.icon = imgCopy;
+		operationInfo.type = 'copy';
+		operationInfo.message = 'Requesting package duplication.';
+		operationInfo.args.packageType = sourcePackage['uiState']['type'].toUpperCase();
+		operationInfo.args.id =  sourcePackage.id;
+		operationInfo.args.name =  sourcePackage.name + ' copy';
+
+		this.addOperation(operationInfo);
+		this.getInstance().requestCatalogPackageCopy(operationInfo, sourcePackage);
+	}
+
+	updateOperationStatus(operation) {
+		console.debug('package manager operation status update', operation);
+		this.updateOperation(operation);
+		if (operation.pending) {
+			delayStatusCheck(this.getInstance().requestCatalogPackageCopyStatus, operation);
+		}
 	}
 
 	uploadCatalogPackage(file) {
 		file.id = file.id || guid();
-		const catalogPackage = _pick(file, packagePropertyNames);
-		catalogPackage.icon = file.riftAction === 'onboard' ? imgOnboard : imgUpdate;
-		catalogPackage.type = 'upload';
-		this.addPackage(catalogPackage);
+		const operation = _pick(file, packagePropertyNames);
+		operation.icon = file.riftAction === 'onboard' ? imgOnboard : imgUpdate;
+		operation.type = 'upload';
+		this.addOperation(operation);
 		// note DropZone.js handles the async upload so we don't have to invoke any async action creators
 	}
 
 	onUploadCatalogPackageStatusUpdated(response) {
 		const upload = updateStatusInfo(response);
-		this.updatePackage(upload);
+		this.updateOperation(upload);
 		console.log('updating package upload')
 		// if pending with no transaction id - do nothing
 		// bc DropZone.js will notify upload progress
@@ -134,8 +171,8 @@
 
 	onUploadCatalogPackageError(response) {
 		console.warn('onUploadCatalogPackageError', response);
-		const catalogPackage = updateStatusInfo(response);
-		this.updatePackage(catalogPackage);
+		const operation = updateStatusInfo(response);
+		this.updateOperation(operation);
 	}
 
 	downloadCatalogPackage(data) {
@@ -144,22 +181,22 @@
 		let grammar = data['selectedGrammar'] || 'osm';
 		let format = "YAML";
 		if (catalogItems.length) {
-			const catalogPackage = Object.assign({}, defaults.downloadPackage, {id: guid()});
-			catalogPackage.name = catalogItems[0].name;
-			catalogPackage.type = 'download';
+			const operation = Object.assign({}, defaults.downloadPackage, {id: guid()});
+			operation.name = catalogItems[0].name;
+			operation.type = 'download';
 			if (catalogItems.length > 1) {
-				catalogPackage.name += ' (' + catalogItems.length + ' items)';
+				operation.name += ' (' + catalogItems.length + ' items)';
 			}
-			catalogPackage.ids = catalogItems.map(d => d.id).sort().toString();
-			catalogPackage.catalogItems = catalogItems;
-			this.addPackage(catalogPackage);
-			this.getInstance().requestCatalogPackageDownload(catalogPackage, format, grammar, schema).catch(exception);
+			operation.ids = catalogItems.map(d => d.id).sort().toString();
+			operation.catalogItems = catalogItems;
+			this.addOperation(operation);
+			this.getInstance().requestCatalogPackageDownload(operation, format, grammar, schema).catch(exception);
 		}
 	}
 
 	onDownloadCatalogPackageStatusUpdated(response) {
 		const download = updateStatusInfo(response);
-		this.updatePackage(download);
+		this.updateOperation(download);
 		if (download.pending) {
 			delayStatusCheck(this.getInstance().requestCatalogPackageDownloadStatus, download);
 		}
@@ -167,8 +204,8 @@
 
 	onDownloadCatalogPackageError(response) {
 		console.warn('onDownloadCatalogPackageError', response);
-		const catalogPackage = updateStatusInfo(response);
-		this.updatePackage(catalogPackage);
+		const operation = updateStatusInfo(response);
+		this.updateOperation(operation);
 	}
 
 }
@@ -188,26 +225,26 @@
 }
 
 function updateStatusInfo(response) {
-	// returns the catalogPackage object with the status fields updated based on the server response
+	// returns the operation object with the status fields updated based on the server response
 	const statusInfo = {
 		pending: false,
 		success: false,
 		error: false
 	};
 	const responseData = (response.data.output) ? response.data.output :  response.data;
-	const catalogPackage = response.state;
+	const operation = response.state;
 	if ( typeof response.data.status !== "number" ) {
 		switch(response.data.status) {
 		case 'upload-progress':
 			statusInfo.pending = true;
 			statusInfo.progress = parseFloat(responseData.progress) || 0;
-			statusInfo.message = calculateUploadProgressMessage(catalogPackage.size, responseData.progress, responseData.bytesSent);
+			statusInfo.message = calculateUploadProgressMessage(operation.size, responseData.progress, responseData.bytesSent);
 			break;
 		case 'upload-success':
 			statusInfo.pending = true;
 			statusInfo.progress = 100;
 			statusInfo.message = 'Upload completed.';
-			statusInfo.transactionId = responseData['transaction_id'] || responseData['transaction-id'] || catalogPackage.transactionId;
+			statusInfo.transactionId = responseData['transaction_id'] || responseData['transaction-id'] || operation.transactionId;
 			break;
 		case 'upload-error':
 			statusInfo.error = true;
@@ -216,7 +253,7 @@
 		case 'download-requested':
 			statusInfo.pending = true;
 			statusInfo.progress = 25;
-			statusInfo.transactionId = responseData['transaction_id'] || responseData['transaction-id']  || catalogPackage.transactionId;
+			statusInfo.transactionId = responseData['transaction_id'] || responseData['transaction-id']  || operation.transactionId;
 			break;
 		case 'pending':
 			statusInfo.pending = true;
@@ -227,12 +264,12 @@
 			statusInfo.success = true;
 			statusInfo.progress = 100;
 			statusInfo.message = responseData.events[responseData.events.length - 1].text;
-			if (catalogPackage.type === 'download') {
+			if (operation.type === 'download') {
 				statusInfo.urlValidUntil = moment().add(defaults.downloadUrlTimeToLiveInMinutes, 'minutes').toISOString();
 				if (responseData.filename) {
 					statusInfo.url = getCatalogPackageManagerServerOrigin() + '/api/export/' + responseData.filename;
 				} else {
-					statusInfo.url = getCatalogPackageManagerServerOrigin() + '/api/export/' + catalogPackage.transactionId + '.tar.gz';
+					statusInfo.url = getCatalogPackageManagerServerOrigin() + '/api/export/' + operation.transactionId + '.tar.gz';
 				}
 			}
 			break;
@@ -248,7 +285,7 @@
 		statusInfo.error = true;
 		statusInfo.message = responseData.statusText || 'Error';
 	}
-	return Object.assign({}, catalogPackage, statusInfo);
+	return Object.assign({}, operation, statusInfo);
 }
 
 export default alt.createStore(CatalogPackageManagerStore, 'CatalogPackageManagerStore');