ui-modules/utils/quick-launch/quick-launch.js (444 lines of code) (raw):

/* * 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', '$timeout', 'brSnackbar', 'brBrandInfo' , 'quickLaunchOverrides', controller], controllerAs: 'vm', }; function controller($scope, $http, $location, $timeout, 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.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 } }; quickLaunch.getWidgetKind = (key, configMap, v) => { if (!configMap || !configMap[key]) return undefined; // ad hoc config? if (configMap[key].json) return 'json'; if (v==null && configMap[key].defaults && configMap[key].defaults.length) return 'defaults'; return configMap[key].type; } quickLaunch.getDefaultsDropdown = (key, configMap) => { const options = []; const defaults = configMap[key].defaults; options.push({ value: null, description: (defaults[0].jsonString || defaults[0].value) + ' ('+defaults[0].source+')', }); for (let i=0; i<defaults.length; i++) { if (options.find(x => angular.equals(x.value, defaults[i].value))) { // skip } else { options.push({ value: defaults[i].value, isJson: defaults[i].isJson, description: 'Use ' + defaults[i].source + ': ' + (defaults[i].jsonString || defaults[i].value), }); } } options.push({ value: '', description: 'Use new value', }); return options; } quickLaunch.onDefaultsDropdown = (key, configMap, v) => { if (v==null) return; //nothing to do $scope.entityToDeploy['brooklyn.config'][key] = v; // already done, if coming from dropdown, but no harm for (let opt of configMap[key].defaults) { if (angular.equals(opt.value, v)) { if (opt.isJson) { $scope.entityToDeployConfigJson[key] = opt.jsonString; configMap[key].json = true; } return; } } // odd, nothing matched; just ignore } $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', async () => { quickLaunch.loadLocation($scope); $scope.clearError(); $scope.editorYaml = $scope.app.plan.data; $scope.editorFormat = quickLaunch.getOriginalPlanFormat(); const {parsedPlan, campPlanModified} = await quickLaunch.getAsCampPlan($scope.app.plan); // 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) { const singleServiceConfig = parsedPlan.services && parsedPlan.services.length === 1 && parsedPlan.services[0][BROOKLYN_CONFIG]; $scope.configMap = $scope.app.config.reduce((result, config) => { result[config.name] = config; result[config.name].defaults = []; let configValueOuter = (parsedPlan[BROOKLYN_CONFIG] || {})[config.name]; const hasTemplateValueOuter = typeof configValueOuter !== 'undefined'; if (hasTemplateValueOuter) { result[config.name].defaults.push({ source: 'template default', value: configValueOuter, }); } const hasTemplateValueInner = !campPlanModified && singleServiceConfig && (typeof singleServiceConfig[config.name] != 'undefined'); if (hasTemplateValueInner) { result[config.name].defaults.push({ source: 'template inner default', value: singleServiceConfig[config.name], }); } const hasParameterDefault = typeof config.defaultValue !== 'undefined'; if (hasParameterDefault) { result[config.name].defaults.push({ source: 'parameter default', value: config.defaultValue, }); } let atLeastOneJsonValue = false; result[config.name].defaults.forEach(d => { let jsonString = getJsonOfConfigValue(d.value); if (jsonString != null) { d.isJson = true; d.jsonString = jsonString; // don't set this yet; it gets set once/if user picks to copy a json value // result[config.name].json = true; // force dropdown so user knows what they are getting into atLeastOneJsonValue = true; } }); // was (isRequired && hasTemplateDefault) -- but that doesn't make sense (rarely matters as root items are always pinned) const showPossiblyWithDefaultsDropdown = hasTemplateValueOuter || hasTemplateValueInner || atLeastOneJsonValue || config.pinned || isRequired(config); if (showPossiblyWithDefaultsDropdown) { // initialize this field so it displays explicitly; null is a good choice because it allows either dropdown or blank if no dropdown $scope.entityToDeploy[BROOKLYN_CONFIG][config.name] = null; // compute dropdowns to show, and if there is exactly one default value (removing duplicates), and if it is editable (not json), // then don't use the dropdown, set that as the editable value if (result[config.name].defaults.length) { result[config.name].defaultsForDropdown = quickLaunch.getDefaultsDropdown(config.name, result); if (result[config.name].defaultsForDropdown.length-2==1 && !atLeastOneJsonValue) { // if just one value, and not json then use it $scope.entityToDeploy[BROOKLYN_CONFIG][config.name] = result[config.name].defaults[0].value; } } } 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(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; applyScope(); }) .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; applyScope(); }) .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; applyScope(); }); Promise.resolve(getComposerLinkWithFallback(true)).then(href => { $scope.composerLinkExpanded = href; applyScope(); }); } function applyScope() { // making sure that $scope is updated from async context $timeout(() => $scope.$apply()); } 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 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); } }