| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| import angular from 'angular'; |
| import yaml from 'js-yaml'; |
| import brAutofocus from '../autofocus/autofocus'; |
| import brYamlEditor from '../yaml-editor/yaml-editor'; |
| import template from './quick-launch.html'; |
| import { get, isEmpty } from 'lodash'; |
| import { stringify as stringifyForQuery } from 'query-string'; |
| |
| const MODULE_NAME = 'brooklyn.components.quick-launch'; |
| |
| angular.module(MODULE_NAME, [brAutofocus, brYamlEditor]) |
| .directive('brooklynQuickLaunch', quickLaunchDirective); |
| |
| export default MODULE_NAME; |
| |
| const BROOKLYN_CONFIG = 'brooklyn.config'; |
| |
| export function quickLaunchDirective() { |
| return { |
| restrict: 'E', |
| template: template, |
| scope: { |
| app: '=', |
| locations: '=', // predefined, uploaded location entries |
| args: '=?', // default behaviour of code is: { noEditButton: false, noComposerButton: false, noCreateLocationLink: false, location: null } |
| callback: '=?', |
| }, |
| controller: ['$scope', '$http', '$location', 'brSnackbar', 'brBrandInfo' , 'quickLaunchOverrides', controller], |
| controllerAs: 'vm', |
| }; |
| |
| function controller($scope, $http, $location, brSnackbar, brBrandInfo, quickLaunchOverrides) { |
| |
| let quickLaunch = this; |
| |
| function removeNullConfig(obj) { |
| if (obj && obj[BROOKLYN_CONFIG]) { |
| for (const key in obj[BROOKLYN_CONFIG]) { |
| const val = obj[BROOKLYN_CONFIG][key]; |
| if (val==null || typeof val === 'undefined') { |
| delete obj[BROOKLYN_CONFIG][key]; |
| } |
| } |
| } |
| return obj; |
| } |
| quickLaunch.buildNewApp = () => { |
| const result = { |
| name: $scope.model.name || $scope.app.displayName, |
| }; |
| if ($scope.model.location) result.location = $scope.model.location; |
| result.services = [removeNullConfig(angular.copy($scope.entityToDeploy))]; |
| if ($scope.setServiceName) result.services[0].name = $scope.model.name; |
| return result; |
| }; |
| quickLaunch.getOriginalPlanFormat = getOriginalPlanFormat; |
| quickLaunch.planSender = |
| (plan) => { |
| if (!plan.format) { |
| return $http.post('/v1/applications', plan.yaml); |
| } else { |
| const formData = new FormData(); |
| formData.append('plan', plan.yaml); |
| formData.append('format', plan.format); |
| return $http.post('/v1/applications', formData, { |
| headers: {'Content-Type': 'multipart/form-data'} |
| }); |
| } |
| }; |
| |
| quickLaunch.convertPlanToPreferredFormat = convertPlanToPreferredFormat; |
| quickLaunch.getComposerHref = getComposerHref; |
| quickLaunch.getPlanObject = getPlanObject; |
| quickLaunch.getCampPlanObjectFromForm = getCampPlanObjectFromForm; |
| quickLaunch.getComposerExpandedYaml = getComposerExpandedYaml; |
| quickLaunch.isComposerOpenExpandPossible = isComposerOpenExpandPossible; |
| |
| quickLaunch.checkForLocationTags = checkForLocationTags; |
| quickLaunch.loadLocation = () => { |
| const { args, model, locations=[] } = $scope; |
| if (args.location) { // inline Location definition passed |
| model.location = args.location; |
| } else if (locations.length === 1) { |
| // we could pre-fill the target location, but a single location pre-installed might not be relevant, so don't |
| // model.location = locations[0].id; // predefined/uploaded Location objects, ID prop is sufficient |
| } |
| }; |
| |
| $scope.formEnabled = true; |
| $scope.editorEnabled = !$scope.args.noEditButton; |
| $scope.forceFormOnly = false; |
| $scope.deploying = false; |
| $scope.composerLink = "#"; |
| $scope.composerLinkExpanded = "#"; |
| $scope.model = { |
| newConfigFormOpen: false, |
| |
| // should never be null, so the placeholder in UI for model.name will never be used; |
| // hence autofocus is disabled |
| // note name is updated if we parse the plan and discover it sets a name, so it can collapse |
| name: get($scope.app, 'displayName') || get($scope.app, 'name') || get($scope.app, 'symbolicName', null), |
| }; |
| $scope.args = $scope.args || {}; |
| |
| $scope.toggleNewConfigForm = toggleNewConfigForm; |
| $scope.addNewConfigKey = addNewConfigKey; |
| $scope.deleteConfigField = deleteConfigField; |
| $scope.deployApp = deployApp; |
| $scope.showEditor = showEditor; |
| $scope.hideEditor = hideEditor; |
| $scope.setComposerLink = setComposerLink; |
| $scope.clearError = () => { delete $scope.model.deployError; }; |
| $scope.transitionsShown = () => $scope.editorEnabled && $scope.formEnabled && !$scope.forceFormOnly; |
| |
| $scope.$watch('app', () => { |
| quickLaunch.loadLocation($scope); |
| $scope.clearError(); |
| $scope.editorYaml = $scope.app.plan.data; |
| $scope.editorFormat = quickLaunch.getOriginalPlanFormat(); |
| |
| let parsedPlan = null; |
| try { |
| parsedPlan = yaml.safeLoad($scope.editorYaml); |
| } catch (e) { /*console.log('Failed to parse YAML', e)*/ } |
| |
| // enable wizard if it's parseble and doesn't specify a location |
| // (if it's not parseable, or it specifies a location, then the YAML view is displayed) |
| $scope.formEnabled = $scope.forceFormOnly || (parsedPlan!==null && !checkForLocationTags(parsedPlan)); |
| $scope.yamlViewDisplayed = !$scope.formEnabled; |
| |
| $scope.entityToDeployConfigJson = {}; |
| $scope.entityToDeploy = { |
| type: $scope.app.symbolicName + ($scope.app.version ? ':' + $scope.app.version : ''), |
| [BROOKLYN_CONFIG]: {}, |
| }; |
| if (parsedPlan && parsedPlan.name) { |
| // per model.name init above, prefer parsed plan name so it can collapse; |
| // in case changed, also set as service name |
| $scope.model.name = parsedPlan.name; |
| $scope.setServiceName = true; |
| } |
| if ($scope.app.config) { |
| $scope.configMap = $scope.app.config.reduce((result, config) => { |
| result[config.name] = config; |
| |
| let configValue = (parsedPlan[BROOKLYN_CONFIG] || {})[config.name]; |
| if (typeof configValue === 'undefined' && parsedPlan.services && parsedPlan.services.length === 1) { |
| configValue = (parsedPlan.services[0] && parsedPlan.services[0][BROOKLYN_CONFIG] || {})[config.name]; |
| } |
| |
| if (typeof configValue !== 'undefined') { |
| $scope.entityToDeploy[BROOKLYN_CONFIG][config.name] = configValue; |
| } else if (config.pinned || (isRequired(config) && (typeof config.defaultValue !== 'undefined'))) { |
| $scope.entityToDeploy[BROOKLYN_CONFIG][config.name] = get(config, 'defaultValue', null); |
| } |
| |
| let json = getJsonOfConfigValue($scope.entityToDeploy[BROOKLYN_CONFIG][config.name]); |
| if (json!=null) { |
| $scope.entityToDeployConfigJson[config.name] = json; |
| result[config.name].json = true; |
| } |
| |
| return result; |
| }, {}); |
| } else { |
| $scope.configMap = {}; |
| } |
| }); |
| |
| $scope.$watch('entityToDeploy', () => { |
| $scope.clearError(); |
| }, true); |
| $scope.$watchGroup(['editorYaml', 'model.name', 'model.location'], () => { |
| $scope.clearError(); |
| }); |
| |
| // Configure this controller from outside. Customization |
| (quickLaunchOverrides.configureQuickLaunch || function () {})(quickLaunch, $scope, $http); |
| |
| // === Private members below ==================== |
| |
| function deployApp() { |
| $scope.deploying = true; |
| Promise.resolve(quickLaunch.getPlanObject({})) |
| .then(quickLaunch.convertPlanToPreferredFormat) |
| .then(plan => { |
| quickLaunch.planSender(plan) |
| .then((response) => { |
| if ($scope.callback) { |
| $scope.callback.apply({}, [{state: 'SUCCESS', data: response.data}]); |
| } else { |
| brSnackbar.create('Application Deployed'); |
| } |
| $scope.deploying = false; |
| }) |
| .catch((senderError) => { |
| // handling API error response. data attribute contains failure message |
| handleDeployError(senderError.data); |
| }); |
| }) |
| .catch(err => { |
| handleDeployError(err); |
| }); |
| } |
| |
| function handleDeployError(error) { |
| $scope.model.deployError = get(error, 'message', 'Unknown error occurred with template preparation.'); |
| $scope.deploying = false; |
| } |
| |
| // add config handler |
| function toggleNewConfigForm() { |
| $scope.model.newConfigFormOpen = !$scope.model.newConfigFormOpen; |
| if ($scope.model.newConfigFormOpen) { |
| delete $scope.model.newKey; |
| } |
| } |
| |
| // serialize value if it happens to be a complex object |
| function getJsonOfConfigValue(item) { |
| return (typeof item === 'object' && !isEmpty(item)) |
| ? JSON.stringify(item) |
| : null; |
| } |
| |
| function deleteConfigField(key) { |
| delete $scope.entityToDeploy[BROOKLYN_CONFIG][key]; |
| if (Object.keys($scope.entityToDeploy[BROOKLYN_CONFIG]).length === 0) { |
| delete $scope.entityToDeploy[BROOKLYN_CONFIG]; |
| } |
| } |
| |
| function addNewConfigKey() { |
| const { newKey } = $scope.model; |
| if (newKey && newKey.length > 0) { |
| let newConfigValue = null; |
| const defaultValue = get($scope, `configMap[${newKey}].defaultValue`, null); |
| const isBoolean = get($scope, `configMap[${newKey}].type`) === 'java.lang.Boolean'; |
| |
| if (defaultValue) { |
| newConfigValue = defaultValue; |
| } |
| if (isBoolean && newConfigValue === null) { |
| newConfigValue = false; |
| } |
| |
| if (!$scope.entityToDeploy[BROOKLYN_CONFIG]) { |
| $scope.entityToDeploy[BROOKLYN_CONFIG] = {}; |
| } |
| $scope.entityToDeploy[BROOKLYN_CONFIG][$scope.model.newKey] = newConfigValue; |
| $scope.focus = $scope.model.newKey; |
| } |
| $scope.model.newConfigFormOpen = false; |
| } |
| |
| function isComposerOpenExpandPossible() { |
| try { |
| getComposerExpandedYaml(true); |
| return true; |
| } catch (error) { |
| //ignore |
| // console.log("cannot open composer expanded", error); |
| } |
| return false; |
| } |
| |
| function getComposerExpandedYaml(validate) { |
| const planText = $scope.app.plan.data || "{}"; |
| let result = {}; |
| |
| // this is set if we're able to parse the plan's text definition, and then: |
| // - we've had to override a field from the plan's text definition, because a value is set _and_ different; or |
| // - the plan's text definition is indented or JSON rather than YAML (not outdented yaml) |
| // and in either case we use the result _object_ ... |
| // unless we didn't actually change anything, in which case this is ignored |
| let cannotUsePlanText = false; |
| |
| if (validate) { |
| result = yaml.safeLoad(planText); |
| if (typeof result !== 'object') { |
| throw "The plan is not a YAML map, but of type "+(typeof result); |
| } |
| if (!result.services) { |
| throw "The plan does not have any services."; |
| } |
| cannotUsePlanText = Object.keys(result).some(property => |
| // plan is not outdented yaml, can't use its text mode |
| !planText.startsWith(property) && !planText.includes('\n'+property+':') |
| ); |
| } |
| |
| let newApp = {}; |
| |
| let newName = $scope.model.name || $scope.app.displayName; |
| if (newName && newName != result.name) { |
| newApp.name = newName; |
| if (result.name) { |
| delete result.name; |
| cannotUsePlanText = true; |
| } |
| } |
| |
| let newLocation = $scope.model.location; |
| if (newLocation && newLocation != result.location) { |
| newApp.location = newLocation; |
| if (result.location) { |
| delete result.location; |
| cannotUsePlanText = true; |
| } |
| } |
| |
| let newConfig = $scope.entityToDeploy[BROOKLYN_CONFIG]; |
| if (newConfig) { |
| if (result[BROOKLYN_CONFIG]) { |
| let oldConfig = result[BROOKLYN_CONFIG]; |
| let mergedConfig = angular.copy(oldConfig); |
| for (const [k,v] of Object.entries(newConfig) ) { |
| if (mergedConfig[k] != v) { |
| cannotUsePlanText = true; |
| mergedConfig[k] = v; |
| } |
| } |
| if (cannotUsePlanText) { |
| newApp[BROOKLYN_CONFIG] = mergedConfig; |
| delete result[BROOKLYN_CONFIG]; |
| } |
| } else { |
| newApp[BROOKLYN_CONFIG] = newConfig; |
| } |
| } |
| |
| // prefer to use the actual yaml input, but if it's not possible |
| let tryMergeByConcatenate = Object.keys(newApp).length |
| ? yaml.safeDump(newApp, {skipInvalid: true}) + `\n${(validate && cannotUsePlanText) ? yaml.safeDump(result) : planText}` |
| : planText; |
| if (validate) { |
| // don't think there's any way we'd wind up with invalid yaml but check to be sure |
| yaml.safeLoad(tryMergeByConcatenate); |
| } |
| return tryMergeByConcatenate; |
| } |
| |
| function showEditor() { |
| Promise.resolve(quickLaunch.getPlanObject({})) |
| .then(quickLaunch.convertPlanToPreferredFormat) |
| .then(appPlan => { |
| $scope.editorYaml = appPlan.yaml; |
| $scope.editorFormat = appPlan.format || quickLaunch.getOriginalPlanFormat; |
| $scope.yamlViewDisplayed = true; |
| $scope.$apply(); // making sure that $scope is updated from async context |
| }) |
| .catch(error => { |
| console.error('Problem with Editor YAML generation:', error); |
| }) |
| } |
| |
| function hideEditor() { |
| $scope.yamlViewDisplayed = false; |
| } |
| |
| function getPlanObject({expanded, validateYaml=true}) { |
| if ($scope.yamlViewDisplayed) { |
| return {format: $scope.editorFormat, yaml: angular.copy($scope.editorYaml)}; |
| |
| } else { |
| return quickLaunch.getCampPlanObjectFromForm({expanded, validateYaml}); |
| } |
| } |
| |
| function getCampPlanObjectFromForm({expanded, validateYaml=true}) { |
| return { |
| format: 'brooklyn-camp', |
| yaml: expanded |
| ? quickLaunch.getComposerExpandedYaml(validateYaml) |
| : yaml.safeDump(quickLaunch.buildNewApp()), |
| } |
| } |
| |
| function getComposerHref({expanded, validateYaml, yamlPrefix, yamlEditor }) { |
| let result = `${brBrandInfo.blueprintComposerBaseUrl}#!/`; |
| let plan = quickLaunch.getPlanObject({expanded, validateYaml}); |
| |
| if ($scope.yamlViewDisplayed) { |
| return result + 'yaml?'+stringifyForQuery(plan); |
| } |
| |
| if (yamlPrefix) plan.yaml = yamlPrefix + plan.yaml; |
| |
| if (yamlEditor) { |
| plan = quickLaunch.convertPlanToPreferredFormat(plan); |
| return result + 'yaml?'+stringifyForQuery(plan); |
| } |
| |
| return result + 'graphical?'+stringifyForQuery(plan); |
| } |
| |
| |
| function setComposerLink() { |
| Promise.resolve(getComposerLinkWithFallback(false)).then(href => { |
| $scope.composerLink = href; |
| }); |
| Promise.resolve(getComposerLinkWithFallback(true)).then(href => { |
| $scope.composerLinkExpanded = href; |
| }); |
| } |
| |
| function openComposer($event, expanded) { |
| $event.preventDefault(); |
| Promise.resolve(getComposerLinkWithFallback(expanded)).then(href => { |
| window.location.href = href; |
| }); |
| } |
| |
| function getComposerLinkWithFallback(expanded) { |
| if (!brBrandInfo.blueprintComposerBaseUrl) { |
| console.warn("Composer unavailable in this build"); |
| return; |
| } |
| return Promise.resolve(quickLaunch.getComposerHref({ expanded, validateYaml: true })) |
| .then(href => { |
| return href; |
| }) |
| .catch((error) => { |
| console.warn("Will open composer in YAML text editor mode because we cannot generate a model for this configuration:", error); |
| Promise.resolve(quickLaunch.getComposerHref({ |
| expanded, yamlEditor: true, validateYaml: false, |
| yamlPrefix: |
| "# This plan may have items which require attention so is being opened in YAML text editor mode.\n"+ |
| "# The YAML was autogenerated by merging the plan with any values provided in UI, but issues were\n"+ |
| "# detected that mean it might not be correct. Please check the blueprint below carefully.\n"+ |
| "\n" })) |
| .then(href => { |
| return href; |
| }) |
| }); |
| } |
| |
| function convertPlanToPreferredFormat(plan) { return plan; } |
| |
| function getOriginalPlanFormat(scope) { |
| scope = scope || $scope; |
| return scope && scope.app && scope.app.plan && scope.app.plan.format; |
| } |
| } |
| |
| function isRequired({ constraints }) { // checks if a config field object is required based on its constraints |
| return Array.isArray(constraints) && constraints.includes('required'); |
| } |
| |
| // recursive function returning the value of the first `location` property found via DFS, or false |
| // if no such property exists. |
| function checkForLocationTags(planSegment) { |
| if (!planSegment) return false; |
| if (planSegment.location) return planSegment.location; |
| |
| return checkForLocationTags(planSegment['brooklyn.children']) || checkForLocationTags(planSegment.services); |
| } |
| } |