blob: 3db4b0d4a54b020e4918bd585b9ff9e782676c4c [file] [log] [blame]
/*
* 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 setComposerLink() {
$scope.composerLink = getComposerHref({ expanded:false , validateYaml: true });
$scope.composerLinkExpanded = getComposerHref({ expanded:true , validateYaml: true });
}
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 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);
}
}