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:22 UTC

[04/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/router.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/router.js b/src/main/webapp/assets/js/router.js
new file mode 100644
index 0000000..d80d35c
--- /dev/null
+++ b/src/main/webapp/assets/js/router.js
@@ -0,0 +1,240 @@
+/*
+ * 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([
+    "brooklyn", "underscore", "jquery", "backbone",
+    "model/application", "model/app-tree", "model/location", 
+    "model/server-extended-status",
+    "view/home", "view/application-explorer", "view/catalog", "view/script-groovy",
+    "text!tpl/help/page.html","text!tpl/labs/page.html", "text!tpl/home/server-caution.html"
+], function (Brooklyn, _, $, Backbone,
+        Application, AppTree, Location, 
+        serverStatus,
+        HomeView, ExplorerView, CatalogView, ScriptGroovyView,
+        HelpHtml, LabsHtml, ServerCautionHtml) {
+
+    var ServerCautionOverlay = Backbone.View.extend({
+        template: _.template(ServerCautionHtml),
+        scheduledRedirect: false,
+        initialize: function() {
+            var that = this;
+            this.carryOnRegardless = false;
+            _.bindAll(this);
+            serverStatus.addCallback(this.renderAndAddCallback);
+        },
+        renderAndAddCallback: function() {
+            this.renderOnUpdate();
+            serverStatus.addCallback(this.renderAndAddCallback);
+        },
+        renderOnUpdate: function() {
+            var that = this;
+            if (this.carryOnRegardless) return this.renderEmpty();
+            
+            var state = {
+                    loaded: serverStatus.loaded,
+                    up: serverStatus.isUp(),
+                    shuttingDown: serverStatus.isShuttingDown(),
+                    healthy: serverStatus.isHealthy(),
+                    master: serverStatus.isMaster(),
+                    masterUri: serverStatus.getMasterUri(),
+                };
+            
+            if (state.loaded && state.up && state.healthy && state.master) {
+                // this div shows nothing in normal operation
+                return this.renderEmpty();
+            }
+            
+            this.warningActive = true;
+            this.$el.html(this.template(state));
+            $('#application-content').fadeTo(500,0.1);
+            this.$el.fadeTo(200,1);
+            
+            $("#dismiss-standby-warning", this.$el).click(function() {
+                that.carryOnRegardless = true;
+                if (that.redirectPending) {
+                    log("Cancelling redirect, using this non-master instance");
+                    clearTimeout(that.redirectPending);
+                    that.redirectPending = null;
+                }       
+                that.renderOnUpdate();
+            });
+            
+            if (!state.master && state.masterUri) {
+                if (!this.scheduledRedirect && !this.redirectPending) {
+                    log("Not master; will redirect shortly to: "+state.masterUri);
+                    var destination = state.masterUri + "#" + Backbone.history.fragment;
+                    var time = 10;
+                    this.scheduledRedirect = true;
+                    log("Redirecting to " + destination + " in " + time + " seconds");
+                    this.redirectPending = setTimeout(function () {
+                        // re-check, in case the server's status changed in the wait
+                        if (!serverStatus.isMaster()) {
+                            if (that.redirectPending) {
+                                window.location.href = destination;
+                            } else {
+                                log("Cancelled redirect, using this non-master instance");
+                            }
+                        } else {
+                            log("Cancelled redirect, this instance is now master");
+                        }
+                    }, time * 1000);
+                }
+            }
+            return this;
+        },
+        renderEmpty: function() {
+            var that = this;
+            this.warningActive = false;
+            this.$el.fadeTo(200,0.2, function() {
+                if (!that.warningActive)
+                    that.$el.empty();
+            });
+            $('#application-content').fadeTo(200,1);
+            return this;
+        },
+        beforeClose: function() {
+            this.stopListening();
+        },
+        warnIfNotLoaded: function() {
+            if (!this.loaded)
+                this.renderOnUpdate();
+        }
+    });
+    // look for ha-standby-overlay for compatibility with older index.html copies
+    var serverCautionOverlay = new ServerCautionOverlay({ el: $("#server-caution-overlay").length ? $("#server-caution-overlay") : $("#ha-standby-overlay")});
+    serverCautionOverlay.render();
+    
+    var Router = Backbone.Router.extend({
+        routes:{
+            'v1/home/*trail':'homePage',
+            'v1/applications/:app/entities/*trail':'applicationsPage',
+            'v1/applications/*trail':'applicationsPage',
+            'v1/applications':'applicationsPage',
+            'v1/locations':'catalogPage',
+            'v1/catalog/:kind(/:id)':'catalogPage',
+            'v1/catalog':'catalogPage',
+            'v1/script/groovy':'scriptGroovyPage',
+            'v1/help':'helpPage',
+            'labs':'labsPage',
+            '*path':'defaultRoute'
+        },
+
+        showView: function(selector, view) {
+            // close the previous view - does binding clean-up and avoids memory leaks
+            if (this.currentView) {
+                this.currentView.close();
+            }
+            // render the view inside the selector element
+            $(selector).html(view.render().el);
+            this.currentView = view;
+            return view;
+        },
+
+        defaultRoute: function() {
+            this.homePage('auto')
+        },
+
+        applications: new Application.Collection,
+        appTree: new AppTree.Collection,
+        locations: new Location.Collection,
+
+        homePage:function (trail) {
+            var that = this;
+            var veryFirstViewLoad, homeView;
+            // render the page after we fetch the collection -- no rendering on error
+            function render() {
+                homeView = new HomeView({
+                    collection:that.applications,
+                    locations:that.locations,
+                    cautionOverlay:serverCautionOverlay,
+                    appRouter:that
+                });
+                veryFirstViewLoad = !that.currentView;
+                that.showView("#application-content", homeView);
+            }
+            this.applications.fetch({success:function () {
+                render();
+                // show add application wizard if none already exist and this is the first page load
+                if ((veryFirstViewLoad && trail=='auto' && that.applications.isEmpty()) || (trail=='add_application') ) {
+                    if (serverStatus.isMaster()) {
+                        homeView.createApplication();
+                    }
+                }
+            }, error: render});
+        },
+        applicationsPage:function (app, trail, tab) {
+            if (trail === undefined) trail = app
+            var that = this
+            this.appTree.fetch({success:function () {
+                var appExplorer = new ExplorerView({
+                    collection:that.appTree,
+                    appRouter:that
+                })
+                that.showView("#application-content", appExplorer)
+                if (trail !== undefined) appExplorer.show(trail)
+            }})
+        },
+        catalogPage: function (catalogItemKind, id) {
+            var catalogResource = new CatalogView({
+                locations: this.locations,
+                appRouter: this,
+                kind: catalogItemKind,
+                id: id
+            });
+            this.showView("#application-content", catalogResource);
+        },
+        scriptGroovyPage:function () {
+            if (this.scriptGroovyResource === undefined)
+                this.scriptGroovyResource = new ScriptGroovyView({})
+            this.showView("#application-content", this.scriptGroovyResource)
+            $(".nav1").removeClass("active")
+            $(".nav1_script").addClass("active")
+            $(".nav1_script_groovy").addClass("active")
+        },
+        helpPage:function () {
+            $("#application-content").html(_.template(HelpHtml, {}))
+            $(".nav1").removeClass("active")
+            $(".nav1_help").addClass("active")
+        },
+        labsPage:function () {
+            $("#application-content").html(_.template(LabsHtml, {}))
+            $(".nav1").removeClass("active")
+        },
+
+        /** Triggers the Backbone.Router process which drives this GUI through Backbone.history,
+         *  after starting background server health checks and waiting for confirmation of health
+         *  (or user click-through). */
+        startBrooklynGui: function() {
+            serverStatus.whenUp(function() { Backbone.history.start(); });
+            serverStatus.autoUpdate();
+            _.delay(serverCautionOverlay.warnIfNotLoaded, 2*1000)
+        }
+    });
+
+    $.ajax({
+        type: "GET",
+        url: "/v1/server/user",
+        dataType: "text"
+    }).done(function (data) {
+        if (data != null) {
+            $("#user").html(_.escape(data));
+        }
+    });
+
+    return Router
+})

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/util/brooklyn-utils.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/util/brooklyn-utils.js b/src/main/webapp/assets/js/util/brooklyn-utils.js
new file mode 100644
index 0000000..5f3915c
--- /dev/null
+++ b/src/main/webapp/assets/js/util/brooklyn-utils.js
@@ -0,0 +1,226 @@
+/*
+ * 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.
+ */
+
+/* brooklyn utility methods */
+
+define([
+    'jquery', 'underscore'
+], function ($, _) {
+
+    var Util = {};
+
+    /**
+     * @return {string} empty string if s is null or undefined, otherwise result of _.escape(s)
+     */
+    Util.escape = function (s) {
+        if (s == undefined || s == null) return "";
+        return _.escape(s);
+    };
+
+    function isWholeNumber(v) {
+        return (Math.abs(Math.round(v) - v) < 0.000000000001);
+    }
+
+    Util.roundIfNumberToNumDecimalPlaces = function (v, mantissa) {
+        if (!_.isNumber(v) || mantissa < 0)
+            return v;
+
+        if (isWholeNumber(v))
+            return Math.round(v);
+
+        var vk = v, xp = 1;
+        for (var i=0; i < mantissa; i++) {
+            vk *= 10;
+            xp *= 10;
+            if (isWholeNumber(vk)) {
+                return Math.round(vk)/xp;
+            }
+        }
+        return Number(v.toFixed(mantissa));
+    };
+
+    Util.toDisplayString = function(data) {
+        if (data==null) return null;
+        data = Util.roundIfNumberToNumDecimalPlaces(data, 4);
+        if (typeof data !== 'string')
+            data = JSON.stringify(data);
+        return Util.escape(data);
+    };
+
+    Util.toTextAreaString = function(data) {
+        if (data==null) return null;
+        data = Util.roundIfNumberToNumDecimalPlaces(data, 8);
+        if (typeof data !== 'string')
+            data = JSON.stringify(data, null, 2);
+        return data;
+    };
+
+    if (!String.prototype.trim) {
+        // some older javascripts do not support 'trim' (including jasmine spec runner) so let's define it
+        String.prototype.trim = function(){
+            return this.replace(/^\s+|\s+$/g, '');
+        };
+    }
+
+    // from http://stackoverflow.com/questions/646628/how-to-check-if-a-string-startswith-another-string
+    if (typeof String.prototype.startsWith != 'function') {
+        String.prototype.startsWith = function (str){
+            return this.slice(0, str.length) == str;
+        };
+    }
+    if (typeof String.prototype.endsWith != 'function') {
+        String.prototype.endsWith = function (str){
+            return this.slice(-str.length) == str;
+        };
+    }
+    
+    // poor-man's copy
+    Util.promptCopyToClipboard = function(text) {
+        window.prompt("To copy to the clipboard, press Ctrl+C then Enter.", text);
+    };
+
+    /**
+     * Returns the path component of a string URL. e.g. http://example.com/bob/bob --> /bob/bob
+     */
+    Util.pathOf = function(string) {
+        if (!string) return "";
+        var a = document.createElement("a");
+        a.href = string;
+        return a.pathname;
+    };
+
+    /**
+     * Extracts the value of the given input. Returns true/false for for checkboxes
+     * rather than "on" or "off".
+     */
+    Util.inputValue = function($input) {
+        if ($input.attr("type") === "checkbox") {
+            return $input.is(":checked");
+        } else {
+            return $input.val();
+        }
+    };
+
+    /**
+     * Updates or initialises the given model with the values of named elements under
+     * the given element. Force-updates the model by setting silent: true.
+     */
+    Util.bindModelFromForm = function(modelOrConstructor, $el) {
+        var model = _.isFunction(modelOrConstructor) ? new modelOrConstructor() : modelOrConstructor;
+        var inputs = {};
+
+        // Look up all named elements
+        $("[name]", $el).each(function(idx, inp) {
+            var input = $(inp);
+            var name = input.attr("name");
+            inputs[name] = Util.inputValue(input);
+        });
+        model.set(inputs, { silent: true });
+        return model;
+    };
+
+    /**
+     * Parses xhrResponse.responseText as JSON and returns its message. Returns
+     * alternate message if parsing fails or the parsed object has no message.
+     * @param {jqXHR} xhrResponse
+     * @param {string} alternateMessage
+     * @param {string=} logMessage if false or null, does not log;
+     *      otherwise it logs a message and the xhrResponse, with logMessage
+     *      (or with alternateMessage if logMessage is true)
+     * @returns {*}
+     */
+    Util.extractError = function (xhrResponse, alternateMessage, logMessage) {
+        if (logMessage) {
+            if (logMessage === true) {
+                console.error(alternateMessage);
+            } else {
+                console.error(logMessage);
+            }
+            console.log(xhrResponse);
+        }
+        
+        try {
+            var response = JSON.parse(xhrResponse.responseText);
+            return response.message ? response.message : alternateMessage;
+        } catch (e) {
+            return alternateMessage;
+        }
+    };
+    
+    secretWords = [ "password", "passwd", "credential", "secret", "private", "access.cert", "access.key" ];
+    
+    Util.isSecret = function (key) {
+        if (!key) return false;
+        key = key.toString().toLowerCase();
+        for (secretWord in secretWords)
+            if (key.indexOf(secretWords[secretWord]) >= 0)
+                return true;
+        return false; 
+    };
+
+    Util.logout = function logout() {
+        $.ajax({
+            type: "POST",
+            dataType: "text",
+            url: "/logout",
+            success: function() {
+                window.location.replace("/");
+            },
+            failure: function() {
+                window.location.replace("/");
+            }
+        });
+    }
+
+    Util.setSelectionRange = function (input, selectionStart, selectionEnd) {
+      if (input.setSelectionRange) {
+        input.focus();
+        input.setSelectionRange(selectionStart, selectionEnd);
+      }
+      else if (input.createTextRange) {
+        var range = input.createTextRange();
+        range.collapse(true);
+        range.moveEnd('character', selectionEnd);
+        range.moveStart('character', selectionStart);
+        range.select();
+      }
+    };
+            
+    Util.setCaretToPos = function (input, pos) {
+      Util.setSelectionRange(input, pos, pos);
+    };
+
+    $.fn.setCaretToStart = function() {
+      this.each(function(index, elem) {
+        Util.setCaretToPos(elem, 0);
+        $(elem).scrollTop(0);
+      });
+      return this;
+    };
+
+    $("#logout-link").on("click", function (e) {
+        e.preventDefault();
+        Util.logout()
+        return false;
+    });
+
+    return Util;
+
+});
+

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/util/brooklyn-view.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/util/brooklyn-view.js b/src/main/webapp/assets/js/util/brooklyn-view.js
new file mode 100644
index 0000000..7151ae1
--- /dev/null
+++ b/src/main/webapp/assets/js/util/brooklyn-view.js
@@ -0,0 +1,352 @@
+/*
+ * 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.
+*/
+
+/* brooklyn extensions for supporting views */
+define([
+    "jquery", "underscore", "backbone", "brooklyn-utils",
+    "text!tpl/lib/basic-modal.html",
+    "text!tpl/lib/config-key-type-value-input-pair.html"
+    ], function (
+    $, _, Backbone, Util,
+    ModalHtml, ConfigKeyInputHtml
+) {
+
+    var module = {};
+
+    module.refresh = true;
+
+    /** Toggles automatic refreshes of instances of View. */
+    module.toggleRefresh = function () {
+        this.refresh = !this.refresh;
+        return this.refresh;
+    };
+
+    // TODO this customising of the View prototype could be expanded to include
+    // other methods from viewutils. see discussion at
+    // https://github.com/brooklyncentral/brooklyn/pull/939
+
+    // add close method to all views for clean-up
+    // (NB we have to update the prototype _here_ before any views are instantiated;
+    //  see "close" called below in "showView")
+    Backbone.View.prototype.close = function () {
+        // call user defined close method if exists
+        this.viewIsClosed = true;
+        if (_.isFunction(this.beforeClose)) {
+            this.beforeClose();
+        }
+        _.each(this._periodicFunctions, function (i) {
+            clearInterval(i);
+        });
+        this.remove();
+        this.unbind();
+    };
+
+    Backbone.View.prototype.viewIsClosed = false;
+
+    /**
+     * Registers a callback (cf setInterval) that is unregistered cleanly when the view
+     * closes. The callback is run in the context of the owning view, so callbacks can
+     * refer to 'this' safely.
+     */
+    Backbone.View.prototype.callPeriodically = function (uid, callback, interval) {
+        if (!this._periodicFunctions) {
+            this._periodicFunctions = {};
+        }
+        var old = this._periodicFunctions[uid];
+        if (old) clearInterval(old);
+
+        // Wrap callback in function that checks whether updates are enabled
+        var periodic = function () {
+            if (module.refresh) {
+                callback.apply(this);
+            }
+        };
+        // Bind this to the view
+        periodic = _.bind(periodic, this);
+        this._periodicFunctions[uid] = setInterval(periodic, interval);
+    };
+
+    /**
+     * A form that listens to modifications to its inputs, maintaining a model that is
+     * submitted when a button with class 'submit' is clicked.
+     *
+     * Expects a body view or a template function to render.
+     */
+    module.Form = Backbone.View.extend({
+        events: {
+            "change": "onChange",
+            "submit": "onSubmit"
+        },
+
+        initialize: function() {
+            if (!this.options.body && !this.options.template) {
+                throw new Error("body view or template function required by GenericForm");
+            } else if (!this.options.onSubmit) {
+                throw new Error("onSubmit function required by GenericForm");
+            }
+            this.onSubmitCallback = this.options.onSubmit;
+            this.model = new (this.options.model || Backbone.Model);
+            _.bindAll(this, "onSubmit", "onChange");
+            this.render();
+        },
+
+        beforeClose: function() {
+            if (this.options.body) {
+                this.options.body.close();
+            }
+        },
+
+        render: function() {
+            if (this.options.body) {
+                this.options.body.render();
+                this.$el.html(this.options.body.$el);
+            } else {
+                this.$el.html(this.options.template());
+            }
+            // Initialise the model with existing values
+            Util.bindModelFromForm(this.model, this.$el);
+            return this;
+        },
+
+        onChange: function(e) {
+            var target = $(e.target);
+            var name = target.attr("name");
+            this.model.set(name, Util.inputValue(target), { silent: true });
+        },
+
+        onSubmit: function(e) {
+            e.preventDefault();
+            // Could validate model
+            this.onSubmitCallback(this.model.clone());
+            return false;
+        }
+
+    });
+
+    /**
+     * A view to render another view in a modal. Give another view to render as
+     * the `body' parameter that has an onSubmit function that will be called
+     * when the modal's `Save' button is clicked, and/or an onCancel callback
+     * that will be called when the modal is closed without saving.
+     *
+     * The onSubmit callback should return either:
+     * <ul>
+     *   <li><b>nothing</b>: the callback is treated as successful
+     *   <li><b>true</b> or <b>false</b>: the callback is treated as appropriate
+     *   <li>a <b>promise</b> with `done' and `fail' callbacks (for example a jqXHR object):
+     *     The callback is treated as successful when the promise is done without error.
+     *   <li><b>anything else</b>: the callback is treated as successful
+     * </ul>
+     * When the onSubmit callback is successful the modal is closed.
+     *
+     * The return value of the onCancel callback is ignored.
+     *
+     * The modal will still be open and visible when the onSubmit callback is called.
+     * The modal will have been closed when the onCancel callback is called.
+     */
+    module.Modal = Backbone.View.extend({
+
+        id: _.uniqueId("modal"),
+        className: "modal",
+        template: _.template(ModalHtml),
+
+        events: {
+            "hide": "onClose",
+            "click .modal-submit": "onSubmit"
+        },
+
+        initialize: function() {
+            if (!this.options.body) {
+                throw new Error("Modal view requires body to render");
+            }
+            _.bindAll(this, "onSubmit", "onCancel", "show");
+            if (this.options.autoOpen) {
+                this.show();
+            }
+        },
+
+        beforeClose: function() {
+            if (this.options.body) {
+                this.options.body.close();
+            }
+        },
+
+        render: function() {
+            var optionalTitle = this.options.body.title;
+            var title = _.isFunction(optionalTitle)
+                    ? optionalTitle()
+                    : _.isString(optionalTitle)
+                        ? optionalTitle : this.options.title;
+            this.$el.html(this.template({
+                title: title,
+                submitButtonText: this.options.submitButtonText || "Apply",
+                cancelButtonText: this.options.cancelButtonText || "Cancel"
+            }));
+            this.options.body.render();
+            this.$(".modal-body").html(this.options.body.$el);
+            return this;
+        },
+
+        show: function() {
+            this.render().$el.modal();
+            return this;
+        },
+
+        onSubmit: function(event) {
+            if (_.isFunction(this.options.body.onSubmit)) {
+                var submission = this.options.body.onSubmit.apply(this.options.body, [event]);
+                var self = this;
+                var submissionSuccess = function() {
+                    // Closes view via event.
+                    self.closingSuccessfully = true;
+                    self.$el.modal("hide");
+                };
+                var submissionFailure = function () {
+                    // Better response.
+                    console.log("modal submission failed!", arguments);
+                };
+                // Assuming no value is fine
+                if (!submission) {
+                    submission = true;
+                }
+                if (_.isBoolean(submission) && submission) {
+                    submissionSuccess();
+                } else if (_.isBoolean(submission)) {
+                    submissionFailure();
+                } else if (_.isFunction(submission.done) && _.isFunction(submission.fail)) {
+                    submission.done(submissionSuccess).fail(submissionFailure);
+                } else {
+                    // assuming success and closing modal
+                    submissionSuccess()
+                }
+            }
+            return false;
+        },
+
+        onCancel: function () {
+            if (_.isFunction(this.options.body.onCancel)) {
+                this.options.body.onCancel.apply(this.options.body);
+            }
+        },
+
+        onClose: function () {
+            if (!this.closingSuccessfully) {
+                this.onCancel();
+            }
+            this.close();
+        }
+    });
+
+    /**
+     * Shows a modal with yes/no buttons as a user confirmation prompt.
+     * @param {string} question The message to show in the body of the modal
+     * @param {string} [title] An optional title to show. Uses generic default if not given.
+     * @returns {jquery.Deferred} The promise from a jquery.Deferred object. The
+     *          promise is resolved if the modal was submitted normally and rejected
+     *          otherwise.
+     */
+    module.requestConfirmation = function (question, title) {
+        var deferred = $.Deferred();
+        var Confirmation = Backbone.View.extend({
+            title: title || "Confirm action",
+            render: function () {
+                this.$el.html(question || "");
+            },
+            onSubmit: function () {
+                deferred.resolve();
+            },
+            onCancel: function () {
+                deferred.reject();
+            }
+        });
+        new module.Modal({
+            body: new Confirmation(),
+            autoOpen: true,
+            submitButtonText: "Yes",
+            cancelButtonText: "No"
+        });
+        return deferred.promise();
+    };
+
+    /** Creates, displays and returns a modal with the given view used as its body */
+    module.showModalWith = function (bodyView) {
+        return new module.Modal({body: bodyView}).show();
+    };
+
+    /**
+     * Presents inputs for config key names/values with  buttons to add/remove entries
+     * and a function to extract a map of name->value.
+     */
+    module.ConfigKeyInputPairList = Backbone.View.extend({
+        template: _.template(ConfigKeyInputHtml),
+        // Could listen to input change events and add 'error' class to any type inputs
+        // that duplicate values.
+        events: {
+            "click .config-key-row-remove": "rowRemove",
+            "keypress .last": "rowAdd"
+        },
+        render: function () {
+            if (this.options.configKeys) {
+                var templated = _.map(this.options.configKeys, function (value, key) {
+                    return this.templateRow(key, value);
+                }, this);
+                this.$el.html(templated.join(""));
+            }
+            this.$el.append(this.templateRow());
+            this.markLast();
+            return this;
+        },
+        rowAdd: function (event) {
+            this.$el.append(this.templateRow());
+            this.markLast();
+        },
+        rowRemove: function (event) {
+            $(event.currentTarget).parent().remove();
+            if (this.$el.children().length == 0) {
+                this.rowAdd();
+            }
+            this.markLast();
+        },
+        markLast: function () {
+            this.$(".last").removeClass("last");
+            // Marking inputs rather than parent div to avoid weird behaviour when
+            // remove row button is triggered with the keyboard.
+            this.$(".config-key-type").last().addClass("last");
+            this.$(".config-key-value").last().addClass("last");
+        },
+        templateRow: function (type, value) {
+            return this.template({type: type || "", value: value || ""});
+        },
+        getConfigKeys: function () {
+            var cks = {};
+            this.$(".config-key-type").each(function (index, input) {
+                input = $(input);
+                var type = input.val() && input.val().trim();
+                var value = input.next().val() && input.next().val().trim();
+                if (type && value) {
+                    cks[type] = value;
+                }
+            });
+            return cks;
+        }
+    });
+
+    return module;
+
+});

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/util/brooklyn.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/util/brooklyn.js b/src/main/webapp/assets/js/util/brooklyn.js
new file mode 100644
index 0000000..702b59b
--- /dev/null
+++ b/src/main/webapp/assets/js/util/brooklyn.js
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+
+/** brooklyn extension to make console methods available and simplify access to other utils */
+
+define([
+    "underscore", "brooklyn-view", "brooklyn-utils"
+], function (_, BrooklynViews, BrooklynUtils) {
+
+    /**
+     * Makes the console API safe to use:
+     *  - Stubs missing methods to prevent errors when no console is present.
+     *  - Exposes a global `log` function that preserves line numbering and formatting.
+     *
+     * Idea from https://gist.github.com/bgrins/5108712
+     */
+    (function () {
+        var noop = function () {},
+            consoleMethods = [
+                'assert', 'clear', 'count', 'debug', 'dir', 'dirxml', 'error',
+                'exception', 'group', 'groupCollapsed', 'groupEnd', 'info', 'log',
+                'markTimeline', 'profile', 'profileEnd', 'table', 'time', 'timeEnd',
+                'timeStamp', 'trace', 'warn'
+            ],
+            length = consoleMethods.length,
+            console = (window.console = window.console || {});
+
+        while (length--) {
+            var method = consoleMethods[length];
+
+            // Only stub undefined methods.
+            if (!console[method]) {
+                console[method] = noop;
+            }
+        }
+
+        if (Function.prototype.bind) {
+            window.log = Function.prototype.bind.call(console.log, console);
+        } else {
+            window.log = function () {
+                Function.prototype.apply.call(console.log, console, arguments);
+            };
+        }
+    })();
+
+    var template = _.template;
+    _.mixin({
+        /**
+         * @param {string} text
+         * @return string The text with HTML comments removed.
+         */
+        stripComments: function (text) {
+            return text.replace(/<!--(.|[\n\r\t])*?-->\r?\n?/g, "");
+        },
+        /**
+         * As the real _.template, calling stripComments on text.
+         */
+        template: function (text, data, settings) {
+            return template(_.stripComments(text), data, settings);
+        }
+    });
+
+    var Brooklyn = {
+        view: BrooklynViews,
+        util: BrooklynUtils
+    };
+
+    return Brooklyn;
+});

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/util/dataTables.extensions.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/util/dataTables.extensions.js b/src/main/webapp/assets/js/util/dataTables.extensions.js
new file mode 100644
index 0000000..74a548e
--- /dev/null
+++ b/src/main/webapp/assets/js/util/dataTables.extensions.js
@@ -0,0 +1,56 @@
+/*
+ * 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 code has been created by the Apache Brooklyn contributors.
+ * It is heavily based on earlier software but rewritten for clarity 
+ * and to preserve license integrity.
+ *
+ * This work is based on the existing jQuery DataTables plug-ins for:
+ *
+ * * fnStandingRedraw by Jonathan Hoguet, 
+ *   http://www.datatables.net/plug-ins/api/fnStandingRedraw
+ *
+ * * fnProcessingIndicator by Allan Chappell
+ *   https://www.datatables.net/plug-ins/api/fnProcessingIndicator
+ *
+ */
+define([
+    "jquery", "jquery-datatables"
+], function($, dataTables) {
+
+$.fn.dataTableExt.oApi.fnStandingRedraw = function(oSettings) {
+    if (oSettings.oFeatures.bServerSide === false) {
+        // remember and restore cursor position
+        var oldDisplayStart = oSettings._iDisplayStart;
+        oSettings.oApi._fnReDraw(oSettings);
+        oSettings._iDisplayStart = oldDisplayStart;
+        oSettings.oApi._fnCalculateEnd(oSettings);
+    }
+    // and force draw
+    oSettings.oApi._fnDraw(oSettings);
+};
+
+
+jQuery.fn.dataTableExt.oApi.fnProcessingIndicator = function(oSettings, bShow) {
+    if (typeof bShow === "undefined") bShow=true;
+    this.oApi._fnProcessingDisplay(oSettings, bShow);
+};
+
+});

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/util/jquery.slideto.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/util/jquery.slideto.js b/src/main/webapp/assets/js/util/jquery.slideto.js
new file mode 100644
index 0000000..17afeed
--- /dev/null
+++ b/src/main/webapp/assets/js/util/jquery.slideto.js
@@ -0,0 +1,61 @@
+/*
+ * 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 code has been created by the Apache Brooklyn contributors.
+ * It is heavily based on earlier software but rewritten for readability 
+ * and to preserve license integrity.
+ *
+ * Our influences are:
+ *
+ * * jquery.slideto.min.js in Swagger UI, provenance unknown, added in:
+ *   https://github.com/wordnik/swagger-ui/commit/d2eb882e5262e135dfa3f5919796bbc3785880b8#diff-bd86720650a2ebd1ab11e870dc475564
+ *
+ *   Swagger UI is distributed under ASL but it is not clear that this code originated in that project.
+ *   No other original author could be identified.
+ *
+ * * Nearly identical code referenced here:
+ *   http://stackoverflow.com/questions/12375440/scrolling-works-in-chrome-but-not-in-firefox-or-ie
+ *
+ * Note that the project https://github.com/Sleavely/jQuery-slideto is NOT this.
+ *
+ */
+(function(jquery){
+jquery.fn.slideto=function(opts) {
+    opts = _.extend( {
+            highlight: true,
+            slide_duration: "slow",
+            highlight_duration: 3000,
+            highlight_color: "#FFFF99" },
+        opts);
+    return this.each(function() {
+        $target=jquery(this);
+        jquery("body").animate(
+            { scrollTop: $target.offset().top },
+            opts.slide_duration,
+            function() {
+                opts.highlight && 
+                jquery.ui.version && 
+                $target.effect(
+                    "highlight",
+                    { color: opts.highlight_color },
+                    opts.highlight_duration)
+            })
+        });
+}}) (jQuery);

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/activity-details.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/view/activity-details.js b/src/main/webapp/assets/js/view/activity-details.js
new file mode 100644
index 0000000..fa8b552
--- /dev/null
+++ b/src/main/webapp/assets/js/view/activity-details.js
@@ -0,0 +1,426 @@
+/*
+ * 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.
+*/
+/**
+ * Displays details on an activity/task
+ */
+define([
+    "underscore", "jquery", "backbone", "brooklyn-utils", "view/viewutils", "moment",
+    "model/task-summary",
+    "text!tpl/apps/activity-details.html", "text!tpl/apps/activity-table.html", 
+
+    "bootstrap", "jquery-datatables", "datatables-extensions"
+], function (_, $, Backbone, Util, ViewUtils, moment,
+    TaskSummary,
+    ActivityDetailsHtml, ActivityTableHtml) {
+
+    var activityTableTemplate = _.template(ActivityTableHtml),
+        activityDetailsTemplate = _.template(ActivityDetailsHtml);
+
+    function makeActivityTable($el) {
+        $el.html(_.template(ActivityTableHtml));
+        var $subTable = $('.activity-table', $el);
+        $subTable.attr('width', 569-6-6 /* subtract padding */)
+
+        return ViewUtils.myDataTable($subTable, {
+            "fnRowCallback": function( nRow, aData, iDisplayIndex, iDisplayIndexFull ) {
+                $(nRow).attr('id', aData[0])
+                $(nRow).addClass('activity-row')
+            },
+            "aoColumnDefs": [ {
+                    "mRender": function ( data, type, row ) { return Util.escape(data) },
+                    "aTargets": [ 1, 2, 3 ]
+                 }, {
+                    "bVisible": false,
+                    "aTargets": [ 0 ]
+                 } ],
+            "aaSorting":[]  // default not sorted (server-side order)
+        });
+    }
+
+    var ActivityDetailsView = Backbone.View.extend({
+        template: activityDetailsTemplate,
+        taskLink: '',
+        task: null,
+        /* children of this task; see HasTaskChildren for difference between this and sub(mitted)Tasks */
+        childrenTable: null,
+        /* tasks in the current execution context (this.collections) whose submittedByTask
+         * is the task we are drilled down on. this defaults to the passed in collection, 
+         * which will be the last-viewed entity's exec-context; when children cross exec-context
+         * boundaries we have to rewire to point to the current entity's exec-context / tasks */
+        subtasksTable: null,
+        children: null,
+        breadcrumbs: [],
+        firstLoad: true,
+        events:{
+            "click #activities-children-table .activity-table tr":"childrenRowClick",
+            "click #activities-submitted-table .activity-table tr":"submittedRowClick",
+            'click .showDrillDownSubmittedByAnchor':'showDrillDownSubmittedByAnchor',
+            'click .showDrillDownBlockerOfAnchor':'showDrillDownBlockerOfAnchor',
+            'click .backDrillDown':'backDrillDown'
+        },
+        // requires taskLink or task; breadcrumbs is optional
+        initialize:function () {
+            var that = this;
+            this.taskLink = this.options.taskLink;
+            this.taskId = this.options.taskId;
+            if (this.options.task)
+                this.task = this.options.task;
+            else if (this.options.tabView)
+                this.task = this.options.tabView.collection.get(this.taskId);
+            if (!this.taskLink && this.task) this.taskLink = this.task.get('links').self;
+            if (!this.taskLink && this.taskId) this.taskLink = "v1/activities/"+this.taskId;;
+            
+            this.tabView = this.options.tabView || null;
+            
+            if (this.options.breadcrumbs) this.breadcrumbs = this.options.breadcrumbs;
+
+            this.$el.html(this.template({ taskLink: this.taskLink, taskId: this.taskId, task: this.task, breadcrumbs: this.breadcrumbs }));
+            this.$el.addClass('activity-detail-panel');
+
+            this.childrenTable = makeActivityTable(this.$('#activities-children-table'));
+            this.subtasksTable = makeActivityTable(this.$('#activities-submitted-table'));
+
+            ViewUtils.attachToggler(this.$el)
+        
+            if (this.task) {
+                this.renderTask()
+                this.setUpPolling()
+            } else {      
+                ViewUtils.fadeToIndicateInitialLoad(this.$el);
+                this.$el.css('cursor', 'wait')
+                $.get(this.taskLink, function(data) {
+                    ViewUtils.cancelFadeOnceLoaded(that.$el);
+                    that.task = new TaskSummary.Model(data)
+                    that.renderTask()
+                    that.setUpPolling();
+                }).fail(function() { log("unable to load "+that.taskLink) })
+            }
+
+            // initial subtasks may be available from parent, so try to render those
+            // (reliable polling for subtasks, and for children, is set up in setUpPolling ) 
+            this.renderSubtasks()
+        },
+        
+        refreshNow: function(initial) {
+            var that = this
+            $.get(this.taskLink, function(data) {
+                that.task = new TaskSummary.Model(data)
+                that.renderTask()
+                if (initial) that.setUpPolling();
+            })
+        },
+        renderTask: function() {
+            // update task fields
+            var that = this, firstLoad = this.firstLoad;
+            this.firstLoad = false;
+            
+            if (firstLoad  && this.task) {
+//                log("rendering "+firstLoad+" "+this.task.get('isError')+" "+this.task.id);
+                if (this.task.get('isError')) {
+                    // on first load, expand the details if there is a problem
+                    var $details = this.$(".toggler-region.task-detail .toggler-header");
+                    ViewUtils.showTogglerClickElement($details);
+                }
+            }
+            
+            this.updateFields('displayName', 'entityDisplayName', 'id', 'description', 'currentStatus', 'blockingDetails');
+            this.updateFieldWith('blockingTask',
+                function(v) { 
+                    return "<a class='showDrillDownBlockerOfAnchor handy' link='"+_.escape(v.link)+"' id='"+v.metadata.id+"'>"+
+                        that.displayTextForLinkedTask(v)+"</a>" })
+            this.updateFieldWith('result',
+                function(v) {
+                    // use display string (JSON.stringify(_.escape(v)) because otherwise list of [null,null] is just ","  
+                    var vs = Util.toDisplayString(v);
+                    if (vs.trim().length==0) {
+                        return " (empty result)";
+                    } else if (vs.length<20 &&  !/\r|\n/.exec(v)) {
+                        return " with result: <span class='result-literal'>"+vs+"</span>";
+                    } else {
+                        return "<div class='result-literal'>"+vs.replace(/\n+/g,"<br>")+"</div>"
+                    }
+                 })
+            this.updateFieldWith('tags', function(tags) {
+                var tagBody = "";
+                for (var tag in tags) {
+                    tagBody += "<div class='activity-tag-giftlabel'>"+Util.toDisplayString(tags[tag])+"</div>";
+                }
+                return tagBody;
+            })
+            
+            var submitTimeUtc = this.updateFieldWith('submitTimeUtc',
+                function(v) { return v <= 0 ? "-" : moment(v).format('D MMM YYYY H:mm:ss.SSS')+" &nbsp; <i>"+moment(v).fromNow()+"</i>" })
+            var startTimeUtc = this.updateFieldWith('startTimeUtc',
+                function(v) { return v <= 0 ? "-" : moment(v).format('D MMM YYYY H:mm:ss.SSS')+" &nbsp; <i>"+moment(v).fromNow()+"</i>" })
+            this.updateFieldWith('endTimeUtc',
+                function(v) { return v <= 0 ? "-" : moment(v).format('D MMM YYYY H:mm:ss.SSS')+" &nbsp; <i>"+moment(v).from(startTimeUtc, true)+" later</i>" })
+
+            ViewUtils.updateTextareaWithData(this.$(".task-json .for-textarea"), 
+                Util.toTextAreaString(this.task), false, false, 150, 400)
+
+            ViewUtils.updateTextareaWithData(this.$(".task-detail .for-textarea"), 
+                this.task.get('detailedStatus'), false, false, 30, 250)
+
+            this.updateFieldWith('streams',
+                function(streams) {
+                    // Stream names presented alphabetically
+                    var keys = _.keys(streams);
+                    keys.sort();
+                    var result = "";
+                    for (var i = 0; i < keys.length; i++) {
+                        var name = keys[i];
+                        var stream = streams[name];
+                        result += "<div class='activity-stream-div'>" +
+                                "<span class='activity-label'>" +
+                                _.escape(name) +
+                                "</span><span>" +
+                                "<a href='" + stream.link + "'>download</a>" +
+                                (stream.metadata["sizeText"] ? " (" + _.escape(stream.metadata["sizeText"]) + ")" : "") +
+                                "</span></div>";
+                    }
+                    return result; 
+                });
+
+            this.updateFieldWith('submittedByTask',
+                function(v) { return "<a class='showDrillDownSubmittedByAnchor handy' link='"+_.escape(v.link)+"' id='"+v.metadata.id+"'>"+
+                    that.displayTextForLinkedTask(v)+"</a>" })
+
+            if (this.task.get("children").length==0)
+                this.$('.toggler-region.tasks-children').hide();
+        },
+        setUpPolling: function() {
+            var that = this
+
+            // on first load, clear any funny cursor
+            this.$el.css('cursor', 'auto')
+
+            this.task.url = this.taskLink;
+            this.task.on("all", this.renderTask, this)
+            
+            ViewUtils.get(this, this.taskLink, function(data) {
+                // if we can get the data, then start fetching certain things repeatedly
+                // (would be good to skip the immediate "doitnow" below but not a big deal)
+                ViewUtils.fetchRepeatedlyWithDelay(that, that.task, { doitnow: true });
+                
+                // and set up to load children (now that the task is guaranteed to be loaded)
+                that.children = new TaskSummary.Collection()
+                that.children.url = that.task.get("links").children
+                that.children.on("reset", that.renderChildren, that)
+                ViewUtils.fetchRepeatedlyWithDelay(that, that.children, { 
+                    fetchOptions: { reset: true }, doitnow: true, fadeTarget: that.$('.tasks-children') });
+            }).fail( function() { that.$('.toggler-region.tasks-children').hide() } );
+
+
+            $.get(this.task.get("links").entity, function(entity) {
+                if (that.collection==null || entity.links.activities != that.collection.url) {
+                    // need to rewire collection to point to the right ExecutionContext
+                    that.collection = new TaskSummary.Collection()
+                    that.collection.url = entity.links.activities
+                    that.collection.on("reset", that.renderSubtasks, that)
+                    ViewUtils.fetchRepeatedlyWithDelay(that, that.collection, { 
+                        fetchOptions: { reset: true }, doitnow: true, fadeTarget: that.$('.tasks-submitted') });
+                } else {
+                    that.collection.on("reset", that.renderSubtasks, that)
+                    that.collection.fetch({reset: true});
+                }
+            });
+        },
+        
+        renderChildren: function() {
+            var that = this
+            var children = this.children
+            ViewUtils.updateMyDataTable(this.childrenTable, children, function(task, index) {
+                return [ task.get("id"),
+                         (task.get("entityId") && task.get("entityId")!=that.task.get("entityId") ? task.get("entityDisplayName") + ": " : "") + 
+                         task.get("displayName"),
+                         task.get("submitTimeUtc") <= 0 ? "-" : moment(task.get("submitTimeUtc")).calendar(),
+                         task.get("currentStatus")
+                    ]; 
+                });
+            if (children && children.length>0) {
+                this.$('.toggler-region.tasks-children').show();
+            } else {
+                this.$('.toggler-region.tasks-children').hide();
+            }
+        },
+        renderSubtasks: function() {
+            var that = this
+            var taskId = this.taskId || (this.task ? this.task.id : null);
+            if (!this.collection) {
+                this.$('.toggler-region.tasks-submitted').hide();
+                return;
+            }
+            if (!taskId) {
+                // task not available yet; just wait for it to be loaded
+                // (and in worst case, if it can't be loaded, this panel stays faded)
+                return;
+            } 
+            
+            // find tasks submitted by this one which aren't included as children
+            // this uses collections -- which is everything in the current execution context
+            var subtasks = []
+            for (var taskI in this.collection.models) {
+                var task = this.collection.models[taskI]
+                var submittedBy = task.get("submittedByTask")
+                if (submittedBy!=null && submittedBy.metadata!=null && submittedBy.metadata["id"] == taskId &&
+                        (!this.children || this.children.get(task.id)==null)) {
+                    subtasks.push(task)
+                }
+            }
+            ViewUtils.updateMyDataTable(this.subtasksTable, subtasks, function(task, index) {
+                return [ task.get("id"),
+                         (task.get("entityId") && (!that.task || task.get("entityId")!=that.task.get("entityId")) ? task.get("entityDisplayName") + ": " : "") + 
+                         task.get("displayName"),
+                         task.get("submitTimeUtc") <= 0 ? "-" : moment(task.get("submitTimeUtc")).calendar(),
+                         task.get("currentStatus")
+                    ];
+                });
+            if (subtasks && subtasks.length>0) {
+                this.$('.toggler-region.tasks-submitted').show();
+            } else {
+                this.$('.toggler-region.tasks-submitted').hide();
+            }
+        },
+        
+        displayTextForLinkedTask: function(v) {
+            return v.metadata.taskName ? 
+                    (v.metadata.entityDisplayName ? _.escape(v.metadata.entityDisplayName)+" <b>"+_.escape(v.metadata.taskName)+"</b>" : 
+                        _.escape(v.metadata.taskName)) :
+                    v.metadata.taskId ? _.escape(v.metadata.taskId) : 
+                    _.escape(v.link)
+        },
+        updateField: function(field) {
+            return this.updateFieldWith(field, _.escape)
+        },
+        updateFields: function() {
+            _.map(arguments, this.updateField, this);
+        },
+        updateFieldWith: function(field, f) {
+            var v = this.task.get(field)
+            if (v !== undefined && v != null && 
+                    (typeof v !== "object" || _.size(v) > 0)) {
+                this.$('.updateField-'+field, this.$el).html( f(v) );
+                this.$('.ifField-'+field, this.$el).show();
+            } else {
+                // blank if there is no value
+                this.$('.updateField-'+field).empty();
+                this.$('.ifField-'+field).hide();
+            }
+            return v
+        },
+        childrenRowClick:function(evt) {
+            var row = $(evt.currentTarget).closest("tr");
+            var id = row.attr("id");
+            this.showDrillDownTask("subtask of", this.children.get(id).get("links").self, id, this.children.get(id))
+        },
+        submittedRowClick:function(evt) {
+            var row = $(evt.currentTarget).closest("tr");
+            var id = row.attr("id");
+            // submitted tasks are guaranteed to be in the collection, so this is safe
+            this.showDrillDownTask("subtask of", this.collection.get(id).get('links').self, id)
+        },
+        
+        showDrillDownSubmittedByAnchor: function(from) {
+            var $a = $(from.target).closest('a');
+            this.showDrillDownTask("submitter of", $a.attr("link"), $a.attr("id"))
+        },
+        showDrillDownBlockerOfAnchor: function(from) {
+            var $a = $(from.target).closest('a');
+            this.showDrillDownTask("blocker of", $a.attr("link"), $a.attr("id"))
+        },
+        showDrillDownTask: function(relation, newTaskLink, newTaskId, newTask) {
+//            log("activities deeper drill down - "+newTaskId +" / "+newTaskLink)
+            var that = this;
+            
+            var newBreadcrumbs = [ relation + ' ' +
+                this.task.get('entityDisplayName') + ' ' +
+                this.task.get('displayName') ].concat(this.breadcrumbs)
+                
+            var activityDetailsPanel = new ActivityDetailsView({
+                taskLink: newTaskLink,
+                taskId: newTaskId,
+                task: newTask,
+                tabView: that.tabView,
+                collection: this.collection,
+                breadcrumbs: newBreadcrumbs
+            });
+            activityDetailsPanel.addToView(this.$el);
+        },
+        addToView: function(parent) {
+            if (this.parent) {
+                log("WARN: adding details to view when already added")
+                this.parent = parent;
+            }
+            
+            if (Backbone.history && (!this.tabView || !this.tabView.openingQueuedTasks)) {
+                Backbone.history.navigate(Backbone.history.fragment+"/"+"subtask"+"/"+this.taskId);
+            }
+
+            var $t = parent.closest('.slide-panel');
+            var $t2 = $t.after('<div>').next();
+            $t2.addClass('slide-panel');
+
+            // load the drill-down page
+            $t2.html(this.render().el)
+
+            var speed = (!this.tabView || !this.tabView.openingQueuedTasks) ? 300 : 0;
+            $t.animate({
+                    left: -600
+                }, speed, function() { 
+                    $t.hide() 
+                });
+
+            $t2.show().css({
+                    left: 600
+                    , top: 0
+                }).animate({
+                    left: 0
+                }, speed);
+        },
+        backDrillDown: function(event) {
+//            log("activities drill back from "+this.taskLink)
+            var that = this
+            var $t2 = this.$el.closest('.slide-panel')
+            var $t = $t2.prev()
+
+            if (Backbone.history) {
+                var fragment = Backbone.history.fragment
+                var thisLoc = fragment.indexOf("/subtask/"+this.taskId);
+                if (thisLoc>=0)
+                    Backbone.history.navigate( fragment.substring(0, thisLoc) );
+            }
+
+            $t2.animate({
+                    left: 569 //prevTable.width()
+                }, 300, function() {
+                    that.$el.empty()
+                    $t2.remove()
+                    that.remove()
+                });
+
+            $t.show().css({
+                    left: -600 //-($t2.width())
+                }).animate({
+                    left: 0
+                }, 300);
+        }
+    });
+
+    return ActivityDetailsView;
+});

http://git-wip-us.apache.org/repos/asf/brooklyn-ui/blob/18b073a9/src/main/webapp/assets/js/view/add-child-invoke.js
----------------------------------------------------------------------
diff --git a/src/main/webapp/assets/js/view/add-child-invoke.js b/src/main/webapp/assets/js/view/add-child-invoke.js
new file mode 100644
index 0000000..1105afe
--- /dev/null
+++ b/src/main/webapp/assets/js/view/add-child-invoke.js
@@ -0,0 +1,61 @@
+/*
+ * 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.
+*/
+/**
+ * Render as a modal
+ */
+define([
+    "underscore", "jquery", "backbone", "brooklyn", "brooklyn-utils", "view/viewutils",
+    "text!tpl/apps/add-child-modal.html"
+], function(_, $, Backbone, Brooklyn, Util, ViewUtils, 
+        AddChildModalHtml) {
+    return Backbone.View.extend({
+        template: _.template(AddChildModalHtml),
+        initialize: function() {
+            this.title = "Add Child to "+this.options.entity.get('name');
+        },
+        render: function() {
+            this.$el.html(this.template(this.options.entity.attributes));
+            return this;
+        },
+        onSubmit: function (event) {
+            var self = this;
+            var childSpec = this.$("#child-spec").val();
+            var start = this.$("#child-autostart").is(":checked");
+            var url = this.options.entity.get('links').children + (!start ? "?start=false" : "");
+            var ajax = $.ajax({
+                type: "POST",
+                url: url,
+                data: childSpec,
+                contentType: "application/yaml",
+                success: function() {
+                    self.options.target.reload();
+                },
+                error: function(response) {
+                    self.showError(Util.extractError(response, "Error contacting server", url));
+                }
+            });
+            return ajax;
+        },
+        showError: function (message) {
+            this.$(".child-add-error-container").removeClass("hide");
+            this.$(".child-add-error-message").html(message);
+        }
+
+    });
+});