You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@brooklyn.apache.org by he...@apache.org on 2016/02/01 18:52:21 UTC

[03/51] [abbrv] [partial] brooklyn-ui git commit: move subdir from incubator up a level as it is promoted to its own repo (first non-incubator commit!)

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/application-add-wizard.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/view/application-add-wizard.js b/src/main/webapp/assets/js/view/application-add-wizard.js
new file mode 100644
index 0000000..2c4f012
--- /dev/null
+++ b/src/main/webapp/assets/js/view/application-add-wizard.js
@@ -0,0 +1,838 @@
+/*
+ * 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.
+*/
+/**
+ * Builds a Twitter Bootstrap modal as the framework for a Wizard.
+ * Also creates an empty Application model.
+ */
+define([
+    "underscore", "jquery", "backbone", "brooklyn-utils", "js-yaml",
+    "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/create-entity-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",
+    "bootstrap"
+    
+], function (_, $, Backbone, Util, JsYaml, Entity, Application, Location, CatalogApplication,
+             ModalHtml, CreateHtml, CreateStepTemplateEntryHtml, CreateEntityEntryHtml,
+             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;
+        }
+    }
+    
+    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.model.mode = "template";  // or "yaml" or "other"
+            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(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))
+
+            // next shown for first step, but not for yaml
+            var nextVisible = (this.currentStep < 1) && (this.model.mode != "yaml")
+            setVisibility(this.$("#next_step"), nextVisible)
+            
+            // previous shown for step 2 (but again, not yaml)
+            var previewVisible = (this.currentStep == 1) && (this.model.mode != "yaml")
+            setVisibility(this.$("#preview_step"), previewVisible)
+            
+            // now set next/preview enablement
+            if (nextVisible || previewVisible) {
+                var nextEnabled = true;
+                if (this.currentStep==0 && this.model.mode=="template" && currentStepObj && currentStepObj.view) {
+                    // disable if this is template selction (lozenge) view, and nothing is selected
+                    if (! currentStepObj.view.selectedTemplate)
+                        nextEnabled = false;
+                }
+                
+                if (nextVisible)
+                    setEnablement(this.$("#next_step"), nextEnabled)
+                if (previewVisible)
+                    setEnablement(this.$("#preview_step"), nextEnabled)
+            }
+            
+            // finish from config step, preview step, and from first step if yaml tab selected (and valid)
+            var finishVisible = (this.currentStep >= 1)
+            var finishEnabled = finishVisible
+            if (!finishEnabled && this.currentStep==0) {
+                if (this.model.mode == "yaml") {
+                    // should do better validation than non-empty
+                    finishVisible = true;
+                    var yaml_code = this.$("#yaml_code").val()
+                    if (yaml_code) {
+                        finishEnabled = true;
+                    }
+                }
+            }
+            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;
+            if (this.model.mode == "yaml") {
+                yaml = this.model.yaml;
+            } else {
+                // 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()) {
+                    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;
+                                  }
+                                }
+                              }
+                            }
+                            yaml = (hasLocation ? true : false);
+                        } catch (e) {
+                            log("Warning: could not parse yaml template")
+                            log(yaml);
+                            yaml = false;
+                        }
+                    }
+                    if (yaml) {
+                        // it's a yaml catalog template which includes a location, show the yaml tab
+           	            $("ul#app-add-wizard-create-tab").find("a[href='#yamlTab']").tab('show');
+                        $("#yaml_code").setCaretToStart();
+                    } else {
+                        // it's a java catalog template or yaml template without a location, go to wizard
+                        this.currentStep += 1;
+                        this.renderCurrentStep();
+                    }
+                } else {
+                    // the call to validate will have done the showFailure
+                }
+            } else {
+                throw "Unexpected step: "+this.currentStep;
+            }
+        },
+        previewStep:function () {
+            if (this.currentView.validate()) {
+                this.currentStep = 0;
+                var that = this;
+                this.renderCurrentStep(function callback(view) {
+                    // Drop any "None" locations.
+                    that.model.spec.pruneLocations();
+                    $("textarea#yaml_code").val(JsYaml.safeDump(oldSpecToCamp(that.model.spec.toJSON())));
+                    $("ul#app-add-wizard-create-tab").find("a[href='#yamlTab']").tab('show');
+                    $("#yaml_code").setCaretToStart();
+                });
+            } else {
+                // call to validate should showFailure
+            }
+        },
+        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 #add-app-entity':'addEntityBox',
+            'click .editable-entity-heading':'expandEntity',
+            'click .remove-entity-button':'removeEntityClick',
+            'click .editable-entity-button':'saveEntityClick',
+            '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',
+            'keyup #yaml_code':'onYamlCodeChange',
+            'change #yaml_code':'onYamlCodeChange',
+            'paste #yaml_code':'onYamlCodeChange',
+            'shown a[data-toggle="tab"]':'onTabChange',
+            'click #templateTab #catalog-add':'switchToCatalogAdd',
+            'click #templateTab #catalog-yaml':'showYamlTab'
+        },
+        template:_.template(CreateHtml),
+        wizard: null,
+        initialize:function () {
+            var self = this
+            self.catalogEntityIds = []
+
+            this.$el.html(this.template({}))
+
+            // for building from entities
+            this.addEntityBox()
+
+            // 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 })
+                self.$(".entity-type-input").typeahead().data('typeahead').source = self.catalogEntityIds
+            })
+            this.options.catalog.applications = new CatalogApplication.Collection();
+            this.options.catalog.applications.fetch({
+                data: $.param({
+                    allVersions: true
+                }),
+                success: function (collection, response, options) {
+                    self.$("#appClassTab .application-type-input").typeahead().data('typeahead').source = collection.getTypes();
+                    $('#catalog-applications-throbber').hide();
+                    $('#catalog-applications-empty').hide();
+                    if (collection.size() > 0) {
+                        self.addTemplateLozenges()
+                    } else {
+                        $('#catalog-applications-empty').show();
+                        self.showYamlTab();
+                    }
+                }
+            });
+        },
+        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
+        },
+        onTabChange: function(e) {
+            var tabText = $(e.target).text();
+            if (tabText=="Catalog") {
+                $("li.text-filter").show()
+            } else {
+                $("li.text-filter").hide()
+            }
+
+            if (tabText=="YAML") {
+                this.model.mode = "yaml";
+            } else if (tabText=="Template") {
+                this.model.mode = "template";
+            } else {
+                this.model.mode = "other";
+            }
+
+            if (this.options.wizard)
+                this.options.wizard.updateButtonVisibility();
+        },
+        onYamlCodeChange: function() {
+            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";
+        },
+        showYamlTab: function() {
+            $("ul#app-add-wizard-create-tab").find("a[href='#yamlTab']").tab('show')
+            $("#yaml_code").focus();
+        },
+        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"),
+                };
+                if (this.selectedTemplate.yaml) {
+                    $("textarea#yaml_code").val(this.selectedTemplate.yaml);
+                } else {
+                    $("textarea#yaml_code").val("services:\n- type: "+this.selectedTemplate.type);
+                }
+            } else {
+                this.selectedTemplate = null;
+            }
+
+            if (this.options.wizard)
+                this.options.wizard.updateButtonVisibility();
+        },
+        expandEntity:function (event) {
+            $(event.currentTarget).next().show('fast').delay(1000).prev().hide('slow')
+        },
+        saveEntityClick:function (event) {
+            this.saveEntity($(event.currentTarget).closest(".editable-entity-group"));
+        },
+        saveEntity:function ($entityGroup) {
+            var that = this
+            var name = $('#entity-name',$entityGroup).val()
+            var type = $('#entity-type',$entityGroup).val()
+            if (type=="" || !_.contains(that.catalogEntityIds, type)) {
+                that.showFailure("Missing or invalid type");
+                return false
+            }
+            var saveTarget = this.model.spec.get("entities")[$entityGroup.index()];
+            this.model.spec.set("type", null)
+            saveTarget.name = name
+            saveTarget.type = type
+            saveTarget.config = this.getConfigMap($entityGroup)
+
+            if (name=="") name=type;
+            if (name=="") name="<i>(new entity)</i>";
+            $('#entity-name-header',$entityGroup).html( name )
+            $('.editable-entity-body',$entityGroup).prev().show('fast').next().hide('fast')
+            return true;
+        },
+        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;
+        },
+        addEntityBox:function () {
+            var entity = new Entity.Model
+            this.model.spec.addEntity( entity )
+            this.addEntityHtml($('#entitiesAccordionish', this.$el), entity)
+        },
+        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 })
+        },
+        removeEntityClick:function (event) {
+            var $entityGroup = $(event.currentTarget).parent().parent().parent();
+            this.model.spec.removeEntityIndex($entityGroup.index())
+            $entityGroup.remove()
+        },
+
+        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
+            var tabName = $('#app-add-wizard-create-tab li[class="active"] a').attr('href')
+            if (tabName=='#entitiesTab') {
+                delete this.model.spec.attributes["id"]
+                var allokay = true
+                $($.find('.editable-entity-group')).each(
+                    function (i,$entityGroup) {
+                        allokay = that.saveEntity($($entityGroup)) & allokay
+                    })
+                if (!allokay) return false;
+                if (this.model.spec.get("entities") && this.model.spec.get("entities").length > 0) {
+                    this.model.spec.set("type", null);
+                    return true;
+                }
+            } else if (tabName=='#templateTab') {
+                delete this.model.spec.attributes["id"]
+                if (this.saveTemplate()) {
+                    this.model.spec.set("entities", []);
+                    return true
+                }
+            } else if (tabName=='#appClassTab') {
+                delete this.model.spec.attributes["id"]
+                if (this.saveAppClass()) {
+                    this.model.spec.set("entities", []);
+                    return true
+                }
+            } else if (tabName=='#yamlTab') {
+                this.model.yaml = this.$("#yaml_code").val();
+                if (this.model.yaml) {
+                    return true;
+                }
+            } else {
+                console.info("NOT IMPLEMENTED YET")
+                // TODO - other tabs not implemented yet 
+                // do nothing, show error return false below
+            }
+            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.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") {
+                this.renderStaticConfig("LOADING")
+                $.get('/v1/catalog/entities/'+this.model.spec.get("type"), {}, 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].get("id"))
+            } 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
+                    _.each(catalogEntryItem.config, function (cfg) {
+                        if (cfg.priority !== undefined) {
+                            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.get("id")==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
+})

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/application-explorer.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/view/application-explorer.js b/src/main/webapp/assets/js/view/application-explorer.js
new file mode 100644
index 0000000..e9c23bc
--- /dev/null
+++ b/src/main/webapp/assets/js/view/application-explorer.js
@@ -0,0 +1,205 @@
+/*
+ * 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.
+*/
+/**
+ * This should render the main content in the Application Explorer page.
+ * Components on this page should be rendered as sub-views.
+ * @type {*}
+ */
+define([
+    "underscore", "jquery", "backbone", "view/viewutils", 
+    "./application-add-wizard", "model/application", "model/entity-summary", "model/app-tree", "./application-tree",  "./entity-details",
+    "text!tpl/apps/details.html", "text!tpl/apps/entity-not-found.html", "text!tpl/apps/page.html"
+], function (_, $, Backbone, ViewUtils,
+        AppAddWizard, Application, EntitySummary, AppTree, ApplicationTreeView, EntityDetailsView,
+        EntityDetailsEmptyHtml, EntityNotFoundHtml, PageHtml) {
+
+    var ApplicationExplorerView = Backbone.View.extend({
+        tagName:"div",
+        className:"container container-fluid",
+        id:'application-explorer',
+        template:_.template(PageHtml),
+        notFoundTemplate: _.template(EntityNotFoundHtml),
+        events:{
+            'click .application-tree-refresh': 'refreshApplicationsInPlace',
+            'click #add-new-application':'createApplication',
+            'click .delete':'deleteApplication'
+        },
+        initialize: function () {
+            this.$el.html(this.template({}))
+            $(".nav1").removeClass("active");
+            $(".nav1_apps").addClass("active");
+
+            this.treeView = new ApplicationTreeView({
+                collection:this.collection,
+                appRouter:this.options.appRouter
+            })
+            this.treeView.on('entitySelected', function(e) {
+               this.displayEntityId(e.id, e.get('applicationId'), false);
+            }, this);
+            this.$('div#app-tree').html(this.treeView.renderFull().el)
+            this.$('div#details').html(EntityDetailsEmptyHtml);
+
+            ViewUtils.fetchRepeatedlyWithDelay(this, this.collection)
+        },
+        refreshApplicationsInPlace: function() {
+            // fetch without reset sets of change events, which now get handled correctly
+            // (not a full visual recompute, which reset does - both in application-tree.js)
+            this.collection.fetch();
+        },
+        beforeClose: function () {
+            this.collection.off("reset", this.render);
+            this.treeView.close();
+            if (this.detailsView)
+                this.detailsView.close();
+        },
+        show: function(entityId) {
+            var tab = "";
+            var tabDetails = "";
+            if (entityId) {
+                if (entityId[0]=='/') entityId = entityId.substring(1);
+                var slash = entityId.indexOf('/');
+                if (slash>0) {
+                    tab = entityId.substring(slash+1)
+                    entityId = entityId.substring(0, slash);
+                }
+            }
+            if (tab) {
+                var slash = tab.indexOf('/');
+                if (slash>0) {
+                    tabDetails = tab.substring(slash+1)
+                    tab = tab.substring(0, slash);
+                }
+                this.preselectTab(tab, tabDetails);
+            }
+            this.treeView.selectEntity(entityId)
+        },
+        createApplication:function () {
+            var that = this;
+            if (this._modal) {
+                this._modal.close()
+            }
+            var wizard = new AppAddWizard({
+                appRouter:that.options.appRouter,
+                callback:function() { that.refreshApplicationsInPlace() }
+            })
+            this._modal = wizard
+            this.$(".add-app #modal-container").html(wizard.render().el)
+            this.$(".add-app #modal-container .modal")
+                .on("hidden",function () {
+                    wizard.close()
+                }).modal('show')
+        },
+        deleteApplication:function (event) {
+            // call Backbone destroy() which does HTTP DELETE on the model
+            this.collection.get(event.currentTarget['id']).destroy({wait:true})
+        },
+        /**
+         * Causes the tab with the given name to be selected automatically when
+         * the view is next rendered.
+         */
+        preselectTab: function(tab, tabDetails) {
+            this.currentTab = tab;
+            this.currentTabDetails = tabDetails;
+        },
+        showDetails: function(app, entitySummary) {
+            var that = this;
+            ViewUtils.cancelFadeOnceLoaded($("div#details"));
+
+            var whichTab = this.currentTab;
+            if (!whichTab) {
+                whichTab = "summary";
+                if (this.detailsView) {
+                    whichTab = this.detailsView.$el.find(".tab-pane.active").attr("id");
+                    this.detailsView.close();
+                }
+            }
+            if (this.detailsView) {
+                this.detailsView.close();
+            }
+            this.detailsView = new EntityDetailsView({
+                model: entitySummary,
+                application: app,
+                appRouter: this.options.appRouter,
+                preselectTab: whichTab,
+                preselectTabDetails: this.currentTabDetails,
+            });
+
+            this.detailsView.on("entity.expunged", function() {
+                that.preselectTab("summary");
+                var id = that.selectedEntityId;
+                var model = that.collection.get(id);
+                if (model && model.get("parentId")) {
+                    that.displayEntityId(model.get("parentId"));
+                } else if (that.collection) {
+                    that.displayEntityId(that.collection.first().id);
+                } else if (id) {
+                    that.displayEntityNotFound(id);
+                } else {
+                    that.displayEntityNotFound("?");
+                }
+                that.collection.fetch();
+            });
+            this.detailsView.render( $("div#details") );
+        },
+        displayEntityId: function (id, appName, afterLoad) {
+            var that = this;
+            var entityLoadFailed = function() {
+                return that.displayEntityNotFound(id);
+            };
+            if (appName === undefined) {
+                if (!afterLoad) {
+                    // try a reload if given an ID we don't recognise
+                    this.collection.includeEntities([id]);
+                    this.collection.fetch({
+                        success: function() { _.defer(function() { that.displayEntityId(id, appName, true); }); },
+                        error: function() { _.defer(function() { that.displayEntityId(id, appName, true); }); }
+                    });
+                    ViewUtils.fadeToIndicateInitialLoad($("div#details"))
+                    return;
+                } else {
+                    // no such app
+                    entityLoadFailed();
+                    return; 
+                }
+            }
+
+            var app = new Application.Model();
+            var entitySummary = new EntitySummary.Model;
+
+            app.url = "/v1/applications/" + appName;
+            entitySummary.url = "/v1/applications/" + appName + "/entities/" + id;
+
+            // in case the server response time is low, fade out while it refreshes
+            // (since we can't show updated details until we've retrieved app + entity details)
+            ViewUtils.fadeToIndicateInitialLoad($("div#details"));
+
+            $.when(app.fetch(), entitySummary.fetch())
+                .done(function() {
+                    that.showDetails(app, entitySummary);
+                })
+                .fail(entityLoadFailed);
+        },
+        displayEntityNotFound: function(id) {
+            $("div#details").html(this.notFoundTemplate({"id": id}));
+            ViewUtils.cancelFadeOnceLoaded($("div#details"))
+        },
+    })
+
+    return ApplicationExplorerView
+})

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/application-tree.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/view/application-tree.js b/src/main/webapp/assets/js/view/application-tree.js
new file mode 100644
index 0000000..2f7a3d0
--- /dev/null
+++ b/src/main/webapp/assets/js/view/application-tree.js
@@ -0,0 +1,367 @@
+/*
+ * 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.
+*/
+/**
+ * Sub-View to render the Application tree.
+ * @type {*}
+ */
+define([
+    "underscore", "jquery", "backbone", "view/viewutils",
+    "model/app-tree", "text!tpl/apps/tree-item.html", "text!tpl/apps/tree-empty.html"
+], function (_, $, Backbone, ViewUtils,
+             AppTree, TreeItemHtml, EmptyTreeHtml) {
+
+    var emptyTreeTemplate = _.template(EmptyTreeHtml);
+    var treeItemTemplate = _.template(TreeItemHtml);
+
+    var findAllTreeboxes = function(id, $scope) {
+        return $('.tree-box[data-entity-id="' + id + '"]', $scope);
+    };
+
+    var findRootTreebox = function(id) {
+        return $('.lozenge-app-tree-wrapper').children('.tree-box[data-entity-id="' + id + '"]', this.$el);
+    };
+
+    var findChildTreebox = function(id, $parentTreebox) {
+        return $parentTreebox.children('.node-children').children('.tree-box[data-entity-id="' + id + '"]');
+    };
+
+    var findMasterTreebox = function(id, $scope) {
+        return $('.tree-box[data-entity-id="' + id + '"]:not(.indirect)', $scope);
+    };
+
+    var createEntityTreebox = function(id, name, $domParent, depth, indirect) {
+        // Tildes in sort key force entities with no name to bottom of list (z < ~).
+        var sortKey = (name ? name.toLowerCase() : "~~~") + "     " + id.toLowerCase();
+
+        // Create the wrapper.
+        var $treebox = $(
+                '<div data-entity-id="'+id+'" data-sort-key="'+sortKey+'" data-depth="'+depth+'" ' +
+                'class="tree-box toggler-group' +
+                    (indirect ? " indirect" : "") +
+                    (depth == 0 ? " outer" : " inner " + (depth % 2 ? " depth-odd" : " depth-even")+
+                    (depth == 1 ? " depth-first" : "")) + '">'+
+                '<div class="entity_tree_node_wrapper"></div>'+
+                '<div class="node-children toggler-target hide"></div>'+
+                '</div>');
+
+        // Insert into the passed DOM parent, maintaining sort order relative to siblings: name then id.
+        var placed = false;
+        var contender = $(".toggler-group", $domParent).first();
+        while (contender.length && !placed) {
+            var contenderKey = contender.data("sort-key");
+            if (sortKey < contenderKey) {
+                contender.before($treebox);
+                placed = true;
+            } else {
+                contender = contender.next(".toggler-group", $domParent);
+            }
+        }
+        if (!placed) {
+            $domParent.append($treebox);
+        }
+        return $treebox;
+    };
+
+    var getOrCreateApplicationTreebox = function(id, name, treeView) {
+        var $treebox = findRootTreebox(id);
+        if (!$treebox.length) {
+            var $insertionPoint = $('.lozenge-app-tree-wrapper', treeView.$el);
+            if (!$insertionPoint.length) {
+                // entire view must be created
+                treeView.$el.html(
+                        '<div class="navbar_main_wrapper treeloz">'+
+                        '<div id="tree-list" class="navbar_main treeloz">'+
+                        '<div class="lozenge-app-tree-wrapper">'+
+                        '</div></div></div>');
+                $insertionPoint = $('.lozenge-app-tree-wrapper', treeView.$el);
+            }
+            $treebox = createEntityTreebox(id, name, $insertionPoint, 0, false);
+        }
+        return $treebox;
+    };
+
+    var getOrCreateChildTreebox = function(id, name, isIndirect, $parentTreebox) {
+        var $treebox = findChildTreebox(id, $parentTreebox);
+        if (!$treebox.length) {
+            $treebox = createEntityTreebox(id, name, $parentTreebox.children('.node-children'), $parentTreebox.data("depth") + 1, isIndirect);
+        }
+        return $treebox;
+    };
+
+    var updateTreeboxContent = function(entity, $treebox, treeView) {
+        var $newContent = $(treeView.template({
+            id: entity.get('id'),
+            parentId:  entity.get('parentId'),
+            model: entity,
+            statusIconUrl: ViewUtils.computeStatusIconInfo(entity.get("serviceUp"), entity.get("serviceState")).url,
+            indirect: $treebox.hasClass('indirect'),
+        }));
+
+        var $wrapper = $treebox.children('.entity_tree_node_wrapper');
+
+        // Preserve old display status (just chevron direction at present).
+        if ($wrapper.find('.tree-node-state').hasClass('icon-chevron-down')) {
+            $newContent.find('.tree-node-state').removeClass('icon-chevron-right').addClass('icon-chevron-down');
+        }
+
+        $wrapper.html($newContent);
+        addEventsToNode($treebox, treeView);
+    };
+
+    var addEventsToNode = function($node, treeView) {
+        // show the "light-popup" (expand / expand all / etc) menu
+        // if user hovers for 500ms. surprising there is no option for this (hover delay).
+        // also, annoyingly, clicks around the time the animation starts don't seem to get handled
+        // if the click is in an overlapping reason; this is why we position relative top: 12px in css
+        $('.light-popup', $node).parent().parent().hover(
+                function(parent) {
+                    treeView.cancelHoverTimer();
+                    treeView.hoverTimer = setTimeout(function() {
+                        var menu = $(parent.currentTarget).find('.light-popup');
+                        menu.show();
+                    }, 500);
+                },
+                function(parent) {
+                    treeView.cancelHoverTimer();
+                    $('.light-popup').hide();
+                }
+        );
+    };
+
+    var selectTreebox = function(id, $treebox, treeView) {
+        $('.entity_tree_node_wrapper').removeClass('active');
+        $treebox.children('.entity_tree_node_wrapper').addClass('active');
+
+        var entity = treeView.collection.get(id);
+        if (entity) {
+            treeView.selectedEntityId = id;
+            treeView.trigger('entitySelected', entity);
+        }
+    };
+
+
+    return Backbone.View.extend({
+        template: treeItemTemplate,
+        hoverTimer: null,
+
+        events: {
+            'click span.entity_tree_node .tree-change': 'treeChange',
+            'click span.entity_tree_node': 'nodeClicked'
+        },
+
+        initialize: function() {
+            this.collection.on('add', this.entityAdded, this);
+            this.collection.on('change', this.entityChanged, this);
+            this.collection.on('remove', this.entityRemoved, this);
+            this.collection.on('reset', this.renderFull, this);
+            _.bindAll(this);
+        },
+
+        beforeClose: function() {
+            this.collection.off("reset", this.renderFull);
+        },
+
+        entityAdded: function(entity) {
+            // Called when the full entity model is fetched into our collection, at which time we can replace
+            // the empty contents of any placeholder tree nodes (.tree-box) that were created earlier.
+            // The entity may have multiple 'treebox' views (in the case of group members).
+
+            // If the new entity is an application, we must create its placeholder in the DOM.
+            if (!entity.get('parentId')) {
+                var $treebox = getOrCreateApplicationTreebox(entity.id, entity.get('name'), this);
+
+                // Select the new app if there's no current selection.
+                if (!this.selectedEntityId)
+                    selectTreebox(entity.id, $treebox, this);
+            }
+
+            this.entityChanged(entity);
+        },
+
+        entityChanged: function(entity) {
+            // The entity may have multiple 'treebox' views (in the case of group members).
+            var that = this;
+            findAllTreeboxes(entity.id).each(function() {
+                var $treebox = $(this);
+                updateTreeboxContent(entity, $treebox, that);
+            });
+        },
+
+        entityRemoved: function(entity) {
+            // The entity may have multiple 'treebox' views (in the case of group members).
+            findAllTreeboxes(entity.id, this.$el).remove();
+            // Collection seems sometimes to retain children of the removed node;
+            // not sure why, but that's okay for now.
+            if (this.collection.getApplications().length == 0)
+                this.renderFull();
+        },
+
+        nodeClicked: function(event) {
+            var $treebox = $(event.currentTarget).closest('.tree-box');
+            var id = $treebox.data('entityId');
+            selectTreebox(id, $treebox, this);
+            return false;
+        },
+
+        selectEntity: function(id) {
+            var $treebox = findMasterTreebox(id, this.$el);
+            selectTreebox(id, $treebox, this);
+        },
+
+        renderFull: function() {
+            var that = this;
+            this.$el.empty();
+
+            // Display tree and highlight the selected entity.
+            if (this.collection.getApplications().length == 0) {
+                this.$el.append(emptyTreeTemplate());
+
+            } else {
+                _.each(this.collection.getApplications(), function(appId) {
+                    var entity = that.collection.get(appId);
+                    var $treebox = getOrCreateApplicationTreebox(entity.id, entity.name, that);
+                    updateTreeboxContent(entity, $treebox, that);
+                });
+            }
+
+            // Select the first app if there's no current selection.
+            if (!this.selectedEntityId) {
+                var firstApp = _.first(this.collection.getApplications());
+                if (firstApp)
+                    this.selectEntity(firstApp);
+            }
+            return this;
+        },
+
+        cancelHoverTimer: function() {
+            if (this.hoverTimer != null) {
+                clearTimeout(this.hoverTimer);
+                this.hoverTimer = null;
+            }
+        },
+
+        treeChange: function(event) {
+            var $target = $(event.currentTarget);
+            var $treeBox = $target.closest('.tree-box');
+            if ($target.hasClass('tr-expand')) {
+                this.showChildrenOf($treeBox, false);
+            } else if ($target.hasClass('tr-expand-all')) {
+                this.showChildrenOf($treeBox, true);
+            } else if ($target.hasClass('tr-collapse')) {
+                this.hideChildrenOf($treeBox, false);
+            } else if ($target.hasClass('tr-collapse-all')) {
+                this.hideChildrenOf($treeBox, true);
+            } else {
+                // default - toggle
+                if ($treeBox.children('.node-children').is(':visible')) {
+                    this.hideChildrenOf($treeBox, false);
+                } else {
+                    this.showChildrenOf($treeBox, false);
+                }
+            }
+            // hide the popup menu
+            this.cancelHoverTimer();
+            $('.light-popup').hide();
+            // don't let other events interfere
+            return false;
+        },
+
+        showChildrenOf: function($treeBox, recurse, excludedEntityIds) {
+            excludedEntityIds = excludedEntityIds || [];
+            var idToExpand = $treeBox.data('entityId');
+            var $wrapper = $treeBox.children('.entity_tree_node_wrapper');
+            var $childContainer = $treeBox.children('.node-children');
+            var model = this.collection.get(idToExpand);
+            if (model == null) {
+                // not yet loaded; parallel thread should load
+                return;
+            }
+
+            var that = this;
+            var children = model.get('children'); // entity summaries: {id: ..., name: ...}
+            var renderChildrenAsIndirect = $treeBox.hasClass("indirect");
+            _.each(children, function(child) {
+                var $treebox = getOrCreateChildTreebox(child.id, child.name, renderChildrenAsIndirect, $treeBox);
+                var model = that.collection.get(child.id);
+                if (model) {
+                    updateTreeboxContent(model, $treebox, that);
+                }
+            });
+            var members = model.get('members'); // entity summaries: {id: ..., name: ...}
+            _.each(members, function(member) {
+                var $treebox = getOrCreateChildTreebox(member.id, member.name, true, $treeBox);
+                var model = that.collection.get(member.id);
+                if (model) {
+                    updateTreeboxContent(model, $treebox, that);
+                }
+            });
+
+            // Avoid infinite recursive expansion using a "taboo list" of indirect entities already expanded in this
+            // operation. Example: a group that contains itself or one of its own ancestors. Such cycles can only
+            // originate via "indirect" subordinates.
+            var expandIfNotExcluded = function($treebox, excludedEntityIds, defer) {
+                if ($treebox.hasClass('indirect')) {
+                    var id = $treebox.data('entityId');
+                    if (_.contains(excludedEntityIds, id))
+                        return;
+                    excludedEntityIds.push(id);
+                }
+                var doExpand = function() { that.showChildrenOf($treebox, recurse, excludedEntityIds); };
+                if (defer) _.defer(doExpand);
+                else doExpand();
+            };
+
+            if (this.collection.includeEntities(_.union(children, members))) {
+                // we have to load entities before we can proceed
+                this.collection.fetch({
+                    success: function() {
+                        if (recurse) {
+                            $childContainer.children('.tree-box').each(function () {
+                                expandIfNotExcluded($(this), excludedEntityIds, true);
+                            });
+                        }
+                    }
+                });
+            }
+
+            $childContainer.slideDown(300);
+            $wrapper.find('.tree-node-state').removeClass('icon-chevron-right').addClass('icon-chevron-down');
+            if (recurse) {
+                $childContainer.children('.tree-box').each(function () {
+                    expandIfNotExcluded($(this), excludedEntityIds, false);
+                });
+            }
+        },
+
+        hideChildrenOf: function($treeBox, recurse) {
+            var $wrapper = $treeBox.children('.entity_tree_node_wrapper');
+            var $childContainer = $treeBox.children('.node-children');
+            if (recurse) {
+                var that = this;
+                $childContainer.children('.tree-box').each(function () {
+                    that.hideChildrenOf($(this), recurse);
+                });
+            }
+            $childContainer.slideUp(300);
+            $wrapper.find('.tree-node-state').removeClass('icon-chevron-down').addClass('icon-chevron-right');
+        },
+
+    });
+
+});

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/catalog.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/view/catalog.js b/src/main/webapp/assets/js/view/catalog.js
new file mode 100644
index 0000000..7d4ab2a
--- /dev/null
+++ b/src/main/webapp/assets/js/view/catalog.js
@@ -0,0 +1,613 @@
+/*
+ * 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.
+*/
+define([
+    "underscore", "jquery", "backbone", "brooklyn",
+    "model/location", "model/entity",
+    "text!tpl/catalog/page.html",
+    "text!tpl/catalog/details-entity.html",
+    "text!tpl/catalog/details-generic.html",
+    "text!tpl/catalog/details-location.html",
+    "text!tpl/catalog/add-catalog-entry.html",
+    "text!tpl/catalog/add-yaml.html",
+    "text!tpl/catalog/add-location.html",
+    "text!tpl/catalog/nav-entry.html",
+
+    "bootstrap", "jquery-form"
+], function(_, $, Backbone, Brooklyn,
+        Location, Entity,
+        CatalogPageHtml, DetailsEntityHtml, DetailsGenericHtml, LocationDetailsHtml,
+        AddCatalogEntryHtml, AddYamlHtml, AddLocationHtml, EntryHtml) {
+
+    // Holds the currently active details type, e.g. applications, policies. Bit of a workaround
+    // to share the active view with all instances of AccordionItemView, so clicking the 'reload
+    // catalog' button (handled by the parent of the AIVs) does not apply the 'active' class to
+    // more than one element.
+    var activeDetailsView;
+
+    var CatalogItemDetailsView = Backbone.View.extend({
+
+        events: {
+            "click .delete": "deleteItem"
+        },
+
+        initialize: function() {
+            _.bindAll(this);
+            this.options.template = _.template(this.options.template || DetailsGenericHtml);
+        },
+
+        render: function() {
+            if (!this.options.model) {
+                return this.renderEmpty();
+            } else {
+                return this.renderDetails();
+            }
+        },
+
+        renderEmpty: function(extraMessage) {
+            this.$el.html("<div class='catalog-details'>" +
+                "<h3>Select an entry on the left</h3>" +
+                (extraMessage ? extraMessage : "") +
+                "</div>");
+            return this;
+        },
+
+        renderDetails: function() {
+            var that = this,
+                model = this.options.model,
+                template = this.options.template;
+            var show = function() {
+                // Keep the previously open section open between items. Duplication between
+                // here and setDetailsView, below. This case handles view refreshes from this
+                // view directly (e.g. when indicating an error), below handles keeping the
+                // right thing open when navigating from view to view.
+                var open = this.$(".in").attr("id");
+                var newHtml = $(template({model: model, viewName: that.options.name}));
+                $(newHtml).find("#"+open).addClass("in");
+                that.$el.html(newHtml);
+                // rewire events. previous callbacks are removed automatically.
+                that.delegateEvents()
+            };
+
+            this.activeModel = model;
+            // Load the view with currently available data and refresh once the load is complete.
+            // Only refreshes the view if the model changes and the user hasn't selected another
+            // item while the load was executing.
+            show();
+            model.on("change", function() {
+                if (that.activeModel.cid === model.cid) {
+                    show();
+                }
+            });
+            model.fetch()
+                .fail(function(xhr, textStatus, errorThrown) {
+                    console.log("error loading", model.id, ":", errorThrown);
+                    if (that.activeModel.cid === model.cid) {
+                        model.error = true;
+                        show();
+                    }
+                })
+                // Runs after the change event fires, or after the xhr completes
+                .always(function () {
+                    model.off("change");
+                });
+            return this;
+        },
+
+        deleteItem: function(event) {
+            // Could use wait flag to block removal of model from collection
+            // until server confirms deletion and success handler to perform
+            // removal. Useful if delete fails for e.g. lack of entitlement.
+            var that = this;
+            var displayName = $(event.currentTarget).data("name") || "item";
+            this.activeModel.destroy({
+                success: function() {
+                    that.renderEmpty("Deleted " + displayName);
+                },
+                error: function(info) {
+                    that.renderEmpty("Unable to permanently delete " + displayName+". Deletion is temporary, client-side only.");
+                }
+            });
+        }
+    });
+
+    var AddCatalogEntryView = Backbone.View.extend({
+        template: _.template(AddCatalogEntryHtml),
+        events: {
+            "click .show-context": "showContext"
+        },
+        initialize: function() {
+            _.bindAll(this);
+        },
+        render: function (initialView) {
+            this.$el.html(this.template());
+            if (initialView) {
+                if (initialView == "entity") initialView = "yaml";
+                
+                this.$("[data-context='"+initialView+"']").addClass("active");
+                this.showFormForType(initialView)
+            }
+            return this;
+        },
+        clearWithHtml: function(template) {
+            if (this.contextView) this.contextView.close();
+            this.context = undefined;
+            this.$(".btn").removeClass("active");
+            this.$("#catalog-add-form").html(template);
+        },
+        beforeClose: function () {
+            if (this.contextView) this.contextView.close();
+        },
+        showContext: function(event) {
+            var $event = $(event.currentTarget);
+            var context = $event.data("context");
+            if (this.context !== context) {
+                if (this.contextView) {
+                    this.contextView.close();
+                }
+                this.showFormForType(context)
+            }
+        },
+        showFormForType: function (type) {
+            this.context = type;
+            if (type == "yaml" || type == "entity") {
+                this.contextView = newYamlForm(this, this.options.parent);
+            } else if (type == "location") {
+                this.contextView = newLocationForm(this, this.options.parent);
+            } else if (type !== undefined) {
+                console.log("unknown catalog type " + type);
+                this.showFormForType("yaml");
+                return;
+            }
+            Backbone.history.navigate("/v1/catalog/new/" + type);
+            this.$("#catalog-add-form").html(this.contextView.$el);
+        }
+    });
+
+    function newYamlForm(addView, addViewParent) {
+        return new Brooklyn.view.Form({
+            template: _.template(AddYamlHtml),
+            onSubmit: function (model) {
+                var submitButton = this.$(".catalog-submit-button");
+                // "loading" is an indicator to Bootstrap, not a string to display
+                submitButton.button("loading");
+                var self = this;
+                var options = {
+                    url: "/v1/catalog/",
+                    data: model.get("yaml"),
+                    processData: false,
+                    type: "post"
+                };
+                $.ajax(options)
+                    .done(function (data, status, xhr) {
+                        // Can extract location of new item with:
+                        //model.url = Brooklyn.util.pathOf(xhr.getResponseHeader("Location"));
+                        if (_.size(data)==0) {
+                          addView.clearWithHtml( "No items supplied." );
+                        } else {
+                          addView.clearWithHtml( "Added: "+_.escape(_.keys(data).join(", ")) 
+                            + (_.size(data)==1 ? ". Loading..." : "") );
+                          addViewParent.loadAnyAccordionItem(_.size(data)==1 ? _.keys(data)[0] : undefined);
+                        }
+                    })
+                    .fail(function (xhr, status, error) {
+                        submitButton.button("reset");
+                        self.$(".catalog-save-error")
+                            .removeClass("hide")
+                            .find(".catalog-error-message")
+                            .html(_.escape(Brooklyn.util.extractError(xhr, "Could not add catalog item:\n'n" + error)));
+                    });
+            }
+        });
+    }
+
+    // Could adapt to edit existing locations too.
+    function newLocationForm(addView, addViewParent) {
+        // Renders with config key list
+        var body = new (Backbone.View.extend({
+            beforeClose: function() {
+                if (this.configKeyList) {
+                    this.configKeyList.close();
+                }
+            },
+            render: function() {
+                this.configKeyList = new Brooklyn.view.ConfigKeyInputPairList().render();
+                var template = _.template(AddLocationHtml);
+                this.$el.html(template);
+                this.$("#new-location-config").html(this.configKeyList.$el);
+            },
+            showError: function (message) {
+                self.$(".catalog-save-error")
+                    .removeClass("hide")
+                    .find(".catalog-error-message")
+                    .html(message);
+            }
+        }));
+        var form = new Brooklyn.view.Form({
+            body: body,
+            model: Location.Model,
+            onSubmit: function (location) {
+                var configKeys = body.configKeyList.getConfigKeys();
+                if (!configKeys.displayName) {
+                    configKeys.displayName = location.get("name");
+                }
+                var submitButton = this.$(".catalog-submit-button");
+                // "loading" is an indicator to Bootstrap, not a string to display
+                submitButton.button("loading");
+                location.set("config", configKeys);
+                location.save()
+                    .done(function (data) {
+                        addView.clearWithHtml( "Added: "+data.id+". Loading..." ); 
+                        addViewParent.loadAccordionItem("locations", data.id);
+                    })
+                    .fail(function (response) {
+                        submitButton.button("reset");
+                        body.showError(Brooklyn.util.extractError(response));
+                    });
+            }
+        });
+
+        return form;
+    }
+
+    var Catalog = Backbone.Collection.extend({
+        modelX: Backbone.Model.extend({
+          url: function() {
+            return "/v1/catalog/" + this.name + "/" + this.id + "?allVersions=true";
+          }
+        }),
+        initialize: function(models, options) {
+            this.name = options["name"];
+            if (!this.name) {
+                throw new Error("Catalog collection must know its name");
+            }
+            //this.model is a constructor so it shouldn't be _.bind'ed to this
+            //It actually works when a browser provided .bind is used, but the
+            //fallback implementation doesn't support it.
+            var that = this; 
+            var model = this.model.extend({
+              url: function() {
+                return "/v1/catalog/" + that.name + "/" + this.id.split(":").join("/");
+              }
+            });
+            _.bindAll(this);
+            this.model = model;
+        },
+        url: function() {
+            return "/v1/catalog/" + this.name+"?allVersions=true";
+        }
+    });
+
+    /** Use to fill single accordion view list. */
+    var AccordionItemView = Backbone.View.extend({
+        tag: "div",
+        className: "accordion-item",
+        events: {
+            'click .accordion-head': 'toggle',
+            'click .accordion-nav-row': 'showDetails'
+        },
+        bodyTemplate: _.template(
+            "<div class='accordion-head capitalized'><%= name %></div>" +
+            "<div class='accordion-body' style='display: <%= display %>'></div>"),
+
+        initialize: function() {
+            _.bindAll(this);
+            this.name = this.options.name;
+            if (!this.name) {
+                throw new Error("Name should have been given for accordion entry");
+            } else if (!this.options.onItemSelected) {
+                throw new Error("onItemSelected(model, element) callback should have been given for accordion entry");
+            }
+
+            // Generic templates
+            this.template = _.template(this.options.template || EntryHtml);
+
+            // Returns template applied to function arguments. Alter if collection altered.
+            // Will be run in the context of the AccordionItemView.
+            this.entryTemplateArgs = this.options.entryTemplateArgs || function(model, index) {
+                return {type: model.getVersionedAttr("type"), id: model.get("id")};
+            };
+
+            // undefined argument is used for existing model items
+            var collectionModel = this.options.model || Backbone.Model;
+            this.collection = this.options.collection || new Catalog(undefined, {
+                name: this.name,
+                model: collectionModel
+            });
+            // Refreshes entries list when the collection is synced with the server or
+            // any of its members are destroyed.
+            this.collection
+                .on("sync", this.renderEntries)
+                .on("destroy", this.renderEntries);
+            this.refresh();
+        },
+
+        beforeClose: function() {
+            this.collection.off();
+        },
+
+        render: function() {
+            this.$el.html(this.bodyTemplate({
+                name: this.name,
+                display: this.options.autoOpen ? "block" : "none"
+            }));
+            this.renderEntries();
+            return this;
+        },
+
+        singleItemTemplater: function(isChild, model, index) {
+            var args = _.extend({
+                    cid: model.cid,
+                    isChild: isChild,
+                    extraClasses: (activeDetailsView == this.name && model.cid == this.activeCid) ? "active" : ""
+                }, this.entryTemplateArgs(model));
+            return this.template(args);
+        },
+
+        renderEntries: function() {
+            var elements = this.collection.map(_.partial(this.singleItemTemplater, false), this);
+            this.updateContent(elements.join(''));
+        },
+
+        updateContent: function(markup) {
+            this.$(".accordion-body")
+                .empty()
+                .append(markup);
+        },
+
+        refresh: function() {
+            this.collection.fetch();
+        },
+
+        showDetails: function(event) {
+            var $event = $(event.currentTarget);
+            var cid = $event.data("cid");
+            if (activeDetailsView !== this.name || this.activeCid !== cid) {
+                activeDetailsView = this.name;
+                this.activeCid = cid;
+                var model = this.collection.get(cid);
+                Backbone.history.navigate("v1/catalog/" + this.name + "/" + model.id);
+                this.options.onItemSelected(activeDetailsView, model, $event);
+            }
+        },
+
+        toggle: function() {
+            var body = this.$(".accordion-body");
+            var hidden = this.hidden = body.css("display") == "none";
+            if (hidden) {
+                body.removeClass("hide").slideDown('fast');
+            } else {
+                body.slideUp('fast')
+            }
+        },
+
+        show: function() {
+            var body = this.$(".accordion-body");
+            var hidden = this.hidden = body.css("display") == "none";
+            if (hidden) {
+                body.removeClass("hide").slideDown('fast');
+            }
+        }
+    });
+    
+    var AccordionEntityView = AccordionItemView.extend({
+        renderEntries: function() {
+            var symbolicNameFn = function(model) {return model.get("symbolicName")};
+            var groups = this.collection.groupBy(symbolicNameFn);
+            var orderedIds = _.uniq(this.collection.map(symbolicNameFn));
+
+            function getLatestStableVersion(items) {
+                //the server sorts items by descending version, snapshots at the back
+                return items[0];
+            }
+
+            var catalogTree = _.map(orderedIds, function(symbolicName) {
+                var group = groups[symbolicName];
+                var root = getLatestStableVersion(group);
+                var children = _.reject(group, function(model) {return root.id == model.id;});
+                return {root: root, children: children};
+            });
+
+            var templater = function(memo, item, index) {
+                memo.push(this.singleItemTemplater(false, item.root));
+                return memo.concat(_.map(item.children, _.partial(this.singleItemTemplater, true), this));
+            };
+
+            var elements = _.reduce(catalogTree, templater, [], this);
+            this.updateContent(elements.join(''));
+        }
+    });
+
+    // Controls whole page. Parent of accordion items and details view.
+    var CatalogResourceView = Backbone.View.extend({
+        tagName:"div",
+        className:"container container-fluid",
+        entryTemplate:_.template(EntryHtml),
+
+        events: {
+            'click .refresh':'refresh',
+            'click #add-new-thing': 'createNewThing'
+        },
+
+        initialize: function() {
+            $(".nav1").removeClass("active");
+            $(".nav1_catalog").addClass("active");
+            // Important that bind happens before accordion object is created. If it happens after
+            // `this' will not be set correctly for the onItemSelected callbacks.
+            _.bindAll(this);
+            this.accordion = this.options.accordion || {
+                "applications": new AccordionEntityView({
+                    name: "applications",
+                    singular: "application",
+                    onItemSelected: _.partial(this.showCatalogItem, DetailsEntityHtml),
+                    model: Entity.Model,
+                    autoOpen: !this.options.kind || this.options.kind == "applications"
+                }),
+                "entities": new AccordionEntityView({
+                    name: "entities",
+                    singular: "entity",
+                    onItemSelected: _.partial(this.showCatalogItem, DetailsEntityHtml),
+                    model: Entity.Model,
+                    autoOpen: this.options.kind == "entities"
+                }),
+                "policies": new AccordionEntityView({
+                    // TODO needs parsing, and probably its own model
+                    // but cribbing "entity" works for now 
+                    // (and not setting a model can cause errors intermittently)
+                    onItemSelected: _.partial(this.showCatalogItem, DetailsEntityHtml),
+                    name: "policies",
+                    singular: "policy",
+                    model: Entity.Model,
+                    autoOpen: this.options.kind == "policies"
+                }),
+                "locations": new AccordionItemView({
+                    name: "locations",
+                    singular: "location",
+                    onItemSelected: _.partial(this.showCatalogItem, LocationDetailsHtml),
+                    collection: this.options.locations,
+                    autoOpen: this.options.kind == "locations",
+                    entryTemplateArgs: function (location, index) {
+                        return {
+                            type: location.getIdentifierName(),
+                            id: location.getLinkByName("self")
+                        };
+                    }
+                })
+            };
+        },
+
+        beforeClose: function() {
+            _.invoke(this.accordion, 'close');
+        },
+
+        render: function() {
+            this.$el.html(_.template(CatalogPageHtml, {}));
+            var parent = this.$(".catalog-accordion-parent");
+            _.each(this.accordion, function(child) {
+                parent.append(child.render().$el);
+            });
+            if (this.options.kind === "new") {
+                this.createNewThing(this.options.id);
+            } else if (this.options.kind && this.options.id) {
+                this.loadAccordionItem(this.options.kind, this.options.id)
+            } else {
+                // Show empty details view to start
+                this.setDetailsView(new CatalogItemDetailsView().render());
+            }
+            return this
+        },
+
+        /** Refreshes the contents of each accordion pane */
+        refresh: function() {
+            _.invoke(this.accordion, 'refresh');
+        },
+
+        createNewThing: function (type) {
+            // Discard if it's the jquery event object.
+            if (!_.isString(type)) {
+                type = undefined;
+            }
+            var viewName = "createNewThing";
+            if (!type) {
+                Backbone.history.navigate("/v1/catalog/new");
+            }
+            activeDetailsView = viewName;
+            this.$(".accordion-nav-row").removeClass("active");
+            var newView = new AddCatalogEntryView({
+                parent: this
+            }).render(type);
+            this.setDetailsView(newView);
+        },
+
+        loadAnyAccordionItem: function (id) {
+            this.loadAccordionItem("entities", id);
+            this.loadAccordionItem("applications", id);
+            this.loadAccordionItem("policies", id);
+            this.loadAccordionItem("locations", id);
+        },
+
+        loadAccordionItem: function (kind, id) {
+            if (!this.accordion[kind]) {
+                console.error("No accordion for: " + kind);
+            } else {
+                var accordion = this.accordion[kind];
+                var self = this;
+                // reset is needed because we rely on server's ordering;
+                // without it, server additions are placed at end of list
+                accordion.collection.fetch({reset: true})
+                    .then(function() {
+                        var model = accordion.collection.get(id);
+                        if (!model) {
+                            // if a version is supplied, try it without a version - needed for locations, navigating after deletion
+                            if (id && id.split(":").length>1) {
+                                model = accordion.collection.get( id.split(":")[0] );
+                            }
+                        }
+                        if (!model) {
+                            // if an ID is supplied without a version, look for first matching version (should be newest)
+                            if (id && id.split(":").length==1 && accordion.collection.models) {
+                                model = _.find(accordion.collection.models, function(m) { 
+                                    return m && m.id && m.id.startsWith(id+":");
+                                });
+                            }
+                        }
+                        // TODO could look in collection for any starting with ID
+                        if (model) {
+                            Backbone.history.navigate("/v1/catalog/"+kind+"/"+id);
+                            activeDetailsView = kind;
+                            accordion.activeCid = model.cid;
+                            accordion.options.onItemSelected(kind, model);
+                            accordion.show();
+                        } else {
+                            // catalog item not found, or not found yet (it might be reloaded and another callback will try again)
+                        }
+                    });
+            }
+        },
+
+        showCatalogItem: function(template, viewName, model, $target) {
+            this.$(".accordion-nav-row").removeClass("active");
+            if ($target) {
+                $target.addClass("active");
+            } else {
+                this.$("[data-cid=" + model.cid + "]").addClass("active");
+            }
+            var newView = new CatalogItemDetailsView({
+                model: model,
+                template: template,
+                name: viewName
+            }).render();
+            this.setDetailsView(newView)
+        },
+
+        setDetailsView: function(view) {
+            this.$("#details").html(view.el);
+            if (this.detailsView) {
+                // Try to re-open sections that were previously visible.
+                var openedItem = this.detailsView.$(".in").attr("id");
+                if (openedItem) {
+                    view.$("#" + openedItem).addClass("in");
+                }
+                this.detailsView.close();
+            }
+            this.detailsView = view;
+        }
+    });
+    
+    return CatalogResourceView
+});