blob: 04449a05f0fe9f41479c6aea7a9b50f28accc59c [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.
*/
/**
* Supplies a modal (wizardy) with a list of apps a user can pick.
* Templates (items with yaml and with a location) go to composer,
* other items (java, or yaml without a location) go to next *or* yaml composer.
*/
define([
"underscore", "jquery", "backbone", "brooklyn-utils", "js-yaml", "codemirror",
"model/entity", "model/application", "model/location", "model/catalog-application",
"text!tpl/app-add-wizard/modal-wizard.html",
"text!tpl/app-add-wizard/create.html",
"text!tpl/app-add-wizard/create-step-template-entry.html",
"text!tpl/app-add-wizard/required-config-entry.html",
"text!tpl/app-add-wizard/edit-config-entry.html",
"text!tpl/app-add-wizard/deploy.html",
"text!tpl/app-add-wizard/deploy-version-option.html",
"text!tpl/app-add-wizard/deploy-location-row.html",
"text!tpl/app-add-wizard/deploy-location-option.html",
//below not part of constructor
"codemirror-mode-yaml",
"codemirror-addon-show-hint",
"codemirror-addon-anyword-hint",
"codemirror-addon-display-placeholder",
"bootstrap"
], function (_, $, Backbone, Util, JsYaml, CodeMirror, Entity, Application, Location, CatalogApplication,
ModalHtml, CreateHtml, CreateStepTemplateEntryHtml,
RequiredConfigEntryHtml, EditConfigEntryHtml, DeployHtml,
DeployVersionOptionHtml, DeployLocationRowHtml, DeployLocationOptionHtml
) {
/** Special ID to indicate that no locations will be provided when starting the server. */
var NO_LOCATION_INDICATOR = "__NONE__";
function setVisibility(obj, isVisible) {
if (isVisible) obj.show();
else obj.hide();
}
function setEnablement(obj, isEnabled) {
obj.attr("disabled", !isEnabled)
}
/** converts old-style spec with "entities" to camp-style spec with services */
function oldSpecToCamp(spec) {
var services;
if (spec.type) {
services = [entityToCamp({type: spec.type, version: spec.version, config: spec.config})];
} else if (spec.entities) {
services = [];
var entities = spec.entities;
for (var i = 0; i < entities.length; i++) {
services.push(entityToCamp(entities[i]));
}
}
var result = {};
if (spec.name) result.name = spec.name;
if (spec.locations) {
if (spec.locations.length>1)
result.locations = spec.locations;
else
result.location = spec.locations[0];
}
if (services) result.services = services;
// NB: currently nothing else is supported in this spec
return result;
}
function entityToCamp(entity) {
var result = {};
if (entity.name && (!options || !options.exclude_name)) result.name = entity.name;
if (entity.type) result.type = entity.type;
if (entity.type && entity.version) result.type += ":" + entity.version;
if (entity.config && _.size(entity.config)) result["brooklyn.config"] = entity.config;
return result;
}
function getConvertedConfigValue(value) {
try {
return $.parseJSON(value);
} catch (e) {
return value;
}
}
function redirectToEditorTabToDeployId(catalogId){
redirectTo("/v1/editor/app/"+ (typeof catalogId === 'string' ? catalogId : ''));
}
function redirectToEditorTabToDeployYaml(yaml){
redirectTo("/v1/editor/app/_/"+encodeURIComponent(yaml));
}
function redirectTo(url){
var $modal = $('.add-app #modal-container .modal');
$modal.modal('hide');
$modal.fadeTo(500,1);
Backbone.history.navigate(url, {trigger: true});
}
var ModalWizard = Backbone.View.extend({
tagName:'div',
className:'modal hide fade',
events:{
'click #prev_step':'prevStep',
'click #next_step':'nextStep',
'click #preview_step':'previewStep',
'click #finish_step':'finishStep'
},
template:_.template(ModalHtml),
initialize:function () {
this.catalog = {}
this.catalog.applications = {}
this.model = {}
this.model.spec = new Application.Spec;
this.model.yaml = "";
this.currentStep = 0;
this.steps = [
{
step_id:'what-app',
title:'Create Application',
instructions:'Choose or build the application to deploy',
view:new ModalWizard.StepCreate({ model:this.model, wizard: this, catalog: this.catalog })
},
{
// TODO rather than make this another step -- since we now on preview revert to the yaml tab
// this should probably be shown in the catalog tab, replacing the other contents.
step_id:'name-and-locations',
title:'<%= appName %>',
instructions:'Specify the locations to deploy to and any additional configuration',
view:new ModalWizard.StepDeploy({ model:this.model, catalog: this.catalog })
}
]
},
beforeClose:function () {
// ensure we close the sub-views
_.each(this.steps, function (step) {
step.view.close()
}, this)
},
render:function () {
this.$el.html(this.template({}))
this.renderCurrentStep()
return this
},
renderCurrentStep:function (callback) {
var name = this.model.name || "";
this.title = this.$("h3#step_title")
this.instructions = this.$("p#step_instructions")
var currentStepObj = this.steps[this.currentStep]
this.title.html(_.template(currentStepObj.title)({appName: name}));
this.instructions.html(_.escape(currentStepObj.instructions))
this.currentView = currentStepObj.view
// delegate to sub-views !!
this.currentView.render()
this.currentView.updateForState()
this.$(".modal-body").replaceWith(this.currentView.el)
if (callback) callback(this.currentView);
this.updateButtonVisibility();
},
updateButtonVisibility:function () {
var currentStepObj = this.steps[this.currentStep]
setVisibility(this.$("#prev_step"), (this.currentStep > 0))
var isTemplateWithLocation = false;
{
var yaml = (this.currentView && this.currentView.selectedTemplate && this.currentView.selectedTemplate.yaml);
if (yaml) {
try {
yaml = JsYaml.safeLoad(yaml);
hasLocation = yaml.location || yaml.locations;
if (!hasLocation) {
// look for locations defined in locations
svcs = yaml.services;
if (svcs) {
for (svcI in svcs) {
if (svcs[svcI].location || svcs[svcI].locations) {
hasLocation = true;
break;
}
}
}
}
isTemplateWithLocation = (hasLocation ? true : false);
} catch (e) {
log("Warning: could not parse yaml template of selected item")
log(yaml);
}
}
}
// preview (aka "YAML Composer") enabled and shown always
setVisibility(this.$("#preview_step"), true);
setEnablement(this.$("#preview_step"), true)
// next shown for first step, but not for templates
var nextVisible = (this.currentStep < 1) && (!this.isTemplate);
setVisibility(this.$("#next_step"), nextVisible);
// now set next/preview enablement
var nextEnabled = true;
if (this.currentStep==0 && currentStepObj && currentStepObj.view) {
// disable if nothing is selected in template (app lozenge list) view
// or if it is a template with a location
if (!currentStepObj.view.selectedTemplate || isTemplateWithLocation)
nextEnabled = false;
}
if (nextVisible) setEnablement(this.$("#next_step"), nextEnabled)
// finish ("deploy") is shown on the config step
var finishVisible = (this.currentStep >= 1);
var finishEnabled = finishVisible;
setVisibility(this.$("#finish_step"), finishVisible)
setEnablement(this.$("#finish_step"), finishEnabled)
},
submitApplication:function (event) {
var that = this
var $modal = $('.add-app #modal-container .modal')
$modal.fadeTo(500,0.5);
var yaml;
// Drop any "None" locations.
this.model.spec.pruneLocations();
yaml = JsYaml.safeDump(oldSpecToCamp(this.model.spec.toJSON()));
$.ajax({
url:'/v1/applications',
type:'post',
contentType:'application/yaml',
processData:false,
data:yaml,
success:function (data) {
that.onSubmissionComplete(true, data, $modal)
},
error:function (data) {
that.onSubmissionComplete(false, data, $modal)
}
});
return false
},
onSubmissionComplete: function(succeeded, data, $modal) {
var that = this;
if (succeeded) {
$modal.modal('hide')
$modal.fadeTo(500,1);
if (that.options.callback) that.options.callback();
} else {
log("ERROR submitting application: "+data.responseText);
var response, summary="Server responded with an error";
try {
if (data.responseText) {
response = JSON.parse(data.responseText)
if (response) {
summary = response.message;
}
}
} catch (e) {
summary = data.responseText;
}
that.$el.fadeTo(100,1).delay(200).fadeTo(200,0.2).delay(200).fadeTo(200,1);
that.steps[that.currentStep].view.showFailure(summary)
}
},
prevStep:function () {
this.currentStep -= 1;
this.renderCurrentStep();
},
nextStep:function () {
if (this.currentStep == 0) {
if (this.currentView.validate()) {
// next not visible if it's a template, so go to create (configure) step
this.currentStep += 1;
this.renderCurrentStep();
} else {
// the call to validate will have done the showFailure
}
} else {
throw "Unexpected step: "+this.currentStep;
}
},
previewStep:function () {
// no need for validation if going to composer
// When user jump to the YAML editor he also must have the config values that he set through the UI.
// The config is set implicitly when user clicks deploy and StepDeploy.validate is called.
this.currentView.model.spec.set("config", this.currentView.getConfigMap());
if (this.currentStep==0) {
// from first step, composer should load yaml from the catalog item
if (this.currentView.selectedTemplate && this.currentView.selectedTemplate.id) {
redirectToEditorTabToDeployId(this.currentView.selectedTemplate.id);
} else {
redirectToEditorTabToDeployId();
}
} else {
// on second step we generate yaml
// Drop any "None" locations.
this.model.spec.pruneLocations();
var yaml = JsYaml.safeDump(oldSpecToCamp(this.model.spec.toJSON()));
redirectToEditorTabToDeployYaml(yaml);
}
},
finishStep:function () {
if (this.currentView.validate()) {
this.submitApplication()
} else {
// call to validate should showFailure
}
},
})
// Note: this does not restore values on a back click; setting type and entity type+name is easy,
// but relevant config lines is a little bit more tedious
ModalWizard.StepCreate = Backbone.View.extend({
className:'modal-body',
events:{
'click #remove-config':'removeConfigRow',
'click #add-config':'addConfigRow',
'click .template-lozenge':'templateClick',
'keyup .text-filter input':'applyFilter',
'change .text-filter input':'applyFilter',
'paste .text-filter input':'applyFilter',
'shown a[data-toggle="tab"]':'onTabChange',
'click #templateTab #catalog-add':'switchToCatalogAdd',
'click #templateTab #catalog-yaml':'redirectToEditorTab'
},
template:_.template(CreateHtml),
wizard: null,
initialize:function () {
var self = this
self.catalogEntityIds = []
this.$el.html(this.template({}))
// TODO: Make into models, allow options to override, then pass in in test
// with overrridden url. Can then think about fixing tests in application-add-wizard-spec.js.
$.get('/v1/catalog/entities', {}, function (result) {
self.catalogEntityItems = result
self.catalogEntityIds = _.map(result, function(item) { return item.id })
});
this.options.catalog.applications = new CatalogApplication.Collection();
this.options.catalog.applications.fetch({
data: $.param({
allVersions: true
}),
success: function (collection, response, options) {
$('#catalog-applications-throbber').hide();
$('#catalog-applications-empty').hide();
if (collection.size() > 0) {
self.addTemplateLozenges();
} else {
$('#catalog-applications-empty').show();
}
}
});
},
renderConfiguredEntities:function () {
var $configuredEntities = this.$('#entitiesAccordionish').empty()
var that = this
if (this.model.spec.get("entities") && this.model.spec.get("entities").length > 0) {
_.each(this.model.spec.get("entities"), function (entity) {
that.addEntityHtml($configuredEntities, entity)
})
}
},
updateForState: function () {},
render:function () {
this.renderConfiguredEntities();
this.delegateEvents();
return this;
},
// TODO tabs no longer relevant
onTabChange: function(e) {
var tabText = $(e.target).text();
if (tabText=="Catalog") {
$("li.text-filter").show()
} else {
$("li.text-filter").hide()
}
if (this.options.wizard)
this.options.wizard.updateButtonVisibility();
},
switchToCatalogAdd: function() {
var $modal = $('.add-app #modal-container .modal')
$modal.modal('hide');
window.location.href="#v1/catalog/new";
},
redirectToEditorTab: function () {
redirectToEditorTabToDeployId();
},
applyFilter: function(e) {
var filter = $(e.currentTarget).val().toLowerCase()
if (!filter) {
$(".template-lozenge").show()
} else {
_.each($(".template-lozenge"), function(it) {
var viz = $(it).text().toLowerCase().indexOf(filter)>=0
if (viz)
$(it).show()
else
$(it).hide()
})
}
},
addTemplateLozenges: function(event) {
var that = this
_.each(this.options.catalog.applications.getDistinctApplications(), function(item) {
that.addTemplateLozenge(that, item[0])
})
},
addTemplateLozenge: function(that, item) {
var $tempel = _.template(CreateStepTemplateEntryHtml, {
id: item.get('id'),
type: item.get('type'),
name: item.get('name') || item.get('id'),
description: item.get('description'),
planYaml: item.get('planYaml'),
iconUrl: item.get('iconUrl')
})
$("#create-step-template-entries", that.$el).append($tempel)
},
templateClick: function(event) {
var $tl = $(event.target).closest(".template-lozenge");
var wasSelected = $tl.hasClass("selected")
$(".template-lozenge").removeClass("selected")
if (!wasSelected) {
$tl.addClass("selected")
this.selectedTemplate = {
id: $tl.attr('id'),
type: $tl.data('type'),
name: $tl.data("name"),
yaml: $tl.data("yaml"),
};
} else {
this.selectedTemplate = null;
}
if (this.options.wizard)
this.options.wizard.updateButtonVisibility();
},
getConfigMap:function (root) {
var map = {}
$('.app-add-wizard-config-entry',root).each( function (index,elt) {
var value = getConvertedConfigValue($('#value',elt).val());
if (value !== null) {
map[$('#key',elt).val()] = value;
}
})
return map;
},
saveTemplate:function () {
if (!this.selectedTemplate) return false
var type = this.selectedTemplate.type;
if (!this.options.catalog.applications.hasType(type)) {
$('.entity-info-message').show('slow').delay(2000).hide('slow')
return false
}
this.model.spec.set("type", type);
this.model.name = this.selectedTemplate.name;
this.model.catalogEntityData = "LOAD"
return true;
},
saveAppClass:function () {
var that = this
var tab = $.find('#appClassTab')
var type = $(tab).find('#app-java-type').val()
if (!this.options.catalog.applications.hasType(type)) {
$('.entity-info-message').show('slow').delay(2000).hide('slow')
return false
}
this.model.spec.set("type", type);
return true;
},
addEntityHtml:function (parent, entity) {
var $entity = _.template(CreateEntityEntryHtml, {})
var that = this
parent.append($entity)
parent.children().last().find('.entity-type-input').typeahead({ source: that.catalogEntityIds })
},
addConfigRow:function (event) {
var $row = _.template(EditConfigEntryHtml, {})
$(event.currentTarget).parent().prev().append($row)
},
removeConfigRow:function (event) {
$(event.currentTarget).parent().remove()
},
validate:function () {
var that = this;
delete this.model.spec.attributes["id"]
if (this.saveTemplate()) {
this.model.spec.set("entities", []);
return true
}
this.showFailure("Invalid application type/spec");
return false
},
showFailure: function(text) {
if (!text) text = "Failure performing the specified action";
this.$('div.error-message .error-message-text').html(_.escape(text));
this.$('div.error-message').slideDown(250).delay(10000).slideUp(500);
}
})
ModalWizard.StepDeploy = Backbone.View.extend({
className:'modal-body',
events:{
'click #add-selector-container':'addLocation',
'click #remove-app-location':'removeLocation',
'change .select-version': 'selectionVersion',
'change .select-location': 'selectionLocation',
'blur #application-name':'updateName',
'click #remove-config':'removeConfigRow',
'click #add-config':'addConfigRow'
},
template:_.template(DeployHtml),
versionOptionTemplate:_.template(DeployVersionOptionHtml),
locationRowTemplate:_.template(DeployLocationRowHtml),
locationOptionTemplate:_.template(DeployLocationOptionHtml),
initialize:function () {
this.model.spec.on("change", this.render, this)
this.$el.html(this.template())
this.locations = new Location.Collection()
},
beforeClose:function () {
this.model.spec.off("change", this.render)
},
renderName:function () {
this.$('#application-name').val(this.model.spec.get("name"))
},
renderVersions: function() {
var optionTemplate = this.versionOptionTemplate
select = this.$('.select-version')
container = this.$('#app-versions')
defaultVersion = '0.0.0.SNAPSHOT';
select.empty();
var versions = this.options.catalog.applications.getVersions(this.model.spec.get('type'));
for (var vi = 0; vi < versions.length; vi++) {
var version = versions[vi];
select.append(optionTemplate({
version: version
}));
}
if (versions.length === 1 && versions[0] === defaultVersion) {
this.model.spec.set('version', '');
container.hide();
} else {
this.model.spec.set('version', versions[0]);
container.show();
}
},
renderAddedLocations:function () {
// renders the locations added to the model
var rowTemplate = this.locationRowTemplate,
optionTemplate = this.locationOptionTemplate,
container = this.$("#selector-container-location");
container.empty();
for (var li = 0; li < this.model.spec.get("locations").length; li++) {
var chosenLocation = this.model.spec.get("locations")[li];
container.append(rowTemplate({
initialValue: chosenLocation,
rowId: li
}));
}
var $locationOptions = container.find('.select-location');
var templated = this.locations.map(function(aLocation) {
return optionTemplate({
id: aLocation.getIdentifierName() || aLocation.id || "",
name: aLocation.getPrettyName()
});
});
// insert "none" location
$locationOptions.append(templated.join(""));
$locationOptions.each(function(i) {
var option = $($locationOptions[i]);
option.val(option.parent().attr('initialValue'));
// Only append dashes if there are any locations
if (option.find("option").length > 0) {
option.append("<option disabled>------</option>");
}
option.append(optionTemplate({
id: NO_LOCATION_INDICATOR,
name: "None"
}));
});
},
render:function () {
this.delegateEvents()
return this
},
updateForState: function () {
var that = this
// clear any error message (we are being displayed fresh; if there are errors in the update, we'll show them in code below)
this.$('div.error-message').hide();
this.renderName()
this.renderVersions()
this.locations.fetch({
success:function () {
if (that.model.spec.get("locations").length==0)
that.addLocation()
else
that.renderAddedLocations()
}})
if (this.model.catalogEntityData==null) {
this.renderStaticConfig(null)
} else if (this.model.catalogEntityData=="LOAD") {
console.log("loading", this.model.spec.get("type"));
this.renderStaticConfig("LOADING")
$.get('/v1/catalog/entities/'+encodeURIComponent(this.model.spec.get("type")) + '/latest', {}, function (result) {
that.model.catalogEntityData = result
that.renderStaticConfig(that.model.catalogEntityData)
})
} else {
this.renderStaticConfig(this.model.catalogEntityData)
}
},
addLocation:function () {
if (this.locations.models.length>0) {
this.model.spec.addLocation(this.locations.models[0].getIdentifierName())
} else {
// i.e. No location
this.model.spec.addLocation(undefined);
}
this.renderAddedLocations()
},
removeLocation:function (event) {
var toBeRemoved = $(event.currentTarget).parent().attr('rowId')
this.model.spec.removeLocationIndex(toBeRemoved)
this.renderAddedLocations()
},
addConfigRow:function (event) {
var $row = _.template(EditConfigEntryHtml, {})
$(event.currentTarget).parent().prev().append($row)
},
removeConfigRow:function (event) {
$(event.currentTarget).parent().parent().remove()
},
renderStaticConfig:function (catalogEntryItem) {
this.$('.config-table').html('')
if (catalogEntryItem=="LOADING") {
this.$('.required-config-loading').show()
} else {
var configs = []
this.$('.required-config-loading').hide()
if (catalogEntryItem!=null && catalogEntryItem.config!=null) {
var that = this
_.chain(catalogEntryItem.config).sortBy(function (cfg){
return !isNaN(cfg.priority) ? cfg.priority : 99999;
}).each(function (cfg) {
if (cfg.pinned === true || (cfg.contraints.indexOf('required') > -1 && (cfg.defaultValue === undefined || cfg.defaultValue === ''))) {
var html = _.template(RequiredConfigEntryHtml, {data:cfg});
that.$('.config-table').append(html);
}
})
}
}
},
getConfigMap:function() {
var map = {};
$('.app-add-wizard-config-entry').each( function (index,elt) {
var value = $('#checkboxValue',elt).length ? $('#checkboxValue',elt).is(':checked') :
getConvertedConfigValue($('#value',elt).val());
if (value !== null) {
map[$('#key',elt).val()] = value;
}
})
return map;
},
selectionVersion:function (event) {
this.model.spec.set("version", $(event.currentTarget).val())
},
selectionLocation:function (event) {
var loc_id = $(event.currentTarget).val(),
isNoneLocation = loc_id === NO_LOCATION_INDICATOR;
var locationValid = isNoneLocation || this.locations.find(function (candidate) {
return candidate.getIdentifierName()==loc_id;
});
if (!locationValid) {
log("invalid location "+loc_id);
this.showFailure("Invalid location "+loc_id);
this.model.spec.set("locations",[]);
} else {
var index = $(event.currentTarget).parent().attr('rowId');
this.model.spec.setLocationAtIndex(index, isNoneLocation ? undefined : loc_id);
}
},
updateName:function () {
var name = this.$('#application-name').val();
if (name)
this.model.spec.set("name", name);
else
this.model.spec.set("name", "");
},
validate:function () {
this.model.spec.set("config", this.getConfigMap())
if (this.model.spec.get("locations").length !== 0) {
return true;
} else {
this.showFailure("A location is required");
return false;
}
},
showFailure: function(text) {
if (!text) text = "Failure performing the specified action";
log("showing error: "+text);
this.$('div.error-message .error-message-text').html(_.escape(text));
// flash the error, but make sure it goes away (we do not currently have any other logic for hiding this error message)
this.$('div.error-message').slideDown(250).delay(10000).slideUp(500);
}
})
return ModalWizard
})