From 187d0e7dd9c1fd5d2c98820d91ab9bcfaa23ec43 Mon Sep 17 00:00:00 2001 From: John B Date: Mon, 10 Jan 2022 16:10:37 -0500 Subject: [PATCH] Add import/export buttons & functionality --- src/ui/src/index.js | 10 +- .../src/js/controllers/admin_garden_view.js | 38 +++- .../garden/export_garden_config.js | 51 +++++ .../garden/import_garden_config.js | 101 ++++++++++ src/ui/src/js/services/garden_service.js | 189 +++++++++++++----- src/ui/src/partials/admin_garden_view.html | 9 +- .../src/templates/import_garden_config.html | 34 ++++ 7 files changed, 366 insertions(+), 66 deletions(-) create mode 100644 src/ui/src/js/controllers/garden/export_garden_config.js create mode 100644 src/ui/src/js/controllers/garden/import_garden_config.js create mode 100644 src/ui/src/templates/import_garden_config.html diff --git a/src/ui/src/index.js b/src/ui/src/index.js index a3aa3bbc7..ff1b3c788 100644 --- a/src/ui/src/index.js +++ b/src/ui/src/index.js @@ -110,6 +110,11 @@ import { } from './js/controllers/admin_role.js'; import adminGardenController from './js/controllers/admin_garden.js'; import adminGardenViewController from './js/controllers/admin_garden_view.js'; +import { + gardenConfigImportController, + gardenConfigImportModalController, +} from './js/controllers/garden/import_garden_config'; +import gardenConfigExportController from './js/controllers/garden/export_garden_config'; import commandIndexController from './js/controllers/command_index.js'; import commandViewController from './js/controllers/command_view.js'; import requestIndexController from './js/controllers/request_index.js'; @@ -210,7 +215,7 @@ angular .controller('AdminQueueController', adminQueueController) .controller( 'AdminSystemForceDeleteController', - adminSystemForceDeleteController + adminSystemForceDeleteController, ) .controller('AdminSystemController', adminSystemController) .controller('AdminSystemLogsController', adminSystemLogsController) @@ -220,6 +225,9 @@ angular .controller('NewRoleController', newRoleController) .controller('AdminGardenController', adminGardenController) .controller('AdminGardenViewController', adminGardenViewController) + .controller('GardenConfigExportController', gardenConfigExportController) + .controller('GardenConfigImportController', gardenConfigImportController) + .controller('GardenConfigImportModalController', gardenConfigImportModalController) .controller('CommandIndexController', commandIndexController) .controller('CommandViewController', commandViewController) .controller('RequestIndexController', requestIndexController) diff --git a/src/ui/src/js/controllers/admin_garden_view.js b/src/ui/src/js/controllers/admin_garden_view.js index e1be63ce5..5a261e65d 100644 --- a/src/ui/src/js/controllers/admin_garden_view.js +++ b/src/ui/src/js/controllers/admin_garden_view.js @@ -43,8 +43,21 @@ export default function adminGardenViewController( $scope.$broadcast('schemaFormRedraw'); }; + $scope.updateModelFromImport = (newGardenDefinition) => { + const existingData = $scope.data; + const newConnType = newGardenDefinition['connection_type']; + const newConnParams = newGardenDefinition['connection_params']; + + const newData = {...existingData, + connection_type: newConnType, + connection_params: newConnParams}; + + $scope.data = newData; + + generateGardenSF(); + }; + $scope.successCallback = function(response) { - console.log('response', response); $scope.response = response; $scope.data = response.data; @@ -121,8 +134,11 @@ export default function adminGardenViewController( (entryPoint, fieldName) => `schemaForm.error.${entryPoint}.${fieldName}`; const fieldErrorMessage = - (errorObject) => - typeof errorObject === 'string' ? Array(errorObject) : errorObject; + (errorObject) => { + const error = typeof errorObject === 'string' ? + Array(errorObject) : errorObject; + return error[0]; + }; const updateValidationMessages = (entryPoint, errorsObject) => { for (const fieldName in errorsObject) { @@ -144,8 +160,8 @@ export default function adminGardenViewController( */ if (response['data'] && response['data']['message']) { const messageData = String(response['data']['message']); - console.log('MESSG', messageData, 'TYPE', typeof messageData); - const cleanedMessages = messageData.replace(/'/g, '"'); + const singleQuoteRegExp = new RegExp('\'', 'g'); + const cleanedMessages = messageData.replace(singleQuoteRegExp, '"'); const messages = JSON.parse(cleanedMessages); if ('connection_params' in messages) { @@ -171,8 +187,14 @@ export default function adminGardenViewController( } }; + const clearScopeAlerts = () => { + while ($scope.alerts.length) { + $scope.alerts.pop(); + } + }; + $scope.submitGardenForm = function(form, model) { - $scope.alerts = []; + clearScopeAlerts(); resetAllValidationErrors(); $scope.$broadcast('schemaFormValidate'); @@ -183,6 +205,8 @@ export default function adminGardenViewController( updatedGarden = GardenService.formToServerModel($scope.data, model); } catch (e) { + console.log(e); + $scope.alerts.push({ type: 'warning', msg: e, @@ -191,7 +215,7 @@ export default function adminGardenViewController( if (updatedGarden) { GardenService.updateGardenConfig(updatedGarden).then( - _.noop, + () => console.log('Garden update saved successfully'), $scope.addErrorAlert, ); } diff --git a/src/ui/src/js/controllers/garden/export_garden_config.js b/src/ui/src/js/controllers/garden/export_garden_config.js new file mode 100644 index 000000000..dcf54ed9f --- /dev/null +++ b/src/ui/src/js/controllers/garden/export_garden_config.js @@ -0,0 +1,51 @@ +gardenConfigExportController.$inject = + ['$scope', '$rootScope', '$filter', 'GardenService']; + +/** + * gardenConfigExportController - Controller for the garden config export page. + * @param {Object} $scope Angular's $scope object. + * @param {Object} $rootScope Angular's $rootScope object. + * @param {Object} $filter Filter + * @param {Object} GardenService Beer-Garden's garden service. + */ +export default function gardenConfigExportController( + $scope, + $rootScope, + $filter, + GardenService, +) { + $scope.response = $rootScope.sysResponse; + + $scope.exportGardenConfig = (gardenName) => { + const filename = + `GardenExport_${gardenName}_` + + $filter('date')(new Date(Date.now()), 'yyyyMMdd_HHmmss'); + + const formModel = GardenService.formToServerModel($scope.data, $scope.gardenModel); + + const [newConnectionInfo, newConnectionParams] = [{}, {}]; + newConnectionInfo['connection_type'] = formModel['connection_type']; + + if (formModel['connection_params']['http']) { + newConnectionParams['http'] = formModel['connection_params']['http']; + } + + if (formModel['connection_params']['stomp']) { + newConnectionParams['stomp'] = formModel['connection_params']['stomp']; + } + + newConnectionInfo['connection_params'] = newConnectionParams; + + const blob = new Blob( + [JSON.stringify(newConnectionInfo)], + { + type: 'application/json;charset=utf-8', + }, + ); + const downloadLink = angular.element(''); + + downloadLink.attr('href', window.URL.createObjectURL(blob)); + downloadLink.attr('download', filename); + downloadLink[0].click(); + }; +} diff --git a/src/ui/src/js/controllers/garden/import_garden_config.js b/src/ui/src/js/controllers/garden/import_garden_config.js new file mode 100644 index 000000000..a26d204fc --- /dev/null +++ b/src/ui/src/js/controllers/garden/import_garden_config.js @@ -0,0 +1,101 @@ +import template from '../../../templates/import_garden_config.html'; + +gardenConfigImportController.$inject = [ + '$scope', + '$rootScope', + '$uibModal', + '$state', +]; + +/** + * gardenConfigImportController - Controller for garden config import. + * @param {Object} $scope Angular's $scope object. + * @param {Object} $rootScope Angular's $rootScope object. + * @param {Object} $uibModal Angular UI's $uibModal object. + * @param {Object} $state State + */ +export function gardenConfigImportController( + $scope, + $rootScope, + $uibModal, + $state, +) { + $scope.response = $rootScope.sysResponse; + + $scope.openImportGardenConfigPopup = () => { + const popupInstance = $uibModal.open({ + animation: true, + controller: 'GardenConfigImportModalController', + template: template, + }); + + popupInstance.result.then((resolvedResponse) => { + const jsonFileContents = resolvedResponse.jsonFileContents; + const newConfig = JSON.parse(jsonFileContents); + + $scope.updateModelFromImport(newConfig); + }, angular.noop); + }; +} + +gardenConfigImportModalController.$inject = + ['$scope', '$window', '$uibModalInstance']; + +/** + * gardenConfigImportModalController - Controller for the garden config import + * popup window. + * + * @param {Object} $scope Angular's $scope object. + * @param {Object} $window Object for the browser window. + * @param {Object} $uibModalInstance Object for the modal popup window. + */ +export function gardenConfigImportModalController( + $scope, $window, $uibModalInstance) { + $scope.import = {}; + $scope.fileName = undefined; + $scope.fileContents = undefined; + $scope.fileIsGoodJson = true; + + $scope.inputClicker = function() { + $window.document.getElementById('fileSelectHiddenControl').click(); + }; + + $scope.doImport = function() { + $scope.import['jsonFileContents'] = $scope.fileContents; + $uibModalInstance.close($scope.import); + }; + + $scope.cancelImport = function() { + $uibModalInstance.dismiss('cancel'); + }; + + function isParsableJson(string) { + try { + JSON.parse(string); + } catch (e) { + return false; + } + return true; + } + + $scope.onFileSelected = function(event) { + const theFile = event.target.files[0]; + $scope.fileName = theFile.name; + + const reader = new FileReader(); + reader.onload = function(e) { + $scope.$apply(function() { + const result = reader.result; + const isGoodJson = isParsableJson(result); + + if (isGoodJson) { + $scope.fileContents = result; + $scope.fileIsGoodJson = true; + } else { + $scope.fileIsGoodJson = false; + } + }); + }; + reader.readAsText(theFile); + }; +} diff --git a/src/ui/src/js/services/garden_service.js b/src/ui/src/js/services/garden_service.js index 21ff453aa..6df047435 100644 --- a/src/ui/src/js/services/garden_service.js +++ b/src/ui/src/js/services/garden_service.js @@ -48,6 +48,19 @@ export default function gardenService($http) { return $http.delete('api/v1/gardens/' + encodeURIComponent(name)); }; + GardenService.importGardenConfig = (gardenDefinition, gardenConfigJson) => { + const gardenName = encodeURIComponent(gardenDefinition['name']); + const url = `api/v1/gardens/${gardenName}`; + const gardenConfig = JSON.parse(gardenConfigJson); + gardenDefinition['connection_params'] = gardenConfig; + + return $http.patch(url, { + operation: 'config', + path: '', + value: gardenDefinition, + }); + }; + GardenService.serverModelToForm = function(model) { const values = {}; const stompHeaders = []; @@ -84,72 +97,137 @@ export default function gardenService($http) { return values; }; - const isEmptyConnection = (entryPointName, entryPointValues) => { - const simpleFieldMissing = (entry) => { - // it's better to be explicit because of the inherent stupidity of - // Javascript "truthiness" - return typeof entryPointValues[entry] === 'undefined' || + const getSimpleFieldPredicate = (entryPointValues) => { + // it's better to be explicit because of the inherent stupidity of + // Javascript "truthiness" + return ( + (entry) => + typeof entryPointValues[entry] === 'undefined' || entryPointValues[entry] === null || - entryPointValues[entry] === ''; - }; - - if (entryPointName === 'stomp') { - const stompSimpleFields = [ - 'host', 'password', 'port', 'send_destination', 'subscribe_destination', - 'username', - ]; - const stompSslFields = ['ca_cert', 'client_cert', 'client_key']; - let nestedFieldsMissing = true; + entryPointValues[entry] === '' + ); + }; - const allSimpleFieldsMissing = stompSimpleFields.every(simpleFieldMissing); + const isEmptyStompHeaders = (headerEntry) => { + const headersExist = !!headerEntry && 'headers' in headerEntry; + const headersZeroLength = ( + !headersExist || + headerEntry['headers'].length === 0); + const headersAllEmpty = ( + !headersZeroLength && + headerEntry['headers'].every((entry) => !!Object.entries(entry)) + ); + const headersAllBlank = ( + !headersAllEmpty && + headerEntry['headers'].every( + (entry) => + ('key' in entry && entry['key'] === {}) || + ('value' in entry && entry['value'] == {})) + ); + + return ( + !headersExist || + headersZeroLength || + headersAllEmpty || + headersAllBlank + ); + }; - const sslIsMissing = typeof entryPointValues['ssl'] === 'undefined' || - entryPointValues['ssl'] === {}; + const cleanEmptyStompHeaders = 'TODO'; + + const isEmptyStompConnection = (entryPointValues) => { + /* If every field is missing, then obviously the stomp connection can be + * considered empty. + * + * It gets a little more complicated in other cases because a lot of garbage + * data is being passed around (an issue for another day). + * + * So we do a lot of checking of the corner cases in + * isEmptyStompConnection and isEmptyStompHeaders so that the results of + * this function is truly representative of whether we would consider the + * connection to be "empty". + * + * (The point of all this is that if the connection meets our common-sense + * definition of empty, then the resulting connection parameter object + * won't even have a 'stomp' entry at all, which is far preferable to + * polluting the database with the cruft that gets picked up in the UI.) + */ + const simpleFieldMissing = getSimpleFieldPredicate(entryPointValues); - if (!sslIsMissing) { - nestedFieldsMissing = stompSslFields.every( - (entry) => - typeof entryPointValues['ssl'][entry] == 'undefined' || - entryPointValues['ssl'][entry] == null || + const stompSimpleFields = [ + 'host', 'password', 'port', 'send_destination', 'subscribe_destination', + 'username', + ]; + const stompSslFields = ['ca_cert', 'client_cert', 'client_key']; + + const allSimpleFieldsMissing = stompSimpleFields.every(simpleFieldMissing); + const headersMissing = isEmptyStompHeaders(entryPointValues); + const sslIsMissing = typeof entryPointValues['ssl'] === 'undefined' || + !!Object.entries(entryPointValues['ssl']); + let nestedFieldsMissing = true; + + if (!sslIsMissing) { + nestedFieldsMissing = stompSslFields.every( + (entry) => + typeof entryPointValues['ssl'][entry] === 'undefined' || + entryPointValues['ssl'][entry] === null || entryPointValues['ssl'][entry] === '', - ); - } + ); + } - return entryPointValues['headers'].length === 0 && - allSimpleFieldsMissing && + const allStompFieldsEmpty = headersMissing && allSimpleFieldsMissing && nestedFieldsMissing; - } - // is 'http' + return allStompFieldsEmpty; + }; + + const isEmptyHttpConnection = (entryPointValues) => { + // Simply decide if every field in the http entry is blank. + const simpleFieldMissing = getSimpleFieldPredicate(entryPointValues); const httpSimpleFields = [ 'ca_cert', 'client_cert', 'host', 'port', 'url_prefix', ]; + const allHttpFieldsEmpty = httpSimpleFields.every(simpleFieldMissing); - return httpSimpleFields.every(simpleFieldMissing); + return allHttpFieldsEmpty; }; - GardenService.formToServerModel = function(model, form) { + GardenService.formToServerModel = function(data, model) { /* Carefully pick apart the form data and translate it to the correct server - * model. Throw an error if the entire form is empty (i.e., cannot have - * empty connection parameters for both entry points). + * model. Throw an error if the entire form is empty (i.e., don't allow + * empty connection parameters for both entry points on a remote garden). */ - const {connection_type: formConnectionType, ...formWithoutConxType} = form; - model['connection_type'] = formConnectionType; + const {connection_type: modelConnectionType, ...modelWithoutConxType} = model; + let newModel = {...data}; + newModel['connection_type'] = modelConnectionType; - const modelUpdatedConnectionParams = {}; + const updatedConnectionParams = {}; const emptyConnections = {}; + const emptyChecker = { + 'stomp': isEmptyStompConnection, + 'http': isEmptyHttpConnection, + }; + + for (const modelEntryPointName of Object.keys(modelWithoutConxType)) { + // modelEntryPointName is either 'http' or 'stomp' + const modelEntryPointMap = modelWithoutConxType[modelEntryPointName]; + const isEmpty = emptyChecker[modelEntryPointName](modelEntryPointMap); - for (const formEntryPointName of Object.keys(formWithoutConxType)) { - // formEntryPointName is either 'http' or 'stomp' - const formEntryPointMap = formWithoutConxType[formEntryPointName]; - const modelUpdatedEntryPoint = {}; + if (isEmpty) { + emptyConnections[modelEntryPointName] = true; + continue; + } else { + emptyConnections[modelEntryPointName] = false; + } + + const updatedEntryPoint = {}; - for (const formEntryPointKey of Object.keys(formEntryPointMap)) { - const formEntryPointValue = formEntryPointMap[formEntryPointKey]; + for (const modelEntryPointKey of Object.keys(modelEntryPointMap)) { + const modelEntryPointValue = modelEntryPointMap[modelEntryPointKey]; - if (formEntryPointName === 'stomp' && formEntryPointKey === 'headers') { + if (modelEntryPointName === 'stomp' && modelEntryPointKey === 'headers') { // the ugly corner case is the stomp headers - const formStompHeaders = formEntryPointValue; + const formStompHeaders = modelEntryPointValue; const modelUpdatedStompHeaderArray = []; for (const formStompHeader of formStompHeaders) { @@ -167,26 +245,29 @@ export default function gardenService($http) { } } - modelUpdatedEntryPoint['headers'] = modelUpdatedStompHeaderArray; + if (modelUpdatedStompHeaderArray.length > 0) { + updatedEntryPoint['headers'] = modelUpdatedStompHeaderArray; + } } else { - modelUpdatedEntryPoint[formEntryPointKey] = formEntryPointValue; + updatedEntryPoint[modelEntryPointKey] = modelEntryPointValue; } } - if (!isEmptyConnection(formEntryPointName, modelUpdatedEntryPoint)) { - modelUpdatedConnectionParams[formEntryPointName] = - modelUpdatedEntryPoint; - } else { - emptyConnections[formEntryPointName] = true; - } + updatedConnectionParams[modelEntryPointName] = updatedEntryPoint; } if (emptyConnections['http'] && emptyConnections['stomp']) { throw Error('One of \'http\' or \'stomp\' connection must be defined'); + } else if (emptyConnections['http'] && modelConnectionType === 'HTTP') { + throw Error('Connection type is \'HTTP\' but http connection parameters' + + 'are blank'); + } else if (emptyConnections['stomp'] && modelConnectionType === 'STOMP') { + throw Error('Connection type is \'STOMP\' but stomp connection ' + + 'parameters are blank'); } - model = {...model, 'connection_params': modelUpdatedConnectionParams}; + newModel = {...newModel, 'connection_params': updatedConnectionParams}; - return model; + return newModel; }; GardenService.CONNECTION_TYPES = ['HTTP', 'STOMP']; diff --git a/src/ui/src/partials/admin_garden_view.html b/src/ui/src/partials/admin_garden_view.html index dd78558ca..0e626c702 100644 --- a/src/ui/src/partials/admin_garden_view.html +++ b/src/ui/src/partials/admin_garden_view.html @@ -74,15 +74,16 @@

Update Connection

diff --git a/src/ui/src/templates/import_garden_config.html b/src/ui/src/templates/import_garden_config.html new file mode 100644 index 000000000..3d13c131e --- /dev/null +++ b/src/ui/src/templates/import_garden_config.html @@ -0,0 +1,34 @@ +
+ + + + + + +